diff --git a/app/api/routes-f/__tests__/binary-to-text.test.ts b/app/api/routes-f/__tests__/binary-to-text.test.ts new file mode 100644 index 00000000..c57a67d3 --- /dev/null +++ b/app/api/routes-f/__tests__/binary-to-text.test.ts @@ -0,0 +1,103 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST, toBinary, fromBinary } from "../binary-to-text/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/binary-to-text", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/binary-to-text", () => { + // --- unit helpers --- + describe("toBinary", () => { + it("encodes ASCII correctly", () => { + expect(toBinary("A", 8)).toBe("01000001"); + expect(toBinary("Hi", 8)).toBe("01001000 01101001"); + }); + + it("round-trips ASCII with fromBinary", () => { + const bin = toBinary("Hello, World!", 8); + expect(fromBinary(bin, 8)).toBe("Hello, World!"); + }); + + it("round-trips emoji (multibyte UTF-8)", () => { + const bin = toBinary("😊", 8); + expect(fromBinary(bin, 8)).toBe("😊"); + }); + + it("round-trips mixed ASCII + emoji", () => { + const input = "hi 🌍"; + expect(fromBinary(toBinary(input, 8), 8)).toBe(input); + }); + }); + + describe("fromBinary", () => { + it("rejects non-binary characters", () => { + expect(() => fromBinary("01000001 0100GG01", 8)).toThrow(); + }); + + it("rejects tokens with wrong bit length", () => { + expect(() => fromBinary("0100000", 8)).toThrow(); // 7 bits + }); + }); + + // --- POST handler --- + it("to_binary returns correct result for ASCII", async () => { + const res = await POST(makeReq({ input: "A", mode: "to_binary" })); + expect(res.status).toBe(200); + const { result } = await res.json(); + expect(result).toBe("01000001"); + }); + + it("from_binary decodes back to original ASCII", async () => { + const res = await POST(makeReq({ input: "01000001", mode: "from_binary" })); + expect(res.status).toBe(200); + const { result } = await res.json(); + expect(result).toBe("A"); + }); + + it("round-trips emoji via POST", async () => { + const encRes = await POST(makeReq({ input: "😊", mode: "to_binary" })); + const { result: bin } = await encRes.json(); + + const decRes = await POST(makeReq({ input: bin, mode: "from_binary" })); + expect(decRes.status).toBe(200); + const { result } = await decRes.json(); + expect(result).toBe("😊"); + }); + + it("returns 400 for malformed binary on decode", async () => { + const res = await POST(makeReq({ input: "0100GG01", mode: "from_binary" })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + it("returns 400 for wrong bit-length token", async () => { + const res = await POST(makeReq({ input: "0100000", mode: "from_binary" })); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing mode", async () => { + const res = await POST(makeReq({ input: "hello" })); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing input", async () => { + const res = await POST(makeReq({ mode: "to_binary" })); + expect(res.status).toBe(400); + }); + + it("handles empty string to_binary", async () => { + const res = await POST(makeReq({ input: "", mode: "to_binary" })); + expect(res.status).toBe(200); + const { result } = await res.json(); + expect(result).toBe(""); + }); +}); diff --git a/app/api/routes-f/__tests__/deep-merge.test.ts b/app/api/routes-f/__tests__/deep-merge.test.ts new file mode 100644 index 00000000..9123987d --- /dev/null +++ b/app/api/routes-f/__tests__/deep-merge.test.ts @@ -0,0 +1,144 @@ +// @ts-nocheck +/** + * @jest-environment node + */ +import { POST, deepMerge } from "../deep-merge/route"; +import { NextRequest } from "next/server"; + +function makeReq(body: unknown) { + return new NextRequest("http://localhost/api/routes-f/deep-merge", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("/api/routes-f/deep-merge", () => { + describe("deepMerge helper", () => { + it("merges two flat objects", () => { + const result = deepMerge([{ a: 1 }, { b: 2 }], "replace"); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it("overwrites primitive values", () => { + const result = deepMerge([{ a: 1 }, { a: 2 }], "replace"); + expect(result).toEqual({ a: 2 }); + }); + + it("deep merges nested objects", () => { + const result = deepMerge( + [{ user: { name: "Alice", age: 30 } }, { user: { age: 31, city: "NYC" } }], + "replace" + ); + expect(result).toEqual({ user: { name: "Alice", age: 31, city: "NYC" } }); + }); + + it("replace strategy replaces arrays", () => { + const result = deepMerge([{ arr: [1, 2] }, { arr: [3, 4] }], "replace"); + expect(result).toEqual({ arr: [3, 4] }); + }); + + it("concat strategy concatenates arrays", () => { + const result = deepMerge([{ arr: [1, 2] }, { arr: [3, 4] }], "concat"); + expect(result).toEqual({ arr: [1, 2, 3, 4] }); + }); + + it("union strategy deduplicates arrays", () => { + const result = deepMerge([{ arr: [1, 2, 2] }, { arr: [2, 3] }], "union"); + expect(result).toEqual({ arr: [1, 2, 3] }); + }); + + it("handles three objects", () => { + const result = deepMerge([{ a: 1 }, { b: 2 }, { c: 3 }], "replace"); + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it("deeply nested merge", () => { + const result = deepMerge( + [ + { config: { db: { host: "localhost" } } }, + { config: { db: { port: 5432 }, cache: true } }, + ], + "replace" + ); + expect(result).toEqual({ + config: { db: { host: "localhost", port: 5432 }, cache: true }, + }); + }); + }); + + describe("POST handler", () => { + it("merges two objects with default strategy", async () => { + const res = await POST(makeReq({ objects: [{ a: 1 }, { b: 2 }] })); + expect(res.status).toBe(200); + const { merged } = await res.json(); + expect(merged).toEqual({ a: 1, b: 2 }); + }); + + it("uses replace strategy by default for arrays", async () => { + const res = await POST(makeReq({ objects: [{ arr: [1] }, { arr: [2] }] })); + const { merged } = await res.json(); + expect(merged.arr).toEqual([2]); + }); + + it("concat strategy works", async () => { + const res = await POST( + makeReq({ objects: [{ arr: [1] }, { arr: [2] }], array_strategy: "concat" }) + ); + const { merged } = await res.json(); + expect(merged.arr).toEqual([1, 2]); + }); + + it("union strategy works", async () => { + const res = await POST( + makeReq({ objects: [{ arr: [1, 2] }, { arr: [2, 3] }], array_strategy: "union" }) + ); + const { merged } = await res.json(); + expect(merged.arr).toEqual([1, 2, 3]); + }); + + it("deep nested merge via POST", async () => { + const res = await POST( + makeReq({ + objects: [ + { user: { name: "Bob", settings: { theme: "dark" } } }, + { user: { settings: { lang: "en" } } }, + ], + }) + ); + const { merged } = await res.json(); + expect(merged.user.settings).toEqual({ theme: "dark", lang: "en" }); + }); + + it("returns 400 for empty objects array", async () => { + const res = await POST(makeReq({ objects: [] })); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing objects", async () => { + const res = await POST(makeReq({})); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid array_strategy", async () => { + const res = await POST( + makeReq({ objects: [{ a: 1 }], array_strategy: "invalid" }) + ); + expect(res.status).toBe(400); + }); + + it("handles single object", async () => { + const res = await POST(makeReq({ objects: [{ a: 1, b: 2 }] })); + const { merged } = await res.json(); + expect(merged).toEqual({ a: 1, b: 2 }); + }); + + it("rejects body exceeding 2MB", async () => { + const large = { objects: [{ data: "x".repeat(3 * 1024 * 1024) }] }; + const res = await POST(makeReq(large)); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("exceeds"); + }); + }); +}); diff --git a/app/api/routes-f/binary-to-text/route.ts b/app/api/routes-f/binary-to-text/route.ts new file mode 100644 index 00000000..e7e167e7 --- /dev/null +++ b/app/api/routes-f/binary-to-text/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const schema = z.object({ + input: z.string(), + mode: z.enum(["to_binary", "from_binary"]), + bits: z.number().int().positive().optional().default(8), +}); + +/** Encode a UTF-8 string to space-separated binary groups. */ +export function toBinary(input: string, bits: number): string { + const bytes = new TextEncoder().encode(input); + return Array.from(bytes) + .map((b) => b.toString(2).padStart(bits, "0")) + .join(" "); +} + +/** Decode space-separated binary groups back to a UTF-8 string. */ +export function fromBinary(input: string, bits: number): string { + const groups = input.trim().split(/\s+/); + + for (const g of groups) { + if (!/^[01]+$/.test(g)) { + throw new Error(`Invalid binary token: "${g}"`); + } + if (g.length !== bits) { + throw new Error(`Token "${g}" has ${g.length} bits, expected ${bits}`); + } + } + + const bytes = new Uint8Array(groups.map((g) => parseInt(g, 2))); + return new TextDecoder().decode(bytes); +} + +export async function POST(request: Request): Promise { + const result = await validateBody(request, schema); + if (result instanceof NextResponse) return result; + + const { input, mode, bits } = result.data; + + try { + const output = mode === "to_binary" ? toBinary(input, bits) : fromBinary(input, bits); + return NextResponse.json({ result: output }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Conversion failed" }, + { status: 400 } + ); + } +} diff --git a/app/api/routes-f/deep-merge/route.ts b/app/api/routes-f/deep-merge/route.ts new file mode 100644 index 00000000..1e535fd9 --- /dev/null +++ b/app/api/routes-f/deep-merge/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +type ArrayStrategy = "replace" | "concat" | "union"; + +const schema = z.object({ + objects: z.array(z.record(z.unknown())).min(1), + array_strategy: z.enum(["replace", "concat", "union"]).optional().default("replace"), +}); + +const MAX_SIZE = 2 * 1024 * 1024; // 2MB + +function isObject(val: unknown): val is Record { + return typeof val === "object" && val !== null && !Array.isArray(val); +} + +export function deepMerge( + objects: Record[], + arrayStrategy: ArrayStrategy +): Record { + if (objects.length === 0) return {}; + if (objects.length === 1) return objects[0]; + + const result: Record = {}; + + for (const obj of objects) { + for (const key in obj) { + const val = obj[key]; + + if (!(key in result)) { + result[key] = val; + continue; + } + + const existing = result[key]; + + if (Array.isArray(existing) && Array.isArray(val)) { + if (arrayStrategy === "replace") { + result[key] = val; + } else if (arrayStrategy === "concat") { + result[key] = existing.concat(val); + } else if (arrayStrategy === "union") { + result[key] = Array.from(new Set([...existing, ...val])); + } + } else if (isObject(existing) && isObject(val)) { + result[key] = deepMerge([existing, val], arrayStrategy); + } else { + result[key] = val; + } + } + } + + return result; +} + +export async function POST(request: Request): Promise { + const bodyText = await request.text(); + + if (bodyText.length > MAX_SIZE) { + return NextResponse.json( + { error: `Request body exceeds ${MAX_SIZE / 1024 / 1024}MB limit` }, + { status: 400 } + ); + } + + let body: unknown; + try { + body = JSON.parse(bodyText); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { objects, array_strategy } = parsed.data; + const merged = deepMerge(objects, array_strategy); + + return NextResponse.json({ merged }); +}