diff --git a/app/api/routes-f/__tests__/conflicts.test.ts b/app/api/routes-f/__tests__/conflicts.test.ts new file mode 100644 index 0000000..cc4e3ea --- /dev/null +++ b/app/api/routes-f/__tests__/conflicts.test.ts @@ -0,0 +1,96 @@ +/** + * Routes-F conflict analysis endpoint 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 { POST } from "../conflicts/route"; + +const makeRequest = (body: any) => { + return new Request(`http://localhost/api/routes-f/conflicts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +}; + +describe("POST /api/routes-f/conflicts", () => { + it("returns 400 for invalid payload (missing base/incoming)", async () => { + const res = await POST(makeRequest({ base: {} })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("Invalid request payload"); + }); + + it("returns empty conflicts for identical payloads", async () => { + const payload = { name: "test", metadata: { v: 1 } }; + const res = await POST(makeRequest({ base: payload, incoming: payload })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.conflicts).toHaveLength(0); + }); + + it("detects simple field conflicts", async () => { + const base = { name: "base", enabled: true }; + const incoming = { name: "incoming", enabled: false }; + const res = await POST(makeRequest({ base, incoming })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.conflicts).toEqual([ + { path: "enabled", base: true, incoming: false }, + { path: "name", base: "base", incoming: "incoming" }, + ]); + }); + + it("detects nested object conflicts in metadata", async () => { + const base = { + name: "test", + metadata: { + color: "red", + settings: { theme: "light", notifications: true } + } + }; + const incoming = { + name: "test", + metadata: { + color: "blue", + settings: { theme: "dark", notifications: true } + } + }; + const res = await POST(makeRequest({ base, incoming })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.conflicts).toEqual([ + { path: "metadata.color", base: "red", incoming: "blue" }, + { path: "metadata.settings.theme", base: "light", incoming: "dark" }, + ]); + }); + + it("handles missing/added fields as conflicts", async () => { + const base = { name: "test" }; + const incoming = { name: "test", tags: ["new"] }; + const res = await POST(makeRequest({ base, incoming })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.conflicts).toEqual([ + { path: "tags", base: undefined, incoming: ["new"] }, + ]); + }); + + it("ensures stable and ordered field diff output", async () => { + // z, a, m keys + const base = { z: 1, a: 1, m: 1 }; + const incoming = { z: 2, a: 2, m: 2 }; + const res = await POST(makeRequest({ base, incoming })); + const body = await res.json(); + // Expect a, m, z order + expect(body.conflicts.map((c: any) => c.path)).toEqual(["a", "m", "z"]); + }); +}); diff --git a/app/api/routes-f/_lib/diff.ts b/app/api/routes-f/_lib/diff.ts new file mode 100644 index 0000000..3572772 --- /dev/null +++ b/app/api/routes-f/_lib/diff.ts @@ -0,0 +1,65 @@ +export interface Conflict { + path: string; + base: any; + incoming: any; +} + +/** + * Analyzes conflicts between two payloads recursively. + * Supports nested objects and ensures stable ordered output. + */ +export function analyzeConflicts(base: any, incoming: any, path = ""): Conflict[] { + const conflicts: Conflict[] = []; + + // If one is not an object or null, compare directly + if ( + typeof base !== "object" || + base === null || + typeof incoming !== "object" || + incoming === null + ) { + if (base !== incoming) { + conflicts.push({ path: path || "(root)", base, incoming }); + } + return conflicts; + } + + // Handle arrays as atomic values for this implementation + if (Array.isArray(base) || Array.isArray(incoming)) { + if (JSON.stringify(base) !== JSON.stringify(incoming)) { + conflicts.push({ path: path || "(root)", base, incoming }); + } + return conflicts; + } + + // Get all unique keys and sort them for stable output + const keys = Array.from(new Set([...Object.keys(base), ...Object.keys(incoming)])).sort(); + + for (const key of keys) { + const currentPath = path ? `${path}.${key}` : key; + const baseValue = base[key]; + const incomingValue = incoming[key]; + + // If values are strictly equal, no conflict for this key + if (baseValue === incomingValue) { + continue; + } + + // If both are objects (and not arrays), recurse + if ( + typeof baseValue === "object" && + baseValue !== null && + typeof incomingValue === "object" && + incomingValue !== null && + !Array.isArray(baseValue) && + !Array.isArray(incomingValue) + ) { + conflicts.push(...analyzeConflicts(baseValue, incomingValue, currentPath)); + } else { + // Primitive mismatch or one is an object and the other is not + conflicts.push({ path: currentPath, base: baseValue, incoming: incomingValue }); + } + } + + return conflicts; +} diff --git a/app/api/routes-f/conflicts/route.ts b/app/api/routes-f/conflicts/route.ts new file mode 100644 index 0000000..5272cbc --- /dev/null +++ b/app/api/routes-f/conflicts/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { analyzeConflicts } from "../_lib/diff"; +import { jsonResponse } from "@/lib/routes-f/version"; + +export async function POST(req: Request) { + let body: any; + try { + body = await req.json(); + } catch { + return jsonResponse({ error: "Invalid JSON body" }, { status: 400 }); + } + + if ( + !body.base || + typeof body.base !== "object" || + !body.incoming || + typeof body.incoming !== "object" || + Array.isArray(body.base) || + Array.isArray(body.incoming) + ) { + return jsonResponse( + { + error: "Invalid request payload", + details: ["Both 'base' and 'incoming' must be JSON objects"], + }, + { status: 400 } + ); + } + + const conflicts = analyzeConflicts(body.base, body.incoming); + + return jsonResponse({ conflicts }, { status: 200 }); +}