dance-mode2.test.js
15.4 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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
// Integration test for T27 (mode 2: 付费传衣服, POST /api/dance/upload-garment),
// dev-plan-4.md. Same mock-H3-Console approach as dance-channel.test.js so
// the full upload -> moderate -> spend -> auto-tag -> generate -> land-in-
// schedule path runs offline and fast.
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 http = require("node:http");
const { spawn } = require("node:child_process");
// A real, valid 2x2 PNG (not just the 8-byte magic signature) — needed
// because sips has to actually decode it during stripMetadataAndSave.
const TINY_VALID_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC",
"base64"
);
const PORT = 29934 + Math.floor(Math.random() * 1000);
const BASE = `http://localhost:${PORT}`;
const ADMIN_PASSWORD = "test-password-only-for-this-run";
let adminCookie, child, cwd, mockH3, mockH3Port, videoPollCounts, character;
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 sessionCookie(res) {
const setCookie = res.headers.get("set-cookie") || "";
return setCookie.split(";")[0];
}
function authed(cookie, opts = {}) {
return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } };
}
// Same shape as dance-channel.test.js's mock — enough of /v1/assets,
// /v1/videos and /v1/videos/:id for generateDanceVideo to run end to end.
// `failSubmit` lets one test force a "submit failed" so T27's async refund
// path (ensureDanceSchedule's failure branch) actually has something to
// trigger on.
// Returns {server, port} rather than mutating module-level mockH3/mockH3Port
// — the "generation failure" test below needs a second, independently
// closeable instance without leaking the first (test.before's) one, which
// would otherwise keep node --test's process alive forever after all tests
// finish (an unclosed http.Server keeps the event loop open).
function startMockH3(failSubmit = false) {
const pollCounts = new Map();
let nextId = 1;
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
req.url = req.url.replace(/^\/+/, "/");
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
if (req.method === "POST" && req.url === "/v1/assets") {
const id = `asset-${nextId++}`;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" }));
return;
}
if (req.method === "POST" && req.url === "/v1/videos") {
if (failSubmit) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ detail: "mock forced submit failure" }));
return;
}
const id = `job-${nextId++}`;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id }));
return;
}
const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/);
if (pollMatch) {
const id = pollMatch[1];
const seen = (pollCounts.get(id) || 0) + 1;
pollCounts.set(id, seen);
if (seen < 2) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id, status: "generating" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id, status: "done", partition: "ref2va", req: { width: 480, height: 832, frames: 240 } }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found in mock" }));
});
});
server.listen(0, () => resolve({ server, port: server.address().port }));
});
}
async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error("condition not met in time");
}
async function registerVerifiedUser(base) {
const email = `mode2-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`;
const registerRes = await fetch(`${base}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password: "correct-horse-battery" }),
});
const { devVerifyUrl } = await registerRes.json();
const cookie = sessionCookie(registerRes);
await fetch(`${base}${devVerifyUrl}`);
return cookie;
}
// Tops up a logged-in user via the real T24 mock-checkout loop (not by
// reaching into db.js directly — the spawned server has its own db.js/
// data.sqlite in its own tmp cwd, so this test process's own db.js import
// wouldn't even point at the same file).
async function topUp(base, cookie, tierKey = "topup_tier_1") {
const checkoutRes = await fetch(`${base}/api/credits/checkout`, authed(cookie, { method: "POST", body: JSON.stringify({ tierKey }) }));
const { url } = await checkoutRes.json();
await fetch(`${base}${url}`, { redirect: "manual" });
}
function uploadGarment(base, cookie, characterId, png = TINY_VALID_PNG) {
const fd = new FormData();
fd.append("file", new Blob([png], { type: "image/png" }), "garment.png");
fd.append("characterId", characterId);
return fetch(`${base}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd });
}
test.before(async () => {
({ server: mockH3, port: mockH3Port } = await startMockH3());
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-mode2-"));
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));
}
fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true });
fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes");
fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes");
child = spawn(process.execPath, ["server.js"], {
cwd,
env: {
...process.env,
PORT: String(PORT),
H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`,
H3_CONSOLE_TOKEN: "mock-token",
ADMIN_PASSWORD,
GENERATION_POLL_INTERVAL_MS: "400",
SCHEDULE_TICK_MS: "150",
},
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 }),
});
adminCookie = sessionCookie(loginRes);
character = await fetch(
`${BASE}/api/admin/characters`,
authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair" }) })
).then((r) => r.json());
await fetch(
`${BASE}/api/admin/dance-refs`,
authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })
);
});
test.after(() => {
child.kill();
mockH3.close();
fs.rmSync(cwd, { recursive: true, force: true });
});
test("upload-garment requires login", async () => {
const res = await uploadGarment(BASE, "", character.id);
assert.equal(res.status, 401);
});
test("upload-garment requires a verified email", async () => {
const email = `mode2-unverified-${Date.now()}@example.com`;
const registerRes = await fetch(`${BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password: "correct-horse-battery" }),
});
const cookie = sessionCookie(registerRes);
const res = await uploadGarment(BASE, cookie, character.id);
assert.equal(res.status, 403);
});
test("upload-garment with insufficient balance is rejected (402) and leaves no garment behind", async () => {
const cookie = await registerVerifiedUser(BASE);
const garmentsBefore = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json());
const res = await uploadGarment(BASE, cookie, character.id);
assert.equal(res.status, 402);
const garmentsAfter = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json());
assert.equal(garmentsAfter.length, garmentsBefore.length, "a rejected-for-balance upload must not leave a phantom garments row");
});
test("a non-image file is rejected before anything is spent or created", async () => {
const cookie = await registerVerifiedUser(BASE);
await topUp(BASE, cookie);
const fd = new FormData();
fd.append("file", new Blob([Buffer.from("not an image")], { type: "text/plain" }), "not-an-image.txt");
const res = await fetch(`${BASE}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd });
assert.equal(res.status, 400);
const me = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
assert.ok(me.creditBalance > 0, "a rejected file upload must not spend credits");
});
test("full happy path: upload -> moderated+auto-tagged -> spent -> generated -> lands in dance schedule, with characterId/garmentId/ownerUserId traceability", async () => {
const cookie = await registerVerifiedUser(BASE);
await topUp(BASE, cookie);
const meBefore = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
const { id: userId } = meBefore;
const res = await uploadGarment(BASE, cookie, character.id);
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(body.garmentId);
assert.ok(body.creditsSpent > 0);
assert.equal(["generating", "ready"].includes(body.status), true);
const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
assert.equal(meAfter.creditBalance, meBefore.creditBalance - body.creditsSpent);
const garments = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json());
const created = garments.find((g) => g.id === body.garmentId);
assert.ok(created, "the uploaded garment must be visible in the (now shared/public) garments library");
assert.equal(created.source, "user_paid");
assert.equal(created.ownerUserId, userId);
assert.equal(created.moderationStatus, "approved");
assert.ok(created.appearanceDescriptor && created.appearanceDescriptor.length > 0, "T25's auto-tagging must fill a non-empty appearanceDescriptor even with no vision vendor configured");
// lands in the dance schedule and plays, same end state dance-channel.test.js checks for mode 1
const state = await waitUntil(async () => {
const s = await fetch(`${BASE}/api/dance/live/state?characterId=${character.id}`).then((r) => r.json());
return s.current && s.current.clip && s.current.clip.garmentId === body.garmentId ? s : null;
});
// T33 fix: mode 2 is a paid request, so the produced clip must carry
// requestedByUserId (M.7) — this was silently always null before T33
// threaded userId through submitGeneration. Still priority:"normal" —
// mode 2 stays in the vote-ranked queue, only mode 3 gets paid_boost.
assert.equal(state.current.clip.requestedByUserId, userId);
assert.equal(state.current.clip.priority, "normal");
assert.equal(state.current.clip.backend, "h3_console"); // no FAL_API_KEY set in this test's env
});
// ---- async generation failure -> refund (its own server + a mock H3 that
// always fails submission, so the refund path in ensureDanceSchedule's
// tick has something real to trigger on) ----
test("a generation failure after upload refunds the spent credits, exactly once", async () => {
const { server: failingMockH3, port: failingMockH3Port } = await startMockH3(true);
const port = 30934 + Math.floor(Math.random() * 1000);
const base = `http://localhost:${port}`;
const failCwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-mode2-fail-"));
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(failCwd, f));
}
fs.mkdirSync(path.join(failCwd, "uploads"), { recursive: true });
fs.writeFileSync(path.join(failCwd, "uploads", "person.png"), "fake-png-bytes");
fs.writeFileSync(path.join(failCwd, "uploads", "ref.mp4"), "fake-mp4-bytes");
const failChild = spawn(process.execPath, ["server.js"], {
cwd: failCwd,
env: {
...process.env,
PORT: String(port),
H3_CONSOLE_BASE: `http://localhost:${failingMockH3Port}`,
H3_CONSOLE_TOKEN: "mock-token",
ADMIN_PASSWORD,
GENERATION_POLL_INTERVAL_MS: "100",
SCHEDULE_TICK_MS: "100",
},
stdio: "pipe",
});
try {
await waitForServer(base);
const failAdminLogin = await fetch(`${base}/api/admin/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: ADMIN_PASSWORD }),
});
const failAdminCookie = sessionCookie(failAdminLogin);
const failCharacter = await fetch(
`${base}/api/admin/characters`,
authed(failAdminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair" }) })
).then((r) => r.json());
await fetch(
`${base}/api/admin/dance-refs`,
authed(failAdminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })
);
const cookie = await registerVerifiedUser(base);
await topUp(base, cookie);
const meBefore = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
const res = await uploadGarment(base, cookie, failCharacter.id);
assert.equal(res.status, 200);
const { creditsSpent } = await res.json();
const meAfterSpend = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
assert.equal(meAfterSpend.creditBalance, meBefore.creditBalance - creditsSpent);
// the mock always fails POST /v1/videos, so the generation job (and
// the danceQueue entry riding on it) should flip to "failed" shortly,
// and ensureDanceSchedule's tick should refund it back.
await waitUntil(async () => {
const me = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
return me.creditBalance === meBefore.creditBalance ? me : null;
});
// give a couple more ticks a chance to run — balance must not drift
// past a full refund (i.e. no double-refund)
await new Promise((r) => setTimeout(r, 500));
const meFinal = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
assert.equal(meFinal.creditBalance, meBefore.creditBalance);
} finally {
failChild.kill();
failingMockH3.close();
fs.rmSync(failCwd, { recursive: true, force: true });
}
});