mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
chore(idempotency): default userIdResolver to cookie session (#118)
Drop the `resolveUserIdForIdempotency` helper that was duplicated across three PWA write routes. `withIdempotency` now reads the userId from `getSession()` by default; routes that authenticate via Bearer token can still pass a custom resolver. Net: −15 LOC across the route layer, +2 test cases for the new default. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
aac2e18668
commit
a8a7c19d0b
5 files changed
+122
-73
No files matched your search
@@ -15,8 +15,13 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
import { withIdempotency } from "../idempotency";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
function makeRequest(
|
||||
method: string,
|
||||
@@ -142,4 +147,33 @@ describe("withIdempotency", () => {
|
||||
await wrapped(makeRequest("GET", { "idempotency-key": "abc-12345678" }));
|
||||
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to the cookie session when no resolver is given", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "s-1", expiresAt: new Date(Date.now() + 60_000) },
|
||||
user: { id: "u-default" },
|
||||
} as never);
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler);
|
||||
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
|
||||
expect(getSession).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
||||
const persisted = vi.mocked(prisma.idempotencyKey.create).mock
|
||||
.calls[0][0] as { data: { userId: string } };
|
||||
expect(persisted.data.userId).toBe("u-default");
|
||||
});
|
||||
|
||||
it("skips caching when the default resolver finds no session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler);
|
||||
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+19
-5
@@ -10,6 +10,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -36,7 +37,9 @@ function getIdempotencyKey(request: Request | NextRequest): string | null {
|
||||
* Look up a cached response for a (userId, key, method, path) tuple.
|
||||
* Returns the cached NextResponse or null.
|
||||
*/
|
||||
async function findCached(ctx: IdempotencyContext): Promise<NextResponse | null> {
|
||||
async function findCached(
|
||||
ctx: IdempotencyContext,
|
||||
): Promise<NextResponse | null> {
|
||||
const row = await prisma.idempotencyKey.findUnique({
|
||||
where: {
|
||||
userId_key_method_path: {
|
||||
@@ -51,7 +54,9 @@ async function findCached(ctx: IdempotencyContext): Promise<NextResponse | null>
|
||||
if (!row) return null;
|
||||
if (row.expiresAt <= new Date()) {
|
||||
// Stale — purge and fall through.
|
||||
await prisma.idempotencyKey.delete({ where: { id: row.id } }).catch(() => {});
|
||||
await prisma.idempotencyKey
|
||||
.delete({ where: { id: row.id } })
|
||||
.catch(() => {});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -82,7 +87,11 @@ async function persistCached(
|
||||
preReadBody?: string,
|
||||
): Promise<void> {
|
||||
const body =
|
||||
preReadBody ?? (await response.clone().text().catch(() => ""));
|
||||
preReadBody ??
|
||||
(await response
|
||||
.clone()
|
||||
.text()
|
||||
.catch(() => ""));
|
||||
|
||||
await prisma.idempotencyKey
|
||||
.create({
|
||||
@@ -108,7 +117,9 @@ async function persistCached(
|
||||
*
|
||||
* The wrapped handler is responsible for authentication itself — this
|
||||
* helper only triggers for methods in {POST, PUT, PATCH, DELETE} and only
|
||||
* once `userIdResolver` returns a non-null value.
|
||||
* once `userIdResolver` returns a non-null value. By default the userId
|
||||
* is read from the cookie session; pass a custom resolver for routes
|
||||
* that authenticate via Bearer token or other means.
|
||||
*
|
||||
* No-op when the header is missing or the value is malformed.
|
||||
*/
|
||||
@@ -116,7 +127,10 @@ export function withIdempotency<
|
||||
Args extends [Request | NextRequest, ...unknown[]],
|
||||
>(
|
||||
handler: (...args: Args) => Promise<Response>,
|
||||
userIdResolver: (...args: Args) => Promise<string | null>,
|
||||
userIdResolver: (...args: Args) => Promise<string | null> = async () => {
|
||||
const session = await getSession().catch(() => null);
|
||||
return session?.user.id ?? null;
|
||||
},
|
||||
): (...args: Args) => Promise<Response> {
|
||||
return async (...args: Args): Promise<Response> => {
|
||||
const request = args[0];
|
||||
|
||||
Reference in new issue
Block a user