import { describe, it, expect, vi, beforeEach } from 'vitest'; import RedisMock from 'ioredis-mock'; const mocks = vi.hoisted(() => ({ redis: null as any })); vi.mock('../redis.js', () => ({ isRedisEnabled: () => true, getRedis: () => mocks.redis, })); import { MemoryRateLimiter, RedisRateLimiter } from './rateLimiter.js'; describe('MemoryRateLimiter', () => { it('allows up to max within the window, then blocks with retryAfter', async () => { const limiter = new MemoryRateLimiter(); for (let i = 0; i < 3; i++) { expect((await limiter.consume('k', 3, 60_000)).allowed).toBe(true); } const blocked = await limiter.consume('k', 3, 60_000); expect(blocked.allowed).toBe(false); expect(blocked.retryAfter).toBeGreaterThan(0); expect(blocked.retryAfter).toBeLessThanOrEqual(60); }); it('resets after the window elapses', async () => { vi.useFakeTimers(); try { const limiter = new MemoryRateLimiter(); expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true); expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(false); vi.advanceTimersByTime(1_500); expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true); } finally { vi.useRealTimers(); } }); }); describe('RedisRateLimiter', () => { beforeEach(async () => { mocks.redis = new RedisMock(); // ioredis-mock shares data between instances by connection string. await mocks.redis.flushall(); }); it('sets the window TTL atomically on the first hit', async () => { const limiter = new RedisRateLimiter(); expect((await limiter.consume('k', 5, 60_000)).allowed).toBe(true); const ttl = await mocks.redis.pttl('rl:k'); expect(ttl).toBeGreaterThan(0); expect(ttl).toBeLessThanOrEqual(60_000); }); it('blocks over the limit with a sane retryAfter', async () => { const limiter = new RedisRateLimiter(); for (let i = 0; i < 2; i++) { expect((await limiter.consume('k', 2, 60_000)).allowed).toBe(true); } const blocked = await limiter.consume('k', 2, 60_000); expect(blocked.allowed).toBe(false); expect(blocked.retryAfter).toBeGreaterThan(0); expect(blocked.retryAfter).toBeLessThanOrEqual(60); }); it('self-heals a counter stranded without a TTL', async () => { await mocks.redis.set('rl:k', '3'); expect(await mocks.redis.pttl('rl:k')).toBeLessThan(0); const limiter = new RedisRateLimiter(); await limiter.consume('k', 10, 60_000); expect(await mocks.redis.pttl('rl:k')).toBeGreaterThan(0); }); it('fails open when the backend errors', async () => { mocks.redis = { rlConsume: () => Promise.reject(new Error('connection refused')) }; const limiter = new RedisRateLimiter(); expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true); }); it('fails open when the client is not initialized', async () => { mocks.redis = null; const limiter = new RedisRateLimiter(); expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true); }); });