dance-admin-ops.test.js 12.1 KB
// 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());

  let wardrobe = await fetch(`${BASE}/api/dance/wardrobe`).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`).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({ 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)));

  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`).then((r) => r.json());
  const beforeStartAt = before.current ? before.current.startAt : null;

  const skipRes = await fetch(`${BASE}/api/admin/dance/schedule/skip`, authed(adminCookie, { method: "POST" }));
  assert.equal(skipRes.status, 200);

  const after = await fetch(`${BASE}/api/dance/live/state`).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({ paused: true }) }));

  // Drain down to a stable size while paused — skip() only removes/expires
  // entries; with the fill loop gated by dancePaused, 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`, 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`).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({ paused: false }) }));
  await waitUntil(async () => {
    const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json());
    return (s.upNext || []).length + 1 > drainedLength ? true : null;
  });
});