// Integration tests for the characters/garments/dance-refs admin CRUD // routes (dev-plan.md T3/T4/T5). Spawns server.js on a throwaway port, // same isolation approach as the other route tests, and points the three // JSON stores at a scratch copy so this never touches real seed data. const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const os = require("node:os"); const { spawn } = require("node:child_process"); const PORT = 20934 + Math.floor(Math.random() * 1000); const BASE = `http://localhost:${PORT}`; const ADMIN_PASSWORD = "test-password-only-for-this-run"; let cookie; let child; let cwd; function waitForServer(url, timeoutMs = 10000) { const deadline = Date.now() + timeoutMs; return (async function poll() { while (Date.now() < deadline) { try { await fetch(url); return; } catch { await new Promise((r) => setTimeout(r, 100)); } } throw new Error("server did not come up in time"); })(); } function authed(opts = {}) { return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } }; } test.before(async () => { // Run the server from a scratch directory (copy of just what it needs) // so characters.json/garments.json/dance-refs.json/library.json start // empty and this test can't collide with real seed data or the live // pm2 instance. cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-crud-")); for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } child = spawn(process.execPath, ["server.js"], { cwd, env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD }, stdio: "pipe", }); await waitForServer(BASE); const loginRes = await fetch(`${BASE}/api/admin/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: ADMIN_PASSWORD }), }); cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0]; }); test.after(() => { child.kill(); fs.rmSync(cwd, { recursive: true, force: true }); }); test("characters: create requires name/imageUrl/appearanceDescriptor", async () => { const res = await fetch(`${BASE}/api/admin/characters`, authed({ method: "POST", body: JSON.stringify({ name: "只有名字" }) })); assert.equal(res.status, 400); }); test("characters: create/list/patch/delete round-trip, defaults filled in", async () => { const createRes = await fetch( `${BASE}/api/admin/characters`, authed({ method: "POST", body: JSON.stringify({ name: "小美", imageUrl: "/uploads/x.png", appearanceDescriptor: "black hair" }) }) ); assert.equal(createRes.status, 200); const created = await createRes.json(); assert.equal(created.source, "admin_seed"); assert.equal(created.moderationStatus, "auto_passed"); assert.equal(created.status, "active"); assert.equal(created.isDefault, false); const listRes = await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })); const list = await listRes.json(); assert.ok(list.some((c) => c.id === created.id)); const patchRes = await fetch(`${BASE}/api/admin/characters/${created.id}`, authed({ method: "PATCH", body: JSON.stringify({ name: "小美改名" }) })); assert.equal(patchRes.status, 200); assert.equal((await patchRes.json()).name, "小美改名"); const delRes = await fetch(`${BASE}/api/admin/characters/${created.id}`, authed({ method: "DELETE" })); assert.equal(delRes.status, 200); assert.deepEqual(await delRes.json(), { deleted: true }); const afterDelete = await fetch(`${BASE}/api/admin/characters/${created.id}`, authed({ method: "PATCH", body: JSON.stringify({ name: "x" }) })); assert.equal(afterDelete.status, 404); }); test("characters: setting isDefault on a second character clears it on the first —全库始终只有一条 isDefault: true", async () => { const mk = (name, isDefault) => fetch(`${BASE}/api/admin/characters`, authed({ method: "POST", body: JSON.stringify({ name, imageUrl: "/x.png", appearanceDescriptor: "d", isDefault }) })).then((r) => r.json()); const a = await mk("人物A", true); assert.equal(a.isDefault, true); const b = await mk("人物B", true); assert.equal(b.isDefault, true); const list = await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json()); const defaults = list.filter((c) => c.isDefault); assert.equal(defaults.length, 1); assert.equal(defaults[0].id, b.id); // Flip default back to A via PATCH — should also demote B. await fetch(`${BASE}/api/admin/characters/${a.id}`, authed({ method: "PATCH", body: JSON.stringify({ isDefault: true }) })); const list2 = await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json()); assert.deepEqual( list2.filter((c) => c.isDefault).map((c) => c.id), [a.id] ); }); test("garments: create/list round-trip with defaults", async () => { const res = await fetch( `${BASE}/api/admin/garments`, authed({ method: "POST", body: JSON.stringify({ name: "红旗袍", imageUrl: "/g.png", appearanceDescriptor: "red qipao" }) }) ); assert.equal(res.status, 200); const created = await res.json(); assert.equal(created.source, "admin_seed"); assert.equal(created.isActive, true); assert.equal(created.thumbnailUrl, "/g.png"); // falls back to imageUrl when not given }); test("dance-refs: motionDescriptor is required at creation time (not something you can add later)", async () => { const res = await fetch( `${BASE}/api/admin/dance-refs`, authed({ method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/d.mp4" }) }) ); assert.equal(res.status, 400); const ok = await fetch( `${BASE}/api/admin/dance-refs`, authed({ method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/d.mp4", motionDescriptor: "hip sways" }) }) ); assert.equal(ok.status, 200); const created = await ok.json(); assert.equal(created.copyrightSource, "test_placeholder"); });