import { describe, test, expect } from 'bun:test'; import { FsRateLimitError, QuotaExceededError, RateLimiter, } from '../../src/index.js'; describe('RateLimiter — op bucket', () => { test('allows up to capacity then rejects', () => { const rl = new RateLimiter({ maxOpsPerMinutePerSender: 5, opCost: { default: 1 } }); for (let i = 0; i < 5; i++) rl.acquire('alice', 'list'); expect(() => rl.acquire('alice', 'list')).toThrow(FsRateLimitError); }); test('rejection includes retryAfterMs', () => { const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1, opCost: { default: 1 } }); rl.acquire('alice', 'list'); try { rl.acquire('alice', 'list'); throw new Error('expected throw'); } catch (err) { expect(err).toBeInstanceOf(FsRateLimitError); const payload = (err as FsRateLimitError).payload; expect(payload.retryAfterMs).toBeGreaterThan(0); } }); test('different op costs respected', () => { const rl = new RateLimiter({ maxOpsPerMinutePerSender: 10, opCost: { write: 5, default: 1 }, }); rl.acquire('alice', 'write'); // 10 - 5 = 5 left rl.acquire('alice', 'write'); // 5 - 5 = 0 left expect(() => rl.acquire('alice', 'write')).toThrow(FsRateLimitError); }); test('per-sender isolation', () => { const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1 }); rl.acquire('alice', 'list'); rl.acquire('bob', 'list'); // bob's bucket independent expect(() => rl.acquire('alice', 'list')).toThrow(FsRateLimitError); expect(() => rl.acquire('bob', 'list')).toThrow(FsRateLimitError); }); test('release returns tokens', () => { const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1 }); rl.acquire('alice', 'list'); rl.release('alice', 'list'); rl.acquire('alice', 'list'); // should succeed again }); }); describe('RateLimiter — byte bucket', () => { test('quota exceeded triggers QuotaExceededError', () => { const rl = new RateLimiter({ maxBytesPerHourPerSender: 1000 }); rl.acquire('alice', 'write', 600); rl.acquire('alice', 'write', 400); expect(() => rl.acquire('alice', 'write', 1)).toThrow(QuotaExceededError); }); test('reconcile returns over-reserved bytes', () => { const rl = new RateLimiter({ maxBytesPerHourPerSender: 1000 }); rl.acquire('alice', 'write', 800); rl.reconcile('alice', 800, 200); // we only used 200 of the 800 reserved rl.acquire('alice', 'write', 600); // capacity now 400 + 600 = 1000, fits 600 }); test('release returns reserved bytes', () => { const rl = new RateLimiter({ maxBytesPerHourPerSender: 100 }); rl.acquire('alice', 'read', 80); rl.release('alice', 'read', 80); rl.acquire('alice', 'read', 80); }); }); describe('RateLimiter — refill', () => { test('tokens refill over time', async () => { const rl = new RateLimiter({ // 6000/min = 100/sec = 0.1/ms; in 50ms we refill 5 maxOpsPerMinutePerSender: 6000, opCost: { default: 1 }, }); // Drain by 5 for (let i = 0; i < 5; i++) rl.acquire('alice', 'list'); const before = rl.snapshot('alice')!.ops; await new Promise((r) => setTimeout(r, 50)); const after = rl.snapshot('alice')!.ops; expect(after).toBeGreaterThan(before); }); });