Initial Commit
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user