diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..d6c50f0
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,82 @@
+name: Tests
+
+on: [push]
+
+jobs:
+ test:
+ name: PHP ${{ matrix.php-version }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version:
+ - "8.2"
+
+ env:
+ extensions: mbstring, pdo, pdo_mysql, intl, gd
+
+ services:
+ mysql:
+ image: mysql:8
+ env:
+ MYSQL_DATABASE: tests
+ MYSQL_ALLOW_EMPTY_PASSWORD: yes
+ ports:
+ - 3306/tcp
+ options: >-
+ --health-cmd "mysqladmin ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 3
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup cache environment
+ id: extcache
+ uses: shivammathur/cache-extensions@v1
+ with:
+ php-version: ${{ matrix.php-version }}
+ extensions: ${{ env.extensions }}
+ key: php-extensions-cache
+
+ - name: Cache extensions
+ uses: actions/cache@v3
+ with:
+ path: ${{ steps.extcache.outputs.dir }}
+ key: ${{ steps.extcache.outputs.key }}
+ restore-keys: ${{ steps.extcache.outputs.key }}
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ extensions: ${{ env.extensions }}
+ coverage: pcov
+ tools: composer:v2
+
+ - name: Get composer cache directory
+ id: composercache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache composer dependencies
+ uses: actions/cache@v3
+ with:
+ path: ${{ steps.composercache.outputs.dir }}
+ key: dependencies-composer-${{ hashFiles('composer.lock') }}-php-${{ matrix.php-version }}
+ restore-keys: dependencies-composer-
+
+ - name: Install composer dependencies
+ run: composer install --prefer-dist --no-interaction
+
+ - name: Setup env
+ run: |
+ cp .env.ci .env
+ php artisan key:generate --ansi
+
+ - name: Run tests
+ run: php artisan test
+ env:
+ DB_PORT: ${{ job.services.mysql.ports[3306] }}
diff --git a/app/Jobs/ProcessProtocolsJob.php b/app/Console/Commands/ProcessProtocolsCommand.php
similarity index 57%
rename from app/Jobs/ProcessProtocolsJob.php
rename to app/Console/Commands/ProcessProtocolsCommand.php
index ca68451..ca26046 100644
--- a/app/Jobs/ProcessProtocolsJob.php
+++ b/app/Console/Commands/ProcessProtocolsCommand.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace App\Jobs;
+namespace App\Console\Commands;
use App\Enum\UserRole;
use App\Models\Organisation;
@@ -11,23 +11,52 @@
use App\Notifications\ExpiringProtocol;
use App\Notifications\SummaryExpiredProtocols;
use App\Notifications\SummaryExpiringProtocols;
-use Illuminate\Bus\Queueable;
-use Illuminate\Contracts\Queue\ShouldQueue;
-use Illuminate\Foundation\Bus\Dispatchable;
-use Illuminate\Queue\InteractsWithQueue;
-use Illuminate\Queue\SerializesModels;
+use Illuminate\Console\Command;
+use Illuminate\Contracts\Console\Isolatable;
use Illuminate\Support\Collection;
-class ProcessProtocolsJob implements ShouldQueue
+class ProcessProtocolsCommand extends Command implements Isolatable
{
- use Dispatchable;
- use InteractsWithQueue;
- use Queueable;
- use SerializesModels;
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'app:protocols';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Process protocols that are about to expire';
public Collection $admins;
- public function __construct()
+ public Collection $organisations;
+
+ /**
+ * Execute the console command.
+ */
+ public function handle(): int
+ {
+ $this->loadPlatformAdmins();
+ $this->loadActiveOrganisations();
+
+ logger()->info(sprintf(
+ 'ProcessProtocols: starting check on %d active organisations...',
+ $this->organisations->count()
+ ));
+
+ $this->handleExpiringProtocols();
+ $this->handleExpiredProtocols();
+
+ logger()->info('ProcessProtocols: done!');
+
+ return static::SUCCESS;
+ }
+
+ private function loadPlatformAdmins(): void
{
$this->admins = User::query()
->withoutGlobalScopes()
@@ -35,12 +64,9 @@ public function __construct()
->get();
}
- /**
- * Execute the job.
- */
- public function handle(): void
+ private function loadActiveOrganisations(): void
{
- $organisations = Organisation::query()
+ $this->organisations = Organisation::query()
->whereActive()
->select(['id', 'name', 'email', 'contact_person'])
->withOnly([
@@ -51,32 +77,29 @@ public function handle(): void
])
->withLastProtocolExpiresAt()
->get();
-
- logger()->info(sprintf('ProcessProtocolsJob: starting check on %d organisations...', $organisations->count()));
-
- $this->handleExpiringProtocols($organisations);
- $this->handleExpiredProtocols($organisations);
-
- logger()->info('ProcessProtocolsJob: done!');
}
- private function handleExpiringProtocols(Collection $organisations): void
+ private function handleExpiringProtocols(): void
{
$checkDate = today()->addDays(30);
logger()->info(sprintf(
- 'ProcessProtocolsJob: checking for protocols expiring in 30 days (%s)...',
+ 'ProcessProtocols: checking for protocols expiring in 30 days (%s)...',
$checkDate->format('Y-m-d')
));
- $organisations
- ->filter(fn (Organisation $organisation) => $checkDate->isSameDay($organisation->last_protocol_expires_at))
+ $this->organisations
+ ->filter(
+ fn (Organisation $organisation) => $organisation
+ ->last_protocol_expires_at
+ ?->isSameDay($checkDate) ?? true
+ )
->each(function (Organisation $organisation) {
$this->sendNotification(ExpiringProtocol::class, $organisation);
})
->tap(function (Collection $organisations) {
logger()->info(sprintf(
- 'ProcessProtocolsJob: found %d organisations with expiring protocols: %s',
+ 'ProcessProtocols: found %d organisations with expiring protocols: %s',
$organisations->count(),
$organisations->pluck('id')->join(', ')
));
@@ -85,17 +108,21 @@ private function handleExpiringProtocols(Collection $organisations): void
});
}
- private function handleExpiredProtocols(Collection $organisations): void
+ private function handleExpiredProtocols(): void
{
$checkDate = today();
logger()->info(sprintf(
- 'ProcessProtocolsJob: checking for protocols expiring today (%s)...',
+ 'ProcessProtocols: checking for protocols expiring today (%s)...',
$checkDate->format('Y-m-d')
));
- $organisations
- ->filter(fn (Organisation $organisation) => $checkDate->isSameDay($organisation->last_protocol_expires_at))
+ $this->organisations
+ ->filter(
+ fn (Organisation $organisation) => $organisation
+ ->last_protocol_expires_at
+ ?->lte($checkDate) ?? true
+ )
->each(function (Organisation $organisation) {
$organisation->setInactive();
@@ -103,7 +130,7 @@ private function handleExpiredProtocols(Collection $organisations): void
})
->tap(function (Collection $organisations) {
logger()->info(sprintf(
- 'ProcessProtocolsJob: found %d organisations with expired protocols: %s',
+ 'ProcessProtocols: found %d organisations with expired protocols: %s',
$organisations->count(),
$organisations->pluck('id')->join(', ')
));
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index 0bf5fd3..6eb9f6e 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -4,7 +4,7 @@
namespace App\Console;
-use App\Jobs\ProcessProtocolsJob;
+use App\Console\Commands\ProcessProtocolsCommand;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@@ -18,12 +18,12 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule)
{
- $schedule->job(new ProcessProtocolsJob)
+ $schedule->command(ProcessProtocolsCommand::class)
->daily()
->at('06:00')
->timezone('Europe/Bucharest')
->withoutOverlapping()
- ->sentryMonitor('process-protocols-job');
+ ->sentryMonitor('process-protocols');
}
/**
diff --git a/composer.json b/composer.json
index c1d4210..57af909 100644
--- a/composer.json
+++ b/composer.json
@@ -62,7 +62,7 @@
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
],
- "test": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --colors=always"
+ "test": "@php artisan test"
},
"extra": {
"laravel": {
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index a9ff412..14a2f98 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -30,8 +30,8 @@
-
-
+
+
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
deleted file mode 100644
index 7cbf57a..0000000
--- a/tests/Feature/ExampleTest.php
+++ /dev/null
@@ -1,23 +0,0 @@
-get('/');
-
- $response->assertStatus(302);
- }
-}
diff --git a/tests/Feature/ProtocolsTest.php b/tests/Feature/ProtocolsTest.php
new file mode 100644
index 0000000..650f7cd
--- /dev/null
+++ b/tests/Feature/ProtocolsTest.php
@@ -0,0 +1,200 @@
+platformAdmin()
+ ->create();
+ }
+
+ /** @test */
+ public function it_does_not_send_notifications_for_protocols_expiring_in_less_than_30_days(): void
+ {
+ $document = Document::factory()
+ ->protocol()
+ ->state([
+ 'expires_at' => today()->addDays(29),
+ ])
+ ->create();
+
+ $this->artisan(ProcessProtocolsCommand::class)
+ ->assertSuccessful();
+
+ Notification::assertNotSentTo(
+ $document->organisation,
+ ExpiringProtocol::class
+ );
+
+ Notification::assertNotSentTo(
+ $document->organisation
+ ->users
+ ->where('role', UserRole::ORG_ADMIN),
+ ExpiringProtocol::class
+ );
+
+ Notification::assertNotSentTo(
+ $this->getPlatformAdmins(),
+ SummaryExpiringProtocols::class
+ );
+ }
+
+ /** @test */
+ public function it_sends_notifications_for_protocols_expiring_in_exactly_30_days(): void
+ {
+ $document = Document::factory()
+ ->protocol()
+ ->state([
+ 'expires_at' => today()->addDays(30),
+ ])
+ ->create();
+
+ $this->artisan(ProcessProtocolsCommand::class)
+ ->assertSuccessful();
+
+ Notification::assertSentTo(
+ $document->organisation,
+ ExpiringProtocol::class
+ );
+
+ Notification::assertSentTo(
+ $document->organisation
+ ->users
+ ->where('role', UserRole::ORG_ADMIN),
+ ExpiringProtocol::class
+ );
+
+ Notification::assertSentTo(
+ $this->getPlatformAdmins(),
+ SummaryExpiringProtocols::class
+ );
+ }
+
+ /** @test */
+ public function it_does_not_send_notifications_for_protocols_expiring_in_more_than_30_days(): void
+ {
+ $document = Document::factory()
+ ->protocol()
+ ->state([
+ 'expires_at' => today()->addDays(31),
+ ])
+ ->create();
+
+ $this->artisan(ProcessProtocolsCommand::class)
+ ->assertSuccessful();
+
+ Notification::assertNotSentTo(
+ $document->organisation,
+ ExpiringProtocol::class
+ );
+
+ Notification::assertNotSentTo(
+ $document->organisation
+ ->users
+ ->where('role', UserRole::ORG_ADMIN),
+ ExpiringProtocol::class
+ );
+
+ Notification::assertNotSentTo(
+ $this->getPlatformAdmins(),
+ SummaryExpiringProtocols::class
+ );
+ }
+
+ /** @test */
+ public function it_sends_notifications_for_protocols_that_expire_today(): void
+ {
+ $document = Document::factory()
+ ->protocol()
+ ->state([
+ 'expires_at' => today(),
+ ])
+ ->create();
+
+ $this->artisan(ProcessProtocolsCommand::class)
+ ->assertSuccessful();
+
+ Notification::assertSentTo(
+ $document->organisation,
+ ExpiredProtocol::class
+ );
+
+ Notification::assertSentTo(
+ $document->organisation
+ ->users
+ ->where('role', UserRole::ORG_ADMIN),
+ ExpiredProtocol::class
+ );
+
+ Notification::assertSentTo(
+ $this->getPlatformAdmins(),
+ SummaryExpiredProtocols::class
+ );
+ }
+
+ /** @test */
+ public function it_sends_notifications_for_protocols_that_have_expired_in_the_past(): void
+ {
+ $document = Document::factory()
+ ->protocol()
+ ->state([
+ 'expires_at' => today()->subDays(3),
+ ])
+ ->create();
+
+ $this->artisan(ProcessProtocolsCommand::class)
+ ->assertSuccessful();
+
+ Notification::assertSentTo(
+ $document->organisation,
+ ExpiredProtocol::class
+ );
+
+ Notification::assertSentTo(
+ $document->organisation
+ ->users
+ ->where('role', UserRole::ORG_ADMIN),
+ ExpiredProtocol::class
+ );
+
+ Notification::assertSentTo(
+ $this->getPlatformAdmins(),
+ SummaryExpiredProtocols::class
+ );
+ }
+
+ private function getPlatformAdmins(): Collection
+ {
+ return User::query()
+ ->withoutGlobalScopes()
+ ->role(UserRole::PLATFORM_ADMIN)
+ ->get();
+ }
+}