810 lines
28 KiB
TypeScript
810 lines
28 KiB
TypeScript
// IMP-52 u3/u4 — vitest coverage for the vite `/api/user-overrides/:key`
|
|
// GET and PUT endpoints and their supporting helpers.
|
|
//
|
|
// Scope:
|
|
// u3 (read path):
|
|
// 1) isValidUserOverridesKey: accept MDX-stem keys (03, 03__DX_BIM,
|
|
// a-b.c), reject empty / leading-dot / `..` / `/` / `\` /
|
|
// disallowed chars. Mirrors src/user_overrides_io.validate_key so
|
|
// backend (u2) and frontend endpoint (u3) agree on every key.
|
|
// 2) userOverridesPath: returns <root>/data/user_overrides/<key>.json.
|
|
// 3) handleGetUserOverrides: method != GET → false (next chained for
|
|
// PUT); invalid key → 400; missing file → 200 {}; corrupt JSON /
|
|
// non-object root → 200 {} (graceful degrade per u1 load contract);
|
|
// valid object JSON → 200 with parsed payload echoed back.
|
|
//
|
|
// u4 (write path):
|
|
// 4) mergeUserOverrides: only KNOWN_USER_OVERRIDES_AXES mutated;
|
|
// foreign top-level keys preserved; null clears axis; non-axis
|
|
// partial keys dropped (allowlist).
|
|
// 5) atomicWriteUserOverrides: tmp + rename; parent dir auto-created.
|
|
// 6) handlePutUserOverrides: method != PUT → false (next chained);
|
|
// invalid key → 400; invalid JSON → 400; non-object body → 400;
|
|
// success → 200 with merged result; partial-merge preserves axes
|
|
// not in payload; foreign-key preserve on disk; allowlist drops
|
|
// unknown payload keys; explicit null clears; corrupt existing →
|
|
// recover to clean state.
|
|
//
|
|
// Tests exercise the pure handlers with mock req/res — no real vite server.
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { EventEmitter } from "node:events";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
KNOWN_USER_OVERRIDES_AXES,
|
|
USER_OVERRIDES_KEY_RE,
|
|
atomicWriteUserOverrides,
|
|
handleGetUserOverrides,
|
|
handlePutUserOverrides,
|
|
isValidUserOverridesKey,
|
|
mergeUserOverrides,
|
|
userOverridesPath,
|
|
} from "../../vite.config";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// mock res helper — captures writeHead(status, headers) + end(body) so the
|
|
// handler can be invoked synchronously without spawning a TCP socket.
|
|
// ---------------------------------------------------------------------------
|
|
function makeMockRes() {
|
|
const state = {
|
|
statusCode: 0,
|
|
headers: {} as Record<string, string>,
|
|
body: "",
|
|
ended: false,
|
|
};
|
|
return {
|
|
state,
|
|
res: {
|
|
writeHead(status: number, headers?: Record<string, string>) {
|
|
state.statusCode = status;
|
|
if (headers) state.headers = headers;
|
|
},
|
|
end(body?: string) {
|
|
state.body = body ?? "";
|
|
state.ended = true;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("USER_OVERRIDES_KEY_RE (IMP-52 u3)", () => {
|
|
it("matches Python validate_key regex literally", () => {
|
|
// The pattern locked in src/user_overrides_io.py:_KEY_RE — any drift here
|
|
// means backend pipeline fallback (u2) and the vite endpoint disagree on
|
|
// which keys are routable, which is the single failure mode that would
|
|
// silently lose persisted overrides.
|
|
expect(USER_OVERRIDES_KEY_RE.source).toBe(
|
|
"^[A-Za-z0-9_][A-Za-z0-9_.\\-]*$",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("isValidUserOverridesKey (IMP-52 u3)", () => {
|
|
it("accepts MDX-stem-style keys actually used in samples/mdx/", () => {
|
|
// 03 / 04 / 05 are the wired sample MDXs (vite.config.ts:SAMPLE_MDX_MAP).
|
|
expect(isValidUserOverridesKey("03")).toBe(true);
|
|
expect(isValidUserOverridesKey("04")).toBe(true);
|
|
expect(isValidUserOverridesKey("05")).toBe(true);
|
|
// Stage 1 EVIDENCE references 03__DX_BIM... — must round-trip.
|
|
expect(isValidUserOverridesKey("03__DX_BIM")).toBe(true);
|
|
expect(isValidUserOverridesKey("a-b.c")).toBe(true);
|
|
expect(isValidUserOverridesKey("a")).toBe(true);
|
|
expect(isValidUserOverridesKey("_leading_underscore")).toBe(true);
|
|
expect(isValidUserOverridesKey("9starts_with_digit")).toBe(true);
|
|
});
|
|
|
|
it("rejects empty and whitespace-only keys", () => {
|
|
expect(isValidUserOverridesKey("")).toBe(false);
|
|
});
|
|
|
|
it("rejects path-traversal substrings", () => {
|
|
// `..` rejected explicitly even if the rest of the regex would allow it
|
|
// — `a..b` would otherwise pass the char class.
|
|
expect(isValidUserOverridesKey("..")).toBe(false);
|
|
expect(isValidUserOverridesKey("a..b")).toBe(false);
|
|
expect(isValidUserOverridesKey("../escape")).toBe(false);
|
|
});
|
|
|
|
it("rejects path separators", () => {
|
|
expect(isValidUserOverridesKey("a/b")).toBe(false);
|
|
expect(isValidUserOverridesKey("a\\b")).toBe(false);
|
|
expect(isValidUserOverridesKey("/")).toBe(false);
|
|
expect(isValidUserOverridesKey("\\")).toBe(false);
|
|
});
|
|
|
|
it("rejects keys starting with a non-word character", () => {
|
|
expect(isValidUserOverridesKey(".hidden")).toBe(false);
|
|
expect(isValidUserOverridesKey("-leading-dash")).toBe(false);
|
|
});
|
|
|
|
it("rejects characters outside [A-Za-z0-9_.-]", () => {
|
|
expect(isValidUserOverridesKey("a b")).toBe(false);
|
|
expect(isValidUserOverridesKey("a:b")).toBe(false);
|
|
expect(isValidUserOverridesKey("a*b")).toBe(false);
|
|
expect(isValidUserOverridesKey("a%2Fb")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("userOverridesPath (IMP-52 u3)", () => {
|
|
it("resolves <root>/data/user_overrides/<key>.json regardless of OS sep", () => {
|
|
const root = path.join("X:", "design_agent");
|
|
const got = userOverridesPath(root, "03");
|
|
expect(got).toBe(path.join(root, "data", "user_overrides", "03.json"));
|
|
});
|
|
});
|
|
|
|
describe("handleGetUserOverrides (IMP-52 u3)", () => {
|
|
let tmpRoot: string;
|
|
let overridesDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u3-"));
|
|
overridesDir = path.join(tmpRoot, "data", "user_overrides");
|
|
fs.mkdirSync(overridesDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns false (next chained) when method != GET", () => {
|
|
const { res, state } = makeMockRes();
|
|
const handled = handleGetUserOverrides(
|
|
{ method: "PUT", url: "/03" },
|
|
res,
|
|
tmpRoot,
|
|
);
|
|
expect(handled).toBe(false);
|
|
// Crucial for u4: PUT must reach its own middleware unobstructed.
|
|
expect(state.ended).toBe(false);
|
|
expect(state.statusCode).toBe(0);
|
|
});
|
|
|
|
it("returns 400 on invalid key (path traversal)", () => {
|
|
const { res, state } = makeMockRes();
|
|
const handled = handleGetUserOverrides(
|
|
{ method: "GET", url: "/../escape" },
|
|
res,
|
|
tmpRoot,
|
|
);
|
|
expect(handled).toBe(true);
|
|
expect(state.statusCode).toBe(400);
|
|
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
|
|
});
|
|
|
|
it("returns 400 on invalid key (missing key segment)", () => {
|
|
const { res, state } = makeMockRes();
|
|
const handled = handleGetUserOverrides(
|
|
{ method: "GET", url: "/" },
|
|
res,
|
|
tmpRoot,
|
|
);
|
|
expect(handled).toBe(true);
|
|
expect(state.statusCode).toBe(400);
|
|
});
|
|
|
|
it("returns 200 {} on missing file (graceful degrade)", () => {
|
|
const { res, state } = makeMockRes();
|
|
const handled = handleGetUserOverrides(
|
|
{ method: "GET", url: "/03" },
|
|
res,
|
|
tmpRoot,
|
|
);
|
|
expect(handled).toBe(true);
|
|
expect(state.statusCode).toBe(200);
|
|
expect(state.body).toBe("{}");
|
|
});
|
|
|
|
it("returns 200 {} on corrupt JSON (graceful degrade)", () => {
|
|
fs.writeFileSync(path.join(overridesDir, "03.json"), "{not json", "utf-8");
|
|
const { res, state } = makeMockRes();
|
|
const handled = handleGetUserOverrides(
|
|
{ method: "GET", url: "/03" },
|
|
res,
|
|
tmpRoot,
|
|
);
|
|
expect(handled).toBe(true);
|
|
expect(state.statusCode).toBe(200);
|
|
expect(state.body).toBe("{}");
|
|
});
|
|
|
|
it("returns 200 {} when JSON root is not an object", () => {
|
|
// Mirrors u1 load() which treats non-object roots as corrupt — covers
|
|
// both arrays and primitives so the frontend never receives a shape
|
|
// the typed service (u5) can't deserialize.
|
|
fs.writeFileSync(
|
|
path.join(overridesDir, "arr.json"),
|
|
JSON.stringify([1, 2, 3]),
|
|
"utf-8",
|
|
);
|
|
const { res, state } = makeMockRes();
|
|
const handled = handleGetUserOverrides(
|
|
{ method: "GET", url: "/arr" },
|
|
res,
|
|
tmpRoot,
|
|
);
|
|
expect(handled).toBe(true);
|
|
expect(state.statusCode).toBe(200);
|
|
expect(state.body).toBe("{}");
|
|
|
|
fs.writeFileSync(path.join(overridesDir, "num.json"), "42", "utf-8");
|
|
const { res: res2, state: state2 } = makeMockRes();
|
|
handleGetUserOverrides({ method: "GET", url: "/num" }, res2, tmpRoot);
|
|
expect(state2.statusCode).toBe(200);
|
|
expect(state2.body).toBe("{}");
|
|
});
|
|
|
|
it("returns 200 with parsed JSON object on hit", () => {
|
|
const payload = {
|
|
layout: "two_zone_split",
|
|
frames: { "03-1+03-2": "frame_07" },
|
|
zone_geometries: {
|
|
top: { x: 0.05, y: 0.1, w: 0.9, h: 0.3 },
|
|
},
|
|
zone_sections: { top: ["03-1", "03-2"] },
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(overridesDir, "03.json"),
|
|
JSON.stringify(payload),
|
|
"utf-8",
|
|
);
|
|
const { res, state } = makeMockRes();
|
|
const handled = handleGetUserOverrides(
|
|
{ method: "GET", url: "/03" },
|
|
res,
|
|
tmpRoot,
|
|
);
|
|
expect(handled).toBe(true);
|
|
expect(state.statusCode).toBe(200);
|
|
expect(state.headers["Content-Type"]).toBe(
|
|
"application/json; charset=utf-8",
|
|
);
|
|
expect(JSON.parse(state.body)).toEqual(payload);
|
|
});
|
|
|
|
it("preserves foreign top-level keys in the response", () => {
|
|
// Forward-compat with future axes (e.g., zone_sizes, image_overrides).
|
|
// u1 save() preserves them on the disk side; u3 GET must surface them
|
|
// so the frontend service (u5) can decide whether to act on them.
|
|
const payload = {
|
|
layout: "single_zone",
|
|
zone_sizes: { top: 0.42 }, // not part of KNOWN_AXES yet
|
|
custom_extension: { foo: "bar" },
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(overridesDir, "future.json"),
|
|
JSON.stringify(payload),
|
|
"utf-8",
|
|
);
|
|
const { res, state } = makeMockRes();
|
|
handleGetUserOverrides({ method: "GET", url: "/future" }, res, tmpRoot);
|
|
expect(state.statusCode).toBe(200);
|
|
expect(JSON.parse(state.body)).toEqual(payload);
|
|
});
|
|
|
|
it("strips the leading slash and ignores query string when keying", () => {
|
|
fs.writeFileSync(
|
|
path.join(overridesDir, "03.json"),
|
|
JSON.stringify({ layout: "x" }),
|
|
"utf-8",
|
|
);
|
|
const { res, state } = makeMockRes();
|
|
handleGetUserOverrides(
|
|
{ method: "GET", url: "/03?ts=1747884800" },
|
|
res,
|
|
tmpRoot,
|
|
);
|
|
expect(state.statusCode).toBe(200);
|
|
expect(JSON.parse(state.body)).toEqual({ layout: "x" });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// IMP-52 u4 — PUT endpoint coverage
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => {
|
|
it("matches the Python KNOWN_AXES tuple in src/user_overrides_io.py", () => {
|
|
// The on-disk schema is shared with backend pipeline fallback (u2).
|
|
// Any drift here means a PUT could write an axis that the Python
|
|
// load() ignores, or vice-versa, silently losing user overrides.
|
|
expect(KNOWN_USER_OVERRIDES_AXES).toEqual([
|
|
"layout",
|
|
"zone_geometries",
|
|
"zone_sections",
|
|
"frames",
|
|
"image_overrides",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("mergeUserOverrides (IMP-52 u4)", () => {
|
|
it("only mutates KNOWN_AXES present in partial", () => {
|
|
const existing = {
|
|
layout: "old",
|
|
frames: { "03-1": "frame_01" },
|
|
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
|
zone_sections: { top: ["03-1"] },
|
|
};
|
|
const merged = mergeUserOverrides(existing, { layout: "new" });
|
|
expect(merged.layout).toBe("new");
|
|
// axes not in partial are preserved
|
|
expect(merged.frames).toEqual({ "03-1": "frame_01" });
|
|
expect(merged.zone_geometries).toEqual({
|
|
top: { x: 0, y: 0, w: 1, h: 0.5 },
|
|
});
|
|
expect(merged.zone_sections).toEqual({ top: ["03-1"] });
|
|
});
|
|
|
|
it("preserves foreign top-level keys in existing", () => {
|
|
// Forward-compat: future axes (zone_sizes, schema_version, etc.) on
|
|
// disk must survive PUT writes that only touch the 5 in-scope axes.
|
|
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2 —
|
|
// it joined KNOWN_USER_OVERRIDES_AXES — so we probe with axes that
|
|
// are still NOT in the allowlist.
|
|
const existing = {
|
|
layout: "old",
|
|
zone_sizes: { top: 0.42 },
|
|
schema_version: 2,
|
|
};
|
|
const merged = mergeUserOverrides(existing, { layout: "new" });
|
|
expect(merged.zone_sizes).toEqual({ top: 0.42 });
|
|
expect(merged.schema_version).toBe(2);
|
|
});
|
|
|
|
it("clears axis when partial value is null (explicit clear)", () => {
|
|
const existing = { layout: "x", frames: { "03-1": "f01" } };
|
|
const merged = mergeUserOverrides(existing, { layout: null });
|
|
expect("layout" in merged).toBe(false);
|
|
expect(merged.frames).toEqual({ "03-1": "f01" });
|
|
});
|
|
|
|
it("drops non-axis keys in partial (allowlist)", () => {
|
|
// PUT payload may carry junk fields (typo, malicious key); allowlist
|
|
// ensures only the 5 axes can be written to disk.
|
|
const merged = mergeUserOverrides(
|
|
{},
|
|
{ layout: "x", random_key: "evil", __proto__: "x" } as Record<
|
|
string,
|
|
unknown
|
|
>,
|
|
);
|
|
expect(merged.layout).toBe("x");
|
|
expect("random_key" in merged).toBe(false);
|
|
});
|
|
|
|
it("merges all 5 axes when present in partial", () => {
|
|
const merged = mergeUserOverrides(
|
|
{},
|
|
{
|
|
layout: "two_zone_split",
|
|
frames: { "03-1+03-2": "frame_07" },
|
|
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
|
zone_sections: { top: ["03-1", "03-2"] },
|
|
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
|
},
|
|
);
|
|
expect(Object.keys(merged).sort()).toEqual([
|
|
"frames",
|
|
"image_overrides",
|
|
"layout",
|
|
"zone_geometries",
|
|
"zone_sections",
|
|
]);
|
|
});
|
|
|
|
it("preserves image_overrides when absent from partial (5th axis IMP-51 #79 u2)", () => {
|
|
// Sibling axis of layout/frames/zone_geometries/zone_sections: a PUT
|
|
// that touches only layout must NOT erase the image_overrides map
|
|
// already on disk. Mirrors the partial-merge invariant for the 4
|
|
// pre-existing axes.
|
|
const existing = {
|
|
layout: "old",
|
|
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
|
};
|
|
const merged = mergeUserOverrides(existing, { layout: "new" });
|
|
expect(merged.image_overrides).toEqual({
|
|
"img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 },
|
|
});
|
|
expect(merged.layout).toBe("new");
|
|
});
|
|
|
|
it("clears image_overrides when partial value is null (explicit clear)", () => {
|
|
// Same null-sentinel contract as the 4 sibling axes — `null` removes
|
|
// the axis from disk so the next render reverts to baseline (no
|
|
// user image position/size override).
|
|
const existing = {
|
|
layout: "x",
|
|
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
|
};
|
|
const merged = mergeUserOverrides(existing, { image_overrides: null });
|
|
expect("image_overrides" in merged).toBe(false);
|
|
expect(merged.layout).toBe("x");
|
|
});
|
|
|
|
it("does not mutate the existing input", () => {
|
|
const existing = { layout: "old", frames: { a: "b" } };
|
|
const snapshot = JSON.parse(JSON.stringify(existing));
|
|
mergeUserOverrides(existing, { layout: "new", layout_evil: "x" } as Record<
|
|
string,
|
|
unknown
|
|
>);
|
|
expect(existing).toEqual(snapshot);
|
|
});
|
|
});
|
|
|
|
describe("atomicWriteUserOverrides (IMP-52 u4)", () => {
|
|
let tmpRoot: string;
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-aw-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("creates parent dir if missing and writes JSON content", () => {
|
|
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
|
expect(fs.existsSync(path.dirname(filePath))).toBe(false);
|
|
atomicWriteUserOverrides(filePath, { layout: "x" });
|
|
expect(fs.existsSync(filePath)).toBe(true);
|
|
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
|
layout: "x",
|
|
});
|
|
});
|
|
|
|
it("leaves no .tmp residue after a successful write", () => {
|
|
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
|
atomicWriteUserOverrides(filePath, { layout: "x" });
|
|
const dirContents = fs.readdirSync(path.dirname(filePath));
|
|
expect(dirContents).toEqual(["03.json"]);
|
|
});
|
|
|
|
it("overwrites an existing file atomically", () => {
|
|
const filePath = path.join(tmpRoot, "data", "user_overrides", "03.json");
|
|
atomicWriteUserOverrides(filePath, { layout: "v1" });
|
|
atomicWriteUserOverrides(filePath, { layout: "v2" });
|
|
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
|
layout: "v2",
|
|
});
|
|
});
|
|
});
|
|
|
|
// req mock — EventEmitter with method/url + a `send(body)` helper that
|
|
// emits the data chunk and then `end`, mirroring the node IncomingMessage
|
|
// flow used by vite's dev middlewares.
|
|
function makeMockReq(opts: {
|
|
method?: string;
|
|
url?: string;
|
|
}): EventEmitter & { method?: string; url?: string; send: (body: string) => void } {
|
|
const ee = new EventEmitter() as EventEmitter & {
|
|
method?: string;
|
|
url?: string;
|
|
send: (body: string) => void;
|
|
};
|
|
ee.method = opts.method;
|
|
ee.url = opts.url;
|
|
ee.send = (body: string) => {
|
|
if (body.length > 0) ee.emit("data", Buffer.from(body, "utf-8"));
|
|
ee.emit("end");
|
|
};
|
|
return ee;
|
|
}
|
|
|
|
describe("handlePutUserOverrides (IMP-52 u4)", () => {
|
|
let tmpRoot: string;
|
|
let overridesDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "imp52-u4-"));
|
|
overridesDir = path.join(tmpRoot, "data", "user_overrides");
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns false (next chained) when method != PUT", () => {
|
|
const req = makeMockReq({ method: "GET", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
const handled = handlePutUserOverrides(req, res, tmpRoot);
|
|
expect(handled).toBe(false);
|
|
expect(state.ended).toBe(false);
|
|
});
|
|
|
|
it("returns 400 on invalid key", () => {
|
|
const req = makeMockReq({ method: "PUT", url: "/../escape" });
|
|
const { res, state } = makeMockRes();
|
|
const handled = handlePutUserOverrides(req, res, tmpRoot);
|
|
expect(handled).toBe(true);
|
|
expect(state.statusCode).toBe(400);
|
|
expect(JSON.parse(state.body)).toEqual({ error: "invalid key" });
|
|
});
|
|
|
|
it("returns 400 on invalid JSON body", () => {
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
req.send("{not json");
|
|
expect(state.statusCode).toBe(400);
|
|
expect(JSON.parse(state.body)).toEqual({ error: "invalid JSON" });
|
|
// file MUST NOT have been created on parse failure
|
|
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(false);
|
|
});
|
|
|
|
it("returns 400 when JSON body is an array", () => {
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
req.send(JSON.stringify([1, 2, 3]));
|
|
expect(state.statusCode).toBe(400);
|
|
expect(JSON.parse(state.body)).toEqual({
|
|
error: "body must be a JSON object",
|
|
});
|
|
});
|
|
|
|
it("returns 400 when JSON body is a primitive", () => {
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
req.send("42");
|
|
expect(state.statusCode).toBe(400);
|
|
expect(JSON.parse(state.body)).toEqual({
|
|
error: "body must be a JSON object",
|
|
});
|
|
});
|
|
|
|
it("creates the override file on first PUT and returns merged body", () => {
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
|
|
const payload = { layout: "two_zone_split" };
|
|
req.send(JSON.stringify(payload));
|
|
|
|
expect(state.statusCode).toBe(200);
|
|
expect(state.headers["Content-Type"]).toBe(
|
|
"application/json; charset=utf-8",
|
|
);
|
|
expect(JSON.parse(state.body)).toEqual({ layout: "two_zone_split" });
|
|
|
|
const filePath = path.join(overridesDir, "03.json");
|
|
expect(fs.existsSync(filePath)).toBe(true);
|
|
expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual({
|
|
layout: "two_zone_split",
|
|
});
|
|
});
|
|
|
|
it("partial-merges: axes absent from payload are preserved on disk", () => {
|
|
fs.mkdirSync(overridesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(overridesDir, "03.json"),
|
|
JSON.stringify({
|
|
layout: "old",
|
|
frames: { "03-1": "frame_01" },
|
|
zone_sections: { top: ["03-1"] },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
req.send(JSON.stringify({ layout: "new" }));
|
|
|
|
expect(state.statusCode).toBe(200);
|
|
const onDisk = JSON.parse(
|
|
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
|
);
|
|
expect(onDisk).toEqual({
|
|
layout: "new",
|
|
frames: { "03-1": "frame_01" },
|
|
zone_sections: { top: ["03-1"] },
|
|
});
|
|
});
|
|
|
|
it("preserves foreign top-level keys on disk (forward-compat)", () => {
|
|
// `image_overrides` is no longer a foreign key after IMP-51 #79 u2;
|
|
// probe with axes that are still NOT in KNOWN_USER_OVERRIDES_AXES.
|
|
fs.mkdirSync(overridesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(overridesDir, "future.json"),
|
|
JSON.stringify({
|
|
layout: "old",
|
|
zone_sizes: { top: 0.42 },
|
|
schema_version: 2,
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
const req = makeMockReq({ method: "PUT", url: "/future" });
|
|
const { res } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
req.send(JSON.stringify({ layout: "new" }));
|
|
|
|
const onDisk = JSON.parse(
|
|
fs.readFileSync(path.join(overridesDir, "future.json"), "utf-8"),
|
|
);
|
|
expect(onDisk.zone_sizes).toEqual({ top: 0.42 });
|
|
expect(onDisk.schema_version).toBe(2);
|
|
expect(onDisk.layout).toBe("new");
|
|
});
|
|
|
|
it("persists image_overrides partial-merge and preserves sibling axes (IMP-51 #79 u2)", () => {
|
|
// 5th axis end-to-end PUT round-trip: writing only image_overrides
|
|
// must NOT touch the 4 sibling axes already on disk. Mirrors the
|
|
// existing partial-merge test for layout above.
|
|
fs.mkdirSync(overridesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(overridesDir, "03.json"),
|
|
JSON.stringify({
|
|
layout: "two_zone_split",
|
|
frames: { "03-1": "frame_01" },
|
|
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
|
zone_sections: { top: ["03-1"] },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
req.send(
|
|
JSON.stringify({
|
|
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
|
}),
|
|
);
|
|
|
|
expect(state.statusCode).toBe(200);
|
|
const onDisk = JSON.parse(
|
|
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
|
);
|
|
expect(onDisk).toEqual({
|
|
layout: "two_zone_split",
|
|
frames: { "03-1": "frame_01" },
|
|
zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } },
|
|
zone_sections: { top: ["03-1"] },
|
|
image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } },
|
|
});
|
|
});
|
|
|
|
it("drops non-axis payload keys (allowlist enforced at write)", () => {
|
|
fs.mkdirSync(overridesDir, { recursive: true });
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
|
|
req.send(
|
|
JSON.stringify({
|
|
layout: "two_zone_split",
|
|
random_evil_key: "should not persist",
|
|
}),
|
|
);
|
|
|
|
expect(state.statusCode).toBe(200);
|
|
const onDisk = JSON.parse(
|
|
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
|
);
|
|
expect(onDisk).toEqual({ layout: "two_zone_split" });
|
|
expect("random_evil_key" in onDisk).toBe(false);
|
|
});
|
|
|
|
it("clears an axis when payload sets it to null", () => {
|
|
fs.mkdirSync(overridesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(overridesDir, "03.json"),
|
|
JSON.stringify({ layout: "old", frames: { "03-1": "f01" } }),
|
|
"utf-8",
|
|
);
|
|
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
req.send(JSON.stringify({ layout: null }));
|
|
|
|
const onDisk = JSON.parse(
|
|
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
|
);
|
|
expect("layout" in onDisk).toBe(false);
|
|
expect(onDisk.frames).toEqual({ "03-1": "f01" });
|
|
});
|
|
|
|
it("recovers from corrupt existing file (graceful degrade)", () => {
|
|
fs.mkdirSync(overridesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(overridesDir, "03.json"),
|
|
"{this is not JSON",
|
|
"utf-8",
|
|
);
|
|
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
req.send(JSON.stringify({ layout: "recovered" }));
|
|
|
|
expect(state.statusCode).toBe(200);
|
|
const onDisk = JSON.parse(
|
|
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
|
);
|
|
expect(onDisk).toEqual({ layout: "recovered" });
|
|
});
|
|
|
|
it("treats array-rooted existing file as empty (graceful degrade)", () => {
|
|
fs.mkdirSync(overridesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(overridesDir, "03.json"),
|
|
JSON.stringify(["not", "an", "object"]),
|
|
"utf-8",
|
|
);
|
|
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
req.send(JSON.stringify({ layout: "recovered" }));
|
|
|
|
expect(state.statusCode).toBe(200);
|
|
const onDisk = JSON.parse(
|
|
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
|
);
|
|
expect(onDisk).toEqual({ layout: "recovered" });
|
|
});
|
|
|
|
it("strips the leading slash and ignores query string when keying", () => {
|
|
const req = makeMockReq({
|
|
method: "PUT",
|
|
url: "/03?ts=1747884800",
|
|
});
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
req.send(JSON.stringify({ layout: "x" }));
|
|
|
|
expect(state.statusCode).toBe(200);
|
|
expect(fs.existsSync(path.join(overridesDir, "03.json"))).toBe(true);
|
|
});
|
|
|
|
it("accepts an empty body as a no-op partial (no axes mutated)", () => {
|
|
fs.mkdirSync(overridesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(overridesDir, "03.json"),
|
|
JSON.stringify({ layout: "kept" }),
|
|
"utf-8",
|
|
);
|
|
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
req.send("");
|
|
|
|
expect(state.statusCode).toBe(200);
|
|
const onDisk = JSON.parse(
|
|
fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"),
|
|
);
|
|
expect(onDisk).toEqual({ layout: "kept" });
|
|
});
|
|
|
|
it("accepts a chunked PUT body (concatenates data events)", () => {
|
|
const req = makeMockReq({ method: "PUT", url: "/03" });
|
|
const { res, state } = makeMockRes();
|
|
expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true);
|
|
|
|
const body = JSON.stringify({
|
|
layout: "two_zone_split",
|
|
frames: { "03-1": "frame_01" },
|
|
});
|
|
// Emit in two halves to simulate a fragmented HTTP body.
|
|
const half = Math.floor(body.length / 2);
|
|
req.emit("data", Buffer.from(body.slice(0, half), "utf-8"));
|
|
req.emit("data", Buffer.from(body.slice(half), "utf-8"));
|
|
req.emit("end");
|
|
|
|
expect(state.statusCode).toBe(200);
|
|
expect(JSON.parse(state.body)).toEqual({
|
|
layout: "two_zone_split",
|
|
frames: { "03-1": "frame_01" },
|
|
});
|
|
});
|
|
});
|