Commit c8844a4b6fa759db15eca6595a6beb949daa4a16
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.
Showing
2 changed files
with
306 additions
and
0 deletions
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(); |