import { describe, expect, it } from "@suaveplan/testing/runner"; import { IdempotencyMismatchError } from "../errors/idempotency-error.js"; import { MemoryIdempotencyStore } from "../store/memory-store.js"; import { createIdempotencyMiddleware } from "./idempotency-middleware.js"; interface TestRequest { headers: Record; body: unknown; operation?: string; } interface TestResponse { status: number; data: unknown; } function makeMiddleware(store = new MemoryIdempotencyStore()) { return createIdempotencyMiddleware({ store, extractKey: (req) => req.headers["idempotency-key"], extractInput: (req) => req.body, extractOperation: (req) => req.operation, ttlMs: 60_000, ttlOverrides: { "send-email": 3_600_000 }, }); } function req( key?: string, body: unknown = {}, operation?: string ): TestRequest { return { headers: { "idempotency-key": key }, body, operation }; } describe("createIdempotencyMiddleware", () => { it("executes handler when no cached result", { timeout: 30000, }, async () => { const middleware = makeMiddleware(); const result = await middleware(req("k1"), async () => ({ status: 200, data: "created", })); expect(result.response.data).toBe("created"); expect(result.cached).toBe(false); expect(result.key).toBe("k1"); }); it("returns cached response on second call", { timeout: 30000, }, async () => { const middleware = makeMiddleware(); let calls = 0; const handler = async () => { calls++; return { status: 200, data: calls }; }; await middleware(req("dup"), handler); const r2 = await middleware(req("dup"), handler); expect(r2.cached).toBe(true); expect(r2.response.data).toBe(1); // same as first call expect(calls).toBe(1); }); it("bypasses cache when no key extracted", { timeout: 30000 }, async () => { const middleware = makeMiddleware(); let calls = 0; const r1 = await middleware(req(undefined), async () => { calls++; return { status: 200, data: calls }; }); const r2 = await middleware(req(undefined), async () => { calls++; return { status: 200, data: calls }; }); expect(r1.cached).toBe(false); expect(r2.cached).toBe(false); expect(calls).toBe(2); expect(r1.key).toBe(""); }); it("throws IdempotencyMismatchError on fingerprint conflict", { timeout: 30000, }, async () => { const middleware = makeMiddleware(); await middleware(req("fp", { a: 1 }), async () => ({ status: 200, data: 1, })); await expect( middleware(req("fp", { a: 2 }), async () => ({ status: 200, data: 2, })) ).rejects.toBeInstanceOf(IdempotencyMismatchError); }); it("stores failed status when handler throws", { timeout: 30000, }, async () => { const store = new MemoryIdempotencyStore(); const middleware = makeMiddleware(store); await expect( middleware(req("err"), async () => { throw new Error("boom"); }) ).rejects.toThrow("boom"); const rec = await store.get("err"); expect(rec?.status).toBe("failed"); }); it("applies transformResponse on cache hit", { timeout: 30000, }, async () => { const store = new MemoryIdempotencyStore(); const middleware = createIdempotencyMiddleware< TestRequest, TestResponse >({ store, extractKey: (r) => r.headers["idempotency-key"], transformResponse: (res) => ({ ...res, status: 304 }), }); await middleware(req("tr"), async () => ({ status: 200, data: "x" })); const r2 = await middleware(req("tr"), async () => ({ status: 200, data: "x", })); expect(r2.response.status).toBe(304); }); it("applies transformResponse on fresh execution", { timeout: 30000, }, async () => { const store = new MemoryIdempotencyStore(); const middleware = createIdempotencyMiddleware< TestRequest, TestResponse >({ store, extractKey: (r) => r.headers["idempotency-key"], transformResponse: (res) => ({ ...res, data: "transformed" }), }); const r = await middleware(req("tf2"), async () => ({ status: 200, data: "original", })); expect(r.response.data).toBe("transformed"); }); it("uses ttlOverrides for known operations", { timeout: 30000, }, async () => { const store = new MemoryIdempotencyStore(); const middleware = makeMiddleware(store); // Just verify it doesn't throw with a known operation const r = await middleware(req("op", {}, "send-email"), async () => ({ status: 200, data: "sent", })); expect(r.response.data).toBe("sent"); }); it("falls back to default TTL for unknown operations", { timeout: 30000, }, async () => { const store = new MemoryIdempotencyStore(); const middleware = makeMiddleware(store); const r = await middleware(req("uop", {}, "unknown-op"), async () => ({ status: 200, data: "ok", })); expect(r.cached).toBe(false); }); it("stores failed status when handler throws a non-Error value", { timeout: 30000, }, async () => { const store = new MemoryIdempotencyStore(); const middleware = makeMiddleware(store); await expect( middleware(req("non-err"), async () => { // eslint-disable-next-line @typescript-eslint/no-throw-literal throw "string-error"; }) ).rejects.toBe("string-error"); const rec = await store.get("non-err"); expect(rec?.status).toBe("failed"); expect(rec?.error).toBe("string-error"); }); it("uses defaultTtlMs when ttlOverrides entry is undefined", { timeout: 30000, }, async () => { const store = new MemoryIdempotencyStore(); // Pass a ttlOverrides map where the matched key has an undefined value // so the `?? defaultTtlMs` fallback on line 68 is exercised const middleware = createIdempotencyMiddleware< TestRequest, TestResponse >({ store, extractKey: (r) => r.headers["idempotency-key"], extractOperation: (r) => r.operation, ttlMs: 60_000, ttlOverrides: { "undef-op": undefined as unknown as number, }, }); const r = await middleware( req("undef-ttl", {}, "undef-op"), async () => ({ status: 200, data: "ok" }) ); expect(r.cached).toBe(false); }); });