'array']); Cache::store('array')->forget(SqsDrainCommand::LOCK_KEY); } protected function tearDown() { Cache::store('array')->forget(SqsDrainCommand::LOCK_KEY); 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 = Carbon::create(2026, 1, 1, 12, 0, 0); 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) { 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." ); } $this->assertGreaterThan($heartbeat, $ttlSeconds, 'The TTL must outlast the refresh interval.'); } /** * 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. * * Asserted against the real cache store this app uses (file), by reading the * expiry stamp FileStore writes as the first 10 bytes of the payload. Clock * travel cannot be used here: Laravel 5.5's ArrayStore::put() discards the TTL * entirely (expiry support landed in a later release), so an array-store test * would pass regardless of what TTL the code requested. */ public function testLockCarriesTheExpectedTtlOnTheRealStore() { config(['cache.default' => 'file']); $store = Cache::store('file'); $store->forget($this->key()); $before = time(); $this->assertTrue($store->add($this->key(), true, SqsDrainCommand::LOCK_MINUTES)); $hash = sha1($this->key()); $parts = array_slice(str_split($hash, 2), 0, 2); $path = config('cache.stores.file.path').'/'.implode('/', $parts).'/'.$hash; $this->assertFileExists($path, 'The file store must have written the lock.'); $expiresAt = (int) substr(file_get_contents($path), 0, 10); $ttlSeconds = SqsDrainCommand::LOCK_MINUTES * 60; // The stored expiry must sit one TTL ahead of now, so an abandoned lock // lapses on its own rather than needing manual clearing. $this->assertGreaterThanOrEqual($before + $ttlSeconds, $expiresAt); $this->assertLessThanOrEqual($before + $ttlSeconds + 2, $expiresAt); $this->assertGreaterThanOrEqual(90, $expiresAt - $before, 'Requirement 4.3 floor.'); // A payload whose expiry has already passed must not be treated as held. $expired = (string) ($before - 1); file_put_contents($path, $expired.serialize(true)); $this->assertFalse($store->has($this->key()), 'An elapsed lock must read as free.'); $this->assertTrue( $store->add($this->key(), true, SqsDrainCommand::LOCK_MINUTES), 'A later trigger must be able to acquire once the lock has lapsed.' ); $store->forget($this->key()); config(['cache.default' => 'array']); } /** * 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 = Carbon::create(2026, 1, 1, 12, 0, 0); Carbon::setTestNow($start); Cache::add($this->key(), true, SqsDrainCommand::LOCK_MINUTES); // Drain finishes after 5s and releases in its finally block. 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 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.' ); } }