feat(api): generic Idempotency-Key support for safe POST retries

Introduces `withIdempotency()` in `src/lib/idempotency.ts` — wraps a
mutation handler so that a POST/PUT/PATCH/DELETE carrying an
`Idempotency-Key` header is replayed from the cached response instead of
running twice. Cache row is keyed by `(userId, key, method, path)` with
24h TTL; replays carry `X-Idempotent-Replay: true` so callers can
distinguish them.

Applied to the three mutation endpoints most exposed to client retries:
  • POST `/api/measurements`
  • POST `/api/mood-entries`
  • POST `/api/medications/[id]/intake`

Without the header: zero behaviour change. Existing clients that don't
send the header simply skip the cache lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-03 21:53:02 +02:00
1 parent c8387d4f2b
commit 9766cedea4
5 files changed
+338 -6

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
idempotencyKey: {
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { withIdempotency } from "../idempotency";
import { prisma } from "@/lib/db";
function makeRequest(
method: string,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest("http://localhost/api/example", {
method,
headers: { "content-type": "application/json", ...headers },
body: method === "GET" ? undefined : JSON.stringify({ ok: true }),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
});
describe("withIdempotency", () => {
it("passes through when no Idempotency-Key header is present", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: { ok: true }, error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
const res = await wrapped(makeRequest("POST"));
expect(res.status).toBe(201);
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
});
it("caches the response and replays it on the second call", async () => {
let callCount = 0;
const handler = vi.fn(async () => {
callCount += 1;
return NextResponse.json(
{ data: { result: callCount }, error: null },
{ status: 201 },
);
});
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
// First call: nothing cached → handler runs → response stored.
const req1 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
const res1 = await wrapped(req1);
const body1 = await res1.json();
expect(res1.status).toBe(201);
expect(body1).toEqual({ data: { result: 1 }, error: null });
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
// Capture the persisted body for replay.
const persistedBody = (
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
data: { responseBody: string; responseStatus: number };
}
).data;
// Second call: cache hit returns persisted envelope.
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
id: "idem-1",
userId: "u-1",
key: "abc-12345678",
method: "POST",
path: "/api/example",
responseStatus: persistedBody.responseStatus,
responseBody: persistedBody.responseBody,
expiresAt: new Date(Date.now() + 86_400_000),
createdAt: new Date(),
} as never);
const req2 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
const res2 = await wrapped(req2);
expect(res2.headers.get("X-Idempotent-Replay")).toBe("true");
expect(res2.status).toBe(201);
const body2 = await res2.json();
expect(body2).toEqual({ data: { result: 1 }, error: null });
expect(handler).toHaveBeenCalledTimes(1); // not called again
});
it("ignores expired cache rows and re-runs the handler", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "fresh", error: null }, { status: 200 }),
);
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
id: "idem-old",
userId: "u-1",
key: "abc-12345678",
method: "POST",
path: "/api/example",
responseStatus: 200,
responseBody: '{"data":"stale","error":null}',
expiresAt: new Date(Date.now() - 1000),
createdAt: new Date(),
} as never);
vi.mocked(prisma.idempotencyKey.delete).mockResolvedValue({} as never);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
const res = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
const body = await res.json();
expect(handler).toHaveBeenCalledTimes(1);
expect(body).toEqual({ data: "fresh", error: null });
expect(prisma.idempotencyKey.delete).toHaveBeenCalled();
});
it("ignores malformed Idempotency-Key headers", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("POST", { "idempotency-key": "!!" }));
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
});
it("does nothing for GET requests", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("GET", { "idempotency-key": "abc-12345678" }));
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
});
});