// Integration test for T39 (playback management + operator blacklist), // dev-plan-5.md. Same mock-H3-Console approach as dance-mode2/3.test.js. 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 = 34934 + 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, defaultCharacter; 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) { return (res.headers.get("set-cookie") || "").split(";")[0]; } function authed(cookie, opts = {}) { return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } }; } 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; } 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(); }); }); } 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 = `adminops-${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; } test.before(async () => { await startMockH3(); cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-admin-ops-")); 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: "150", SCHEDULE_TICK_MS: "100" }, 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); defaultCharacter = await fetch( `${BASE}/api/admin/characters`, authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) }) ).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("blacklist CRUD: create/list/delete, and creation is attributed to the acting admin", async () => { const created = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie, { method: "POST", body: JSON.stringify({ targetType: "garment", targetId: "some-garment-id", reason: "测试" }) })).then((r) => r.json()); assert.ok(created.id); assert.equal(created.targetType, "garment"); assert.equal(created.createdBy, null); // legacy ADMIN_PASSWORD channel has no per-admin identity, same as T30's credit/adjust const list = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie)).then((r) => r.json()); assert.ok(list.some((b) => b.id === created.id)); const del = await fetch(`${BASE}/api/admin/blacklist/${created.id}`, authed(adminCookie, { method: "DELETE" })).then((r) => r.json()); assert.equal(del.deleted, true); const listAfter = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie)).then((r) => r.json()); assert.ok(!listAfter.some((b) => b.id === created.id)); }); test("blacklisting a garment removes it from the wardrobe and rejects /api/dance/pick, without deleting the record", async () => { const garment = await fetch( `${BASE}/api/admin/garments`, authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "待拉黑的裙子", imageUrl: "/uploads/garment.png", appearanceDescriptor: "black dress" }) }) ).then((r) => r.json()); // Wardrobe only lists garments this character already has a video for — // generate one first via the admin path (same M.7 cache /pick would hit). const genRes = await fetch( `${BASE}/api/admin/generate`, authed(adminCookie, { method: "POST", body: JSON.stringify({ characterId: defaultCharacter.id, garmentId: garment.id }) }) ).then((r) => r.json()); await waitUntil(async () => { const job = await fetch(`${BASE}/api/admin/generate/${genRes.jobId}`, authed(adminCookie)).then((r) => r.json()); return job.status === "done" ? job : null; }); let wardrobe = await fetch(`${BASE}/api/dance/wardrobe?characterId=${defaultCharacter.id}`).then((r) => r.json()); assert.ok(wardrobe.some((g) => g.id === garment.id)); const entry = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie, { method: "POST", body: JSON.stringify({ targetType: "garment", targetId: garment.id, reason: "举报" }) })).then((r) => r.json()); wardrobe = await fetch(`${BASE}/api/dance/wardrobe?characterId=${defaultCharacter.id}`).then((r) => r.json()); assert.ok(!wardrobe.some((g) => g.id === garment.id), "a blacklisted garment must not appear in the wardrobe"); const pickRes = await fetch(`${BASE}/api/dance/pick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ characterId: defaultCharacter.id, garmentId: garment.id }), }); assert.equal(pickRes.status, 403); // record itself is untouched const stillThere = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json()); assert.ok(stillThere.some((g) => g.id === garment.id)); await fetch(`${BASE}/api/admin/blacklist/${entry.id}`, authed(adminCookie, { method: "DELETE" })); // cleanup for later tests }); test("blacklisting a user rejects their paid uploads at the entry point (403, before any spend)", async () => { const cookie = await registerVerifiedUser(BASE); const me = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie, { method: "POST", body: JSON.stringify({ targetType: "user", targetId: me.id, reason: "测试" }) })); const fd = new FormData(); fd.append("file", new Blob([Buffer.from("not-really-an-image")], { type: "image/png" }), "x.png"); const res = await fetch(`${BASE}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd }); assert.equal(res.status, 403); }); test("admin can pin an existing clip to play next", async () => { const garment = await fetch( `${BASE}/api/admin/garments`, authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "置顶测试裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "pink dress" }) }) ).then((r) => r.json()); const genRes = await fetch( `${BASE}/api/admin/generate`, authed(adminCookie, { method: "POST", body: JSON.stringify({ characterId: defaultCharacter.id, garmentId: garment.id }) }) ).then((r) => r.json()); const clip = await waitUntil(async () => { const job = await fetch(`${BASE}/api/admin/generate/${genRes.jobId}`, authed(adminCookie)).then((r) => r.json()); return job.status === "done" ? job : null; }).then((job) => fetch(`${BASE}/api/library`).then((r) => r.json()).then((lib) => lib.find((c) => c.id === job.generatedVideoId))); // 2026-09 上架/下架: the random-fill pool only draws from published clips // now, so this room needs at least one before pin/skip/pause below have // anything to fill it with — publish it the same way an admin clearing QC // by hand would (PATCH published, no need to go through qc-check here). await fetch(`${BASE}/api/admin/library/${clip.id}`, authed(adminCookie, { method: "PATCH", body: JSON.stringify({ published: true }) })); const pinRes = await fetch(`${BASE}/api/admin/dance/schedule/pin`, authed(adminCookie, { method: "POST", body: JSON.stringify({ videoId: clip.id }) })); assert.equal(pinRes.status, 200); const { schedule } = await pinRes.json(); assert.ok(schedule.length >= 2); assert.equal(schedule[1].videoId, clip.id, "the pinned clip must land in the very next slot"); }); test("admin can skip the currently-playing clip", async () => { const before = await fetch(`${BASE}/api/dance/live/state?characterId=${defaultCharacter.id}`).then((r) => r.json()); const beforeStartAt = before.current ? before.current.startAt : null; const skipRes = await fetch(`${BASE}/api/admin/dance/schedule/skip?characterId=${defaultCharacter.id}`, authed(adminCookie, { method: "POST" })); assert.equal(skipRes.status, 200); const after = await fetch(`${BASE}/api/dance/live/state?characterId=${defaultCharacter.id}`).then((r) => r.json()); // Compare startAt, not clip id — with a small candidate pool, skip's // replacement can legitimately be the same clip content again; what // must change is that it's a *freshly scheduled* entry, not the same // stale one. assert.notEqual(after.current.startAt, beforeStartAt, "skip must replace the current schedule entry with a freshly scheduled one"); }); test("pausing auto-fill stops the schedule from growing, resuming lets it grow again", async () => { await fetch(`${BASE}/api/admin/dance/pause`, authed(adminCookie, { method: "POST", body: JSON.stringify({ characterId: defaultCharacter.id, paused: true }) })); // Drain down to a stable size while paused — skip() only removes/expires // entries; with the fill loop gated by dancePausedRooms, nothing refills // it, so repeated skips converge on length 1 (skip's own "only one entry // left" branch just re-stamps it in place rather than removing it). let drainedLength = null; for (let i = 0; i < 6; i++) { const { schedule } = await fetch(`${BASE}/api/admin/dance/schedule/skip?characterId=${defaultCharacter.id}`, authed(adminCookie, { method: "POST" })).then((r) => r.json()); if (schedule.length === drainedLength) break; drainedLength = schedule.length; } await new Promise((r) => setTimeout(r, 500)); // several SCHEDULE_TICK_MS, still paused const stillPaused = await fetch(`${BASE}/api/dance/live/state?characterId=${defaultCharacter.id}`).then((r) => r.json()); assert.equal((stillPaused.upNext || []).length + 1, drainedLength, "schedule must not grow while paused"); await fetch(`${BASE}/api/admin/dance/pause`, authed(adminCookie, { method: "POST", body: JSON.stringify({ characterId: defaultCharacter.id, paused: false }) })); await waitUntil(async () => { const s = await fetch(`${BASE}/api/dance/live/state?characterId=${defaultCharacter.id}`).then((r) => r.json()); return (s.upNext || []).length + 1 > drainedLength ? true : null; }); }); test("上架/下架: an unpublished clip never fills a room on its own; passing QC (correct) auto-publishes it, failing (incorrect) auto-unpublishes it", async () => { const character = await fetch( `${BASE}/api/admin/characters`, authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "上架测试角色", imageUrl: "/uploads/person.png", appearanceDescriptor: "brown hair" }) }) ).then((r) => r.json()); const garment = await fetch( `${BASE}/api/admin/garments`, authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "上架测试裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "green dress" }) }) ).then((r) => r.json()); const genRes = await fetch(`${BASE}/api/admin/generate`, authed(adminCookie, { method: "POST", body: JSON.stringify({ characterId: character.id, garmentId: garment.id }) })).then((r) => r.json()); const clip = await waitUntil(async () => { const job = await fetch(`${BASE}/api/admin/generate/${genRes.jobId}`, authed(adminCookie)).then((r) => r.json()); return job.status === "done" ? job : null; }).then((job) => fetch(`${BASE}/api/library`).then((r) => r.json()).then((lib) => lib.find((c) => c.id === job.generatedVideoId))); assert.equal(clip.published, undefined, "a freshly generated clip is not published until it clears QC"); await new Promise((r) => setTimeout(r, 400)); // several SCHEDULE_TICK_MS let state = await fetch(`${BASE}/api/dance/live/state?characterId=${character.id}`).then((r) => r.json()); assert.equal(state.current, null, "an unpublished clip must not fill the room's schedule on its own"); const markedCorrect = await fetch(`${BASE}/api/admin/library/${clip.id}`, authed(adminCookie, { method: "PATCH", body: JSON.stringify({ qcStatus: "correct" }) })).then((r) => r.json()); assert.equal(markedCorrect.published, true, "marking a clip's QC verdict correct auto-publishes it"); await waitUntil(async () => { state = await fetch(`${BASE}/api/dance/live/state?characterId=${character.id}`).then((r) => r.json()); return state.current && state.current.clip.id === clip.id ? true : null; }); const markedIncorrect = await fetch(`${BASE}/api/admin/library/${clip.id}`, authed(adminCookie, { method: "PATCH", body: JSON.stringify({ qcStatus: "incorrect" }) })).then((r) => r.json()); assert.equal(markedIncorrect.published, false, "marking a clip's QC verdict incorrect auto-unpublishes it"); });