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
96 changes: 96 additions & 0 deletions app/api/routes-f/__tests__/conflicts.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
52 changes: 52 additions & 0 deletions app/api/routes-f/__tests__/validation-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Routes-F validation metadata 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 { GET } from "../validation-rules/route";

describe("GET /api/routes-f/validation-rules", () => {
it("returns 200 and correct schema shape", async () => {
const res = await GET();
expect(res.status).toBe(200);

const body = await res.json();

// Check core fields
expect(body).toHaveProperty("name");
expect(body).toHaveProperty("path");
expect(body).toHaveProperty("method");
expect(body).toHaveProperty("priority");
expect(body).toHaveProperty("enabled");
expect(body).toHaveProperty("tags");
expect(body).toHaveProperty("metadata");

// Verify specific constraints
expect(body.name.constraints.maxLength).toBe(100);
expect(body.path.constraints.pattern).toBe("^/");
expect(body.method.type).toBe("enum");
expect(body.method.constraints.enum).toContain("POST");
expect(body.tags.constraints.maxItems).toBe(8);
});

it("returns stable response for all fields", async () => {
const res = await GET();
const body = await res.json();

// Ensure type and required flags are present for all fields
const fields = ["name", "path", "method", "priority", "enabled", "tags", "metadata"];
fields.forEach(field => {
expect(body[field]).toHaveProperty("type");
expect(body[field]).toHaveProperty("required");
expect(body[field]).toHaveProperty("constraints");
});
});
});
65 changes: 65 additions & 0 deletions app/api/routes-f/_lib/diff.ts
Original file line number Diff line number Diff line change
@@ -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;
}
33 changes: 33 additions & 0 deletions app/api/routes-f/conflicts/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
62 changes: 62 additions & 0 deletions app/api/routes-f/validation-rules/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { jsonResponse } from "@/lib/routes-f/version";
import { ROUTES_F_ALLOWED_METHODS } from "@/lib/routes-f/schema";

/**
* GET /api/routes-f/validation-rules
* Returns field-level validation metadata for Routes-F items.
*/
export async function GET() {
const rules = {
name: {
type: "string",
required: true,
constraints: {
minLength: 1,
maxLength: 100,
},
},
path: {
type: "string",
required: true,
constraints: {
minLength: 1,
maxLength: 200,
pattern: "^/",
},
},
method: {
type: "enum",
required: true,
constraints: {
enum: ROUTES_F_ALLOWED_METHODS,
},
},
priority: {
type: "number",
required: false,
constraints: {
min: 0,
max: 100,
},
},
enabled: {
type: "boolean",
required: false,
constraints: {},
},
tags: {
type: "array",
required: false,
constraints: {
maxItems: 8,
},
},
metadata: {
type: "object",
required: false,
constraints: {},
},
};

return jsonResponse(rules, { status: 200 });
}
Loading