You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

233 lines
6.0 KiB

import { beforeEach, describe, expect, it, vi } from "vitest";
import {
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<string, { value: string; expireAt?: number }>;
get: ReturnType<typeof vi.fn>;
set: ReturnType<typeof vi.fn>;
del: ReturnType<typeof vi.fn>;
exists: ReturnType<typeof vi.fn>;
incr: ReturnType<typeof vi.fn>;
expire: ReturnType<typeof vi.fn>;
};
function createRedisMock(): RedisMock {
const store = new Map<string, { value: string; expireAt?: number }>();
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<string | null> => {
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<number>();
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);
});
});
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);
});
});