- Refactor shouldRunNow() to only fire on first run (empty cache) if actually due by cron schedule, preventing spurious executions after cache loss or service restart - Add enrichSkipLogsWithLinks() method to fetch and populate resource names and links for tasks, backups, and docker cleanup jobs in skip logs - Update skip logs UI to display resource column with links to related resources, improving navigation and context - Add fallback display when linked resources are deleted - Expand tests to cover both restart scenarios: non-due jobs (should not fire) and due jobs (should fire)
222 lines
8.4 KiB
PHP
222 lines
8.4 KiB
PHP
<?php
|
|
|
|
use App\Jobs\ScheduledJobManager;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
beforeEach(function () {
|
|
// Clear any dedup keys
|
|
Cache::flush();
|
|
});
|
|
|
|
it('dispatches backup when job runs on time at the cron minute', function () {
|
|
// Freeze time at exactly 02:00 — daily cron "0 2 * * *" is due
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
|
|
|
$job = new ScheduledJobManager;
|
|
|
|
// Use reflection to test shouldRunNow
|
|
$reflection = new ReflectionClass($job);
|
|
|
|
$executionTimeProp = $reflection->getProperty('executionTime');
|
|
$executionTimeProp->setAccessible(true);
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
|
|
$method = $reflection->getMethod('shouldRunNow');
|
|
$method->setAccessible(true);
|
|
|
|
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
it('catches delayed job when cache has a baseline from previous run', function () {
|
|
// Simulate a previous dispatch yesterday at 02:00
|
|
Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
|
|
|
|
// Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC'));
|
|
|
|
$job = new ScheduledJobManager;
|
|
|
|
$reflection = new ReflectionClass($job);
|
|
|
|
$executionTimeProp = $reflection->getProperty('executionTime');
|
|
$executionTimeProp->setAccessible(true);
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
|
|
$method = $reflection->getMethod('shouldRunNow');
|
|
$method->setAccessible(true);
|
|
|
|
// isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today
|
|
// lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires
|
|
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
it('does not double-dispatch on subsequent runs within same cron window', function () {
|
|
// First run at 02:00 — dispatches and sets cache
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
|
|
|
$job = new ScheduledJobManager;
|
|
$reflection = new ReflectionClass($job);
|
|
|
|
$executionTimeProp = $reflection->getProperty('executionTime');
|
|
$executionTimeProp->setAccessible(true);
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
|
|
$method = $reflection->getMethod('shouldRunNow');
|
|
$method->setAccessible(true);
|
|
|
|
$first = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
|
|
expect($first)->toBeTrue();
|
|
|
|
// Second run at 02:01 — should NOT dispatch (previousDue=02:00, lastDispatched=02:00)
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
|
|
$second = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:2');
|
|
expect($second)->toBeFalse();
|
|
});
|
|
|
|
it('fires every_minute cron correctly on consecutive minutes', function () {
|
|
$job = new ScheduledJobManager;
|
|
$reflection = new ReflectionClass($job);
|
|
|
|
$executionTimeProp = $reflection->getProperty('executionTime');
|
|
$executionTimeProp->setAccessible(true);
|
|
|
|
$method = $reflection->getMethod('shouldRunNow');
|
|
$method->setAccessible(true);
|
|
|
|
// Minute 1
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
$result1 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
|
|
expect($result1)->toBeTrue();
|
|
|
|
// Minute 2
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 1, 0, 'UTC'));
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
$result2 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
|
|
expect($result2)->toBeTrue();
|
|
|
|
// Minute 3
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 2, 0, 'UTC'));
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
$result3 = $method->invoke($job, '* * * * *', 'UTC', 'test-backup:3');
|
|
expect($result3)->toBeTrue();
|
|
});
|
|
|
|
it('does not fire non-due jobs on restart when cache is empty', function () {
|
|
// Time is 10:00, cron is daily at 02:00 — NOT due right now
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
|
|
|
|
$job = new ScheduledJobManager;
|
|
$reflection = new ReflectionClass($job);
|
|
|
|
$executionTimeProp = $reflection->getProperty('executionTime');
|
|
$executionTimeProp->setAccessible(true);
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
|
|
$method = $reflection->getMethod('shouldRunNow');
|
|
$method->setAccessible(true);
|
|
|
|
// Cache is empty (fresh restart) — should NOT fire daily backup at 10:00
|
|
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
|
|
expect($result)->toBeFalse();
|
|
});
|
|
|
|
it('fires due jobs on restart when cache is empty', function () {
|
|
// Time is exactly 02:00, cron is daily at 02:00 — IS due right now
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
|
|
|
$job = new ScheduledJobManager;
|
|
$reflection = new ReflectionClass($job);
|
|
|
|
$executionTimeProp = $reflection->getProperty('executionTime');
|
|
$executionTimeProp->setAccessible(true);
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
|
|
$method = $reflection->getMethod('shouldRunNow');
|
|
$method->setAccessible(true);
|
|
|
|
// Cache is empty (fresh restart) — but cron IS due → should fire
|
|
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b');
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
it('does not dispatch when cron is not due and was not recently due', function () {
|
|
// Time is 10:00, cron is daily at 02:00 — last due was 8 hours ago
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
|
|
|
|
$job = new ScheduledJobManager;
|
|
$reflection = new ReflectionClass($job);
|
|
|
|
$executionTimeProp = $reflection->getProperty('executionTime');
|
|
$executionTimeProp->setAccessible(true);
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
|
|
$method = $reflection->getMethod('shouldRunNow');
|
|
$method->setAccessible(true);
|
|
|
|
// previousDue = 02:00, but lastDispatched was set at 02:00 (simulate)
|
|
Cache::put('test-backup:5', Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
|
|
|
|
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:5');
|
|
expect($result)->toBeFalse();
|
|
});
|
|
|
|
it('falls back to isDue when no dedup key is provided', function () {
|
|
// Time is exactly 02:00, cron is "0 2 * * *" — should be due
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
|
|
|
|
$job = new ScheduledJobManager;
|
|
$reflection = new ReflectionClass($job);
|
|
|
|
$executionTimeProp = $reflection->getProperty('executionTime');
|
|
$executionTimeProp->setAccessible(true);
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
|
|
$method = $reflection->getMethod('shouldRunNow');
|
|
$method->setAccessible(true);
|
|
|
|
// No dedup key → simple isDue check
|
|
$result = $method->invoke($job, '0 2 * * *', 'UTC');
|
|
expect($result)->toBeTrue();
|
|
|
|
// At 02:01 without dedup key → isDue returns false
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
|
|
$result2 = $method->invoke($job, '0 2 * * *', 'UTC');
|
|
expect($result2)->toBeFalse();
|
|
});
|
|
|
|
it('respects server timezone for cron evaluation', function () {
|
|
// UTC time is 22:00 Feb 28, which is 06:00 Mar 1 in Asia/Singapore (+8)
|
|
Carbon::setTestNow(Carbon::create(2026, 2, 28, 22, 0, 0, 'UTC'));
|
|
|
|
$job = new ScheduledJobManager;
|
|
$reflection = new ReflectionClass($job);
|
|
|
|
$executionTimeProp = $reflection->getProperty('executionTime');
|
|
$executionTimeProp->setAccessible(true);
|
|
$executionTimeProp->setValue($job, Carbon::now());
|
|
|
|
$method = $reflection->getMethod('shouldRunNow');
|
|
$method->setAccessible(true);
|
|
|
|
// Simulate that today's 06:00 UTC run was already dispatched (at 06:00 UTC)
|
|
Cache::put('test-backup:7', Carbon::create(2026, 2, 28, 6, 0, 0, 'UTC')->toIso8601String(), 86400);
|
|
|
|
// Cron "0 6 * * *" in Asia/Singapore: local time is 06:00 Mar 1 → previousDue = 06:00 Mar 1 SGT
|
|
// That's a NEW cron window (Mar 1) that hasn't been dispatched → should fire
|
|
$resultSingapore = $method->invoke($job, '0 6 * * *', 'Asia/Singapore', 'test-backup:6');
|
|
expect($resultSingapore)->toBeTrue();
|
|
|
|
// Cron "0 6 * * *" in UTC: previousDue = 06:00 Feb 28 UTC, already dispatched at 06:00 → should NOT fire
|
|
$resultUtc = $method->invoke($job, '0 6 * * *', 'UTC', 'test-backup:7');
|
|
expect($resultUtc)->toBeFalse();
|
|
});
|