dressup-crud.test.js
10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
// 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]
);
});
// T31 (dev-plan-5.md): totalSegments/remainingSegments/status were already
// part of the schema (T1) but never actually read/written until mode 3
// (T35) exists. This just proves the plumbing (PATCH, admin.html's
// segmentsLabel()) has something real to render — admin_seed characters
// stay null/active by default (unaffected).
test("characters: totalSegments/remainingSegments/status round-trip via PATCH (mode 3's shape, before T35 exists)", async () => {
const created = await fetch(
`${BASE}/api/admin/characters`,
authed({ method: "POST", body: JSON.stringify({ name: "付费人物占位", imageUrl: "/x.png", appearanceDescriptor: "d" }) })
).then((r) => r.json());
assert.equal(created.totalSegments, null);
assert.equal(created.remainingSegments, null);
assert.equal(created.status, "active");
const patched = await fetch(
`${BASE}/api/admin/characters/${created.id}`,
authed({ method: "PATCH", body: JSON.stringify({ totalSegments: 3, remainingSegments: 3, status: "active" }) })
).then((r) => r.json());
assert.equal(patched.totalSegments, 3);
assert.equal(patched.remainingSegments, 3);
const expired = await fetch(
`${BASE}/api/admin/characters/${created.id}`,
authed({ method: "PATCH", body: JSON.stringify({ remainingSegments: 0, status: "expired" }) })
).then((r) => r.json());
assert.equal(expired.status, "expired");
assert.equal(expired.totalSegments, 3, "totalSegments is history, not reset by going expired");
});
test("character-images: creating a character auto-creates a cover row; adding a new cover image swaps characters.imageUrl and demotes the old cover; deleting the cover promotes the next image; deleting the character cascades", async () => {
const character = await fetch(
`${BASE}/api/admin/characters`,
authed({ method: "POST", body: JSON.stringify({ name: "多角度人物", imageUrl: "/uploads/angle1.png", appearanceDescriptor: "d" }) })
).then((r) => r.json());
// the add-character form's one image becomes the first gallery row, auto-cover
let images = await fetch(`${BASE}/api/admin/character-images`, authed({ method: "GET" })).then((r) => r.json());
let mine = images.filter((i) => i.characterId === character.id);
assert.equal(mine.length, 1);
assert.equal(mine[0].isCover, true);
assert.equal(mine[0].imageUrl, "/uploads/angle1.png");
// adding a second, non-cover angle doesn't touch characters.imageUrl
const side = await fetch(
`${BASE}/api/admin/character-images`,
authed({ method: "POST", body: JSON.stringify({ characterId: character.id, imageUrl: "/uploads/angle2.png", angleLabel: "侧面" }) })
).then((r) => r.json());
assert.equal(side.isCover, false);
// no single-item GET route exists — read via the list instead
let refetched = (await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json())).find((c) => c.id === character.id);
assert.equal(refetched.imageUrl, "/uploads/angle1.png");
// promoting the side angle to cover swaps characters.imageUrl and demotes the old cover
await fetch(`${BASE}/api/admin/character-images/${side.id}`, authed({ method: "PATCH", body: JSON.stringify({ isCover: true }) }));
images = await fetch(`${BASE}/api/admin/character-images`, authed({ method: "GET" })).then((r) => r.json());
mine = images.filter((i) => i.characterId === character.id);
assert.deepEqual(
mine.filter((i) => i.isCover).map((i) => i.id),
[side.id]
);
refetched = (await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json())).find((c) => c.id === character.id);
assert.equal(refetched.imageUrl, "/uploads/angle2.png");
// deleting the (now) cover image promotes whatever's left
const originalCoverRow = mine.find((i) => i.imageUrl === "/uploads/angle1.png");
await fetch(`${BASE}/api/admin/character-images/${side.id}`, authed({ method: "DELETE" }));
refetched = (await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json())).find((c) => c.id === character.id);
assert.equal(refetched.imageUrl, "/uploads/angle1.png");
images = await fetch(`${BASE}/api/admin/character-images`, authed({ method: "GET" })).then((r) => r.json());
assert.equal(images.find((i) => i.id === originalCoverRow.id).isCover, true);
// deleting the character cascades to its remaining gallery rows
await fetch(`${BASE}/api/admin/characters/${character.id}`, authed({ method: "DELETE" }));
images = await fetch(`${BASE}/api/admin/character-images`, authed({ method: "GET" })).then((r) => r.json());
assert.equal(
images.filter((i) => i.characterId === character.id).length,
0
);
});
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");
});