import { describe, it, expect, beforeEach, jest } from "@jest/globals"; import type { Request, Response, NextFunction } from "express"; type Row = { method: string; path: string; key: string; request_hash: string; status_code: number; response_body: unknown; }; const store = new Map(); jest.mock("../../src/db/postgres", () => ({ query: async (sql: string, params: unknown[] = []) => { if (sql.startsWith("SELECT request_hash")) { const [method, path, key] = params as [string, string, string]; const row = store.get(`${method}|${path}|${key}`); return row ? [row] : []; } if (sql.startsWith("INSERT INTO idempotency_keys")) { const [method, path, key, request_hash, status_code, body] = params as [ string, string, string, string, number, string, ]; const k = `${method}|${path}|${key}`; if (!store.has(k)) { store.set(k, { method, path, key, request_hash, status_code, response_body: JSON.parse(body), }); } return []; } return []; }, })); import { idempotencyMiddleware, IDEMPOTENCY_HEADER } from "../../src/middleware/idempotency"; function makeReqRes(overrides: { header?: string; method?: string; baseUrl?: string; path?: string; body?: unknown; }) { const req = { method: overrides.method ?? "POST", baseUrl: overrides.baseUrl ?? "", path: overrides.path ?? "/api/plans", body: overrides.body ?? { a: 1 }, header(name: string) { return name.toLowerCase() === IDEMPOTENCY_HEADER ? overrides.header : undefined; }, } as unknown as Request; const captured: { status?: number; body?: unknown; headers: Record } = { headers: {}, }; const res: Partial = { statusCode: 200, status(code: number) { this.statusCode = code; captured.status = code; return this as Response; }, json(body: unknown) { captured.body = body; if (captured.status === undefined) captured.status = this.statusCode; return this as Response; }, setHeader(name: string, value: string | number | readonly string[]) { captured.headers[name] = String(value); return this as Response; }, }; return { req, res: res as Response, captured }; } describe("Idempotency middleware", () => { beforeEach(() => { store.clear(); }); it("skips when no Idempotency-Key header is set", async () => { const { req, res } = makeReqRes({}); const next = jest.fn() as unknown as NextFunction; await idempotencyMiddleware(req, res, next); expect(next).toHaveBeenCalledTimes(1); }); it("rejects malformed keys with 400", async () => { const { req, res, captured } = makeReqRes({ header: "short" }); const next = jest.fn() as unknown as NextFunction; await idempotencyMiddleware(req, res, next); expect(next).not.toHaveBeenCalled(); expect(captured.status).toBe(400); expect((captured.body as { error: string }).error).toBe("idempotency_key_invalid"); }); it("caches 2xx responses on first call and replays on second", async () => { const key = "ABC12345_test-key"; const first = makeReqRes({ header: key }); const next1 = jest.fn() as unknown as NextFunction; await idempotencyMiddleware(first.req, first.res, next1); expect(next1).toHaveBeenCalledTimes(1); // Simulate handler sending JSON response first.res.status(201); first.res.json({ plan_id: "p-1", created: true }); // Let the fire-and-forget INSERT microtask flush await new Promise((r) => setImmediate(r)); const second = makeReqRes({ header: key }); const next2 = jest.fn() as unknown as NextFunction; await idempotencyMiddleware(second.req, second.res, next2); expect(next2).not.toHaveBeenCalled(); expect(second.captured.status).toBe(201); expect(second.captured.body).toEqual({ plan_id: "p-1", created: true }); expect(second.captured.headers["Idempotent-Replayed"]).toBe("true"); }); it("rejects reuse with a different body as 422", async () => { const key = "ABC12345_test-key"; const first = makeReqRes({ header: key, body: { a: 1 } }); const next1 = jest.fn() as unknown as NextFunction; await idempotencyMiddleware(first.req, first.res, next1); first.res.status(200); first.res.json({ ok: true }); await new Promise((r) => setImmediate(r)); const second = makeReqRes({ header: key, body: { a: 2 } }); const next2 = jest.fn() as unknown as NextFunction; await idempotencyMiddleware(second.req, second.res, next2); expect(next2).not.toHaveBeenCalled(); expect(second.captured.status).toBe(422); expect((second.captured.body as { error: string }).error).toBe("idempotency_key_reused"); }); it("does NOT cache non-2xx responses (retryable)", async () => { const key = "ABC12345_test-key"; const first = makeReqRes({ header: key }); await idempotencyMiddleware(first.req, first.res, jest.fn() as unknown as NextFunction); first.res.status(500); first.res.json({ error: "boom" }); await new Promise((r) => setImmediate(r)); // Retry should go through (no replay) const second = makeReqRes({ header: key }); const next2 = jest.fn() as unknown as NextFunction; await idempotencyMiddleware(second.req, second.res, next2); expect(next2).toHaveBeenCalledTimes(1); }); it("scopes by (method, path, key)", async () => { const key = "ABC12345_test-key"; const createPlan = makeReqRes({ header: key, path: "/api/plans" }); await idempotencyMiddleware(createPlan.req, createPlan.res, jest.fn() as unknown as NextFunction); createPlan.res.status(201); createPlan.res.json({ plan_id: "p-1" }); await new Promise((r) => setImmediate(r)); // Same key on a different path: should pass through, not replay const execute = makeReqRes({ header: key, path: "/api/plans/p-1/execute" }); const nextExec = jest.fn() as unknown as NextFunction; await idempotencyMiddleware(execute.req, execute.res, nextExec); expect(nextExec).toHaveBeenCalledTimes(1); }); });