Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions app/api/routes-f/__tests__/items.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
71 changes: 71 additions & 0 deletions app/api/routes-f/items/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
35 changes: 35 additions & 0 deletions lib/routes-f/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RoutesFRecord>,
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;
Expand Down
1 change: 1 addition & 0 deletions lib/routes-f/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface RoutesFRecord {
tags: string[];
createdAt: string;
updatedAt?: string;
etag?: string;
}

export interface MaintenanceWindow {
Expand Down