import { beforeEach, describe, expect, it, vi } from "vitest"; import { ADDRESS_CANDIDATES_CACHE_TTL_SECONDS, L1_TTL_SECONDS, L2_TTL_BASE_SECONDS, L2_TTL_JITTER_MAX_SECONDS, L3_TTL_SECONDS, LOCK_TTL_SECONDS, } from "@/lib/constants/cache"; type RedisMock = { store: Map; get: ReturnType; set: ReturnType; del: ReturnType; exists: ReturnType; incr: ReturnType; expire: ReturnType; }; function createRedisMock(): RedisMock { const store = new Map(); const isExpired = (key: string): boolean => { const entry = store.get(key); if (!entry?.expireAt) { return false; } return Date.now() > entry.expireAt; }; return { store, get: vi.fn(async (key: string) => { if (isExpired(key)) { store.delete(key); return null; } return store.get(key)?.value ?? null; }), set: vi.fn( async ( key: string, value: string, ...args: (string | number)[] ): Promise => { const nx = args.includes("NX"); if (nx && store.has(key) && !isExpired(key)) { return null; } let ttlSeconds: number | undefined; const exIndex = args.indexOf("EX"); if (exIndex >= 0) { ttlSeconds = Number(args[exIndex + 1]); } store.set(key, { value, expireAt: ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined, }); return "OK"; }, ), del: vi.fn(async (key: string) => { store.delete(key); return 1; }), exists: vi.fn(async (key: string) => { if (isExpired(key)) { store.delete(key); return 0; } return store.has(key) ? 1 : 0; }), incr: vi.fn(async (key: string) => { const current = store.get(key); const next = current ? Number(current.value) + 1 : 1; store.set(key, { value: String(next) }); return next; }), expire: vi.fn(async (key: string, seconds: number) => { const entry = store.get(key); if (!entry) { return 0; } entry.expireAt = Date.now() + seconds * 1000; return 1; }), }; } let redisMock: RedisMock; vi.mock("@/lib/redis", () => ({ getRedis: () => redisMock, })); describe("modules/cache/redis-cache", () => { beforeEach(async () => { redisMock = createRedisMock(); vi.resetModules(); }); async function loadCache() { return import("@/modules/cache/redis-cache"); } it("L1 setL1 SETNX 不覆盖 + TTL 86400", async () => { const cache = await loadCache(); const payload = { quote_id: "Q1", status: "done" }; expect(await cache.setL1("req-1", payload)).toBe(true); expect(await cache.setL1("req-1", { quote_id: "Q2" })).toBe(false); expect(await cache.getL1("req-1")).toEqual(payload); const [, , , ttl] = redisMock.set.mock.calls[0]; expect(ttl).toBe(L1_TTL_SECONDS); }); it("L2 TTL 在 180~210s 之间", async () => { const cache = await loadCache(); const ttls = new Set(); for (let i = 0; i < 20; i++) { redisMock.store.clear(); const ttl = await cache.setL2("hash-1", { quotes: [] }); ttls.add(ttl); } for (const ttl of ttls) { expect(ttl).toBeGreaterThanOrEqual(L2_TTL_BASE_SECONDS); expect(ttl).toBeLessThanOrEqual( L2_TTL_BASE_SECONDS + L2_TTL_JITTER_MAX_SECONDS, ); } }); it("L3 TTL 1800s", async () => { const cache = await loadCache(); await cache.setL3("hash-1", { quotes: [] }); const [, , , ttl] = redisMock.set.mock.calls[0]; expect(ttl).toBe(L3_TTL_SECONDS); }); it("acquireLock SETNX TTL 5s", async () => { const cache = await loadCache(); expect(await cache.acquireLock("hash-1")).toBe(true); expect(await cache.acquireLock("hash-1")).toBe(false); const [, , , ttl] = redisMock.set.mock.calls[0]; expect(ttl).toBe(LOCK_TTL_SECONDS); }); it("addr-cand 缓存 TTL 120s", async () => { const cache = await loadCache(); await cache.setAddressCandidatesCache("hash-addr", { pickup_candidates: [], delivery_candidates: [], }); const [key, , , ttl] = redisMock.set.mock.calls[0]; expect(key).toBe("addr-cand:hash-addr"); expect(ttl).toBe(ADDRESS_CANDIDATES_CACHE_TTL_SECONDS); }); }); describe("modules/cache/rate-limiter", () => { beforeEach(async () => { redisMock = createRedisMock(); vi.resetModules(); }); it("60 次通过,第 61 次 RATE_LIMITED", async () => { const { checkRateLimit } = await import("@/modules/cache/rate-limiter"); for (let i = 0; i < 60; i++) { expect(await checkRateLimit("CUST_001")).toEqual({ allowed: true }); } expect(await checkRateLimit("CUST_001")).toEqual({ allowed: false, errorCode: "RATE_LIMITED", }); }); }); describe("modules/cache/ip-rate-limiter", () => { beforeEach(async () => { redisMock = createRedisMock(); vi.resetModules(); }); it("超 1000 次封禁 IP", async () => { const { checkIpRateLimit } = await import( "@/modules/cache/ip-rate-limiter" ); for (let i = 0; i < 1000; i++) { expect(await checkIpRateLimit("1.2.3.4")).toEqual({ allowed: true }); } const blocked = await checkIpRateLimit("1.2.3.4"); expect(blocked).toEqual({ allowed: false, errorCode: "RATE_LIMITED", banned: true, }); expect(await checkIpRateLimit("1.2.3.4")).toEqual({ allowed: false, errorCode: "RATE_LIMITED", banned: true, }); }); }); describe("modules/cache/circuit-breaker", () => { beforeEach(async () => { redisMock = createRedisMock(); vi.resetModules(); }); it("连续 3 次失败熔断", async () => { const breaker = await import("@/modules/cache/circuit-breaker"); expect(await breaker.isCircuitOpen()).toBe(false); await breaker.recordRpaFailure(); await breaker.recordRpaFailure(); expect(await breaker.isCircuitOpen()).toBe(false); await breaker.recordRpaFailure(); expect(await breaker.isCircuitOpen()).toBe(true); }); it("closeCircuit 清除熔断状态", async () => { const breaker = await import("@/modules/cache/circuit-breaker"); await breaker.openCircuit(); expect(await breaker.isCircuitOpen()).toBe(true); await breaker.closeCircuit(); expect(await breaker.isCircuitOpen()).toBe(false); }); });