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