fal-generation.test.js
9.12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
// 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);
});