// Integration test for T34 (priorityInbox/paid_boost scheduling) + T35 // (mode 3: 付费传人物), dev-plan-5.md. Same mock-H3-Console approach as // dance-mode2.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 TINY_VALID_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC", "base64" ); const PORT = 32934 + 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, freeGarment; 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 mock as dance-mode2.test.js — enough of /v1/assets, /v1/videos, // /v1/videos/:id for generateDanceVideoViaH3 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; } 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 = `mode3-${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; } async function topUp(base, cookie, tierKey = "topup_tier_2") { 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 uploadCharacter(base, cookie, { png = TINY_VALID_PNG, garmentId, segments } = {}) { const fd = new FormData(); fd.append("file", new Blob([png], { type: "image/png" }), "character.png"); if (garmentId) fd.append("garmentId", garmentId); if (segments) fd.append("segments", String(segments)); return fetch(`${base}/api/dance/upload-character`, { method: "POST", headers: { Cookie: cookie }, body: fd }); } test.before(async () => { await startMockH3(); cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-mode3-")); 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: "200", 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()); freeGarment = await fetch( `${BASE}/api/admin/garments`, authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "免费红裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red dress" }) }) ).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-character requires login and a verified email", async () => { const noAuth = await uploadCharacter(BASE, ""); assert.equal(noAuth.status, 401); const registerRes = await fetch(`${BASE}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: `mode3-unverified-${Date.now()}@example.com`, password: "correct-horse-battery" }), }); const unverifiedCookie = sessionCookie(registerRes); const unverified = await uploadCharacter(BASE, unverifiedCookie); assert.equal(unverified.status, 403); }); test("insufficient balance is rejected (402) and leaves no character behind", async () => { const cookie = await registerVerifiedUser(BASE); const before = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); const res = await uploadCharacter(BASE, cookie, { garmentId: freeGarment.id, segments: 2 }); assert.equal(res.status, 402); const after = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); assert.equal(after.length, before.length); }); test("with no garmentId and no active garments, upload is rejected before any spend", async () => { // Deactivate the only garment for this one check, then reactivate. await fetch(`${BASE}/api/admin/garments/${freeGarment.id}`, authed(adminCookie, { method: "PATCH", body: JSON.stringify({ isActive: false }) })); const cookie = await registerVerifiedUser(BASE); await topUp(BASE, cookie); const res = await uploadCharacter(BASE, cookie, { segments: 1 }); // no garmentId -> forced to random-pick, pool is empty assert.equal(res.status, 400); await fetch(`${BASE}/api/admin/garments/${freeGarment.id}`, authed(adminCookie, { method: "PATCH", body: JSON.stringify({ isActive: true }) })); }); test("full happy path: cost scales with requested segments, character carries user_paid/ownerUserId/totalSegments, clip is paid_boost", 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 res = await uploadCharacter(BASE, cookie, { garmentId: freeGarment.id, segments: 4 }); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.totalSegments, 4); assert.ok(body.creditsSpent > 0); const pricingEntry = await fetch(`${BASE}/api/pricing`).then((r) => r.json()).then((rows) => rows.find((p) => p.key === "spend_character_per_segment")); const expectedCost = Math.ceil((pricingEntry.credits / (pricingEntry.segmentsPerUnit || 1)) * 4); assert.equal(body.creditsSpent, expectedCost, "cost must scale with requested segments, not be a flat price like mode 2's"); const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); assert.equal(meAfter.creditBalance, meBefore.creditBalance - expectedCost); const characters = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); const created = characters.find((c) => c.id === body.characterId); assert.ok(created); assert.equal(created.source, "user_paid"); assert.equal(created.ownerUserId, meBefore.id); assert.equal(created.isDefault, false, "a paid character must never become the shared default (需求 3.3 第 6 点)"); assert.equal(created.totalSegments, 4); assert.equal(created.remainingSegments, 4); assert.equal(created.status, "active"); assert.ok(created.appearanceDescriptor && created.appearanceDescriptor.length > 0); // Its own new room (getDanceRoomCharacterIds: this purchase is what // creates it — mode 3 never shares defaultCharacter's room). const state = await waitUntil(async () => { const s = await fetch(`${BASE}/api/dance/live/state?characterId=${body.characterId}`).then((r) => r.json()); const all = [s.current, ...(s.upNext || [])].filter(Boolean); return all.find((c) => c.clip ? c.clip.characterId === body.characterId : c.characterId === body.characterId) ? s : null; }); const clipView = state.current && state.current.clip && state.current.clip.characterId === body.characterId ? state.current.clip : state.upNext.find((c) => c.characterId === body.characterId); assert.equal(clipView.priority, "paid_boost"); assert.equal(clipView.requestedByUserId, meBefore.id); assert.equal(clipView.garmentId, freeGarment.id); }); test("T36: a character bought with 1 segment expires after that single play, but its record and video stay queryable", async () => { const oneOffGarment = await fetch( `${BASE}/api/admin/garments`, authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "一次性紫裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "purple dress" }) }) ).then((r) => r.json()); const cookie = await registerVerifiedUser(BASE); await topUp(BASE, cookie); const uploadRes = await uploadCharacter(BASE, cookie, { garmentId: oneOffGarment.id, segments: 1 }); assert.equal(uploadRes.status, 200); const { characterId } = await uploadRes.json(); // Scheduling (T34's eager priorityInbox insert) is what consumes the // segment — same tick that makes it visible in current/upNext, so no // extra wait beyond that is needed. This character's own new room. await waitUntil(async () => { const s = await fetch(`${BASE}/api/dance/live/state?characterId=${characterId}`).then((r) => r.json()); const all = [s.current, ...(s.upNext || [])].filter(Boolean); return all.some((c) => (c.clip ? c.clip.characterId : c.characterId) === characterId) ? true : null; }); const expired = await waitUntil(async () => { const list = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); const found = list.find((c) => c.id === characterId); return found && found.status === "expired" ? found : null; }); assert.equal(expired.remainingSegments, 0); assert.equal(expired.totalSegments, 1, "totalSegments is history, unaffected by playing out"); // Record/video still retrievable, per 需求文档 3.3 第 7 点 — not deleted, // just no longer eligible for future random-fill picks (can't easily // assert the negative here without a long real-time wait, but the pool // filter in pickNextDanceClip is what dance-mode3's other tests already // exercise the positive side of). const clips = await fetch(`${BASE}/api/library`).then((r) => r.json()); assert.ok(clips.some((c) => c.characterId === characterId), "the generated video must still be retrievable after the character expires"); }); // T34's original scenario ("paid_boost overtakes an already-ready free pick // in the shared schedule") doesn't translate directly to one-room-per- // character: mode 3 always creates a brand-new character (its own room), // so a fresh paid_boost purchase never shares a schedule with an existing // free vote-ranked entry the way the single old global room did. What still // must hold is the underlying guarantee priorityInbox exists for: a // paid_boost clip is inserted and playing immediately, without ever sitting // in that room's vote-ranked queue (room.queue) the way a mode-1/2 pick // would. test("T34: a paid_boost character's first clip is playing immediately in its own room, never sitting in that room's vote-ranked queue", async () => { const freeGarment2 = await fetch( `${BASE}/api/admin/garments`, authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "免费蓝裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "blue dress" }) }) ).then((r) => r.json()); const cookie = await registerVerifiedUser(BASE); await topUp(BASE, cookie); const paidRes = await uploadCharacter(BASE, cookie, { garmentId: freeGarment2.id, segments: 1 }); assert.equal(paidRes.status, 200); const paidBody = await paidRes.json(); const state = await waitUntil(async () => { const s = await fetch(`${BASE}/api/dance/live/state?characterId=${paidBody.characterId}`).then((r) => r.json()); return s.current && s.current.clip && s.current.clip.characterId === paidBody.characterId ? s : null; }); assert.equal(state.current.clip.priority, "paid_boost"); assert.equal(state.queue.length, 0, "priorityInbox drains straight into schedule, never through room.queue's vote-ranked path"); });