// Integration tests for dance.html's backend (T11 独立播出状态机, T12
// GET /api/dance/wardrobe, T15 POST /api/dance/pick, T17 点赞/在线人数).
// Same mock-H3-Console approach as dressup-generate.test.js so the full
// submit -> 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");
const PORT = 22934 + 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;
let mockH3;
let mockH3Port;
let videoPollCounts;
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 } };
}
// Same shape as dressup-generate.test.js's mock — enough of /v1/assets,
// /v1/videos and /v1/videos/:id for generateDanceVideo to run end to end.
function startMockH3() {
videoPollCounts = new Map();
let nextId = 1;
return new Promise((resolve) => {
mockH3 = 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") {
const id = `job-${nextId++}`;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id }));
return;
}
// /lib/video/:id's cache-warming (ensureVideoCached, server.js) hits
// this same mock's GET /v1/videos/:id/file — must not match the
// status-poll regex below, or its background requests get counted
// as extra "job polls" and trip the dedupe test's job-count assert.
if (req.method === "GET" && /^\/v1\/videos\/[^/]+\/file$/.test(req.url)) {
res.writeHead(200, { "Content-Type": "video/mp4" });
res.end("fake-mp4-bytes");
return;
}
const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/([^/]+)$/);
if (pollMatch) {
const id = pollMatch[1];
const seen = (videoPollCounts.get(id) || 0) + 1;
videoPollCounts.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" }));
});
});
mockH3.listen(0, () => {
mockH3Port = mockH3.address().port;
resolve();
});
});
}
test.before(async () => {
await startMockH3();
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-channel-"));
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", "garment.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 }),
});
cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0];
});
test.after(() => {
child.kill();
mockH3.close();
fs.rmSync(cwd, { recursive: true, force: true });
});
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");
}
let character, activeGarment, inactiveGarment, neverGeneratedGarment, danceRef;
test("seed: a character (its room) + active/inactive garments + dance-ref", async () => {
character = await fetch(
`${BASE}/api/admin/characters`,
authed({ method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair" }) })
).then((r) => r.json());
assert.ok(character.id);
activeGarment = await fetch(
`${BASE}/api/admin/garments`,
authed({ method: "POST", body: JSON.stringify({ name: "上架红裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red dress", isActive: true }) })
).then((r) => r.json());
inactiveGarment = await fetch(
`${BASE}/api/admin/garments`,
authed({ method: "POST", body: JSON.stringify({ name: "下架旗袍", imageUrl: "/uploads/garment.png", appearanceDescriptor: "qipao", isActive: false }) })
).then((r) => r.json());
neverGeneratedGarment = await fetch(
`${BASE}/api/admin/garments`,
authed({ method: "POST", body: JSON.stringify({ name: "从没生成过", imageUrl: "/uploads/garment.png", appearanceDescriptor: "never rendered", isActive: true }) })
).then((r) => r.json());
danceRef = await fetch(
`${BASE}/api/admin/dance-refs`,
authed({ method: "POST", body: JSON.stringify({ name: "衣柜拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })
).then((r) => r.json());
assert.ok(danceRef.id);
});
test("T15 POST /api/dance/pick: rejects unknown characterId/garmentId, and inactive garments", async () => {
const badCharacter = await fetch(`${BASE}/api/dance/pick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: "nope", garmentId: activeGarment.id }),
});
assert.equal(badCharacter.status, 400);
const missingGarment = await fetch(`${BASE}/api/dance/pick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: character.id, garmentId: "nope" }),
});
assert.equal(missingGarment.status, 400);
const inactive = await fetch(`${BASE}/api/dance/pick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: character.id, garmentId: inactiveGarment.id }),
});
assert.equal(inactive.status, 400);
});
test("T15/T11 pick a garment for this character's room (no login/cookie needed) -> generates -> lands in that room's schedule; T17 vote + viewer count along the way", async () => {
const pickRes = await fetch(`${BASE}/api/dance/pick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: character.id, garmentId: activeGarment.id }),
});
assert.equal(pickRes.status, 200);
const picked = await pickRes.json();
assert.equal(picked.status, "generating");
assert.ok(picked.id);
// While it's still generating, it should show up in this room's public
// queue and accept a vote (T17) — separate viewer/vote namespace per room.
const stateWhileGenerating = await fetch(`${BASE}/api/dance/live/state?characterId=${character.id}&cid=test-cid-1`).then((r) => r.json());
assert.ok(stateWhileGenerating.queue.some((q) => q.id === picked.id && q.status === "generating"));
assert.equal(stateWhileGenerating.watching, 1);
const voteRes = await fetch(`${BASE}/api/dance/queue/vote/${picked.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uid: "viewer-1" }),
});
assert.equal(voteRes.status, 200);
const vote = await voteRes.json();
assert.equal(vote.voted, true);
assert.equal(vote.votes, 1);
// Eventually the mock generation finishes and this room's ensureRoomSchedule
// sweeps it into the schedule — current or upNext should carry the right
// character/garment traceability, and the entry should drop out of queue.
const finalState = await waitUntil(async () => {
const state = await fetch(`${BASE}/api/dance/live/state?characterId=${character.id}&cid=test-cid-1`).then((r) => r.json());
const inQueue = state.queue.some((q) => q.id === picked.id);
const landed = (state.current && state.current.clip && state.current.clip.garmentId === activeGarment.id) || state.upNext.some((c) => c.garmentId === activeGarment.id);
return !inQueue && landed ? state : null;
});
const landedClip = finalState.current && finalState.current.clip && finalState.current.clip.garmentId === activeGarment.id ? finalState.current.clip : finalState.upNext.find((c) => c.garmentId === activeGarment.id);
assert.equal(landedClip.characterId, character.id);
assert.equal(landedClip.refDanceVideoId, danceRef.id);
assert.equal(landedClip.channel, "dance");
});
test("T15 dedupe: re-picking the same garment returns ready immediately (M.7 cache hit), no new H3 submission", async () => {
const before = videoPollCounts.size;
const res = await fetch(`${BASE}/api/dance/pick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: character.id, garmentId: activeGarment.id }),
});
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.status, "ready");
assert.ok(data.videoId);
assert.equal(videoPollCounts.size, before); // no new /v1/videos/:id job started
});
// T48 (dev-plan-6.md): a uid with a free pick still generating can't queue
// a second one; an anonymous (no uid) pick is never limited; once the first
// entry lands (swept out of room.queue), the same uid can pick again.
test("T48 same-uid limit on free /api/dance/pick: blocks a second in-flight pick, never blocks uid-less requests, unblocks once the first lands", async () => {
const garmentA = await fetch(
`${BASE}/api/admin/garments`,
authed({ method: "POST", body: JSON.stringify({ name: "限流测试裙A", imageUrl: "/uploads/garment.png", appearanceDescriptor: "limiter test A", isActive: true }) })
).then((r) => r.json());
const garmentB = await fetch(
`${BASE}/api/admin/garments`,
authed({ method: "POST", body: JSON.stringify({ name: "限流测试裙B", imageUrl: "/uploads/garment.png", appearanceDescriptor: "limiter test B", isActive: true }) })
).then((r) => r.json());
const pickA = await fetch(`${BASE}/api/dance/pick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: character.id, garmentId: garmentA.id, uid: "uid-t48" }),
});
assert.equal(pickA.status, 200);
assert.equal((await pickA.json()).status, "generating");
// same uid, still in flight -> rejected, and no new job started
const beforeB = videoPollCounts.size;
const pickBBlocked = await fetch(`${BASE}/api/dance/pick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: character.id, garmentId: garmentB.id, uid: "uid-t48" }),
});
assert.equal(pickBBlocked.status, 400);
assert.ok((await pickBBlocked.json()).error);
assert.equal(videoPollCounts.size, beforeB); // rejected before submitGeneration ever ran
// no uid at all -> never limited, even while uid-t48's pick is in flight
const anonPick = await fetch(`${BASE}/api/dance/pick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: character.id, garmentId: garmentB.id }),
});
assert.equal(anonPick.status, 200);
// wait for garmentA's entry to land (swept out of queue), then uid-t48 can pick again.
// Reuses the T15/T11 test's cid rather than a fresh one — a new cid here
// would inflate this room's distinct-viewer count and break T17's exact
// watching-count assertion later in the file.
await waitUntil(async () => {
const state = await fetch(`${BASE}/api/dance/live/state?characterId=${character.id}&cid=test-cid-1`).then((r) => r.json());
return state.queue.some((q) => q.garmentId === garmentA.id) ? null : state;
});
const pickAgain = await fetch(`${BASE}/api/dance/pick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: character.id, garmentId: garmentB.id, uid: "uid-t48" }),
});
assert.equal(pickAgain.status, 200);
});
// T12's contract changed with the one-room-per-character redesign: the
// wardrobe is no longer "every active garment" (a request-anything catalog)
// — it's "which of this character's already-rendered garments can a viewer
// queue up next", so it only ever lists (characterId, garmentId) pairs with
// an existing dance clip (same M.7 index /api/dance/pick's dedupe uses).
test("GET /api/dance/wardrobe?characterId=: only THIS character's already-generated, active garments, no admin auth needed", async () => {
const res = await fetch(`${BASE}/api/dance/wardrobe?characterId=${character.id}`); // deliberately no cookie
assert.equal(res.status, 200);
const list = await res.json();
assert.ok(list.some((g) => g.id === activeGarment.id), "has a generated video for this character -> listed");
assert.ok(!list.some((g) => g.id === inactiveGarment.id), "inactive -> never listed even if rendered");
assert.ok(!list.some((g) => g.id === neverGeneratedGarment.id), "no video for this character yet -> not listed");
const missingCharacterId = await fetch(`${BASE}/api/dance/wardrobe`);
assert.equal(missingCharacterId.status, 400);
});
test("T17: two different characters' rooms keep independent viewer counts, and live.html's is separate again", async () => {
const otherCharacter = await fetch(
`${BASE}/api/admin/characters`,
authed({ method: "POST", body: JSON.stringify({ name: "另一个房间", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair" }) })
).then((r) => r.json());
await fetch(`${BASE}/api/live/state?cid=live-viewer-a`);
await fetch(`${BASE}/api/live/state?cid=live-viewer-b`);
const danceState = await fetch(`${BASE}/api/dance/live/state?characterId=${character.id}&cid=dance-viewer-only`).then((r) => r.json());
assert.equal(danceState.watching, 2); // test-cid-1 (still within TTL) + dance-viewer-only, not the two live viewers
const otherRoomState = await fetch(`${BASE}/api/dance/live/state?characterId=${otherCharacter.id}&cid=other-room-viewer`).then((r) => r.json());
assert.equal(otherRoomState.watching, 1); // this room has never seen character's viewers
});
// T46 (dev-plan-6.md): dance room chat — separate endpoint from /api/chat
// (that one triggers a generation job via submitPromptToQueue; this one
// must never do that), isolated per characterId, its own looser rate limit,
// and rejected messages never get published (unlike /api/chat's behavior).
test("T46 dance room chat: GET/POST round-trip, per-room isolation, moderation, validation, rate limit", async () => {
const roomA = await fetch(
`${BASE}/api/admin/characters`,
authed({ method: "POST", body: JSON.stringify({ name: "聊天室 A", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair" }) })
).then((r) => r.json());
const roomB = await fetch(
`${BASE}/api/admin/characters`,
authed({ method: "POST", body: JSON.stringify({ name: "聊天室 B", imageUrl: "/uploads/person.png", appearanceDescriptor: "brown hair" }) })
).then((r) => r.json());
const missingCharacterId = await fetch(`${BASE}/api/dance/chat`);
assert.equal(missingCharacterId.status, 400);
const emptyHistory = await fetch(`${BASE}/api/dance/chat?characterId=${roomA.id}`).then((r) => r.json());
assert.deepEqual(emptyHistory, []);
const postRes = await fetch(`${BASE}/api/dance/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: roomA.id, uid: "chat-uid-1", nickname: "小美", text: "这件也太好看了吧" }),
});
assert.equal(postRes.status, 200);
const posted = await postRes.json();
assert.equal(posted.status, "approved");
assert.ok(posted.id);
const historyA = await fetch(`${BASE}/api/dance/chat?characterId=${roomA.id}`).then((r) => r.json());
assert.equal(historyA.length, 1);
assert.equal(historyA[0].text, "这件也太好看了吧");
assert.equal(historyA[0].nickname, "小美");
// T46 isolation: roomB never sees roomA's messages
const historyB = await fetch(`${BASE}/api/dance/chat?characterId=${roomB.id}`).then((r) => r.json());
assert.deepEqual(historyB, []);
// validation: missing text
const missingText = await fetch(`${BASE}/api/dance/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: roomA.id, uid: "chat-uid-1", nickname: "小美" }),
});
assert.equal(missingText.status, 400);
// moderation: a banned-word message is rejected AND never published (T46
// deliberately diverges from /api/chat, which does publish rejected text)
const rejectedRes = await fetch(`${BASE}/api/dance/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: roomA.id, uid: "chat-uid-2", nickname: "路人", text: "这里有色情内容" }),
});
assert.equal(rejectedRes.status, 200);
const rejected = await rejectedRes.json();
assert.equal(rejected.status, "rejected");
assert.ok(rejected.reason);
const historyAfterReject = await fetch(`${BASE}/api/dance/chat?characterId=${roomA.id}`).then((r) => r.json());
assert.equal(historyAfterReject.length, 1, "rejected message must not be published to the room");
// rate limit: independent from /api/chat's generation-request budget
// (DANCE_CHAT_RATE_LIMIT_MAX=10/min). The limiter counts every POST
// (checked before body validation, so a retry-loop of malformed requests
// can't dodge it) — 3 already consumed above (postRes, missingText,
// rejectedRes), so 7 more exactly exhausts the budget of 10 before the
// next one gets blocked.
for (let i = 0; i < 7; i++) {
const r = await fetch(`${BASE}/api/dance/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: roomA.id, uid: "chat-uid-1", nickname: "小美", text: `第 ${i} 条` }),
});
assert.equal(r.status, 200, `send #${i} should succeed`);
}
const overLimit = await fetch(`${BASE}/api/dance/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId: roomA.id, uid: "chat-uid-1", nickname: "小美", text: "超限了" }),
});
assert.equal(overLimit.status, 429);
});