dressup-crud.test.js 10.7 KB
// Integration tests for the characters/garments/dance-refs admin CRUD
// routes (dev-plan.md T3/T4/T5). Spawns server.js on a throwaway port,
// same isolation approach as the other route tests, and points the three
// JSON stores at a scratch copy so this never touches real seed data.
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 { spawn } = require("node:child_process");

const PORT = 20934 + Math.floor(Math.random() * 1000);
const BASE = `http://localhost:${PORT}`;
const ADMIN_PASSWORD = "test-password-only-for-this-run";
let cookie;
let child;
let cwd;

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 authed(opts = {}) {
  return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } };
}

test.before(async () => {
  // Run the server from a scratch directory (copy of just what it needs)
  // so characters.json/garments.json/dance-refs.json/library.json start
  // empty and this test can't collide with real seed data or the live
  // pm2 instance.
  cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-crud-"));
  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));
  }

  child = spawn(process.execPath, ["server.js"], {
    cwd,
    env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD },
    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 }),
  });
  cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0];
});

test.after(() => {
  child.kill();
  fs.rmSync(cwd, { recursive: true, force: true });
});

test("characters: create requires name/imageUrl/appearanceDescriptor", async () => {
  const res = await fetch(`${BASE}/api/admin/characters`, authed({ method: "POST", body: JSON.stringify({ name: "只有名字" }) }));
  assert.equal(res.status, 400);
});

test("characters: create/list/patch/delete round-trip, defaults filled in", async () => {
  const createRes = await fetch(
    `${BASE}/api/admin/characters`,
    authed({ method: "POST", body: JSON.stringify({ name: "小美", imageUrl: "/uploads/x.png", appearanceDescriptor: "black hair" }) })
  );
  assert.equal(createRes.status, 200);
  const created = await createRes.json();
  assert.equal(created.source, "admin_seed");
  assert.equal(created.moderationStatus, "auto_passed");
  assert.equal(created.status, "active");
  assert.equal(created.isDefault, false);

  const listRes = await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" }));
  const list = await listRes.json();
  assert.ok(list.some((c) => c.id === created.id));

  const patchRes = await fetch(`${BASE}/api/admin/characters/${created.id}`, authed({ method: "PATCH", body: JSON.stringify({ name: "小美改名" }) }));
  assert.equal(patchRes.status, 200);
  assert.equal((await patchRes.json()).name, "小美改名");

  const delRes = await fetch(`${BASE}/api/admin/characters/${created.id}`, authed({ method: "DELETE" }));
  assert.equal(delRes.status, 200);
  assert.deepEqual(await delRes.json(), { deleted: true });

  const afterDelete = await fetch(`${BASE}/api/admin/characters/${created.id}`, authed({ method: "PATCH", body: JSON.stringify({ name: "x" }) }));
  assert.equal(afterDelete.status, 404);
});

test("characters: setting isDefault on a second character clears it on the first —全库始终只有一条 isDefault: true", async () => {
  const mk = (name, isDefault) =>
    fetch(`${BASE}/api/admin/characters`, authed({ method: "POST", body: JSON.stringify({ name, imageUrl: "/x.png", appearanceDescriptor: "d", isDefault }) })).then((r) => r.json());

  const a = await mk("人物A", true);
  assert.equal(a.isDefault, true);

  const b = await mk("人物B", true);
  assert.equal(b.isDefault, true);

  const list = await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json());
  const defaults = list.filter((c) => c.isDefault);
  assert.equal(defaults.length, 1);
  assert.equal(defaults[0].id, b.id);

  // Flip default back to A via PATCH — should also demote B.
  await fetch(`${BASE}/api/admin/characters/${a.id}`, authed({ method: "PATCH", body: JSON.stringify({ isDefault: true }) }));
  const list2 = await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json());
  assert.deepEqual(
    list2.filter((c) => c.isDefault).map((c) => c.id),
    [a.id]
  );
});

// T31 (dev-plan-5.md): totalSegments/remainingSegments/status were already
// part of the schema (T1) but never actually read/written until mode 3
// (T35) exists. This just proves the plumbing (PATCH, admin.html's
// segmentsLabel()) has something real to render — admin_seed characters
// stay null/active by default (unaffected).
test("characters: totalSegments/remainingSegments/status round-trip via PATCH (mode 3's shape, before T35 exists)", async () => {
  const created = await fetch(
    `${BASE}/api/admin/characters`,
    authed({ method: "POST", body: JSON.stringify({ name: "付费人物占位", imageUrl: "/x.png", appearanceDescriptor: "d" }) })
  ).then((r) => r.json());
  assert.equal(created.totalSegments, null);
  assert.equal(created.remainingSegments, null);
  assert.equal(created.status, "active");

  const patched = await fetch(
    `${BASE}/api/admin/characters/${created.id}`,
    authed({ method: "PATCH", body: JSON.stringify({ totalSegments: 3, remainingSegments: 3, status: "active" }) })
  ).then((r) => r.json());
  assert.equal(patched.totalSegments, 3);
  assert.equal(patched.remainingSegments, 3);

  const expired = await fetch(
    `${BASE}/api/admin/characters/${created.id}`,
    authed({ method: "PATCH", body: JSON.stringify({ remainingSegments: 0, status: "expired" }) })
  ).then((r) => r.json());
  assert.equal(expired.status, "expired");
  assert.equal(expired.totalSegments, 3, "totalSegments is history, not reset by going expired");
});

test("character-images: creating a character auto-creates a cover row; adding a new cover image swaps characters.imageUrl and demotes the old cover; deleting the cover promotes the next image; deleting the character cascades", async () => {
  const character = await fetch(
    `${BASE}/api/admin/characters`,
    authed({ method: "POST", body: JSON.stringify({ name: "多角度人物", imageUrl: "/uploads/angle1.png", appearanceDescriptor: "d" }) })
  ).then((r) => r.json());

  // the add-character form's one image becomes the first gallery row, auto-cover
  let images = await fetch(`${BASE}/api/admin/character-images`, authed({ method: "GET" })).then((r) => r.json());
  let mine = images.filter((i) => i.characterId === character.id);
  assert.equal(mine.length, 1);
  assert.equal(mine[0].isCover, true);
  assert.equal(mine[0].imageUrl, "/uploads/angle1.png");

  // adding a second, non-cover angle doesn't touch characters.imageUrl
  const side = await fetch(
    `${BASE}/api/admin/character-images`,
    authed({ method: "POST", body: JSON.stringify({ characterId: character.id, imageUrl: "/uploads/angle2.png", angleLabel: "侧面" }) })
  ).then((r) => r.json());
  assert.equal(side.isCover, false);
  // no single-item GET route exists — read via the list instead
  let refetched = (await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json())).find((c) => c.id === character.id);
  assert.equal(refetched.imageUrl, "/uploads/angle1.png");

  // promoting the side angle to cover swaps characters.imageUrl and demotes the old cover
  await fetch(`${BASE}/api/admin/character-images/${side.id}`, authed({ method: "PATCH", body: JSON.stringify({ isCover: true }) }));
  images = await fetch(`${BASE}/api/admin/character-images`, authed({ method: "GET" })).then((r) => r.json());
  mine = images.filter((i) => i.characterId === character.id);
  assert.deepEqual(
    mine.filter((i) => i.isCover).map((i) => i.id),
    [side.id]
  );
  refetched = (await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json())).find((c) => c.id === character.id);
  assert.equal(refetched.imageUrl, "/uploads/angle2.png");

  // deleting the (now) cover image promotes whatever's left
  const originalCoverRow = mine.find((i) => i.imageUrl === "/uploads/angle1.png");
  await fetch(`${BASE}/api/admin/character-images/${side.id}`, authed({ method: "DELETE" }));
  refetched = (await fetch(`${BASE}/api/admin/characters`, authed({ method: "GET" })).then((r) => r.json())).find((c) => c.id === character.id);
  assert.equal(refetched.imageUrl, "/uploads/angle1.png");
  images = await fetch(`${BASE}/api/admin/character-images`, authed({ method: "GET" })).then((r) => r.json());
  assert.equal(images.find((i) => i.id === originalCoverRow.id).isCover, true);

  // deleting the character cascades to its remaining gallery rows
  await fetch(`${BASE}/api/admin/characters/${character.id}`, authed({ method: "DELETE" }));
  images = await fetch(`${BASE}/api/admin/character-images`, authed({ method: "GET" })).then((r) => r.json());
  assert.equal(
    images.filter((i) => i.characterId === character.id).length,
    0
  );
});

test("garments: create/list round-trip with defaults", async () => {
  const res = await fetch(
    `${BASE}/api/admin/garments`,
    authed({ method: "POST", body: JSON.stringify({ name: "红旗袍", imageUrl: "/g.png", appearanceDescriptor: "red qipao" }) })
  );
  assert.equal(res.status, 200);
  const created = await res.json();
  assert.equal(created.source, "admin_seed");
  assert.equal(created.isActive, true);
  assert.equal(created.thumbnailUrl, "/g.png"); // falls back to imageUrl when not given
});

test("dance-refs: motionDescriptor is required at creation time (not something you can add later)", async () => {
  const res = await fetch(
    `${BASE}/api/admin/dance-refs`,
    authed({ method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/d.mp4" }) })
  );
  assert.equal(res.status, 400);

  const ok = await fetch(
    `${BASE}/api/admin/dance-refs`,
    authed({ method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/d.mp4", motionDescriptor: "hip sways" }) })
  );
  assert.equal(ok.status, 200);
  const created = await ok.json();
  assert.equal(created.copyrightSource, "test_placeholder");
});