261 lines
7.8 KiB
TypeScript
261 lines
7.8 KiB
TypeScript
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");
|
||
});
|
||
});
|
||
});
|