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.
246 lines
6.5 KiB
246 lines
6.5 KiB
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<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);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|