my-works.test.js 8.14 KB
// Integration test for T38 (GET /api/dance/my-works), 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 TINY_VALID_PNG = Buffer.from(
  "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC",
  "base64"
);

const PORT = 33934 + 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 = `mywork-${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, id } = await registerRes.json();
  const cookie = sessionCookie(registerRes);
  await fetch(`${base}${devVerifyUrl}`);
  return { cookie, userId: id };
}
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" });
}

test.before(async () => {
  await startMockH3();
  cwd = fs.mkdtempSync(path.join(os.tmpdir(), "my-works-"));
  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: "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("my-works requires login", async () => {
  const res = await fetch(`${BASE}/api/dance/my-works`);
  assert.equal(res.status, 401);
});

test("my-works returns only this user's own garments/characters, with their generated videos, and stays visible after a character expires", async () => {
  const { cookie, userId } = await registerVerifiedUser(BASE);
  await topUp(BASE, cookie);

  // Mode 2: upload a garment (into defaultCharacter's room)
  const fd = new FormData();
  fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "garment.png");
  fd.append("characterId", defaultCharacter.id);
  const garmentRes = await fetch(`${BASE}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd }).then((r) => r.json());

  // Mode 3: upload a character with exactly 1 segment, so it's expired by the time we check
  const fd2 = new FormData();
  fd2.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "character.png");
  fd2.append("garmentId", garmentRes.garmentId);
  fd2.append("segments", "1");
  const characterRes = await fetch(`${BASE}/api/dance/upload-character`, { method: "POST", headers: { Cookie: cookie }, body: fd2 }).then((r) => r.json());

  // A second, unrelated user — must not see the first user's stuff
  const { cookie: otherCookie } = await registerVerifiedUser(BASE);

  const mine = await waitUntil(async () => {
    const body = await fetch(`${BASE}/api/dance/my-works`, { headers: { Cookie: cookie } }).then((r) => r.json());
    const character = body.characters.find((c) => c.id === characterRes.characterId);
    return character && character.status === "expired" ? body : null;
  });

  assert.equal(mine.characters.length, 1);
  assert.equal(mine.characters[0].ownerUserId, userId);
  assert.equal(mine.characters[0].status, "expired", "an expired character must still be listed, not hidden");
  assert.ok(mine.characters[0].videos.length >= 1, "the expired character's generated video(s) must still be attached");

  assert.equal(mine.garments.length, 1);
  assert.equal(mine.garments[0].id, garmentRes.garmentId);
  assert.ok(mine.garments[0].videos.length >= 1);

  const others = await fetch(`${BASE}/api/dance/my-works`, { headers: { Cookie: otherCookie } }).then((r) => r.json());
  assert.equal(others.characters.length, 0);
  assert.equal(others.garments.length, 0);
});