From bce2fae284731fb3c73bffb451865458973f1fdb Mon Sep 17 00:00:00 2001 From: jerome peter Date: Tue, 24 Feb 2026 16:50:28 -0800 Subject: [PATCH] feat: add routes-f update endpoint with optimistic concurrency --- app/api/routes-f/__tests__/items.test.ts | 97 ++++++++++++++++++++++++ app/api/routes-f/items/[id]/route.ts | 71 +++++++++++++++++ lib/routes-f/store.ts | 35 +++++++++ lib/routes-f/types.ts | 1 + 4 files changed, 204 insertions(+) create mode 100644 app/api/routes-f/__tests__/items.test.ts create mode 100644 app/api/routes-f/items/[id]/route.ts diff --git a/app/api/routes-f/__tests__/items.test.ts b/app/api/routes-f/__tests__/items.test.ts new file mode 100644 index 00000000..8bd4bd2e --- /dev/null +++ b/app/api/routes-f/__tests__/items.test.ts @@ -0,0 +1,97 @@ +/** + * Routes-F items endpoints tests. + */ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + }), + }, +})); + +import { GET, PATCH } from "../items/[id]/route"; +import { + __test__setRoutesFRecords, + getRoutesFRecords, +} from "@/lib/routes-f/store"; + +const makeRequest = (method: string, body?: any, headersInit?: HeadersInit) => { + return new Request(`http://localhost/api/routes-f/items/rf-1`, { + method, + headers: headersInit, + body: body ? JSON.stringify(body) : null, + }); +}; + +const makeContext = (id: string) => ({ + params: { id }, +}); + +const initialRecords = getRoutesFRecords(); + +describe("PATCH /api/routes-f/items/[id]", () => { + beforeEach(() => { + __test__setRoutesFRecords([ + { + id: "rf-1", + title: "Test Record", + description: "Initial", + tags: ["test"], + createdAt: "2026-02-22T00:00:00.000Z", + updatedAt: "2026-02-22T00:00:00.000Z", + etag: '"2026-02-22T00:00:00.000Z"' + }, + ]); + }); + + it("returns 428 when If-Match header is missing", async () => { + const res = await PATCH(makeRequest("PATCH", { title: "New" }), makeContext("rf-1")); + expect(res.status).toBe(428); + const body = await res.json(); + expect(body.error).toBe("Precondition Required"); + }); + + it("returns 412 when ETag mismatches", async () => { + const res = await PATCH( + makeRequest("PATCH", { title: "New" }, { "if-match": '"wrong-etag"' }), + makeContext("rf-1") + ); + expect(res.status).toBe(412); + const body = await res.json(); + expect(body.error).toBe("Precondition Failed"); + }); + + it("returns 200 and updates record when ETag matches", async () => { + const req = makeRequest( + "PATCH", + { title: "Updated Title" }, + { "if-match": '"2026-02-22T00:00:00.000Z"' } + ); + const res = await PATCH(req, makeContext("rf-1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.title).toBe("Updated Title"); + + // Check if store actually updated + const getRes = await GET(makeRequest("GET"), makeContext("rf-1")); + const getBody = await getRes.json(); + expect(getBody.title).toBe("Updated Title"); + expect(getBody.etag).not.toBe('"2026-02-22T00:00:00.000Z"'); // new etag + }); + + it("handles missing record with 404", async () => { + const req = makeRequest( + "PATCH", + { title: "New" }, + { "if-match": '"any"' } + ); + const res = await PATCH(req, makeContext("rf-999")); + expect(res.status).toBe(404); + }); + + afterAll(() => { + __test__setRoutesFRecords(initialRecords); + }); +}); diff --git a/app/api/routes-f/items/[id]/route.ts b/app/api/routes-f/items/[id]/route.ts new file mode 100644 index 00000000..cda0a446 --- /dev/null +++ b/app/api/routes-f/items/[id]/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { getRoutesFRecordById, updateRoutesFRecord } from "@/lib/routes-f/store"; + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> | { id: string } } +) { + // Handle both Next.js 14 and 15+ param formats + const resolvedParams = await Promise.resolve(params); + const { id } = resolvedParams; + + const ifMatch = req.headers.get("if-match"); + if (!ifMatch) { + return NextResponse.json( + { error: "Precondition Required", message: "If-Match header is missing" }, + { status: 428 } + ); + } + + let updates; + try { + updates = await req.json(); + } catch (error) { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + try { + const updated = updateRoutesFRecord(id, updates, ifMatch); + + if (!updated) { + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + + const headers = new Headers(); + if (updated.etag) { + headers.set("ETag", updated.etag); + } + + return NextResponse.json(updated, { status: 200, headers }); + } catch (e: any) { + if (e.message === "ETAG_MISMATCH") { + return NextResponse.json( + { error: "Precondition Failed", message: "ETag mismatch" }, + { status: 412 } + ); + } + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 } + ); + } +} + +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> | { id: string } } +) { + const resolvedParams = await Promise.resolve(params); + const { id } = resolvedParams; + + const record = getRoutesFRecordById(id); + if (!record) { + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + + const headers = new Headers(); + const etag = record.etag || `"${record.updatedAt || record.createdAt}"`; + headers.set("ETag", etag); + + return NextResponse.json(record, { status: 200, headers }); +} diff --git a/lib/routes-f/store.ts b/lib/routes-f/store.ts index b62e6ce3..3b3f44ea 100644 --- a/lib/routes-f/store.ts +++ b/lib/routes-f/store.ts @@ -59,6 +59,41 @@ export function getRecentRoutesFRecords(limit: number): RoutesFRecord[] { .slice(0, limit); } +export function getRoutesFRecordById(id: string): RoutesFRecord | undefined { + return routesFRecords.find((r) => r.id === id); +} + +export function updateRoutesFRecord( + id: string, + updates: Partial, + ifMatchHeader?: string +): RoutesFRecord | null { + const index = routesFRecords.findIndex((r) => r.id === id); + if (index === -1) return null; + + const current = routesFRecords[index]; + + // If-Match concurrency control + if (ifMatchHeader) { + const etag = current.etag || `"${current.updatedAt || current.createdAt}"`; + if (ifMatchHeader !== etag) { + throw new Error("ETAG_MISMATCH"); + } + } + + const updated: RoutesFRecord = { + ...current, + ...updates, + id: current.id, // Cannot update ID + updatedAt: new Date().toISOString(), + }; + + updated.etag = `"${updated.updatedAt}"`; + + routesFRecords[index] = updated; + return updated; +} + export function searchRoutesFRecords(params: { query?: string; tag?: string; diff --git a/lib/routes-f/types.ts b/lib/routes-f/types.ts index 8d660d1c..6e6320f9 100644 --- a/lib/routes-f/types.ts +++ b/lib/routes-f/types.ts @@ -5,6 +5,7 @@ export interface RoutesFRecord { tags: string[]; createdAt: string; updatedAt?: string; + etag?: string; } export interface MaintenanceWindow {