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