dressup-crud.test.js
6.12 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
// 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"]) {
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");
});