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 | 50 | sniffImageType, |
| 51 | 51 | stripMetadataAndSave, |
| 52 | 52 | } = require("./uploads.js"); |
| 53 | +const { characters, garments, danceRefs } = require("./dressup-store.js"); | |
| 53 | 54 | |
| 54 | 55 | const LIBRARY_PATH = path.join(__dirname, "library.json"); |
| 55 | 56 | function getLibrary() { |
| ... | ... | @@ -122,6 +123,156 @@ function isAdminAuthed(req) { |
| 122 | 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 | 276 | function h3ConsoleFetch(pathAndQuery, options) { |
| 126 | 277 | return new Promise((resolve, reject) => { |
| 127 | 278 | const proxyReq = http.request( |
| ... | ... | @@ -541,6 +692,10 @@ http |
| 541 | 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 | 699 | if (pathname === "/api/library") { |
| 545 | 700 | const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; |
| 546 | 701 | const lib = getLibrary().slice(); | ... | ... |