diff --git a/.env.ci b/.env.ci
new file mode 100644
index 0000000..eef504e
--- /dev/null
+++ b/.env.ci
@@ -0,0 +1,21 @@
+APP_ENV=ci
+APP_KEY=
+APP_DEBUG=true
+APP_URL=http://localhost
+
+LOG_CHANNEL=stack
+LOG_LEVEL=debug
+
+DB_CONNECTION=mysql
+DB_HOST=127.0.0.1
+DB_DATABASE=tests
+DB_USERNAME=root
+DB_PASSWORD=
+
+BROADCAST_DRIVER=log
+CACHE_DRIVER=array
+FILESYSTEM_DISK=local
+QUEUE_CONNECTION=sync
+SESSION_DRIVER=array
+
+MAIL_MAILER=log
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/Console/Commands/PingCommand.php b/app/Console/Commands/PingCommand.php
new file mode 100644
index 0000000..51a3f8e
--- /dev/null
+++ b/app/Console/Commands/PingCommand.php
@@ -0,0 +1,34 @@
+info('Pong!');
+
+ return static::SUCCESS;
+ }
+}
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..b8ed08e 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -4,7 +4,8 @@
namespace App\Console;
-use App\Jobs\ProcessProtocolsJob;
+use App\Console\Commands\PingCommand;
+use App\Console\Commands\ProcessProtocolsCommand;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@@ -18,12 +19,17 @@ 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');
+
+ $schedule->command(PingCommand::class)
+ ->everyTenMinutes()
+ ->withoutOverlapping()
+ ->sentryMonitor('ping');
}
/**
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/database/factories/DocumentFactory.php b/database/factories/DocumentFactory.php
index 603fe8b..9056bbc 100644
--- a/database/factories/DocumentFactory.php
+++ b/database/factories/DocumentFactory.php
@@ -5,6 +5,7 @@
namespace Database\Factories;
use App\Enum\DocumentType;
+use App\Models\Organisation;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Factories\Factory;
@@ -23,6 +24,7 @@ public function definition()
return [
'name' => fake()->sentence(),
'type' => DocumentType::other,
+ 'organisation_id' => Organisation::factory(),
];
}
diff --git a/database/factories/OrganisationFactory.php b/database/factories/OrganisationFactory.php
index 088218b..0154a84 100644
--- a/database/factories/OrganisationFactory.php
+++ b/database/factories/OrganisationFactory.php
@@ -30,7 +30,7 @@ public function definition()
$contactPerson = [
'first_name' => fake()->firstName(),
'last_name' => fake()->lastName(),
- 'email' => fake()->safeEmail(),
+ 'email' => fake()->unique()->safeEmail(),
'phone' => fake()->phoneNumber(),
'role' => fake()->jobTitle(),
];
@@ -51,7 +51,7 @@ public function definition()
'ngo_type' => OrganisationType::ngo->is($type)
? fake()->randomElement(NGOType::values())
: null,
- 'status' => fake()->randomElement(OrganisationStatus::values()),
+ 'status' => OrganisationStatus::active,
'email' => fake()->unique()->safeEmail(),
'phone' => fake()->phoneNumber(),
'year' => fake()->year(),
@@ -69,7 +69,21 @@ public function definition()
];
}
- public function configure(): static
+ public function inactive(): static
+ {
+ return $this->state([
+ 'status' => OrganisationStatus::inactive,
+ ]);
+ }
+
+ public function randomStatus(): static
+ {
+ return $this->state(fn () => [
+ 'status' => fake()->randomElement(OrganisationStatus::values()),
+ ]);
+ }
+
+ public function withRelated(): static
{
return $this->afterCreating(function (Organisation $organisation) {
User::factory(['email' => $organisation->email])
diff --git a/database/factories/ResourceFactory.php b/database/factories/ResourceFactory.php
index ed98332..c279713 100644
--- a/database/factories/ResourceFactory.php
+++ b/database/factories/ResourceFactory.php
@@ -27,8 +27,8 @@ public function definition()
'name' => fake()->name,
'city_id' => $city->id,
'county_id' => $city->county_id,
- 'subcategory_id' => $subcategory?->id,
- 'category_id' => $subcategory?->category_id,
+ 'subcategory_id' => $subcategory->id,
+ 'category_id' => $subcategory->category_id,
'type_id' => fake()->randomElement($subcategory?->types?->pluck('id')->toArray() ?? []),
'contact_name' => fake()->name(),
'contact_phone' => fake()->phoneNumber(),
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 04a4a57..f899cbd 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -64,6 +64,8 @@ public function run()
'county_id' => $county->id,
])->toArray()
)
+ ->randomStatus()
+ ->withRelated()
->createQuietly();
}
}
diff --git a/docker/s6-rc.d/schedule/dependencies b/docker/s6-rc.d/cron/dependencies
similarity index 100%
rename from docker/s6-rc.d/schedule/dependencies
rename to docker/s6-rc.d/cron/dependencies
diff --git a/docker/s6-rc.d/cron/run b/docker/s6-rc.d/cron/run
new file mode 100644
index 0000000..d00d653
--- /dev/null
+++ b/docker/s6-rc.d/cron/run
@@ -0,0 +1,5 @@
+#!/bin/sh -e
+
+echo "* * * * * php /var/www/artisan schedule:run >> /dev/null 2>&1" | crontab -
+
+crond -f -l 2
diff --git a/docker/s6-rc.d/schedule/type b/docker/s6-rc.d/cron/type
similarity index 100%
rename from docker/s6-rc.d/schedule/type
rename to docker/s6-rc.d/cron/type
diff --git a/docker/s6-rc.d/schedule/run b/docker/s6-rc.d/schedule/run
deleted file mode 100644
index 62c0032..0000000
--- a/docker/s6-rc.d/schedule/run
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/command/with-contenv sh
-
-php /var/www/artisan schedule:work --quiet
diff --git a/docker/s6-rc.d/user/contents.d/schedule b/docker/s6-rc.d/user/contents.d/cron
similarity index 100%
rename from docker/s6-rc.d/user/contents.d/schedule
rename to docker/s6-rc.d/user/contents.d/cron
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index a9ff412..7292614 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -3,7 +3,6 @@
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
- testdox="true"
processIsolation="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
@@ -30,8 +29,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();
+ }
+}