JobLockRepositoryTest.php 2.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Jobs;
  4. use App\Infrastructure\Jobs\JobLockRepository;
  5. use App\Tests\Integration\Support\AppTestCase;
  6. use DateTimeImmutable;
  7. use DateTimeZone;
  8. /**
  9. * Lock semantics tested directly against SQLite. Two scenarios that matter
  10. * for SPEC §4 (`tryAcquire`):
  11. * 1. Two concurrent acquires → one wins, one returns false.
  12. * 2. An expired lock from a crashed prior owner is reclaimed on next
  13. * acquire (the delete-if-expired pre-step inside the transaction).
  14. */
  15. final class JobLockRepositoryTest extends AppTestCase
  16. {
  17. private JobLockRepository $locks;
  18. protected function setUp(): void
  19. {
  20. parent::setUp();
  21. $this->locks = new JobLockRepository($this->db);
  22. }
  23. public function testAcquireReleaseAcquireRoundTrip(): void
  24. {
  25. $now = new DateTimeImmutable('2026-04-29T00:00:00Z', new DateTimeZone('UTC'));
  26. $expires = $now->modify('+5 minutes');
  27. self::assertTrue($this->locks->tryAcquire('demo', $now, $expires, 'A'));
  28. $this->locks->release('demo', 'A');
  29. self::assertTrue($this->locks->tryAcquire('demo', $now, $expires, 'B'));
  30. }
  31. public function testSecondAcquireWhileHeldReturnsFalse(): void
  32. {
  33. $now = new DateTimeImmutable('2026-04-29T00:00:00Z', new DateTimeZone('UTC'));
  34. $expires = $now->modify('+5 minutes');
  35. self::assertTrue($this->locks->tryAcquire('demo', $now, $expires, 'A'));
  36. self::assertFalse($this->locks->tryAcquire('demo', $now, $expires, 'B'));
  37. }
  38. public function testExpiredLockReclaimedOnNextAcquire(): void
  39. {
  40. $past = new DateTimeImmutable('2026-04-29T00:00:00Z', new DateTimeZone('UTC'));
  41. $expires = $past->modify('+5 minutes');
  42. self::assertTrue($this->locks->tryAcquire('demo', $past, $expires, 'A'));
  43. // jump forward past expires_at — the next acquire must succeed
  44. // because the in-transaction delete sweeps the stale row first.
  45. $now = $past->modify('+1 hour');
  46. self::assertTrue($this->locks->tryAcquire('demo', $now, $now->modify('+5 minutes'), 'B'));
  47. // The lock is now held by B.
  48. $status = $this->locks->status('demo');
  49. self::assertNotNull($status);
  50. self::assertSame('B', $status['acquired_by']);
  51. }
  52. public function testReleaseWithDifferentOwnerIsNoOp(): void
  53. {
  54. $now = new DateTimeImmutable('2026-04-29T00:00:00Z', new DateTimeZone('UTC'));
  55. self::assertTrue($this->locks->tryAcquire('demo', $now, $now->modify('+5 minutes'), 'A'));
  56. // Defensive: B can't kick A out by releasing.
  57. $this->locks->release('demo', 'B');
  58. self::assertFalse($this->locks->tryAcquire('demo', $now, $now->modify('+5 minutes'), 'C'));
  59. }
  60. }