import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; describe("rate limiter", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-30T10:00:00.000Z")); }); afterEach(() => { vi.useRealTimers(); vi.resetModules(); vi.unmock("ioredis"); vi.unstubAllEnvs(); }); it("enforces limits and reset in memory mode", async () => { const { createRateLimiter } = await import("../middleware/rate-limit.js"); const limiter = createRateLimiter(60_000, 2, { backend: "memory", name: "memory-test" }); const first = await limiter("user-1"); const second = await limiter("user-1"); const third = await limiter("user-1"); expect(first.allowed).toBe(true); expect(first.remaining).toBe(1); expect(second.allowed).toBe(true); expect(second.remaining).toBe(0); expect(third.allowed).toBe(false); expect(third.remaining).toBe(0); await limiter.reset(); const afterReset = await limiter("user-1"); expect(afterReset.allowed).toBe(true); expect(afterReset.remaining).toBe(1); }); it("uses a shared Redis counter when Redis mode is enabled", async () => { const store = new Map(); const delMock = vi.fn(async (...keys: string[]) => { for (const key of keys) { store.delete(key); } return keys.length; }); vi.doMock("ioredis", () => ({ Redis: vi.fn().mockImplementation(() => ({ on: vi.fn(), eval: vi.fn(async (_script: string, _numKeys: number, key: string, windowMsValue: string) => { const now = Date.now(); const windowMs = Number(windowMsValue); const existing = store.get(key); if (!existing || existing.resetAt <= now) { store.set(key, { count: 1, resetAt: now + windowMs }); } else { existing.count += 1; } const current = store.get(key)!; return [current.count, current.resetAt - now]; }), scan: vi.fn(async () => ["0", [...store.keys()]]), del: delMock, })), })); const { createRateLimiter } = await import("../middleware/rate-limit.js"); const limiter = createRateLimiter(60_000, 2, { backend: "redis", redisUrl: "redis://test", name: "redis-test", }); const first = await limiter("user-1"); const second = await limiter("user-1"); const third = await limiter("user-1"); expect(first.allowed).toBe(true); expect(second.allowed).toBe(true); expect(third.allowed).toBe(false); expect(third.remaining).toBe(0); await limiter.reset(); expect(delMock).toHaveBeenCalled(); const afterReset = await limiter("user-1"); expect(afterReset.allowed).toBe(true); expect(afterReset.remaining).toBe(1); }); it("falls back to in-memory counters when Redis is unavailable", async () => { vi.doMock("ioredis", () => ({ Redis: vi.fn().mockImplementation(() => ({ on: vi.fn(), eval: vi.fn(async () => { throw new Error("redis down"); }), scan: vi.fn(async () => ["0", []]), del: vi.fn(async () => 0), })), })); const { createRateLimiter } = await import("../middleware/rate-limit.js"); const limiter = createRateLimiter(60_000, 2, { backend: "redis", redisUrl: "redis://test", name: "redis-fallback-test", }); const first = await limiter("user-1"); const second = await limiter("user-1"); const third = await limiter("user-1"); expect(first.allowed).toBe(true); expect(second.allowed).toBe(true); expect(third.allowed).toBe(false); expect(third.remaining).toBe(0); }); });