diff --git a/dressup-crud.test.js b/dressup-crud.test.js new file mode 100644 index 0000000..c4d9506 --- /dev/null +++ b/dressup-crud.test.js @@ -0,0 +1,151 @@ +// 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"]) { + 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"); +}); diff --git a/server.js b/server.js index 4417008..e9a3e9d 100644 --- a/server.js +++ b/server.js @@ -50,6 +50,7 @@ const { sniffImageType, stripMetadataAndSave, } = require("./uploads.js"); +const { characters, garments, danceRefs } = require("./dressup-store.js"); const LIBRARY_PATH = path.join(__dirname, "library.json"); function getLibrary() { @@ -122,6 +123,156 @@ function isAdminAuthed(req) { return true; } +// Generic CRUD router for the three cold-start asset libraries (T3-T5) — +// GET list / POST create / PATCH update / DELETE, backed by store.js. The +// three entities' shapes differ (see MVCP M.3/M.4/M.5) but the routing and +// validation boilerplate is identical, so this is mounted three times below +// instead of copy-pasted. +function mountCrud(prefix, store, opts = {}) { + const { requiredFields = [], sanitize = (b) => b, onCreate, onUpdate } = opts; + + function missingField(body) { + return requiredFields.find((f) => { + const v = body[f]; + return v === undefined || v === null || (typeof v === "string" && !v.trim()); + }); + } + + return function handle(pathname, req, res) { + if (pathname === prefix && req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(store.list())); + return true; + } + + if (pathname === prefix && req.method === "POST") { + readJsonBody(req) + .then((body) => { + const missing = missingField(body); + if (missing) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: `${missing} required` })); + return; + } + const item = store.create(sanitize(body)); + if (onCreate) onCreate(item, store); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(item)); + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + }); + return true; + } + + if (pathname.startsWith(`${prefix}/`) && req.method === "PATCH") { + const id = pathname.slice(prefix.length + 1); + readJsonBody(req) + .then((body) => { + // Partial update: only reject an explicit blank-out of a required + // field, not the field's mere absence from this patch. + const missing = requiredFields.find((f) => f in body && missingField({ [f]: body[f] }) === f); + if (missing) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: `${missing} required` })); + return; + } + const updated = store.update(id, sanitize(body, true)); + if (!updated) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + if (onUpdate) onUpdate(updated, store); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(updated)); + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + }); + return true; + } + + if (pathname.startsWith(`${prefix}/`) && req.method === "DELETE") { + const id = pathname.slice(prefix.length + 1); + const removed = store.remove(id); + res.writeHead(removed ? 200 : 404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ deleted: removed })); + return true; + } + + return false; + }; +} + +// isDefault is a singleton across the whole characters library (2 节: +// "第一期只允许同时有一个人物被设为默认") — enforced here rather than in +// store.js, which stays schema-agnostic. +function unsetOtherDefaultCharacters(item, store) { + if (!item.isDefault) return; + for (const c of store.list()) { + if (c.id !== item.id && c.isDefault) store.update(c.id, { isDefault: false }); + } +} + +const charactersCrud = mountCrud("/api/admin/characters", characters, { + requiredFields: ["name", "imageUrl", "appearanceDescriptor"], + sanitize: (body, isPatch) => + isPatch + ? body + : { + name: body.name, + imageUrl: body.imageUrl, + tags: Array.isArray(body.tags) ? body.tags : [], + source: "admin_seed", + ownerUserId: null, + appearanceDescriptor: body.appearanceDescriptor, + isDefault: Boolean(body.isDefault), + moderationStatus: "auto_passed", + totalSegments: null, + remainingSegments: null, + status: "active", + }, + onCreate: unsetOtherDefaultCharacters, + onUpdate: unsetOtherDefaultCharacters, +}); + +const garmentsCrud = mountCrud("/api/admin/garments", garments, { + requiredFields: ["name", "imageUrl", "appearanceDescriptor"], + sanitize: (body, isPatch) => + isPatch + ? body + : { + name: body.name, + category: body.category || "", + thumbnailUrl: body.thumbnailUrl || body.imageUrl, + imageUrl: body.imageUrl, + appearanceDescriptor: body.appearanceDescriptor, + source: "admin_seed", + ownerUserId: null, + moderationStatus: "approved", + isActive: body.isActive === undefined ? true : Boolean(body.isActive), + }, +}); + +const danceRefsCrud = mountCrud("/api/admin/dance-refs", danceRefs, { + requiredFields: ["name", "videoUrl", "motionDescriptor"], + sanitize: (body, isPatch) => + isPatch + ? body + : { + name: body.name, + videoUrl: body.videoUrl, + styleTags: Array.isArray(body.styleTags) ? body.styleTags : [], + durationSec: body.durationSec || null, + motionDescriptor: body.motionDescriptor, + copyrightSource: "test_placeholder", + isActive: body.isActive === undefined ? true : Boolean(body.isActive), + }, +}); + function h3ConsoleFetch(pathAndQuery, options) { return new Promise((resolve, reject) => { const proxyReq = http.request( @@ -541,6 +692,10 @@ http return; } + if (charactersCrud(pathname, req, res)) return; + if (garmentsCrud(pathname, req, res)) return; + if (danceRefsCrud(pathname, req, res)) return; + if (pathname === "/api/library") { const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; const lib = getLibrary().slice();