'array']); Cache::store('array')->forget(SqsDrainCommand::LOCK_KEY); } protected function tearDown() { Cache::store('array')->forget(SqsDrainCommand::LOCK_KEY); \Illuminate\Support\Carbon::setTestNow(); parent::tearDown(); } private function key() { return SqsDrainCommand::LOCK_KEY; } /** * Feature: async-sqs-job-trigger, Property 7: Exactly one drain launches per * lock window - the first add() succeeds, every later one fails while held. */ public function testOnlyTheFirstAcquisitionSucceedsWhileHeld() { $this->assertTrue(Cache::add($this->key(), true, SqsDrainCommand::LOCK_MINUTES)); for ($attempt = 0; $attempt < 50; $attempt++) { $this->assertFalse( Cache::add($this->key(), true, SqsDrainCommand::LOCK_MINUTES), 'No second acquisition may succeed while the lock is held.' ); } } /** * Feature: async-sqs-job-trigger, Property 8: A live drain holds the lock * indefinitely while heartbeats continue. * * Simulates a drain far longer than the TTL, refreshing on the command's * heartbeat interval, and asserts no trigger can acquire at any point. */ public function testHeartbeatHoldsTheLockAcrossALongDrain() { $start = \Illuminate\Support\Carbon::create(2026, 1, 1, 12, 0, 0); \Illuminate\Support\Carbon::setTestNow($start); $this->assertTrue(Cache::add($this->key(), true, SqsDrainCommand::LOCK_MINUTES)); $ttlSeconds = SqsDrainCommand::LOCK_MINUTES * 60; $heartbeat = SqsDrainCommand::HEARTBEAT_SECONDS; // Ten minutes of drain: well past the 120s TTL. for ($elapsed = $heartbeat; $elapsed <= 600; $elapsed += $heartbeat) { \Illuminate\Support\Carbon::setTestNow($start->copy()->addSeconds($elapsed)); // The drain refreshes on its heartbeat interval. Cache::put($this->key(), true, SqsDrainCommand::LOCK_MINUTES); $this->assertTrue(Cache::has($this->key()), 'A refreshed lock must stay held.'); $this->assertFalse( Cache::add($this->key(), true, SqsDrainCommand::LOCK_MINUTES), "A trigger must not acquire at {$elapsed}s into a live drain." ); } // The refresh interval must leave slack against the TTL. $this->assertGreaterThan($heartbeat, $ttlSeconds); } /** * Feature: async-sqs-job-trigger, Property 8: a dead drain releases the lock - * once heartbeats stop, it expires within the TTL with no manual action. */ public function testLockExpiresAfterTheDrainDies() { $start = \Illuminate\Support\Carbon::create(2026, 1, 1, 12, 0, 0); \Illuminate\Support\Carbon::setTestNow($start); $this->assertTrue(Cache::add($this->key(), true, SqsDrainCommand::LOCK_MINUTES)); $ttlSeconds = SqsDrainCommand::LOCK_MINUTES * 60; // Still held one second before expiry. \Illuminate\Support\Carbon::setTestNow($start->copy()->addSeconds($ttlSeconds - 1)); $this->assertTrue(Cache::has($this->key())); $this->assertFalse(Cache::add($this->key(), true, SqsDrainCommand::LOCK_MINUTES)); // Free once the TTL has elapsed, with no intervention. \Illuminate\Support\Carbon::setTestNow($start->copy()->addSeconds($ttlSeconds + 1)); $this->assertFalse(Cache::has($this->key()), 'The lock must lapse after its TTL.'); $this->assertTrue( Cache::add($this->key(), true, SqsDrainCommand::LOCK_MINUTES), 'A later trigger must be able to acquire after expiry.' ); } /** * Feature: async-sqs-job-trigger, Property 9: the lock is released explicitly * when the drain ends, so the next trigger launches immediately rather than * waiting out the TTL. */ public function testExplicitReleaseFreesTheLockImmediately() { $start = \Illuminate\Support\Carbon::create(2026, 1, 1, 12, 0, 0); \Illuminate\Support\Carbon::setTestNow($start); Cache::add($this->key(), true, SqsDrainCommand::LOCK_MINUTES); // Drain finishes after 5s and releases in its finally block. \Illuminate\Support\Carbon::setTestNow($start->copy()->addSeconds(5)); Cache::forget($this->key()); $this->assertFalse(Cache::has($this->key())); $this->assertTrue( Cache::add($this->key(), true, SqsDrainCommand::LOCK_MINUTES), 'After an explicit release the next trigger must not wait for the TTL.' ); } /** * Requirement 4.3: the TTL exceeds the heartbeat interval by at least the * margin the design claims, so several heartbeats must be missed before a live * drain loses its lock. */ public function testTtlLeavesTheDocumentedMarginOverTheHeartbeat() { $ttlSeconds = SqsDrainCommand::LOCK_MINUTES * 60; $this->assertSame(120, $ttlSeconds); $this->assertSame(30, SqsDrainCommand::HEARTBEAT_SECONDS); $this->assertGreaterThanOrEqual(90, $ttlSeconds, 'Requirement 4.3 floor.'); $this->assertGreaterThanOrEqual( 3, (int) floor($ttlSeconds / SqsDrainCommand::HEARTBEAT_SECONDS), 'At least three heartbeats must be missed before the lock lapses.' ); } }