Files
Edu/services/msg/test/unit/idempotency.guard.spec.ts

261 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
});
});
});