import { describe, it, expect, vi, beforeEach } from "vitest"; // ============================================================ // Mock 外部依赖 // ============================================================ // Mock cuid2 vi.mock("@paralleldrive/cuid2", () => ({ createId: vi.fn(() => "mock-idempotency-key"), })); // Mock Redis client —— getRedis 返回模拟 redis 或 null const mockRedis = { set: vi.fn(), exists: vi.fn(), }; let redisReturnValue: unknown = null; vi.mock("../../src/shared/redis/redis.client.js", () => ({ getRedis: () => redisReturnValue, })); // Mock database —— getDb 返回模拟 db const mockInsertChain = { values: vi.fn().mockResolvedValue(undefined), }; const mockSelectChain = { from: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]), }; const mockDb = { insert: vi.fn(() => mockInsertChain), select: vi.fn(() => mockSelectChain), }; vi.mock("../../src/config/database.js", () => ({ getDb: () => mockDb, })); // Mock logger vi.mock("../../src/shared/observability/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); // 导入被测模块(在 mock 之后) import { checkAndMark, isProcessed, generateIdempotencyKey, } from "../../src/shared/redis/idempotency.guard.js"; // ============================================================ // Tests // ============================================================ describe("idempotency.guard", () => { beforeEach(() => { vi.clearAllMocks(); redisReturnValue = null; mockInsertChain.values.mockResolvedValue(undefined); // 重置 select chain mockSelectChain.from.mockReturnThis(); mockSelectChain.where.mockReturnThis(); mockSelectChain.limit.mockResolvedValue([]); }); // ---------------------------------------------------------- // checkAndMark // ---------------------------------------------------------- describe("checkAndMark", () => { it("Redis SETNX 成功(返回 OK)→ isFirst=true", async () => { redisReturnValue = mockRedis; mockRedis.set.mockResolvedValue("OK"); const result = await checkAndMark("evt-1"); expect(result.isFirst).toBe(true); expect(result.key).toBe("msg:processed:evt-1"); expect(mockRedis.set).toHaveBeenCalledWith( "msg:processed:evt-1", "1", "EX", 7 * 24 * 60 * 60, "NX", ); // 不应走 DB 降级 expect(mockDb.insert).not.toHaveBeenCalled(); }); it("Redis SETNX 失败(返回 null)→ isFirst=false(已处理)", async () => { redisReturnValue = mockRedis; mockRedis.set.mockResolvedValue(null); const result = await checkAndMark("evt-1"); expect(result.isFirst).toBe(false); expect(result.key).toBe("msg:processed:evt-1"); // 不应走 DB 降级 expect(mockDb.insert).not.toHaveBeenCalled(); }); it("Redis 异常 → 降级到 DB 插入,成功时 isFirst=true", async () => { redisReturnValue = mockRedis; mockRedis.set.mockRejectedValue(new Error("Redis connection lost")); mockInsertChain.values.mockResolvedValue(undefined); const result = await checkAndMark("evt-1", "edu.notification.sent"); expect(result.isFirst).toBe(true); expect(result.key).toBe("msg:processed:evt-1"); // 应走 DB 降级 expect(mockDb.insert).toHaveBeenCalledTimes(1); expect(mockInsertChain.values).toHaveBeenCalledWith({ eventId: "evt-1", topic: "edu.notification.sent", }); }); it("Redis 异常 + DB 唯一索引冲突 → isFirst=false", async () => { redisReturnValue = mockRedis; mockRedis.set.mockRejectedValue(new Error("Redis down")); // DB 插入冲突 mockInsertChain.values.mockRejectedValue(new Error("Duplicate entry")); const result = await checkAndMark("evt-1"); expect(result.isFirst).toBe(false); expect(mockDb.insert).toHaveBeenCalledTimes(1); }); it("Redis 不可用(getRedis 返回 null)→ 直接走 DB 降级", async () => { redisReturnValue = null; mockInsertChain.values.mockResolvedValue(undefined); const result = await checkAndMark("evt-1", "edu.notification.sent"); expect(result.isFirst).toBe(true); expect(mockRedis.set).not.toHaveBeenCalled(); expect(mockDb.insert).toHaveBeenCalledTimes(1); }); it("Redis 不可用 + DB 冲突 → isFirst=false", async () => { redisReturnValue = null; mockInsertChain.values.mockRejectedValue(new Error("Duplicate")); const result = await checkAndMark("evt-1"); expect(result.isFirst).toBe(false); }); it("无 topic 参数时 DB 降级应使用 'unknown'", async () => { redisReturnValue = null; mockInsertChain.values.mockResolvedValue(undefined); await checkAndMark("evt-1"); expect(mockInsertChain.values).toHaveBeenCalledWith({ eventId: "evt-1", topic: "unknown", }); }); it("key 格式应为 msg:processed:{eventId}", async () => { redisReturnValue = mockRedis; mockRedis.set.mockResolvedValue("OK"); const result = await checkAndMark("my-event-123"); expect(result.key).toBe("msg:processed:my-event-123"); }); }); // ---------------------------------------------------------- // isProcessed // ---------------------------------------------------------- describe("isProcessed", () => { it("Redis exists=1 → true(已处理)", async () => { redisReturnValue = mockRedis; mockRedis.exists.mockResolvedValue(1); const result = await isProcessed("evt-1"); expect(result).toBe(true); expect(mockRedis.exists).toHaveBeenCalledWith("msg:processed:evt-1"); }); it("Redis exists=0 → false(未处理)", async () => { redisReturnValue = mockRedis; mockRedis.exists.mockResolvedValue(0); const result = await isProcessed("evt-1"); expect(result).toBe(false); }); it("Redis 异常 → 降级到 DB 查询", async () => { redisReturnValue = mockRedis; mockRedis.exists.mockRejectedValue(new Error("Redis error")); mockSelectChain.limit.mockResolvedValue([{ eventId: "evt-1" }]); const result = await isProcessed("evt-1"); expect(result).toBe(true); expect(mockDb.select).toHaveBeenCalledTimes(1); }); it("Redis 异常 + DB 无记录 → false", async () => { redisReturnValue = mockRedis; mockRedis.exists.mockRejectedValue(new Error("Redis error")); mockSelectChain.limit.mockResolvedValue([]); const result = await isProcessed("evt-1"); expect(result).toBe(false); }); it("Redis 不可用 → 直接走 DB 查询", async () => { redisReturnValue = null; mockSelectChain.limit.mockResolvedValue([{ eventId: "evt-1" }]); const result = await isProcessed("evt-1"); expect(result).toBe(true); expect(mockRedis.exists).not.toHaveBeenCalled(); expect(mockDb.select).toHaveBeenCalledTimes(1); }); it("Redis 不可用 + DB 无记录 → false", async () => { redisReturnValue = null; mockSelectChain.limit.mockResolvedValue([]); const result = await isProcessed("evt-1"); expect(result).toBe(false); }); }); // ---------------------------------------------------------- // generateIdempotencyKey // ---------------------------------------------------------- describe("generateIdempotencyKey", () => { it("应返回一个字符串", () => { const key = generateIdempotencyKey(); expect(typeof key).toBe("string"); expect(key.length).toBeGreaterThan(0); }); it("应使用 cuid2 生成", () => { const key = generateIdempotencyKey(); // createId 被 mock 为返回 "mock-idempotency-key" expect(key).toBe("mock-idempotency-key"); }); }); });