File: /home/xedaptot/be.naniguide.com/tests/Unit/RollingRateTrackerTest.php
<?php
use App\Library\Exception\RateLimitExceeded;
use App\Library\RateLimit;
use App\Library\RollingRateTracker;
use Carbon\Carbon;
test('Rolling rate tracker counts and rollbacks by minute', function () {
$file = '/tmp/test-rolling-rate-'.uniqid();
$tracker = new RollingRateTracker($file);
$now = Carbon::create(2026, 4, 21, 12, 30, 0);
$oneMinuteAgo = $now->copy()->subMinute();
$tracker->count($oneMinuteAgo);
$tracker->count($oneMinuteAgo);
$tracker->count($now);
$tracker->count($now);
expect($tracker->getCreditsUsed($oneMinuteAgo, $now))->toBe(4);
expect(count($tracker->getRecords($oneMinuteAgo, $now)))->toBe(2);
$tracker->rollback();
expect($tracker->getCreditsUsed($oneMinuteAgo, $now))->toBe(3);
$tracker->rollback();
expect($tracker->getCreditsUsed($oneMinuteAgo, $now))->toBe(2);
expect(count($tracker->getRecords($oneMinuteAgo, $now)))->toBe(1);
});
test('Rolling rate tracker cleanup keeps recent minutes only', function () {
$file = '/tmp/test-rolling-rate-'.uniqid();
$tracker = new RollingRateTracker($file);
$now = Carbon::create(2026, 4, 21, 12, 30, 0);
$twoMinutesAgo = $now->copy()->subMinutes(2);
$oneMinuteAgo = $now->copy()->subMinute();
Carbon::setTestNow($now);
$tracker->count($twoMinutesAgo);
$tracker->count($twoMinutesAgo);
$tracker->count($oneMinuteAgo);
$tracker->count($oneMinuteAgo);
$tracker->count($oneMinuteAgo);
$tracker->count($now);
expect($tracker->getCreditsUsed())->toBe(6);
$tracker->cleanup('2 minute');
expect($tracker->getCreditsUsed())->toBe(6);
$tracker->cleanup('1 minute');
expect($tracker->getCreditsUsed())->toBe(4);
Carbon::setTestNow();
});
test('Rolling rate tracker enforces minute limits', function () {
$file = '/tmp/test-rolling-rate-'.uniqid();
$tracker = new RollingRateTracker($file, [
new RateLimit(2, 1, 'minute', '2 per minute'),
]);
$now = Carbon::create(2026, 4, 21, 12, 30, 0);
$tracker->count($now);
$tracker->count($now);
$this->expectException(RateLimitExceeded::class);
$tracker->count($now);
});
test('Rolling rate tracker enforces hour limits across multiple minutes', function () {
$file = '/tmp/test-rolling-rate-'.uniqid();
$tracker = new RollingRateTracker($file, [
new RateLimit(3, 1, 'hour', '3 per hour'),
]);
$now = Carbon::create(2026, 4, 21, 12, 30, 0);
$tracker->count($now->copy()->subMinutes(50));
$tracker->count($now->copy()->subMinutes(30));
$tracker->count($now);
$this->expectException(RateLimitExceeded::class);
$tracker->count($now);
});
test('Rolling rate tracker enforces day limits across multiple hours', function () {
$file = '/tmp/test-rolling-rate-'.uniqid();
$tracker = new RollingRateTracker($file, [
new RateLimit(3, 1, 'day', '3 per day'),
]);
$now = Carbon::create(2026, 4, 21, 12, 30, 0);
$tracker->count($now->copy()->subHours(23));
$tracker->count($now->copy()->subHours(10));
$tracker->count($now);
$this->expectException(RateLimitExceeded::class);
$tracker->count($now);
});
test('Rolling rate tracker test checks limits without mutating state', function () {
$file = '/tmp/test-rolling-rate-'.uniqid();
$tracker = new RollingRateTracker($file, [
new RateLimit(5, 1, 'minute', '5 per minute'),
]);
$now = Carbon::create(2026, 4, 21, 12, 30, 0);
$tracker->count($now);
$tracker->count($now);
$tracker->count($now);
$before = file_get_contents($tracker->getLockFilePath());
$tracker->test($now);
expect(file_get_contents($tracker->getLockFilePath()))->toBe($before);
expect($tracker->getCreditsUsed($now, $now))->toBe(3);
});
test('Rolling rate tracker rejects unsupported limits', function () {
$file = '/tmp/test-rolling-rate-'.uniqid();
$this->expectException(Exception::class);
new RollingRateTracker($file, [
new RateLimit(10, 1, 'month', '10 per month'),
]);
});
test('Rolling rate tracker prunes old buckets during count when retention is configured', function () {
$file = '/tmp/test-rolling-rate-'.uniqid();
$tracker = new RollingRateTracker($file, [], [
'retention_period' => '2 minute',
]);
$now = Carbon::create(2026, 4, 21, 12, 30, 0);
$tracker->count($now->copy()->subMinutes(3));
$tracker->count($now->copy()->subMinute());
$tracker->count($now);
$records = $tracker->getRecords($now->copy()->subMinutes(2), $now);
expect($records)->toBe([
['202604211229', 1],
['202604211230', 1],
]);
});
test('Rolling rate tracker rejects retention shorter than the configured limit window', function () {
$file = '/tmp/test-rolling-rate-'.uniqid();
$this->expectException(Exception::class);
$this->expectExceptionMessage('retention_period "1 hour" is shorter than the configured rate limit window "1 day"');
new RollingRateTracker($file, [
new RateLimit(100, 1, 'day', '100 per day'),
], [
'retention_period' => '1 hour',
]);
});
test('Rolling rate tracker rejects queries older than the retained data window', function () {
$file = '/tmp/test-rolling-rate-'.uniqid();
$tracker = new RollingRateTracker($file, [], [
'retention_period' => '2 minute',
]);
$now = Carbon::create(2026, 4, 21, 12, 30, 0);
$tracker->count($now->copy()->subMinutes(3));
$tracker->count($now->copy()->subMinute());
$tracker->count($now);
$this->expectException(Exception::class);
$this->expectExceptionMessage('query start "202604211227" is older than the retained data window');
$tracker->getCreditsUsed($now->copy()->subMinutes(3), $now);
});