locks = new JobLockRepository($this->db); } public function testAcquireReleaseAcquireRoundTrip(): void { $now = new DateTimeImmutable('2026-04-29T00:00:00Z', new DateTimeZone('UTC')); $expires = $now->modify('+5 minutes'); self::assertTrue($this->locks->tryAcquire('demo', $now, $expires, 'A')); $this->locks->release('demo', 'A'); self::assertTrue($this->locks->tryAcquire('demo', $now, $expires, 'B')); } public function testSecondAcquireWhileHeldReturnsFalse(): void { $now = new DateTimeImmutable('2026-04-29T00:00:00Z', new DateTimeZone('UTC')); $expires = $now->modify('+5 minutes'); self::assertTrue($this->locks->tryAcquire('demo', $now, $expires, 'A')); self::assertFalse($this->locks->tryAcquire('demo', $now, $expires, 'B')); } public function testExpiredLockReclaimedOnNextAcquire(): void { $past = new DateTimeImmutable('2026-04-29T00:00:00Z', new DateTimeZone('UTC')); $expires = $past->modify('+5 minutes'); self::assertTrue($this->locks->tryAcquire('demo', $past, $expires, 'A')); // jump forward past expires_at — the next acquire must succeed // because the in-transaction delete sweeps the stale row first. $now = $past->modify('+1 hour'); self::assertTrue($this->locks->tryAcquire('demo', $now, $now->modify('+5 minutes'), 'B')); // The lock is now held by B. $status = $this->locks->status('demo'); self::assertNotNull($status); self::assertSame('B', $status['acquired_by']); } public function testReleaseWithDifferentOwnerIsNoOp(): void { $now = new DateTimeImmutable('2026-04-29T00:00:00Z', new DateTimeZone('UTC')); self::assertTrue($this->locks->tryAcquire('demo', $now, $now->modify('+5 minutes'), 'A')); // Defensive: B can't kick A out by releasing. $this->locks->release('demo', 'B'); self::assertFalse($this->locks->tryAcquire('demo', $now, $now->modify('+5 minutes'), 'C')); } }