tmpDir = sys_get_temp_dir() . '/xamxam_ratelimit_test_' . uniqid(); mkdir($this->tmpDir, 0755, true); } protected function tearDown(): void { $files = glob($this->tmpDir . '/*.json'); foreach ($files as $file) { unlink($file); } rmdir($this->tmpDir); } private function newRateLimit(int $max = 5, int $window = 60): RateLimit { return new RateLimit($max, $window, $this->tmpDir); } // ── checkKey: per-key limits, not global ────────────────────────────────── public function testCheckKeyCountsPerKey(): void { $rl = $this->newRateLimit(2, 60); $this->assertTrue($rl->checkKey('key-a')); $this->assertTrue($rl->checkKey('key-b')); $this->assertTrue($rl->checkKey('key-a')); // second hit for key-a, still allowed // key-a is now at limit (2) $this->assertFalse($rl->checkKey('key-a')); // key-b still has room $this->assertTrue($rl->checkKey('key-b')); $this->assertFalse($rl->checkKey('key-b')); } public function testCheckKeyDoesNotAffectDefaultCheck(): void { $rl = $this->newRateLimit(3, 60); $rl->checkKey('separate-key'); $rl->checkKey('separate-key'); $rl->checkKey('separate-key'); $rl->checkKey('separate-key'); // exhausted for this key // Default check (uses REMOTE_ADDR) should be unaffected by key-based tracking $ipKey = md5($_SERVER['REMOTE_ADDR'] ?? 'unknown'); $ipFile = $this->tmpDir . '/' . $ipKey . '.json'; $this->assertFileDoesNotExist($ipFile); } // ── getRemaining: tied to client IP (REMOTE_ADDR) ───────────────────────── public function testGetRemainingStartsAtMax(): void { $rl = $this->newRateLimit(5, 60); $remaining = $rl->getRemaining(); $this->assertSame(5, $remaining); } public function testCheckDecrementsRemainingForSameIp(): void { $rl = $this->newRateLimit(5, 60); // check() uses IP-based identifier. We need to use check() directly, // not checkKey(), for getRemaining() to reflect usage. $rl->check(); // hit 1 $this->assertSame(4, $rl->getRemaining()); $rl->check(); // hit 2 $this->assertSame(3, $rl->getRemaining()); } public function testCheckAndCheckKeyAreIndependent(): void { $rl = $this->newRateLimit(5, 60); // checkKey hits don't affect IP-based remaining $rl->checkKey('some-key'); $rl->checkKey('some-key'); $this->assertSame(5, $rl->getRemaining()); } // ── Consistent client identifier ───────────────────────────────────────── public function testMultipleChecksFromSameClient(): void { $rl = $this->newRateLimit(1, 60); // First check passes, second from same IP fails $this->assertTrue($rl->check()); $this->assertFalse($rl->check()); } public function testGetRemainingReturnsZeroAfterExhaustion(): void { $rl = $this->newRateLimit(1, 60); $rl->check(); $this->assertSame(0, $rl->getRemaining()); } // ── reset time ──────────────────────────────────────────────────────────── public function testGetResetTimeReturnsZeroWhenNoData(): void { $rl = $this->newRateLimit(5, 60); $this->assertSame(0, $rl->getResetTime()); } public function testGetResetTimePositiveAfterHits(): void { $rl = $this->newRateLimit(5, 60); $rl->check(); // use IP-based check so file is written $reset = $rl->getResetTime(); $this->assertGreaterThan(0, $reset); $this->assertLessThanOrEqual(60, $reset); } // ── cleanup ─────────────────────────────────────────────────────────────── public function testCleanupRemovesOldFiles(): void { $rl = $this->newRateLimit(5, 60); $rl->checkKey('cleanup-test'); // Touch the cache file to make it old $files = glob($this->tmpDir . '/*.json'); $this->assertNotEmpty($files); foreach ($files as $file) { touch($file, time() - 90000); // 25 hours ago } $rl->cleanup(); // The old file should now be gone $filesAfter = glob($this->tmpDir . '/*.json'); $this->assertEmpty($filesAfter); } }