owner->clientSet($key, $value, $args); } public function pttl(string $key): int { return $this->owner->clientPttl($key); } } class GlobalShellyRateLimitRedisFake { /** @var array */ private array $store = []; /** @var array */ private array $expiresAt = []; private GlobalShellyRateLimitRedisClientFake $client; public function __construct(private readonly GlobalShellyRateLimitClock $clock) { $this->client = new GlobalShellyRateLimitRedisClientFake($this); } public function get_client(): GlobalShellyRateLimitRedisClientFake { return $this->client; } public function clientSet(string $key, string $value, array $args): bool|string { $this->purgeExpired($key); $useNx = false; $ttlMs = null; $count = count($args); for ($i = 0; $i < $count; $i++) { $token = strtoupper((string)$args[$i]); if ($token === 'NX') { $useNx = true; continue; } if ($token === 'PX' && isset($args[$i + 1])) { $ttlMs = max(1, (int)$args[$i + 1]); $i++; } } if ($useNx && array_key_exists($key, $this->store)) { return false; } $this->store[$key] = $value; if ($ttlMs === null) { unset($this->expiresAt[$key]); } else { $this->expiresAt[$key] = $this->clock->now + ($ttlMs / 1000); } return 'OK'; } public function clientPttl(string $key): int { $this->purgeExpired($key); if (!array_key_exists($key, $this->store)) { return -2; } if (!isset($this->expiresAt[$key])) { return -1; } $remainingMs = (int)ceil(($this->expiresAt[$key] - $this->clock->now) * 1000); return $remainingMs > 0 ? $remainingMs : 0; } private function purgeExpired(string $key): void { if (!isset($this->expiresAt[$key])) { return; } if ($this->clock->now < $this->expiresAt[$key]) { return; } unset($this->expiresAt[$key], $this->store[$key]); } } class GlobalShellyRateLimitHarness extends shelly { /** @var array */ public array $sleepCalls = []; public function __construct( private readonly GlobalShellyRateLimitClock $clock, private readonly ?GlobalShellyRateLimitRedisFake $redis = null ) { } public function waitForShellyRateLimitWindowForTest(): void { $invoke = \Closure::bind(function (): void { $this->waitForShellyRateLimitWindow(); }, $this, shelly::class); $invoke(); } protected function redisFacade(): mixed { return $this->redis; } protected function nowTimestamp(): float { return $this->clock->now; } protected function sleepMicroseconds(int $microseconds): void { if ($microseconds <= 0) { return; } $this->sleepCalls[] = $microseconds; $this->clock->now += ($microseconds / 1000000); } } it('enforces the 2 second Shelly gate across separate request contexts', function (): void { $clock = new GlobalShellyRateLimitClock(); $redis = new GlobalShellyRateLimitRedisFake($clock); $requestA = new GlobalShellyRateLimitHarness($clock, $redis); $requestB = new GlobalShellyRateLimitHarness($clock, $redis); $requestA->waitForShellyRateLimitWindowForTest(); $timeBeforeRequestB = $clock->now; $requestB->waitForShellyRateLimitWindowForTest(); expect(array_sum($requestA->sleepCalls))->toBe(0); expect(array_sum($requestB->sleepCalls))->toBeGreaterThanOrEqual(2000000); expect($clock->now - $timeBeforeRequestB)->toBeGreaterThanOrEqual(2.0); }); it('does not delay when the Shelly gate is already expired', function (): void { $clock = new GlobalShellyRateLimitClock(); $redis = new GlobalShellyRateLimitRedisFake($clock); $firstRequest = new GlobalShellyRateLimitHarness($clock, $redis); $firstRequest->waitForShellyRateLimitWindowForTest(); $clock->now += 2.1; $secondRequest = new GlobalShellyRateLimitHarness($clock, $redis); $secondRequest->waitForShellyRateLimitWindowForTest(); expect(array_sum($secondRequest->sleepCalls))->toBe(0); });