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(); + } +}