Files

134 lines
5.0 KiB
TypeScript
Raw Permalink Normal View History

2026-08-19 12:00:05 +05:30
import { describe, it, expect, vi, beforeEach } from "vitest";
import { IdempotencyGuard } from "@/idempotency/index.js";
describe("IdempotencyGuard (src/idempotency/index.ts)", () => {
let mockRedis: any;
beforeEach(() => {
mockRedis = {
set: vi.fn(),
get: vi.fn(),
del: vi.fn(),
};
});
it("checkAndMark returns true on first call and sets key with NX and default TTL", async () => {
mockRedis.set.mockResolvedValueOnce("OK");
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
const result = await guard.checkAndMark("msg-100");
expect(result).toBe(true);
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:msg-100", "1", "EX", 86_400, "NX");
});
it("checkAndMark returns false when Redis SET NX returns null (already seen)", async () => {
mockRedis.set.mockResolvedValueOnce(null);
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
const result = await guard.checkAndMark("msg-100");
expect(result).toBe(false);
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:msg-100", "1", "EX", 86_400, "NX");
});
it("checkAndMark applies custom TTL when provided", async () => {
mockRedis.set.mockResolvedValueOnce("OK");
const guard = new IdempotencyGuard({
redis: mockRedis,
keyPrefix: "notif:idem",
ttlSeconds: 3600,
});
const result = await guard.checkAndMark("msg-200", 60);
expect(result).toBe(true);
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:msg-200", "1", "EX", 60, "NX");
});
it("markProcessed sets the key unconditionally with given or default TTL", async () => {
mockRedis.set.mockResolvedValueOnce("OK");
const guard = new IdempotencyGuard({
redis: mockRedis,
keyPrefix: "notif:idem",
ttlSeconds: 7200,
});
await guard.markProcessed("msg-300");
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:msg-300", "1", "EX", 7200);
await guard.markProcessed("msg-300", 120);
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:msg-300", "1", "EX", 120);
});
it("isProcessed returns true when key exists in Redis", async () => {
mockRedis.get.mockResolvedValueOnce("1");
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
const processed = await guard.isProcessed("msg-400");
expect(processed).toBe(true);
expect(mockRedis.get).toHaveBeenCalledWith("notif:idem:msg-400");
});
it("isProcessed returns false when key does not exist (null)", async () => {
mockRedis.get.mockResolvedValueOnce(null);
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
const processed = await guard.isProcessed("msg-missing");
expect(processed).toBe(false);
expect(mockRedis.get).toHaveBeenCalledWith("notif:idem:msg-missing");
});
it("unmark deletes the key from Redis", async () => {
mockRedis.del.mockResolvedValueOnce(1);
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
await guard.unmark("msg-rollback");
expect(mockRedis.del).toHaveBeenCalledWith("notif:idem:msg-rollback");
});
describe("acquireLease two-phase state", () => {
it("returns 'acquired' on first attempt via fast-path SET NX", async () => {
mockRedis.set.mockResolvedValueOnce("OK");
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
const state = await guard.acquireLease("task-1", 30);
expect(state).toBe("acquired");
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:task-1", "L", "EX", 30, "NX");
});
it("returns 'completed' when key already marked completed ('S')", async () => {
mockRedis.set.mockResolvedValueOnce(null); // SET NX fails (key exists)
mockRedis.get.mockImplementation(async (key: string) => {
if (key === "notif:idem:task-2") return "S";
return null;
});
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
const state = await guard.acquireLease("task-2", 30);
expect(state).toBe("completed");
});
it("returns 'locked' when lock lease is currently held ('L')", async () => {
mockRedis.set.mockResolvedValueOnce(null); // SET NX fails (held)
mockRedis.get.mockImplementation(async (key: string) => {
if (key === "notif:idem:task-3") return "L";
return null;
});
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
const state = await guard.acquireLease("task-3", 30);
expect(state).toBe("locked");
});
it("markProcessed records completed marker '1' on main key with given TTL", async () => {
mockRedis.set.mockResolvedValue("OK");
const guard = new IdempotencyGuard({ redis: mockRedis, keyPrefix: "notif:idem" });
await guard.markProcessed("task-4", 86400);
expect(mockRedis.set).toHaveBeenCalledWith("notif:idem:task-4", "1", "EX", 86400);
});
});
2026-08-19 12:00:05 +05:30
});