| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Jobs;
- use App\Application\Jobs\TickJob;
- use App\Domain\Jobs\Job;
- use App\Domain\Jobs\JobContext;
- use App\Domain\Jobs\JobResult;
- use App\Domain\Jobs\JobStatus;
- use App\Domain\Time\FixedClock;
- use App\Infrastructure\Jobs\JobLockRepository;
- use App\Infrastructure\Jobs\JobRunner;
- use App\Infrastructure\Jobs\JobRunRepository;
- use App\Tests\Integration\Support\AppTestCase;
- use Monolog\Handler\NullHandler;
- use Monolog\Logger;
- /**
- * Tick must invoke only jobs whose interval has elapsed since their last
- * finished run. Two stub jobs with different intervals + a freshly-recorded
- * run for one of them validates the dispatcher's "is due?" logic.
- */
- final class TickJobTest extends AppTestCase
- {
- public function testInvokesOnlyDueJobs(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $logger = new Logger('test');
- $logger->pushHandler(new NullHandler());
- $hot = new StubJob('hot', 60); // due every minute
- $cold = new StubJob('cold', 3600); // due every hour
- $never = new StubJob('never-run', 60); // no prior run → always due
- // Pre-write `job_runs` rows: "hot" finished 30 seconds ago (NOT
- // due yet), "cold" finished 30 seconds ago (also not due).
- $this->db->insert('job_runs', [
- 'job_name' => 'hot',
- 'status' => 'success',
- 'items_processed' => 0,
- 'triggered_by' => 'manual',
- 'started_at' => $clock->now()->modify('-31 seconds')->format('Y-m-d H:i:s'),
- 'finished_at' => $clock->now()->modify('-30 seconds')->format('Y-m-d H:i:s'),
- ]);
- $this->db->insert('job_runs', [
- 'job_name' => 'cold',
- 'status' => 'success',
- 'items_processed' => 0,
- 'triggered_by' => 'manual',
- 'started_at' => $clock->now()->modify('-31 seconds')->format('Y-m-d H:i:s'),
- 'finished_at' => $clock->now()->modify('-30 seconds')->format('Y-m-d H:i:s'),
- ]);
- $runner = new JobRunner(
- new JobLockRepository($this->db),
- new JobRunRepository($this->db),
- $clock,
- $logger,
- );
- $tick = new TickJob(
- jobsResolver: static fn (): array => [
- 'hot' => $hot,
- 'cold' => $cold,
- 'never-run' => $never,
- ],
- runner: $runner,
- runs: new JobRunRepository($this->db),
- );
- $result = $tick->run(new JobContext($clock, $logger, []));
- // Only `never-run` should fire — the other two are within their
- // interval window.
- self::assertSame(1, $result->itemsProcessed);
- self::assertSame(['never-run'], $result->details['invoked']);
- self::assertFalse($hot->wasInvoked, 'hot must not have run');
- self::assertFalse($cold->wasInvoked, 'cold must not have run');
- self::assertTrue($never->wasInvoked, 'never-run should have run');
- }
- public function testDueAfterIntervalElapses(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $logger = new Logger('test');
- $logger->pushHandler(new NullHandler());
- $job = new StubJob('schedulable', 60);
- $this->db->insert('job_runs', [
- 'job_name' => 'schedulable',
- 'status' => 'success',
- 'items_processed' => 0,
- 'triggered_by' => 'schedule',
- 'started_at' => $clock->now()->modify('-2 minutes')->format('Y-m-d H:i:s'),
- 'finished_at' => $clock->now()->modify('-2 minutes')->format('Y-m-d H:i:s'),
- ]);
- $runner = new JobRunner(
- new JobLockRepository($this->db),
- new JobRunRepository($this->db),
- $clock,
- $logger,
- );
- $tick = new TickJob(
- jobsResolver: static fn (): array => ['schedulable' => $job],
- runner: $runner,
- runs: new JobRunRepository($this->db),
- );
- $result = $tick->run(new JobContext($clock, $logger, []));
- self::assertSame(1, $result->itemsProcessed);
- self::assertTrue($job->wasInvoked);
- }
- public function testFailingChildJobDoesNotAbortDispatcher(): void
- {
- $clock = FixedClock::at('2026-04-29T00:00:00Z');
- $logger = new Logger('test');
- $logger->pushHandler(new NullHandler());
- $broken = new StubJob('broken', 60, throw: true);
- $healthy = new StubJob('healthy', 60);
- $runner = new JobRunner(
- new JobLockRepository($this->db),
- new JobRunRepository($this->db),
- $clock,
- $logger,
- );
- $tick = new TickJob(
- jobsResolver: static fn (): array => ['broken' => $broken, 'healthy' => $healthy],
- runner: $runner,
- runs: new JobRunRepository($this->db),
- );
- $result = $tick->run(new JobContext($clock, $logger, []));
- // Both due (no prior runs); both invoked.
- self::assertSame(2, $result->itemsProcessed);
- // The broken child's row exists with failure status.
- $rows = $this->db->fetchAllAssociative('SELECT job_name, status FROM job_runs ORDER BY id');
- $brokenRow = array_values(array_filter(
- $rows,
- static fn (array $r): bool => $r['job_name'] === 'broken'
- ))[0];
- self::assertSame(JobStatus::Failure->value, $brokenRow['status']);
- }
- }
- final class StubJob implements Job
- {
- public bool $wasInvoked = false;
- public function __construct(
- private readonly string $name,
- private readonly int $interval,
- private readonly bool $throw = false,
- ) {
- }
- public function name(): string
- {
- return $this->name;
- }
- public function defaultIntervalSeconds(): int
- {
- return $this->interval;
- }
- public function maxRuntimeSeconds(): int
- {
- return 30;
- }
- public function run(JobContext $context): JobResult
- {
- $this->wasInvoked = true;
- if ($this->throw) {
- throw new \RuntimeException('stub failure');
- }
- return new JobResult(itemsProcessed: 1);
- }
- }
|