TickJobTest.php 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Jobs;
  4. use App\Application\Jobs\TickJob;
  5. use App\Domain\Jobs\Job;
  6. use App\Domain\Jobs\JobContext;
  7. use App\Domain\Jobs\JobResult;
  8. use App\Domain\Jobs\JobStatus;
  9. use App\Domain\Time\FixedClock;
  10. use App\Infrastructure\Jobs\JobLockRepository;
  11. use App\Infrastructure\Jobs\JobRunner;
  12. use App\Infrastructure\Jobs\JobRunRepository;
  13. use App\Tests\Integration\Support\AppTestCase;
  14. use Monolog\Handler\NullHandler;
  15. use Monolog\Logger;
  16. /**
  17. * Tick must invoke only jobs whose interval has elapsed since their last
  18. * finished run. Two stub jobs with different intervals + a freshly-recorded
  19. * run for one of them validates the dispatcher's "is due?" logic.
  20. */
  21. final class TickJobTest extends AppTestCase
  22. {
  23. public function testInvokesOnlyDueJobs(): void
  24. {
  25. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  26. $logger = new Logger('test');
  27. $logger->pushHandler(new NullHandler());
  28. $hot = new StubJob('hot', 60); // due every minute
  29. $cold = new StubJob('cold', 3600); // due every hour
  30. $never = new StubJob('never-run', 60); // no prior run → always due
  31. // Pre-write `job_runs` rows: "hot" finished 30 seconds ago (NOT
  32. // due yet), "cold" finished 30 seconds ago (also not due).
  33. $this->db->insert('job_runs', [
  34. 'job_name' => 'hot',
  35. 'status' => 'success',
  36. 'items_processed' => 0,
  37. 'triggered_by' => 'manual',
  38. 'started_at' => $clock->now()->modify('-31 seconds')->format('Y-m-d H:i:s'),
  39. 'finished_at' => $clock->now()->modify('-30 seconds')->format('Y-m-d H:i:s'),
  40. ]);
  41. $this->db->insert('job_runs', [
  42. 'job_name' => 'cold',
  43. 'status' => 'success',
  44. 'items_processed' => 0,
  45. 'triggered_by' => 'manual',
  46. 'started_at' => $clock->now()->modify('-31 seconds')->format('Y-m-d H:i:s'),
  47. 'finished_at' => $clock->now()->modify('-30 seconds')->format('Y-m-d H:i:s'),
  48. ]);
  49. $runner = new JobRunner(
  50. new JobLockRepository($this->db),
  51. new JobRunRepository($this->db),
  52. $clock,
  53. $logger,
  54. );
  55. $tick = new TickJob(
  56. jobsResolver: static fn (): array => [
  57. 'hot' => $hot,
  58. 'cold' => $cold,
  59. 'never-run' => $never,
  60. ],
  61. runner: $runner,
  62. runs: new JobRunRepository($this->db),
  63. );
  64. $result = $tick->run(new JobContext($clock, $logger, []));
  65. // Only `never-run` should fire — the other two are within their
  66. // interval window.
  67. self::assertSame(1, $result->itemsProcessed);
  68. self::assertSame(['never-run'], $result->details['invoked']);
  69. self::assertFalse($hot->wasInvoked, 'hot must not have run');
  70. self::assertFalse($cold->wasInvoked, 'cold must not have run');
  71. self::assertTrue($never->wasInvoked, 'never-run should have run');
  72. }
  73. public function testDueAfterIntervalElapses(): void
  74. {
  75. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  76. $logger = new Logger('test');
  77. $logger->pushHandler(new NullHandler());
  78. $job = new StubJob('schedulable', 60);
  79. $this->db->insert('job_runs', [
  80. 'job_name' => 'schedulable',
  81. 'status' => 'success',
  82. 'items_processed' => 0,
  83. 'triggered_by' => 'schedule',
  84. 'started_at' => $clock->now()->modify('-2 minutes')->format('Y-m-d H:i:s'),
  85. 'finished_at' => $clock->now()->modify('-2 minutes')->format('Y-m-d H:i:s'),
  86. ]);
  87. $runner = new JobRunner(
  88. new JobLockRepository($this->db),
  89. new JobRunRepository($this->db),
  90. $clock,
  91. $logger,
  92. );
  93. $tick = new TickJob(
  94. jobsResolver: static fn (): array => ['schedulable' => $job],
  95. runner: $runner,
  96. runs: new JobRunRepository($this->db),
  97. );
  98. $result = $tick->run(new JobContext($clock, $logger, []));
  99. self::assertSame(1, $result->itemsProcessed);
  100. self::assertTrue($job->wasInvoked);
  101. }
  102. public function testFailingChildJobDoesNotAbortDispatcher(): void
  103. {
  104. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  105. $logger = new Logger('test');
  106. $logger->pushHandler(new NullHandler());
  107. $broken = new StubJob('broken', 60, throw: true);
  108. $healthy = new StubJob('healthy', 60);
  109. $runner = new JobRunner(
  110. new JobLockRepository($this->db),
  111. new JobRunRepository($this->db),
  112. $clock,
  113. $logger,
  114. );
  115. $tick = new TickJob(
  116. jobsResolver: static fn (): array => ['broken' => $broken, 'healthy' => $healthy],
  117. runner: $runner,
  118. runs: new JobRunRepository($this->db),
  119. );
  120. $result = $tick->run(new JobContext($clock, $logger, []));
  121. // Both due (no prior runs); both invoked.
  122. self::assertSame(2, $result->itemsProcessed);
  123. // The broken child's row exists with failure status.
  124. $rows = $this->db->fetchAllAssociative('SELECT job_name, status FROM job_runs ORDER BY id');
  125. $brokenRow = array_values(array_filter(
  126. $rows,
  127. static fn (array $r): bool => $r['job_name'] === 'broken'
  128. ))[0];
  129. self::assertSame(JobStatus::Failure->value, $brokenRow['status']);
  130. }
  131. }
  132. final class StubJob implements Job
  133. {
  134. public bool $wasInvoked = false;
  135. public function __construct(
  136. private readonly string $name,
  137. private readonly int $interval,
  138. private readonly bool $throw = false,
  139. ) {
  140. }
  141. public function name(): string
  142. {
  143. return $this->name;
  144. }
  145. public function defaultIntervalSeconds(): int
  146. {
  147. return $this->interval;
  148. }
  149. public function maxRuntimeSeconds(): int
  150. {
  151. return 30;
  152. }
  153. public function run(JobContext $context): JobResult
  154. {
  155. $this->wasInvoked = true;
  156. if ($this->throw) {
  157. throw new \RuntimeException('stub failure');
  158. }
  159. return new JobResult(itemsProcessed: 1);
  160. }
  161. }