diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 91869eb12d..cfdfdbc3ed 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -5,6 +5,8 @@ use App\Events\ProxyStatusChangedUI; use App\Models\Server; use App\Notifications\Server\TraefikVersionOutdated; +use InvalidArgumentException; +use Illuminate\Bus\Batchable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -14,7 +16,7 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public $tries = 3; @@ -25,8 +27,14 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue */ public function __construct( public Server $server, - public array $traefikVersions - ) {} + public array $traefikVersions, + public bool $shouldNotify = true, + public ?string $scanId = null + ) { + if (! $this->shouldNotify && ($this->scanId === null || trim($this->scanId) === '')) { + throw new InvalidArgumentException('Batched Traefik version checks require a shared scan identifier.'); + } + } /** * Execute the job. @@ -142,7 +150,7 @@ private function getNewerBranchInfo(string $currentBranch): ?array } /** - * Store outdated information in database and send immediate notification. + * Store outdated information in database and optionally send an immediate notification. */ private function storeOutdatedInfo(string $current, string $latest, string $type, ?string $upgradeTarget = null, ?array $newerBranchInfo = null): void { @@ -153,6 +161,10 @@ private function storeOutdatedInfo(string $current, string $latest, string $type 'checked_at' => now()->toIso8601String(), ]; + if ($this->scanId !== null) { + $outdatedInfo['scan_id'] = $this->scanId; + } + // For minor upgrades, add the upgrade_target field (e.g., "v3.6") if ($type === 'minor_upgrade' && $upgradeTarget) { $outdatedInfo['upgrade_target'] = $upgradeTarget; @@ -166,8 +178,13 @@ private function storeOutdatedInfo(string $current, string $latest, string $type $this->server->update(['traefik_outdated_info' => $outdatedInfo]); - // Send immediate notification to the team - $this->sendNotification($outdatedInfo); + if (! $this->shouldNotify && $this->scanId !== null) { + CheckTraefikVersionJob::recordOutdatedServerSnapshot($this->scanId, $this->server, $outdatedInfo); + } + + if ($this->shouldNotify) { + $this->sendNotification($outdatedInfo); + } } /** diff --git a/app/Jobs/CheckTraefikVersionJob.php b/app/Jobs/CheckTraefikVersionJob.php index ac94aa23f5..ef4573160c 100644 --- a/app/Jobs/CheckTraefikVersionJob.php +++ b/app/Jobs/CheckTraefikVersionJob.php @@ -4,17 +4,29 @@ use App\Enums\ProxyTypes; use App\Models\Server; +use Illuminate\Bus\Batch; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; +use Throwable; class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + private const SCAN_LOCK_KEY = 'traefik-version-scan'; + private const SCAN_SNAPSHOT_CACHE_KEY_PREFIX = 'traefik-version-scan:snapshot:'; + private const SCAN_SNAPSHOT_LOCK_KEY_PREFIX = 'traefik-version-scan:snapshot-lock:'; + + private const SCAN_LOCK_TTL_SECONDS = 21600; + private const SCAN_SNAPSHOT_LOCK_TTL_SECONDS = 10; + public $tries = 3; public function handle(): void @@ -37,10 +49,113 @@ public function handle(): void return; } - // Dispatch individual server check jobs in parallel - // Each job will send immediate notifications when outdated Traefik is detected - foreach ($servers as $server) { - CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions); + $lock = Cache::lock(self::SCAN_LOCK_KEY, self::SCAN_LOCK_TTL_SECONDS); + if (! $lock->get()) { + return; } + + $lockOwner = $lock->owner(); + $scanId = (string) Str::uuid(); + $jobs = $servers + ->map(fn (Server $server) => new CheckTraefikVersionForServerJob($server, $traefikVersions, false, $scanId)) + ->all(); + + try { + Bus::batch($jobs) + ->finally(function (Batch $batch) use ($scanId, $lockOwner): void { + if ($batch->cancelled()) { + self::forgetOutdatedServerSnapshots($scanId); + self::releaseScanLock($lockOwner); + + return; + } + + self::dispatchNotificationJobs($scanId, $lockOwner); + }) + ->dispatch(); + } catch (Throwable $exception) { + self::forgetOutdatedServerSnapshots($scanId); + self::releaseScanLock($lockOwner); + + throw $exception; + } + } + + public static function recordOutdatedServerSnapshot(string $scanId, Server $server, array $outdatedInfo): void + { + Cache::lock(self::snapshotLockKey($scanId), self::SCAN_SNAPSHOT_LOCK_TTL_SECONDS) + ->block(self::SCAN_SNAPSHOT_LOCK_TTL_SECONDS, function () use ($scanId, $server, $outdatedInfo): void { + $snapshots = Cache::get(self::snapshotCacheKey($scanId), []); + + $teamSnapshots = $snapshots[$server->team_id] ?? []; + $teamSnapshots[$server->id] = [ + 'id' => $server->id, + 'name' => $server->name, + 'uuid' => $server->uuid, + 'outdatedInfo' => $outdatedInfo, + ]; + + $snapshots[$server->team_id] = $teamSnapshots; + + Cache::put(self::snapshotCacheKey($scanId), $snapshots, self::SCAN_LOCK_TTL_SECONDS); + }); + } + + private static function dispatchNotificationJobs(string $scanId, ?string $lockOwner): void + { + $serverSnapshotsByTeam = collect(Cache::get(self::snapshotCacheKey($scanId), [])); + + if ($serverSnapshotsByTeam->isEmpty()) { + self::forgetOutdatedServerSnapshots($scanId); + self::releaseScanLock($lockOwner); + + return; + } + + $jobs = $serverSnapshotsByTeam + ->map(fn (array $teamServers, int|string $teamId) => new NotifyOutdatedTraefikServersJob($teamId, $scanId, array_values($teamServers))) + ->all(); + + try { + Bus::batch($jobs) + ->allowFailures() + ->finally(function () use ($scanId, $lockOwner): void { + self::forgetOutdatedServerSnapshots($scanId); + self::releaseScanLock($lockOwner); + }) + ->dispatch(); + } catch (Throwable $exception) { + self::forgetOutdatedServerSnapshots($scanId); + self::releaseScanLock($lockOwner); + + throw $exception; + } + } + + private static function forgetOutdatedServerSnapshots(string $scanId): void + { + Cache::forget(self::snapshotCacheKey($scanId)); + } + + private static function releaseScanLock(?string $lockOwner): void + { + if (! $lockOwner) { + return; + } + + rescue( + fn () => Cache::restoreLock(self::SCAN_LOCK_KEY, $lockOwner)->release(), + report: false + ); + } + + private static function snapshotCacheKey(string $scanId): string + { + return self::SCAN_SNAPSHOT_CACHE_KEY_PREFIX.$scanId; + } + + private static function snapshotLockKey(string $scanId): string + { + return self::SCAN_SNAPSHOT_LOCK_KEY_PREFIX.$scanId; } } diff --git a/app/Jobs/NotifyOutdatedTraefikServersJob.php b/app/Jobs/NotifyOutdatedTraefikServersJob.php new file mode 100644 index 0000000000..5762a35a50 --- /dev/null +++ b/app/Jobs/NotifyOutdatedTraefikServersJob.php @@ -0,0 +1,48 @@ +onQueue('high'); + } + + public function handle(): void + { + $teamServers = collect($this->servers) + ->map(fn (array $server) => new Fluent($server)) + ->values(); + + if ($teamServers->isEmpty()) { + return; + } + + $team = Team::find($this->teamId); + if (! $team) { + return; + } + + $team->notify(new TraefikVersionOutdated($teamServers)); + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index 00843b3dae..d904a9ad18 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -43,6 +43,7 @@ * latest: string, * type: 'patch_update'|'minor_upgrade', * checked_at: string, + * scan_id?: string, * newer_branch_target?: string, * newer_branch_latest?: string, * upgrade_target?: string @@ -58,6 +59,7 @@ * 'latest' => '3.5.2', // Latest patch version available * 'type' => 'patch_update', // Update type identifier * 'checked_at' => '2025-11-14T10:00:00Z', // ISO8601 timestamp + * 'scan_id' => '32f5f6d7-7ae0-4aa7-9fcb-3a0b75155eab', // (Optional) Batched scan identifier * 'newer_branch_target' => 'v3.6', // (Optional) Available major/minor version * 'newer_branch_latest' => '3.6.2' // (Optional) Latest version in that branch * ] @@ -70,7 +72,8 @@ * 'latest' => '3.6.2', // Latest version in target branch * 'type' => 'minor_upgrade', // Update type identifier * 'upgrade_target' => 'v3.6', // Target branch (with 'v' prefix) - * 'checked_at' => '2025-11-14T10:00:00Z' // ISO8601 timestamp + * 'checked_at' => '2025-11-14T10:00:00Z', // ISO8601 timestamp + * 'scan_id' => '32f5f6d7-7ae0-4aa7-9fcb-3a0b75155eab' // (Optional) Batched scan identifier * ] * ``` * diff --git a/tests/Feature/CheckTraefikVersionJobTest.php b/tests/Feature/CheckTraefikVersionJobTest.php index cee1564855..6bce70753a 100644 --- a/tests/Feature/CheckTraefikVersionJobTest.php +++ b/tests/Feature/CheckTraefikVersionJobTest.php @@ -1,16 +1,29 @@ 0, + 'fqdn' => 'https://coolify.example.com', + ]); }); it('detects servers table has detected_traefik_version column', function () { @@ -180,39 +193,155 @@ expect($grouped[$team2->id])->toHaveCount(1); }); -it('server check job exists and has correct structure', function () { - expect(class_exists(\App\Jobs\CheckTraefikVersionForServerJob::class))->toBeTrue(); +it('scheduled traefik scan jobs exist and have correct structure', function () { + expect(class_exists(CheckTraefikVersionForServerJob::class))->toBeTrue(); + expect(class_exists(NotifyOutdatedTraefikServersJob::class))->toBeTrue(); // Verify CheckTraefikVersionForServerJob has required properties - $reflection = new \ReflectionClass(\App\Jobs\CheckTraefikVersionForServerJob::class); + $reflection = new \ReflectionClass(CheckTraefikVersionForServerJob::class); expect($reflection->hasProperty('tries'))->toBeTrue(); expect($reflection->hasProperty('timeout'))->toBeTrue(); + expect($reflection->hasProperty('shouldNotify'))->toBeTrue(); + expect($reflection->hasProperty('scanId'))->toBeTrue(); // Verify it implements ShouldQueue - $interfaces = class_implements(\App\Jobs\CheckTraefikVersionForServerJob::class); + $interfaces = class_implements(CheckTraefikVersionForServerJob::class); expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldQueue::class); }); -it('sends immediate notifications when outdated traefik is detected', function () { - // Notifications are now sent immediately from CheckTraefikVersionForServerJob - // when outdated Traefik is detected, rather than being aggregated and delayed +it('dispatches server checks in a batch without immediate notifications', function () { + Bus::fake(); + $team = Team::factory()->create(); - $server = Server::factory()->make([ - 'name' => 'Server 1', + $server1 = Server::factory()->create([ 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], ]); + $server1->settings->update(['is_reachable' => true, 'is_usable' => true]); - $server->outdatedInfo = [ - 'current' => '3.5.0', - 'latest' => '3.5.6', - 'type' => 'patch_update', - ]; + $server2 = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + ]); + $server2->settings->update(['is_reachable' => true, 'is_usable' => true]); - // Each server triggers its own notification immediately - $notification = new TraefikVersionOutdated(collect([$server])); + $batchScanId = null; - expect($notification->servers)->toHaveCount(1); - expect($notification->servers->first()->outdatedInfo['type'])->toBe('patch_update'); + (new CheckTraefikVersionJob)->handle(); + + Bus::assertBatched(function (PendingBatch $batch) use (&$batchScanId) { + if (count($batch->jobs) !== 2) { + return false; + } + + $jobs = collect($batch->jobs); + $batchScanId = $jobs->first()?->scanId; + + return $jobs->every(function ($job) use ($batchScanId) { + return $job instanceof CheckTraefikVersionForServerJob + && $job->shouldNotify === false + && filled($job->scanId) + && $job->scanId === $batchScanId; + }); + }); +}); + +it('dispatches one notification job per affected team after the scan batch finishes', function () { + Bus::fake(); + + $checkedAt = '2026-03-29T00:00:00+00:00'; + $team1 = Team::factory()->create(); + $team2 = Team::factory()->create(); + + $team1Server = Server::factory()->create([ + 'team_id' => $team1->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + ]); + $team1Server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $team2Server = Server::factory()->create([ + 'team_id' => $team2->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + ]); + $team2Server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + (new CheckTraefikVersionJob)->handle(); + + $scanBatch = collect(Bus::dispatchedBatches()) + ->first(function (PendingBatch $batch): bool { + return $batch->jobs->every(fn ($job) => $job instanceof CheckTraefikVersionForServerJob); + }); + + expect($scanBatch)->not->toBeNull(); + + $scanId = $scanBatch->jobs->first()->scanId; + + $team1Server->update([ + 'traefik_outdated_info' => [ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + 'scan_id' => $scanId, + 'checked_at' => $checkedAt, + ], + ]); + $team2Server->update([ + 'traefik_outdated_info' => [ + 'current' => '3.4.0', + 'latest' => '3.4.9', + 'type' => 'patch_update', + 'scan_id' => $scanId, + 'checked_at' => $checkedAt, + ], + ]); + CheckTraefikVersionJob::recordOutdatedServerSnapshot($scanId, $team1Server->fresh(), $team1Server->fresh()->traefik_outdated_info); + CheckTraefikVersionJob::recordOutdatedServerSnapshot($scanId, $team2Server->fresh(), $team2Server->fresh()->traefik_outdated_info); + + $team1Server->settings->update(['is_reachable' => false, 'is_usable' => false]); + $team2Server->settings->update(['is_reachable' => false, 'is_usable' => false]); + + $scanBatch->finallyCallbacks()[0](\Mockery::mock(Batch::class, function ($mock): void { + $mock->shouldReceive('cancelled')->andReturnFalse(); + })); + + Bus::assertBatched(function (PendingBatch $batch) use ($scanBatch, $scanId, $team1, $team2, $team1Server, $team2Server) { + if ($batch === $scanBatch) { + return false; + } + + $jobs = collect($batch->jobs); + $serversByTeam = $jobs + ->mapWithKeys(fn (NotifyOutdatedTraefikServersJob $job) => [$job->teamId => collect($job->servers)->pluck('id')->all()]) + ->all(); + + return $jobs->count() === 2 + && $jobs->every(fn ($job) => $job instanceof NotifyOutdatedTraefikServersJob && $job->scanId === $scanId) + && array_keys($serversByTeam) === [$team1->id, $team2->id] + && $serversByTeam[$team1->id] === [$team1Server->id] + && $serversByTeam[$team2->id] === [$team2Server->id]; + }); +}); + +it('skips dispatching a new batch while another traefik scan is still locked', function () { + Bus::fake(); + + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + ]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $lock = Cache::lock('traefik-version-scan', 60); + expect($lock->get())->toBeTrue(); + + try { + (new CheckTraefikVersionJob)->handle(); + + Bus::assertNothingBatched(); + } finally { + $lock->release(); + } }); it('notification generates correct server proxy URLs', function () { diff --git a/tests/Feature/NotifyOutdatedTraefikServersJobTest.php b/tests/Feature/NotifyOutdatedTraefikServersJobTest.php new file mode 100644 index 0000000000..1df9255403 --- /dev/null +++ b/tests/Feature/NotifyOutdatedTraefikServersJobTest.php @@ -0,0 +1,194 @@ +snapshot = fn (Server $server): array => [ + 'id' => $server->id, + 'name' => $server->name, + 'uuid' => $server->uuid, + 'outdatedInfo' => $server->traefik_outdated_info, + ]; +}); + +it('has correct queue and retry configuration', function () { + $team = Team::factory()->create(); + $scanId = 'scan-2026-03-29'; + $servers = [[ + 'id' => 1, + 'name' => 'server-1', + 'uuid' => 'uuid-1', + 'outdatedInfo' => ['current' => '3.5.0', 'latest' => '3.5.6', 'type' => 'patch_update'], + ]]; + $job = new NotifyOutdatedTraefikServersJob($team->id, $scanId, $servers); + + expect($job->tries)->toBe(3); + expect($job->queue)->toBe('high'); + expect($job->teamId)->toBe($team->id); + expect($job->scanId)->toBe($scanId); + expect($job->servers)->toBe($servers); +}); + +it('sends one aggregated notification for the requested team only', function () { + $scanId = 'scan-2026-03-29'; + $checkedAt = '2026-03-29T00:00:00+00:00'; + + $team1 = Team::factory()->create(); + $team2 = Team::factory()->create(); + $team1->emailNotificationSettings->update([ + 'use_instance_email_settings' => true, + 'traefik_outdated_email_notifications' => true, + ]); + $team2->emailNotificationSettings->update([ + 'use_instance_email_settings' => true, + 'traefik_outdated_email_notifications' => true, + ]); + + $server1 = Server::factory()->create([ + 'team_id' => $team1->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'traefik_outdated_info' => [ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + 'scan_id' => $scanId, + 'checked_at' => $checkedAt, + ], + ]); + $server1->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $server2 = Server::factory()->create([ + 'team_id' => $team1->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'traefik_outdated_info' => [ + 'current' => '3.5.6', + 'latest' => '3.6.2', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.6', + 'scan_id' => $scanId, + 'checked_at' => $checkedAt, + ], + ]); + $server2->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $server3 = Server::factory()->create([ + 'team_id' => $team2->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'traefik_outdated_info' => [ + 'current' => '3.4.0', + 'latest' => '3.4.9', + 'type' => 'patch_update', + 'scan_id' => $scanId, + 'checked_at' => $checkedAt, + ], + ]); + $server3->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $previousBatchServer = Server::factory()->create([ + 'team_id' => $team1->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'traefik_outdated_info' => [ + 'current' => '3.3.0', + 'latest' => '3.3.9', + 'type' => 'patch_update', + 'scan_id' => 'scan-previous', + 'checked_at' => $checkedAt, + ], + ]); + $previousBatchServer->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $unusableServer = Server::factory()->create([ + 'team_id' => $team2->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'traefik_outdated_info' => [ + 'current' => '3.4.1', + 'latest' => '3.4.9', + 'type' => 'patch_update', + 'scan_id' => $scanId, + 'checked_at' => $checkedAt, + ], + ]); + $unusableServer->settings->update(['is_reachable' => true, 'is_usable' => false]); + + $otherProxyServer = Server::factory()->create([ + 'team_id' => $team2->id, + 'proxy' => ['type' => ProxyTypes::CADDY->value], + 'traefik_outdated_info' => [ + 'current' => '3.4.1', + 'latest' => '3.4.9', + 'type' => 'patch_update', + 'scan_id' => $scanId, + 'checked_at' => $checkedAt, + ], + ]); + $otherProxyServer->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $job = new NotifyOutdatedTraefikServersJob($team1->id, $scanId, [ + ($this->snapshot)($server1), + ($this->snapshot)($server2), + ]); + $job->handle(); + + Notification::assertSentTo($team1, TraefikVersionOutdated::class, function (TraefikVersionOutdated $notification) use ($server1, $server2) { + return $notification->servers->pluck('id')->sort()->values()->all() === collect([$server1->id, $server2->id])->sort()->values()->all(); + }); + + Notification::assertNotSentTo($team2, TraefikVersionOutdated::class); + expect(count(Notification::sent($team1, TraefikVersionOutdated::class)))->toBe(1); +}); + +it('uses the captured scan snapshot even if a server becomes unusable before notification dispatch', function () { + $scanId = 'scan-2026-03-29'; + $team = Team::factory()->create(); + $team->emailNotificationSettings->update([ + 'use_instance_email_settings' => true, + 'traefik_outdated_email_notifications' => true, + ]); + + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + 'traefik_outdated_info' => [ + 'current' => '3.5.0', + 'latest' => '3.5.6', + 'type' => 'patch_update', + 'scan_id' => 'scan-previous', + 'checked_at' => '2026-03-29T00:00:00+00:00', + ], + ]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + + $job = new NotifyOutdatedTraefikServersJob($team->id, $scanId, [ + ($this->snapshot)($server), + ]); + + $server->settings->update(['is_reachable' => false, 'is_usable' => false]); + $job->handle(); + + Notification::assertSentTo($team, TraefikVersionOutdated::class, function (TraefikVersionOutdated $notification) use ($server) { + return $notification->servers->pluck('id')->all() === [$server->id]; + }); +}); + +it('does not send notifications when the captured snapshot is empty', function () { + $scanId = 'scan-current'; + $team = Team::factory()->create(); + $team->emailNotificationSettings->update([ + 'use_instance_email_settings' => true, + 'traefik_outdated_email_notifications' => true, + ]); + + $job = new NotifyOutdatedTraefikServersJob($team->id, $scanId, []); + $job->handle(); + + Notification::assertNothingSent(); +}); diff --git a/tests/Unit/CheckTraefikVersionForServerJobTest.php b/tests/Unit/CheckTraefikVersionForServerJobTest.php index 5da6f97d82..4b11ac5869 100644 --- a/tests/Unit/CheckTraefikVersionForServerJobTest.php +++ b/tests/Unit/CheckTraefikVersionForServerJobTest.php @@ -2,6 +2,7 @@ use App\Jobs\CheckTraefikVersionForServerJob; use App\Models\Server; +use App\Notifications\Server\TraefikVersionOutdated; beforeEach(function () { $this->traefikVersions = [ @@ -18,6 +19,93 @@ expect($job->timeout)->toBe(60); expect($job->server)->toBe($server); expect($job->traefikVersions)->toBe($this->traefikVersions); + expect($job->shouldNotify)->toBeTrue(); + expect($job->scanId)->toBeNull(); +}); + +it('can suppress immediate notifications for batched scans', function () { + $server = \Mockery::mock(Server::class)->makePartial(); + $scanId = 'scan-2026-03-29'; + $job = new CheckTraefikVersionForServerJob($server, $this->traefikVersions, false, $scanId); + + expect($job->shouldNotify)->toBeFalse(); + expect($job->scanId)->toBe($scanId); +}); + +it('rejects batched scans without a shared scan identifier', function () { + $server = \Mockery::mock(Server::class)->makePartial(); + + expect(fn () => new CheckTraefikVersionForServerJob($server, $this->traefikVersions, false)) + ->toThrow(InvalidArgumentException::class); +}); + +it('rejects batched scans with empty or whitespace scan identifiers', function () { + $server = \Mockery::mock(Server::class)->makePartial(); + + expect(fn () => new CheckTraefikVersionForServerJob($server, $this->traefikVersions, false, '')) + ->toThrow(InvalidArgumentException::class); + + expect(fn () => new CheckTraefikVersionForServerJob($server, $this->traefikVersions, false, " \n\t ")) + ->toThrow(InvalidArgumentException::class); +}); + +it('includes non-null scan ids in outdated info even when they are whitespace only', function () { + $scanId = " \n\t "; + $server = \Mockery::mock(Server::class)->makePartial(); + $teamRelation = \Mockery::mock(); + $team = \Mockery::mock(); + + $server->shouldReceive('update') + ->once() + ->with(\Mockery::on(function (array $payload) use ($scanId): bool { + $outdatedInfo = $payload['traefik_outdated_info'] ?? null; + + return is_array($outdatedInfo) + && $outdatedInfo['current'] === '3.5.0' + && $outdatedInfo['latest'] === '3.5.6' + && $outdatedInfo['type'] === 'patch_update' + && $outdatedInfo['scan_id'] === $scanId; + })); + $server->shouldReceive('team')->once()->andReturn($teamRelation); + $teamRelation->shouldReceive('first')->once()->andReturn($team); + $team->shouldReceive('notify')->once()->with(\Mockery::type(TraefikVersionOutdated::class)); + + $job = new CheckTraefikVersionForServerJob($server, $this->traefikVersions, true, $scanId); + + $method = new ReflectionMethod(CheckTraefikVersionForServerJob::class, 'storeOutdatedInfo'); + $method->setAccessible(true); + $method->invoke($job, '3.5.0', '3.5.6', 'patch_update', null, null); +}); + +it('sends an immediate notification when shouldNotify is enabled', function () { + $server = \Mockery::mock(Server::class)->makePartial(); + $teamRelation = \Mockery::mock(); + $team = \Mockery::mock(); + + $server->shouldReceive('update') + ->once() + ->with(\Mockery::on(function (array $payload): bool { + $outdatedInfo = $payload['traefik_outdated_info'] ?? null; + + return is_array($outdatedInfo) + && $outdatedInfo['current'] === '3.5.0' + && $outdatedInfo['latest'] === '3.5.6' + && $outdatedInfo['type'] === 'patch_update' + && ! array_key_exists('scan_id', $outdatedInfo); + })); + $server->shouldReceive('team')->once()->andReturn($teamRelation); + $teamRelation->shouldReceive('first')->once()->andReturn($team); + $team->shouldReceive('notify')->once()->with(\Mockery::on(function (TraefikVersionOutdated $notification) use ($server): bool { + return $notification->servers->count() === 1 + && $notification->servers->first() === $server + && ($notification->servers->first()->outdatedInfo['type'] ?? null) === 'patch_update'; + })); + + $job = new CheckTraefikVersionForServerJob($server, $this->traefikVersions); + + $method = new ReflectionMethod(CheckTraefikVersionForServerJob::class, 'storeOutdatedInfo'); + $method->setAccessible(true); + $method->invoke($job, '3.5.0', '3.5.6', 'patch_update', null, null); }); it('parses version strings correctly', function () { diff --git a/tests/Unit/NotifyOutdatedTraefikServersJobTest.php b/tests/Unit/NotifyOutdatedTraefikServersJobTest.php deleted file mode 100644 index 82edfb0d9c..0000000000 --- a/tests/Unit/NotifyOutdatedTraefikServersJobTest.php +++ /dev/null @@ -1,56 +0,0 @@ -tries)->toBe(3); -}); - -it('handles servers with null traefik_outdated_info gracefully', function () { - // Create a mock server with null traefik_outdated_info - $server = \Mockery::mock('App\Models\Server')->makePartial(); - $server->traefik_outdated_info = null; - - // Accessing the property should not throw an error - $result = $server->traefik_outdated_info; - - expect($result)->toBeNull(); -}); - -it('handles servers with traefik_outdated_info data', function () { - $expectedInfo = [ - 'current' => '3.5.0', - 'latest' => '3.6.2', - 'type' => 'minor_upgrade', - 'upgrade_target' => 'v3.6', - 'checked_at' => '2025-11-14T10:00:00Z', - ]; - - $server = \Mockery::mock('App\Models\Server')->makePartial(); - $server->traefik_outdated_info = $expectedInfo; - - // Should return the outdated info - $result = $server->traefik_outdated_info; - - expect($result)->toBe($expectedInfo); -}); - -it('handles servers with patch update info without upgrade_target', function () { - $expectedInfo = [ - 'current' => '3.5.0', - 'latest' => '3.5.2', - 'type' => 'patch_update', - 'checked_at' => '2025-11-14T10:00:00Z', - ]; - - $server = \Mockery::mock('App\Models\Server')->makePartial(); - $server->traefik_outdated_info = $expectedInfo; - - // Should return the outdated info without upgrade_target - $result = $server->traefik_outdated_info; - - expect($result)->toBe($expectedInfo); - expect($result)->not->toHaveKey('upgrade_target'); -});