mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
Fourth PR of the template-variables Studio stack — the HTTP plumbing.
- preview routes (/preview and /preview/comp/*) accept
?variables=<url-encoded json> and inject
`window.__hfVariables = {...}` into <head>, before the runtime and any
composition script — the exact global the engine sets via
evaluateOnNewDocument at render time, so preview-with-values cannot
diverge from render output. Values are escaped against </script>
breakout, malformed payloads 400 instead of silently previewing
defaults, and the ETag is salted with a hash of the payload so cached
previews revalidate when values change.
- POST /projects/:id/render accepts variables ({variableId: value}) and
forwards them through StudioApiAdapter.startRender into the producer's
RenderConfig.variables — the same channel `hyperframes render
--variables` uses. Wired in both adapters (CLI embedded server + vite
dev adapter).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
622 lines
22 KiB
TypeScript
622 lines
22 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { Hono } from "hono";
|
|
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
|
|
import { registerRenderRoutes } from "./render";
|
|
import type { StudioApiAdapter } from "../types";
|
|
|
|
function createAdapter(
|
|
startRenderSpy: ReturnType<typeof vi.fn>,
|
|
rendersDir = mkdtempSync(join(tmpdir(), "hf-render-test-")),
|
|
): { adapter: StudioApiAdapter; rendersDir: string } {
|
|
const adapter: StudioApiAdapter = {
|
|
listProjects: () => [],
|
|
// Use a real, existing dir: isSafePath() canonicalizes the project dir with
|
|
// realpath and fails closed if it doesn't exist (real projects always do).
|
|
resolveProject: async (id: string) => ({ id, dir: tmpdir() }),
|
|
bundle: async () => null,
|
|
lint: async () => ({ findings: [] }),
|
|
runtimeUrl: "/api/runtime.js",
|
|
rendersDir: () => rendersDir,
|
|
startRender: (opts) => {
|
|
startRenderSpy(opts);
|
|
return {
|
|
id: opts.jobId,
|
|
status: "rendering",
|
|
progress: 0,
|
|
outputPath: opts.outputPath,
|
|
};
|
|
},
|
|
};
|
|
return { adapter, rendersDir };
|
|
}
|
|
|
|
function buildApp(spy: ReturnType<typeof vi.fn>): { app: Hono; cleanup: () => void } {
|
|
const { adapter, rendersDir } = createAdapter(spy);
|
|
const app = new Hono();
|
|
registerRenderRoutes(app, adapter);
|
|
return { app, cleanup: () => rmSync(rendersDir, { recursive: true, force: true }) };
|
|
}
|
|
|
|
describe("POST /projects/:id/render — outputResolution forwarding", () => {
|
|
it("forwards a valid resolution preset to the adapter", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
fps: 30,
|
|
quality: "high",
|
|
format: "mp4",
|
|
resolution: "landscape-4k",
|
|
}),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(spy).toHaveBeenCalledOnce();
|
|
const opts = spy.mock.calls[0][0];
|
|
expect(opts.outputResolution).toBe("landscape-4k");
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("omits outputResolution when the request does not specify one", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const opts = spy.mock.calls[0][0];
|
|
expect(opts.outputResolution).toBeUndefined();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("drops an invalid resolution string (defense-in-depth, not a 400)", async () => {
|
|
// The route is intentionally lenient on unknown enum values — the producer
|
|
// is the source of truth for validation and emits a clear error message.
|
|
// We just want to make sure garbage doesn't propagate as if it were valid.
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", resolution: "8k" }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const opts = spy.mock.calls[0][0];
|
|
expect(opts.outputResolution).toBeUndefined();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("accepts each canonical preset value", async () => {
|
|
for (const preset of VALID_CANVAS_RESOLUTIONS) {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", resolution: preset }),
|
|
});
|
|
expect(spy.mock.calls[0][0].outputResolution).toBe(preset);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("POST /projects/:id/render — composition forwarding", () => {
|
|
it("forwards a valid composition path to the adapter", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
composition: "compositions/intro.html",
|
|
}),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(spy).toHaveBeenCalledOnce();
|
|
expect(spy.mock.calls[0][0].composition).toBe("compositions/intro.html");
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("omits composition when not specified", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(spy.mock.calls[0][0].composition).toBeUndefined();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("omits composition when empty string", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition: "" }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(spy.mock.calls[0][0].composition).toBeUndefined();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("rejects path-traversal attempts with 400", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
composition: "../../../etc/passwd",
|
|
}),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
expect(spy).not.toHaveBeenCalled();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("POST /projects/:id/render — fps wire format", () => {
|
|
// The fps fraction-syntax feature accepts JSON `number` (integer fps) and
|
|
// JSON `string` (ffmpeg-style rational) on the wire, normalizing both to
|
|
// the structured Fps form before invoking the adapter.
|
|
it("forwards integer fps as { num, den: 1 }", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ fps: 60, quality: "standard", format: "mp4" }),
|
|
});
|
|
expect(spy.mock.calls[0][0].fps).toEqual({ num: 60, den: 1 });
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("parses '30000/1001' string body as exact NTSC", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ fps: "30000/1001", quality: "standard", format: "mp4" }),
|
|
});
|
|
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30000, den: 1001 });
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("falls back to 30/1 for malformed fps values", async () => {
|
|
// Matches the lenient handling of `quality` and `resolution` in the same
|
|
// route — the producer surfaces a clearer downstream error if the value
|
|
// is genuinely unusable.
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ fps: "abc", quality: "standard", format: "mp4" }),
|
|
});
|
|
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30, den: 1 });
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("falls back to 30/1 when fps is omitted", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ quality: "standard", format: "mp4" }),
|
|
});
|
|
expect(spy.mock.calls[0][0].fps).toEqual({ num: 30, den: 1 });
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("POST /projects/:id/render — composition path safety", () => {
|
|
const tmpDirs: string[] = [];
|
|
|
|
function buildAppWithProjectDir(spy: ReturnType<typeof vi.fn>): {
|
|
app: Hono;
|
|
projectDir: string;
|
|
} {
|
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-proj-"));
|
|
const rendersDir = mkdtempSync(join(tmpdir(), "hf-render-out-"));
|
|
tmpDirs.push(projectDir, rendersDir);
|
|
const adapter: StudioApiAdapter = {
|
|
listProjects: () => [],
|
|
resolveProject: async (id: string) => ({ id, dir: projectDir }),
|
|
bundle: async () => null,
|
|
lint: async () => ({ findings: [] }),
|
|
runtimeUrl: "/api/runtime.js",
|
|
rendersDir: () => rendersDir,
|
|
startRender: (opts) => {
|
|
spy(opts);
|
|
return { id: opts.jobId, status: "rendering", progress: 0, outputPath: opts.outputPath };
|
|
},
|
|
};
|
|
const app = new Hono();
|
|
registerRenderRoutes(app, adapter);
|
|
return { app, projectDir };
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
|
|
tmpDirs.length = 0;
|
|
});
|
|
|
|
async function postComposition(app: Hono, composition: string): Promise<Response> {
|
|
return app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4", composition }),
|
|
});
|
|
}
|
|
|
|
// Mirror the repo convention (preview.test.ts): skip symlink cases on
|
|
// non-symlink-privileged Windows runners rather than crash the suite.
|
|
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
|
|
try {
|
|
symlinkSync(target, path, type);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
it("accepts a composition path inside the project directory", async () => {
|
|
const spy = vi.fn();
|
|
const { app } = buildAppWithProjectDir(spy);
|
|
const res = await postComposition(app, "scenes/intro.html");
|
|
expect(res.status).toBe(200);
|
|
expect(spy).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("rejects a `..` traversal in the composition path", async () => {
|
|
const spy = vi.fn();
|
|
const { app } = buildAppWithProjectDir(spy);
|
|
const res = await postComposition(app, "../../etc/passwd");
|
|
expect(res.status).toBe(400);
|
|
expect(spy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a composition reached through an in-project symlink pointing outside the project", async () => {
|
|
const spy = vi.fn();
|
|
const { app, projectDir } = buildAppWithProjectDir(spy);
|
|
const external = mkdtempSync(join(tmpdir(), "hf-render-external-"));
|
|
tmpDirs.push(external);
|
|
writeFileSync(join(external, "secret.html"), "<html></html>");
|
|
if (!tryCreateSymlink(external, join(projectDir, "link"), "dir")) return;
|
|
const res = await postComposition(app, "link/secret.html");
|
|
expect(res.status).toBe(400);
|
|
expect(spy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows a composition reached through an in-project symlink that stays inside the project", async () => {
|
|
const spy = vi.fn();
|
|
const { app, projectDir } = buildAppWithProjectDir(spy);
|
|
mkdirSync(join(projectDir, "real"));
|
|
writeFileSync(join(projectDir, "real", "scene.html"), "<html></html>");
|
|
if (!tryCreateSymlink(join(projectDir, "real"), join(projectDir, "alias"), "dir")) return;
|
|
const res = await postComposition(app, "alias/scene.html");
|
|
expect(res.status).toBe(200);
|
|
expect(spy).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("GET /projects/:id/renders/file/* — path safety", () => {
|
|
const tmpDirs: string[] = [];
|
|
|
|
function buildApp(): { app: Hono; rendersDir: string } {
|
|
const rendersDir = mkdtempSync(join(tmpdir(), "hf-renders-out-"));
|
|
tmpDirs.push(rendersDir);
|
|
const adapter: StudioApiAdapter = {
|
|
listProjects: () => [],
|
|
resolveProject: async (id: string) => ({ id, dir: tmpdir() }),
|
|
bundle: async () => null,
|
|
lint: async () => ({ findings: [] }),
|
|
runtimeUrl: "/api/runtime.js",
|
|
rendersDir: () => rendersDir,
|
|
startRender: (opts) => ({
|
|
id: opts.jobId,
|
|
status: "rendering",
|
|
progress: 0,
|
|
outputPath: opts.outputPath,
|
|
}),
|
|
};
|
|
const app = new Hono();
|
|
registerRenderRoutes(app, adapter);
|
|
return { app, rendersDir };
|
|
}
|
|
|
|
// Mirror the repo convention (preview.test.ts / composition tests above):
|
|
// skip symlink cases on non-symlink-privileged Windows runners.
|
|
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
|
|
try {
|
|
symlinkSync(target, path, type);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
|
|
tmpDirs.length = 0;
|
|
});
|
|
|
|
it("serves a render file that lives inside rendersDir", async () => {
|
|
const { app, rendersDir } = buildApp();
|
|
writeFileSync(join(rendersDir, "demo.mp4"), "render-bytes");
|
|
const res = await app.request("http://localhost/projects/demo/renders/file/demo.mp4");
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe("render-bytes");
|
|
});
|
|
|
|
it("rejects a file reached through a symlink inside rendersDir pointing outside it", async () => {
|
|
const { app, rendersDir } = buildApp();
|
|
// A bare join()+readFileSync followed the symlink and leaked the target;
|
|
// the resolveWithinProject chokepoint canonicalizes with realpath first.
|
|
const external = mkdtempSync(join(tmpdir(), "hf-renders-external-"));
|
|
tmpDirs.push(external);
|
|
writeFileSync(join(external, "secret.txt"), "TOP-SECRET");
|
|
if (!tryCreateSymlink(join(external, "secret.txt"), join(rendersDir, "leak.txt"), "file"))
|
|
return;
|
|
const res = await app.request("http://localhost/projects/demo/renders/file/leak.txt");
|
|
expect(res.status).toBe(403);
|
|
expect(await res.text()).not.toContain("TOP-SECRET");
|
|
});
|
|
|
|
it("serves a render file reached through a symlink that stays inside rendersDir", async () => {
|
|
const { app, rendersDir } = buildApp();
|
|
mkdirSync(join(rendersDir, "nested"));
|
|
writeFileSync(join(rendersDir, "nested", "clip.mp4"), "nested-bytes");
|
|
if (!tryCreateSymlink(join(rendersDir, "nested"), join(rendersDir, "alias"), "dir")) return;
|
|
const res = await app.request("http://localhost/projects/demo/renders/file/alias/clip.mp4");
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe("nested-bytes");
|
|
});
|
|
});
|
|
|
|
describe("POST /render/:jobId/cancel", () => {
|
|
async function startJob(app: Hono): Promise<string> {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
return ((await res.json()) as { jobId: string }).jobId;
|
|
}
|
|
|
|
it("marks a rendering job cancelled and invokes the adapter abort hook", async () => {
|
|
const spy = vi.fn();
|
|
let aborted = false;
|
|
const { adapter, rendersDir } = createAdapter(spy);
|
|
const baseStartRender = adapter.startRender.bind(adapter);
|
|
adapter.startRender = (opts) => {
|
|
const state = baseStartRender(opts);
|
|
state.cancel = () => {
|
|
aborted = true;
|
|
};
|
|
return state;
|
|
};
|
|
const app = new Hono();
|
|
registerRenderRoutes(app, adapter);
|
|
try {
|
|
const jobId = await startJob(app);
|
|
const res = await app.request(`http://localhost/render/${jobId}/cancel`, { method: "POST" });
|
|
expect(res.status).toBe(200);
|
|
expect(((await res.json()) as { status: string }).status).toBe("cancelled");
|
|
expect(aborted).toBe(true);
|
|
// SSE progress for a cancelled job must terminate (status is terminal).
|
|
const progress = await app.request(`http://localhost/render/${jobId}/progress`);
|
|
expect(progress.status).toBe(200);
|
|
} finally {
|
|
rmSync(rendersDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not cancel a job that already completed", async () => {
|
|
const spy = vi.fn();
|
|
const states: Array<{ status: string }> = [];
|
|
const { adapter, rendersDir } = createAdapter(spy);
|
|
const baseStartRender = adapter.startRender.bind(adapter);
|
|
adapter.startRender = (opts) => {
|
|
const state = baseStartRender(opts);
|
|
states.push(state);
|
|
return state;
|
|
};
|
|
const app = new Hono();
|
|
registerRenderRoutes(app, adapter);
|
|
try {
|
|
const jobId = await startJob(app);
|
|
const [state] = states;
|
|
if (state) state.status = "complete";
|
|
const res = await app.request(`http://localhost/render/${jobId}/cancel`, { method: "POST" });
|
|
expect(res.status).toBe(200);
|
|
expect(((await res.json()) as { status: string }).status).toBe("complete");
|
|
} finally {
|
|
rmSync(rendersDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("404s for unknown jobs", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/render/nope/cancel", { method: "POST" });
|
|
expect(res.status).toBe(404);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("POST /projects/:id/render — telemetryDistinctId forwarding", () => {
|
|
it("forwards the browser telemetryDistinctId to the adapter as distinctId", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
telemetryDistinctId: "browser-user-123",
|
|
}),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(spy.mock.calls[0][0].distinctId).toBe("browser-user-123");
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("passes undefined when no telemetryDistinctId is sent (older clients)", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ fps: 30, quality: "standard", format: "mp4" }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(spy.mock.calls[0][0].distinctId).toBeUndefined();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("ignores a non-string telemetryDistinctId", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
fps: 30,
|
|
quality: "standard",
|
|
format: "mp4",
|
|
telemetryDistinctId: 42,
|
|
}),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(spy.mock.calls[0][0].distinctId).toBeUndefined();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("POST /projects/:id/render — variables forwarding", () => {
|
|
it("forwards a variables object to the adapter", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
format: "mp4",
|
|
variables: { title: "Custom", count: 5, dark: true },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(spy).toHaveBeenCalledOnce();
|
|
expect(spy.mock.calls[0][0].variables).toEqual({ title: "Custom", count: 5, dark: true });
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("omits variables from adapter opts when not provided", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ format: "mp4" }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(spy.mock.calls[0][0].variables).toBeUndefined();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it("rejects non-object variables payloads with 400 (no silent drop)", async () => {
|
|
const spy = vi.fn();
|
|
const { app, cleanup } = buildApp(spy);
|
|
try {
|
|
for (const variables of [["a"], "str", 42, null]) {
|
|
const res = await app.request("http://localhost/projects/demo/render", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ format: "mp4", variables }),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
}
|
|
expect(spy).not.toHaveBeenCalled();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|