fal-generation.test.js 9.12 KB
// Integration test for T33 (fal.ai real-time compute path), dev-plan-5.md.
// Runs server.js against a mock fal (both the storage/upload/initiate REST
// host and the queue.fal.run host, pointed at the same mock server via
// FAL_REST_BASE/FAL_QUEUE_BASE) so the whole submit -> asset-upload ->
// queue-submit -> poll -> fetch-result -> land-in-library path runs offline
// and fast, without needing a real FAL_API_KEY. This does NOT prove the
// prompt fal receives is interpreted correctly by the real model (that
// needs a real key + call, see server.js's generateDanceVideoViaFal doc
// comment) — it proves this codebase's side of the integration (the exact
// endpoints/params/response-parsing) is wired correctly against fal's
// documented contract.
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 = 31934 + Math.floor(Math.random() * 1000);
const BASE = `http://localhost:${PORT}`;
const ADMIN_PASSWORD = "test-password-only-for-this-run";
const FAL_API_KEY = "mock-fal-key";
let adminCookie, child, cwd, mockFal, mockFalPort;
let statusPollCounts;

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 } };
}

// Serves both fal's storage-upload contract (initiate + the PUT target)
// and its queue contract (submit/status/result) for
// minimax/h3/reference-to-video — verified against fal's published docs
// and the fal-js client's source, see uploads.js's forwardToFalAssets.
function startMockFal() {
  statusPollCounts = new Map();
  let nextAssetId = 1;
  let nextRequestId = 1;
  return new Promise((resolve) => {
    mockFal = http.createServer((req, res) => {
      const chunks = [];
      req.on("data", (c) => chunks.push(c));
      req.on("end", () => {
        if (req.method === "POST" && req.url.startsWith("/storage/upload/initiate")) {
          assert.equal(req.headers.authorization, `Key ${FAL_API_KEY}`, "storage upload must authenticate with Authorization: Key <FAL_API_KEY>");
          const id = nextAssetId++;
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ file_url: `${BASE_URL()}/asset/${id}`, upload_url: `${BASE_URL()}/put/${id}` }));
          return;
        }
        if (req.method === "PUT" && req.url.startsWith("/put/")) {
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ ok: true }));
          return;
        }
        if (req.method === "POST" && req.url === "/minimax/h3/reference-to-video") {
          assert.equal(req.headers.authorization, `Key ${FAL_API_KEY}`, "queue submit must authenticate with Authorization: Key <FAL_API_KEY>");
          const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
          assert.ok(Array.isArray(body.reference_image_urls) && body.reference_image_urls.length === 2, "must submit exactly 2 reference images (character + garment)");
          assert.ok(Array.isArray(body.reference_video_urls) && body.reference_video_urls.length === 1, "must submit exactly 1 reference video");
          assert.equal(typeof body.prompt, "string");
          const id = `req-${nextRequestId++}`;
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ request_id: id, status_url: `${BASE_URL()}/minimax/h3/reference-to-video/requests/${id}/status` }));
          return;
        }
        const statusMatch = req.method === "GET" && req.url.match(/^\/minimax\/h3\/reference-to-video\/requests\/(.+)\/status$/);
        if (statusMatch) {
          const id = statusMatch[1];
          const seen = (statusPollCounts.get(id) || 0) + 1;
          statusPollCounts.set(id, seen);
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ status: seen < 2 ? "IN_PROGRESS" : "COMPLETED" }));
          return;
        }
        const resultMatch = req.method === "GET" && req.url.match(/^\/minimax\/h3\/reference-to-video\/requests\/(.+)$/);
        if (resultMatch) {
          const id = resultMatch[1];
          res.writeHead(200, { "Content-Type": "application/json" });
          res.end(JSON.stringify({ video: { url: `https://mock-fal-cdn.example/${id}.mp4`, content_type: "video/mp4" }, expanded_prompt: "expanded" }));
          return;
        }
        res.writeHead(404, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: "not found in mock" }));
      });
    });
    mockFal.listen(0, () => {
      mockFalPort = mockFal.address().port;
      resolve();
    });
  });
}
function BASE_URL() {
  return `http://localhost:${mockFalPort}`;
}

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");
}

let defaultCharacter, garment, danceRef;

test.before(async () => {
  await startMockFal();

  cwd = fs.mkdtempSync(path.join(os.tmpdir(), "fal-generation-"));
  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: "",
      H3_CONSOLE_TOKEN: "",
      FAL_API_KEY,
      FAL_REST_BASE: BASE_URL_ENV(),
      FAL_QUEUE_BASE: BASE_URL_ENV(),
      ADMIN_PASSWORD,
      GENERATION_POLL_INTERVAL_MS: "200",
    },
    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());
  garment = 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());
  danceRef = await fetch(
    `${BASE}/api/admin/dance-refs`,
    authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })
  ).then((r) => r.json());
});
// mockFalPort isn't known until startMockFal() resolves, but env vars for
// spawn() are needed before that — this indirection just defers reading it.
function BASE_URL_ENV() {
  return `http://localhost:${mockFalPort}`;
}

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

test("a paid request (userId set) with FAL_API_KEY configured routes through fal, not H3 Console", async () => {
  const res = await fetch(
    `${BASE}/api/admin/generate`,
    authed(adminCookie, { method: "POST", body: JSON.stringify({ characterId: defaultCharacter.id, garmentId: garment.id, refDanceVideoId: danceRef.id, userId: "user-123" }) })
  );
  assert.equal(res.status, 200);
  const { jobId } = await res.json();
  assert.ok(jobId);

  const clip = await waitUntil(async () => {
    const job = await fetch(`${BASE}/api/admin/generate/${jobId}`, authed(adminCookie)).then((r) => r.json());
    if (job.status === "failed") throw new Error(`generation failed: ${job.error}`);
    if (job.status !== "done") return null;
    const lib = await fetch(`${BASE}/api/library`).then((r) => r.json());
    return lib.find((c) => c.id === job.generatedVideoId);
  });

  assert.equal(clip.backend, "fal");
  assert.equal(clip.source, "fal");
  assert.match(clip.videoUrl, /^https:\/\/mock-fal-cdn\.example\//);
  assert.equal(clip.priority, "normal"); // this route (POST /api/admin/generate) never sets paid_boost itself
  assert.equal(clip.requestedByUserId, "user-123");
  assert.equal(clip.channel, "dance");
  assert.equal(clip.duration, 10);
});