Commit c8844a4b6fa759db15eca6595a6beb949daa4a16

Authored by yibo
1 parent b8c474b8

Add characters/garments/dance-refs CRUD API routes (T3-T5 backend)

mountCrud() is a small generic GET-list/POST-create/PATCH/DELETE
router shared across the three entities (same shape, see MVCP
M.3/M.4/M.5) instead of copy-pasting the boilerplate three times.
Each entity fills in its own admin_seed-appropriate defaults on
create. characters additionally enforces the single-default-character
invariant (2 节) across create/update.

HTML admin pages for these come next.
dressup-crud.test.js 0 → 100644
  1 +// Integration tests for the characters/garments/dance-refs admin CRUD
  2 +// routes (dev-plan.md T3/T4/T5). Spawns server.js on a throwaway port,
  3 +// same isolation approach as the other route tests, and points the three
  4 +// JSON stores at a scratch copy so this never touches real seed data.
  5 +const test = require("node:test");
  6 +const assert = require("node:assert/strict");
  7 +const fs = require("node:fs");
  8 +const path = require("node:path");
  9 +const os = require("node:os");
  10 +const { spawn } = require("node:child_process");
  11 +
  12 +const PORT = 20934 + Math.floor(Math.random() * 1000);
  13 +const BASE = `http://localhost:${PORT}`;
  14 +const ADMIN_PASSWORD = "test-password-only-for-this-run";
  15 +let cookie;
  16 +let child;
  17 +let cwd;
  18 +
  19 +function waitForServer(url, timeoutMs = 10000) {
  20 + const deadline = Date.now() + timeoutMs;
  21 + return (async function poll() {
  22 + while (Date.now() < deadline) {
  23 + try {
  24 + await fetch(url);
  25 + return;
  26 + } catch {
  27 + await new Promise((r) => setTimeout(r, 100));
  28 + }
  29 + }
  30 + throw new Error("server did not come up in time");
  31 + })();
  32 +}
  33 +
  34 +function authed(opts = {}) {
  35 + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } };
  36 +}
  37 +
  38 +test.before(async () => {
  39 + // Run the server from a scratch directory (copy of just what it needs)
  40 + // so characters.json/garments.json/dance-refs.json/library.json start
  41 + // empty and this test can't collide with real seed data or the live
  42 + // pm2 instance.
  43 + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-crud-"));
  44 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js"]) {
  45 + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
  46 + }
  47 +
  48 + child = spawn(process.execPath, ["server.js"], {
  49 + cwd,
  50 + env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD },
  51 + stdio: "pipe",
  52 + });
  53 + await waitForServer(BASE);
  54 +
  55 + const loginRes = await fetch(`${BASE}/api/admin/login`, {
  56 + method: "POST",
  57 + headers: { "Content-Type": "application/json" },
  58 + body: JSON.stringify({ password: ADMIN_PASSWORD }),
  59 + });
  60 + cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0];
  61 +});
  62 +
  63 +test.after(() => {
  64 + child.kill();
  65 + fs.rmSync(cwd, { recursive: true, force: true });
  66 +});
  67 +
  68 +test("characters: create requires name/imageUrl/appearanceDescriptor", async () => {
  69 + const res = await fetch(`${BASE}/api/admin/characters`, authed({ method: "POST", body: JSON.stringify({ name: "只有名字" }) }));
  70 + assert.equal(res.status, 400);
  71 +});
  72 +
  73 +test("characters: create/list/patch/delete round-trip, defaults filled in", async () => {
  74 + const createRes = await fetch(
  75 + `${BASE}/api/admin/characters`,
  76 + authed({ method: "POST", body: JSON.stringify({ name: "小美", imageUrl: "/uploads/x.png", appearanceDescriptor: "black hair" }) })
  77 + );
  78 + assert.equal(createRes.status, 200);
  79 + const created = await createRes.json();
  80 + assert.equal(created.source, "admin_seed");
  81 + assert.equal(created.moderationStatus, "auto_passed");
  82 + assert.equal(created.status, "active");
  83 + assert.equal(created.isDefault, false);
  84 +
  85 + const listRes = await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" }));
  86 + const list = await listRes.json();
  87 + assert.ok(list.some((c) => c.id === created.id));
  88 +
  89 + const patchRes = await fetch(`${BASE}/api/admin/characters/${created.id}`, authed({ method: "PATCH", body: JSON.stringify({ name: "小美改名" }) }));
  90 + assert.equal(patchRes.status, 200);
  91 + assert.equal((await patchRes.json()).name, "小美改名");
  92 +
  93 + const delRes = await fetch(`${BASE}/api/admin/characters/${created.id}`, authed({ method: "DELETE" }));
  94 + assert.equal(delRes.status, 200);
  95 + assert.deepEqual(await delRes.json(), { deleted: true });
  96 +
  97 + const afterDelete = await fetch(`${BASE}/api/admin/characters/${created.id}`, authed({ method: "PATCH", body: JSON.stringify({ name: "x" }) }));
  98 + assert.equal(afterDelete.status, 404);
  99 +});
  100 +
  101 +test("characters: setting isDefault on a second character clears it on the first —全库始终只有一条 isDefault: true", async () => {
  102 + const mk = (name, isDefault) =>
  103 + fetch(`${BASE}/api/admin/characters`, authed({ method: "POST", body: JSON.stringify({ name, imageUrl: "/x.png", appearanceDescriptor: "d", isDefault }) })).then((r) => r.json());
  104 +
  105 + const a = await mk("人物A", true);
  106 + assert.equal(a.isDefault, true);
  107 +
  108 + const b = await mk("人物B", true);
  109 + assert.equal(b.isDefault, true);
  110 +
  111 + const list = await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json());
  112 + const defaults = list.filter((c) => c.isDefault);
  113 + assert.equal(defaults.length, 1);
  114 + assert.equal(defaults[0].id, b.id);
  115 +
  116 + // Flip default back to A via PATCH — should also demote B.
  117 + await fetch(`${BASE}/api/admin/characters/${a.id}`, authed({ method: "PATCH", body: JSON.stringify({ isDefault: true }) }));
  118 + const list2 = await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json());
  119 + assert.deepEqual(
  120 + list2.filter((c) => c.isDefault).map((c) => c.id),
  121 + [a.id]
  122 + );
  123 +});
  124 +
  125 +test("garments: create/list round-trip with defaults", async () => {
  126 + const res = await fetch(
  127 + `${BASE}/api/admin/garments`,
  128 + authed({ method: "POST", body: JSON.stringify({ name: "红旗袍", imageUrl: "/g.png", appearanceDescriptor: "red qipao" }) })
  129 + );
  130 + assert.equal(res.status, 200);
  131 + const created = await res.json();
  132 + assert.equal(created.source, "admin_seed");
  133 + assert.equal(created.isActive, true);
  134 + assert.equal(created.thumbnailUrl, "/g.png"); // falls back to imageUrl when not given
  135 +});
  136 +
  137 +test("dance-refs: motionDescriptor is required at creation time (not something you can add later)", async () => {
  138 + const res = await fetch(
  139 + `${BASE}/api/admin/dance-refs`,
  140 + authed({ method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/d.mp4" }) })
  141 + );
  142 + assert.equal(res.status, 400);
  143 +
  144 + const ok = await fetch(
  145 + `${BASE}/api/admin/dance-refs`,
  146 + authed({ method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/d.mp4", motionDescriptor: "hip sways" }) })
  147 + );
  148 + assert.equal(ok.status, 200);
  149 + const created = await ok.json();
  150 + assert.equal(created.copyrightSource, "test_placeholder");
  151 +});
server.js
@@ -50,6 +50,7 @@ const { @@ -50,6 +50,7 @@ const {
50 sniffImageType, 50 sniffImageType,
51 stripMetadataAndSave, 51 stripMetadataAndSave,
52 } = require("./uploads.js"); 52 } = require("./uploads.js");
  53 +const { characters, garments, danceRefs } = require("./dressup-store.js");
53 54
54 const LIBRARY_PATH = path.join(__dirname, "library.json"); 55 const LIBRARY_PATH = path.join(__dirname, "library.json");
55 function getLibrary() { 56 function getLibrary() {
@@ -122,6 +123,156 @@ function isAdminAuthed(req) { @@ -122,6 +123,156 @@ function isAdminAuthed(req) {
122 return true; 123 return true;
123 } 124 }
124 125
  126 +// Generic CRUD router for the three cold-start asset libraries (T3-T5) —
  127 +// GET list / POST create / PATCH update / DELETE, backed by store.js. The
  128 +// three entities' shapes differ (see MVCP M.3/M.4/M.5) but the routing and
  129 +// validation boilerplate is identical, so this is mounted three times below
  130 +// instead of copy-pasted.
  131 +function mountCrud(prefix, store, opts = {}) {
  132 + const { requiredFields = [], sanitize = (b) => b, onCreate, onUpdate } = opts;
  133 +
  134 + function missingField(body) {
  135 + return requiredFields.find((f) => {
  136 + const v = body[f];
  137 + return v === undefined || v === null || (typeof v === "string" && !v.trim());
  138 + });
  139 + }
  140 +
  141 + return function handle(pathname, req, res) {
  142 + if (pathname === prefix && req.method === "GET") {
  143 + res.writeHead(200, { "Content-Type": "application/json" });
  144 + res.end(JSON.stringify(store.list()));
  145 + return true;
  146 + }
  147 +
  148 + if (pathname === prefix && req.method === "POST") {
  149 + readJsonBody(req)
  150 + .then((body) => {
  151 + const missing = missingField(body);
  152 + if (missing) {
  153 + res.writeHead(400, { "Content-Type": "application/json" });
  154 + res.end(JSON.stringify({ error: `${missing} required` }));
  155 + return;
  156 + }
  157 + const item = store.create(sanitize(body));
  158 + if (onCreate) onCreate(item, store);
  159 + res.writeHead(200, { "Content-Type": "application/json" });
  160 + res.end(JSON.stringify(item));
  161 + })
  162 + .catch(() => {
  163 + res.writeHead(400, { "Content-Type": "application/json" });
  164 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  165 + });
  166 + return true;
  167 + }
  168 +
  169 + if (pathname.startsWith(`${prefix}/`) && req.method === "PATCH") {
  170 + const id = pathname.slice(prefix.length + 1);
  171 + readJsonBody(req)
  172 + .then((body) => {
  173 + // Partial update: only reject an explicit blank-out of a required
  174 + // field, not the field's mere absence from this patch.
  175 + const missing = requiredFields.find((f) => f in body && missingField({ [f]: body[f] }) === f);
  176 + if (missing) {
  177 + res.writeHead(400, { "Content-Type": "application/json" });
  178 + res.end(JSON.stringify({ error: `${missing} required` }));
  179 + return;
  180 + }
  181 + const updated = store.update(id, sanitize(body, true));
  182 + if (!updated) {
  183 + res.writeHead(404, { "Content-Type": "application/json" });
  184 + res.end(JSON.stringify({ error: "not found" }));
  185 + return;
  186 + }
  187 + if (onUpdate) onUpdate(updated, store);
  188 + res.writeHead(200, { "Content-Type": "application/json" });
  189 + res.end(JSON.stringify(updated));
  190 + })
  191 + .catch(() => {
  192 + res.writeHead(400, { "Content-Type": "application/json" });
  193 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  194 + });
  195 + return true;
  196 + }
  197 +
  198 + if (pathname.startsWith(`${prefix}/`) && req.method === "DELETE") {
  199 + const id = pathname.slice(prefix.length + 1);
  200 + const removed = store.remove(id);
  201 + res.writeHead(removed ? 200 : 404, { "Content-Type": "application/json" });
  202 + res.end(JSON.stringify({ deleted: removed }));
  203 + return true;
  204 + }
  205 +
  206 + return false;
  207 + };
  208 +}
  209 +
  210 +// isDefault is a singleton across the whole characters library (2 节:
  211 +// "第一期只允许同时有一个人物被设为默认") — enforced here rather than in
  212 +// store.js, which stays schema-agnostic.
  213 +function unsetOtherDefaultCharacters(item, store) {
  214 + if (!item.isDefault) return;
  215 + for (const c of store.list()) {
  216 + if (c.id !== item.id && c.isDefault) store.update(c.id, { isDefault: false });
  217 + }
  218 +}
  219 +
  220 +const charactersCrud = mountCrud("/api/admin/characters", characters, {
  221 + requiredFields: ["name", "imageUrl", "appearanceDescriptor"],
  222 + sanitize: (body, isPatch) =>
  223 + isPatch
  224 + ? body
  225 + : {
  226 + name: body.name,
  227 + imageUrl: body.imageUrl,
  228 + tags: Array.isArray(body.tags) ? body.tags : [],
  229 + source: "admin_seed",
  230 + ownerUserId: null,
  231 + appearanceDescriptor: body.appearanceDescriptor,
  232 + isDefault: Boolean(body.isDefault),
  233 + moderationStatus: "auto_passed",
  234 + totalSegments: null,
  235 + remainingSegments: null,
  236 + status: "active",
  237 + },
  238 + onCreate: unsetOtherDefaultCharacters,
  239 + onUpdate: unsetOtherDefaultCharacters,
  240 +});
  241 +
  242 +const garmentsCrud = mountCrud("/api/admin/garments", garments, {
  243 + requiredFields: ["name", "imageUrl", "appearanceDescriptor"],
  244 + sanitize: (body, isPatch) =>
  245 + isPatch
  246 + ? body
  247 + : {
  248 + name: body.name,
  249 + category: body.category || "",
  250 + thumbnailUrl: body.thumbnailUrl || body.imageUrl,
  251 + imageUrl: body.imageUrl,
  252 + appearanceDescriptor: body.appearanceDescriptor,
  253 + source: "admin_seed",
  254 + ownerUserId: null,
  255 + moderationStatus: "approved",
  256 + isActive: body.isActive === undefined ? true : Boolean(body.isActive),
  257 + },
  258 +});
  259 +
  260 +const danceRefsCrud = mountCrud("/api/admin/dance-refs", danceRefs, {
  261 + requiredFields: ["name", "videoUrl", "motionDescriptor"],
  262 + sanitize: (body, isPatch) =>
  263 + isPatch
  264 + ? body
  265 + : {
  266 + name: body.name,
  267 + videoUrl: body.videoUrl,
  268 + styleTags: Array.isArray(body.styleTags) ? body.styleTags : [],
  269 + durationSec: body.durationSec || null,
  270 + motionDescriptor: body.motionDescriptor,
  271 + copyrightSource: "test_placeholder",
  272 + isActive: body.isActive === undefined ? true : Boolean(body.isActive),
  273 + },
  274 +});
  275 +
125 function h3ConsoleFetch(pathAndQuery, options) { 276 function h3ConsoleFetch(pathAndQuery, options) {
126 return new Promise((resolve, reject) => { 277 return new Promise((resolve, reject) => {
127 const proxyReq = http.request( 278 const proxyReq = http.request(
@@ -541,6 +692,10 @@ http @@ -541,6 +692,10 @@ http
541 return; 692 return;
542 } 693 }
543 694
  695 + if (charactersCrud(pathname, req, res)) return;
  696 + if (garmentsCrud(pathname, req, res)) return;
  697 + if (danceRefsCrud(pathname, req, res)) return;
  698 +
544 if (pathname === "/api/library") { 699 if (pathname === "/api/library") {
545 const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; 700 const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest";
546 const lib = getLibrary().slice(); 701 const lib = getLibrary().slice();