218 lines
7.1 KiB
TypeScript
218 lines
7.1 KiB
TypeScript
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<string, string | undefined>;
|
|
body: unknown;
|
|
operation?: string;
|
|
}
|
|
|
|
interface TestResponse {
|
|
status: number;
|
|
data: unknown;
|
|
}
|
|
|
|
function makeMiddleware(store = new MemoryIdempotencyStore()) {
|
|
return createIdempotencyMiddleware<TestRequest, TestResponse>({
|
|
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);
|
|
});
|
|
});
|