dance-mode3.test.js 15.4 KB
// 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);

  const state = await waitUntil(async () => {
    const s = await fetch(`${BASE}/api/dance/live/state`).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.
  await waitUntil(async () => {
    const s = await fetch(`${BASE}/api/dance/live/state`).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");
});

test("T34: a paid_boost character skips the vote queue — it's scheduled ahead of an already-ready free pick", async () => {
  // A second, distinct garment so this free pick doesn't hit M.7's
  // dedupe cache against a clip from an earlier test using the same
  // (defaultCharacter, freeGarment) combo.
  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());

  // Submit the free pick first, giving it every opportunity to be
  // generated and scheduled before the paid_boost one even exists.
  await fetch(`${BASE}/api/dance/pick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ garmentId: freeGarment2.id, nickname: "免费观众" }) });

  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();

  // Matched by characterId — free entries always use defaultCharacter.id,
  // paid_boost entries always create a brand-new character, so this
  // unambiguously tells the two apart even though both target the same
  // garment.
  const state = await waitUntil(async () => {
    const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json());
    const all = [s.current, ...(s.upNext || [])].filter(Boolean);
    const hasPaid = all.some((c) => (c.clip ? c.clip.characterId : c.characterId) === paidBody.characterId);
    return hasPaid ? s : null;
  });
  const all = [state.current, ...(state.upNext || [])].filter(Boolean);
  const characterIdOf = (c) => (c.clip ? c.clip.characterId : c.characterId);
  const paidIndex = all.findIndex((c) => characterIdOf(c) === paidBody.characterId);
  const freeIndex = all.findIndex((c) => characterIdOf(c) === defaultCharacter.id && (c.clip ? c.clip.garmentId : c.garmentId) === freeGarment2.id);
  assert.ok(paidIndex !== -1, "paid_boost clip must appear in current/upNext");
  if (freeIndex !== -1) {
    assert.ok(paidIndex <= freeIndex, `paid_boost (index ${paidIndex}) must not be scheduled after the competing free pick (index ${freeIndex})`);
  }
});