Browse Source

fix: enforce fal.ai API key requirement and update error handling

- Added validation to ensure fal.ai API key is provided in requests, returning a 401 status with an appropriate error message if not configured.
- Updated related tests to reflect the new API key requirement and adjusted assertions for expected responses.
- Modified documentation to clarify that fal.ai API key is now mandatory for model access.
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
95d8f10ba7
  1. 57
      src/app/api/generate/__tests__/route.test.ts
  2. 10
      src/app/api/generate/route.ts
  3. 9
      src/app/api/models/[modelId]/route.ts
  4. 15
      src/app/api/models/__tests__/route.test.ts
  5. 12
      src/app/api/models/route.ts
  6. 15
      src/app/api/providers/fal/models/route.ts
  7. 62
      src/components/CubicBezierEditor.tsx
  8. 7
      src/components/__tests__/ConnectionDropMenu.test.tsx
  9. 226
      src/components/__tests__/VideoStitchNode.test.tsx

57
src/app/api/generate/__tests__/route.test.ts

@ -878,7 +878,7 @@ describe("/api/generate route", () => {
beforeEach(() => {
global.fetch = mockFetch;
mockFetch.mockClear();
mockFetch.mockReset();
});
afterEach(() => {
@ -1617,7 +1617,7 @@ describe("/api/generate route", () => {
beforeEach(() => {
global.fetch = mockFetch;
mockFetch.mockClear();
mockFetch.mockReset();
});
afterEach(() => {
@ -1720,30 +1720,9 @@ describe("/api/generate route", () => {
expect(data.contentType).toBe("video");
});
it("should work without API key (fal.ai allows unauthenticated)", async () => {
it("should return 401 without API key", async () => {
delete process.env.FAL_API_KEY;
// Schema fetch (for input mapping when no dynamicInputs)
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ models: [] }),
});
// fal.run API call
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
images: [{ url: "https://fal.media/output.png" }],
}),
});
// Fetch output media
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/png" }),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
});
const request = createMockPostRequest({
prompt: "A beautiful landscape",
selectedModel: {
@ -1756,12 +1735,9 @@ describe("/api/generate route", () => {
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
// Verify fal.run request was made without Authorization header (2nd call)
const falRunCall = mockFetch.mock.calls[1];
expect(falRunCall[1].headers).not.toHaveProperty("Authorization");
expect(response.status).toBe(401);
expect(data.success).toBe(false);
expect(data.error).toContain("API key not configured");
});
it("should handle rate limit (429) with API key", async () => {
@ -1799,22 +1775,9 @@ describe("/api/generate route", () => {
expect(data.error).toContain("Try again in a moment");
});
it("should handle rate limit (429) without API key", async () => {
it("should return 401 for rate limit scenario without API key", async () => {
delete process.env.FAL_API_KEY;
// Schema fetch (for input mapping when no dynamicInputs)
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ models: [] }),
});
// fal.run returns 429
mockFetch.mockResolvedValueOnce({
ok: false,
status: 429,
text: () => Promise.resolve(JSON.stringify({ detail: "Rate limit exceeded" })),
});
const request = createMockPostRequest({
prompt: "Test prompt",
selectedModel: {
@ -1827,10 +1790,10 @@ describe("/api/generate route", () => {
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(500);
// Without API key, route returns 401 before reaching fal.ai
expect(response.status).toBe(401);
expect(data.success).toBe(false);
expect(data.error).toContain("Rate limit exceeded");
expect(data.error).toContain("Add an API key");
expect(data.error).toContain("API key not configured");
});
it("should handle image object response format", async () => {

10
src/app/api/generate/route.ts

@ -1394,6 +1394,16 @@ export async function POST(request: NextRequest) {
// User-provided key takes precedence over env variable
const falApiKey = request.headers.get("X-Fal-API-Key") || process.env.FAL_API_KEY || null;
if (!falApiKey) {
return NextResponse.json<GenerateResponse>(
{
success: false,
error: "fal.ai API key not configured. Add FAL_API_KEY to .env.local or configure in Settings.",
},
{ status: 401 }
);
}
// For fal.ai, keep Data URIs as-is since localhost URLs won't work
// fal.ai accepts Data URIs directly
const processedImages: string[] = images ? [...images] : [];

9
src/app/api/models/[modelId]/route.ts

@ -529,6 +529,15 @@ export async function GET(
} else {
// User-provided key takes precedence over env variable
const apiKey = request.headers.get("X-Fal-Key") || process.env.FAL_API_KEY || null;
if (!apiKey) {
return NextResponse.json<SchemaErrorResponse>(
{
success: false,
error: "fal.ai API key not configured. Add FAL_API_KEY to .env.local or configure in Settings.",
},
{ status: 401 }
);
}
result = await fetchFalSchema(decodedModelId, apiKey);
}

15
src/app/api/models/__tests__/route.test.ts

@ -90,6 +90,8 @@ function createFalResponse(models: Array<{ id: string; name: string; category: s
describe("/api/models route", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset fetch mock fully (clears mockResolvedValueOnce queue to prevent leaks)
mockFetch.mockReset();
// Reset env to original
process.env = { ...originalEnv };
// Clear API keys
@ -108,7 +110,7 @@ describe("/api/models route", () => {
describe("basic functionality", () => {
it("GET: should return models from fal.ai when no Replicate key", async () => {
// fal.ai works without key
process.env.FAL_API_KEY = "test-fal-key";
mockFetch.mockResolvedValueOnce(
createFalResponse([
{ id: "fal-ai/flux", name: "Flux", category: "text-to-image" },
@ -256,6 +258,7 @@ describe("/api/models route", () => {
it("GET: should return cached=true when all from cache", async () => {
process.env.REPLICATE_API_KEY = "test-replicate-key";
process.env.FAL_API_KEY = "test-fal-key";
// Set up cache hits for both providers
mockGetCachedModels.mockImplementation((key: string) => {
@ -282,6 +285,7 @@ describe("/api/models route", () => {
});
it("GET: should return cached=false when fresh fetch", async () => {
process.env.FAL_API_KEY = "test-fal-key";
// No cache hits
mockGetCachedModels.mockReturnValue(null);
@ -416,6 +420,7 @@ describe("/api/models route", () => {
describe("error handling", () => {
it("GET: should handle partial provider failures gracefully", async () => {
process.env.REPLICATE_API_KEY = "test-key";
process.env.FAL_API_KEY = "test-fal-key";
// Mock fetch to handle both providers
mockFetch.mockImplementation((url: string) => {
@ -449,6 +454,7 @@ describe("/api/models route", () => {
});
it("GET: should return 500 when all requested providers fail", async () => {
process.env.FAL_API_KEY = "test-fal-key";
// Filter to only fal provider (exclude gemini which is always available)
// When fal fails and it's the only provider requested, should get 500
mockFetch.mockImplementation((url: string) => {
@ -472,6 +478,7 @@ describe("/api/models route", () => {
describe("pagination", () => {
it("GET: should paginate through Replicate results (max 15 pages)", async () => {
process.env.REPLICATE_API_KEY = "test-key";
process.env.FAL_API_KEY = "test-fal-key";
// Track Replicate page fetches
let replicatePageCount = 0;
@ -523,7 +530,7 @@ describe("/api/models route", () => {
});
it("GET: should paginate through fal.ai results (max 15 pages)", async () => {
// Only fal.ai (no Replicate key)
process.env.FAL_API_KEY = "test-fal-key";
let falPageCount = 0;
mockFetch.mockImplementation((url: string) => {
@ -657,7 +664,7 @@ describe("/api/models route", () => {
describe("fal.ai category mapping", () => {
it("GET: should map fal.ai categories to ModelCapability", async () => {
// Filter to only fal provider so we can test without Replicate
process.env.FAL_API_KEY = "test-fal-key";
mockFetch.mockImplementation((url: string) => {
if (url.includes("fal.ai")) {
return Promise.resolve(
@ -686,6 +693,7 @@ describe("/api/models route", () => {
});
it("GET: should filter out non-relevant fal.ai categories", async () => {
process.env.FAL_API_KEY = "test-fal-key";
mockFetch.mockImplementation((url: string) => {
if (url.includes("fal.ai")) {
return Promise.resolve(
@ -713,6 +721,7 @@ describe("/api/models route", () => {
describe("sorting", () => {
it("GET: should sort models by provider, then by name", async () => {
process.env.REPLICATE_API_KEY = "test-key";
process.env.FAL_API_KEY = "test-fal-key";
mockFetch.mockImplementation((url: string) => {
if (url.includes("replicate.com")) {

12
src/app/api/models/route.ts

@ -13,7 +13,7 @@
*
* Headers:
* - X-Replicate-Key: Replicate API key
* - X-Fal-Key: fal.ai API key (optional, works without but rate limited)
* - X-Fal-Key: fal.ai API key (required for fal.ai models)
*
* Response:
* {
@ -357,18 +357,18 @@ export async function GET(
includeGemini = true;
} else if (providerFilter === "replicate" && replicateKey) {
providersToFetch.push("replicate");
} else if (providerFilter === "fal") {
// fal.ai works without key
} else if (providerFilter === "fal" && falKey) {
providersToFetch.push("fal");
}
} else {
// Include all providers
// Include all providers that have keys configured
includeGemini = true; // Gemini always available
if (replicateKey) {
providersToFetch.push("replicate");
}
// fal.ai always included (works without key)
providersToFetch.push("fal");
if (falKey) {
providersToFetch.push("fal");
}
}
// Gemini is always available, so we don't fail if no external providers

15
src/app/api/providers/fal/models/route.ts

@ -108,10 +108,21 @@ type ModelsResponse = ModelsSuccessResponse | ModelsErrorResponse;
export async function GET(
request: NextRequest
): Promise<NextResponse<ModelsResponse>> {
// Get optional API key from header only (never from query params to avoid credential leakage)
// Get API key from header or env (never from query params to avoid credential leakage)
const apiKey =
request.headers.get("X-API-Key") ||
request.headers.get("Authorization")?.replace(/^Key\s+/i, "");
request.headers.get("Authorization")?.replace(/^Key\s+/i, "") ||
process.env.FAL_API_KEY;
if (!apiKey) {
return NextResponse.json<ModelsErrorResponse>(
{
success: false,
error: "fal.ai API key not configured. Add FAL_API_KEY to .env.local or configure in Settings.",
},
{ status: 401 }
);
}
const searchQuery = request.nextUrl.searchParams.get("search");

62
src/components/CubicBezierEditor.tsx

@ -1,22 +1,24 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
interface CubicBezierEditorProps {
value: [number, number, number, number];
onChange: (value: [number, number, number, number]) => void;
onCommit?: (value: [number, number, number, number]) => void;
disabled?: boolean;
/** SVG polyline points string (in 0–100 coordinate space) for the actual easing curve overlay */
easingCurve?: string;
}
const clamp = (value: number) => Math.max(0, Math.min(1, value));
// Hardcoded dark-theme palette (node-banana is dark mode only)
// Hardcoded dark-theme palette matching easy-peasy-ease color scheme
const palette = {
background: "#171717", // neutral-900
background: "#0f1720", // dark navy (easy-peasy-ease bg tint)
border: "rgba(255,255,255,0.12)",
muted: "rgba(255,255,255,0.35)",
primary: "#f59e0b", // amber-500
primary: "#bef264", // lime-300 (easy-peasy-ease primary)
};
const now = () =>
@ -24,11 +26,12 @@ const now = () =>
? performance.now()
: Date.now();
function CubicBezierEditorComponent({
export function CubicBezierEditor({
value,
onChange,
onCommit,
disabled = false,
easingCurve,
}: CubicBezierEditorProps) {
const editorRef = useRef<HTMLDivElement | null>(null);
const valueRef = useRef(value);
@ -40,9 +43,12 @@ function CubicBezierEditorComponent({
capturedAt: number;
} | null>(null);
const [v0, v1, v2, v3] = value;
// Sync valueRef with prop values (used by drag handlers)
useEffect(() => {
valueRef.current = value;
}, [value]);
}, [v0, v1, v2, v3]); // eslint-disable-line react-hooks/exhaustive-deps
const flushPendingChange = useCallback(() => {
commitFrameRef.current = null;
@ -132,25 +138,25 @@ function CubicBezierEditorComponent({
const controlStyles = useMemo(
() => ({
p1: {
left: `${(value[0] * 100).toFixed(2)}%`,
top: `${((1 - value[1]) * 100).toFixed(2)}%`,
left: `${(v0 * 100).toFixed(2)}%`,
top: `${((1 - v1) * 100).toFixed(2)}%`,
},
p2: {
left: `${(value[2] * 100).toFixed(2)}%`,
top: `${((1 - value[3]) * 100).toFixed(2)}%`,
left: `${(v2 * 100).toFixed(2)}%`,
top: `${((1 - v3) * 100).toFixed(2)}%`,
},
}),
[value]
[v0, v1, v2, v3]
);
const svgPoints = useMemo(
() => ({
start: { x: 0, y: 100 },
end: { x: 100, y: 0 },
c1: { x: value[0] * 100, y: (1 - value[1]) * 100 },
c2: { x: value[2] * 100, y: (1 - value[3]) * 100 },
c1: { x: v0 * 100, y: (1 - v1) * 100 },
c2: { x: v2 * 100, y: (1 - v3) * 100 },
}),
[value]
[v0, v1, v2, v3]
);
const curvePath = useMemo(
@ -199,21 +205,33 @@ function CubicBezierEditorComponent({
<line x1="0" y1="100" x2={svgPoints.c1.x} y2={svgPoints.c1.y} />
<line x1="100" y1="0" x2={svgPoints.c2.x} y2={svgPoints.c2.y} />
</g>
{/* Bezier curve */}
{/* Bezier curve — dimmed when easing overlay is active */}
<path
d={curvePath}
fill="none"
stroke={palette.primary}
strokeWidth="1.5"
stroke={easingCurve ? palette.muted : palette.primary}
strokeWidth={easingCurve ? "0.8" : "1.5"}
strokeLinecap="round"
strokeDasharray={easingCurve ? "3 2" : undefined}
/>
{/* Actual easing function curve overlay */}
{easingCurve && (
<polyline
points={easingCurve}
fill="none"
stroke={palette.primary}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
{/* Control point 1 - nodrag nopan touch-none prevents React Flow node dragging */}
<button
type="button"
aria-label="Adjust control point 1"
className={`nodrag nopan absolute h-6 w-6 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border-2 border-neutral-900/80 bg-amber-500/80 shadow transition active:cursor-grabbing active:scale-95 disabled:cursor-not-allowed disabled:pointer-events-none touch-none ${
draggingHandle === "p1" ? "ring-2 ring-amber-500/80" : ""
className={`nodrag nopan absolute h-6 w-6 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border-2 border-neutral-900/80 bg-lime-300/80 shadow transition active:cursor-grabbing active:scale-95 disabled:cursor-not-allowed disabled:pointer-events-none touch-none ${
draggingHandle === "p1" ? "ring-2 ring-lime-300/80" : ""
}`}
style={controlStyles.p1}
onPointerDown={(event) => startDragging("p1", event)}
@ -223,8 +241,8 @@ function CubicBezierEditorComponent({
<button
type="button"
aria-label="Adjust control point 2"
className={`nodrag nopan absolute h-6 w-6 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border-2 border-neutral-900/80 bg-amber-500/80 shadow transition active:cursor-grabbing active:scale-95 disabled:cursor-not-allowed disabled:pointer-events-none touch-none ${
draggingHandle === "p2" ? "ring-2 ring-amber-500/80" : ""
className={`nodrag nopan absolute h-6 w-6 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border-2 border-neutral-900/80 bg-lime-300/80 shadow transition active:cursor-grabbing active:scale-95 disabled:cursor-not-allowed disabled:pointer-events-none touch-none ${
draggingHandle === "p2" ? "ring-2 ring-lime-300/80" : ""
}`}
style={controlStyles.p2}
onPointerDown={(event) => startDragging("p2", event)}
@ -234,5 +252,3 @@ function CubicBezierEditorComponent({
</div>
);
}
export const CubicBezierEditor = memo(CubicBezierEditorComponent);

7
src/components/__tests__/ConnectionDropMenu.test.tsx

@ -199,7 +199,7 @@ describe("ConnectionDropMenu", () => {
fireEvent.keyDown(document, { key: "ArrowUp" });
// Last item should now be highlighted
const lastButton = screen.getByText("Output").closest("button");
const lastButton = screen.getByText("Image Compare").closest("button");
expect(lastButton).toHaveClass("bg-neutral-700");
});
@ -226,8 +226,9 @@ describe("ConnectionDropMenu", () => {
it("should wrap around when navigating past last item", () => {
render(<ConnectionDropMenu {...defaultProps} handleType="text" connectionType="source" />);
// Text target options: Prompt, nanoBanana, generateVideo, llmGenerate (4 items)
// Navigate down 4 times to wrap to first
// Text target options: Prompt, Prompt Constructor, nanoBanana, generateVideo, llmGenerate (5 items)
// Navigate down 5 times to wrap to first
fireEvent.keyDown(document, { key: "ArrowDown" });
fireEvent.keyDown(document, { key: "ArrowDown" });
fireEvent.keyDown(document, { key: "ArrowDown" });
fireEvent.keyDown(document, { key: "ArrowDown" });

226
src/components/__tests__/VideoStitchNode.test.tsx

@ -0,0 +1,226 @@
import React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { VideoStitchNodeData } from "@/types";
// Mock the workflow store
const mockUpdateNodeData = vi.fn();
const mockRegenerateNode = vi.fn();
const mockRemoveEdge = vi.fn();
const mockUseWorkflowStore = vi.fn();
vi.mock("@/store/workflowStore", () => ({
useWorkflowStore: (selector?: (state: unknown) => unknown) => {
if (selector) {
return mockUseWorkflowStore(selector);
}
return mockUseWorkflowStore((s: unknown) => s);
},
}));
// Mock @xyflow/react
vi.mock("@xyflow/react", () => {
const React = require("react");
const MockHandle = (props: Record<string, unknown>) =>
React.createElement("div", {
"data-testid": `handle-${props.id}`,
"data-handleid": props.id,
"data-handletype": props["data-handletype"],
"data-type": props.type,
"data-position": props.position,
"data-connectable": String(props.isConnectable ?? ""),
className: `react-flow__handle react-flow__handle-${props.position}`,
style: props.style,
});
return {
Handle: MockHandle,
NodeResizer: () => null,
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => children,
useReactFlow: () => ({
getNodes: () => [],
setNodes: () => {},
screenToFlowPosition: (pos: unknown) => pos,
}),
};
});
// Mock useStitchVideos (this also prevents mediabunny from loading)
const mockCheckEncoderSupport = vi.fn();
vi.mock("@/hooks/useStitchVideos", () => ({
checkEncoderSupport: () => mockCheckEncoderSupport(),
}));
vi.mock("@/components/Toast", () => ({
useToast: { getState: () => ({ show: vi.fn() }) },
}));
vi.mock("@/hooks/useCommentNavigation", () => ({
useCommentNavigation: () => null,
}));
vi.mock("@/components/nodes/BaseNode", () => {
const React = require("react");
return {
BaseNode: ({ children, ...props }: Record<string, unknown>) =>
React.createElement(
"div",
{ "data-testid": "base-node", "data-title": props.title },
children as React.ReactNode
),
};
});
import { VideoStitchNode } from "@/components/nodes/VideoStitchNode";
/** Set up mock store state, merging overrides onto the base state. */
function setMockStoreState(overrides: Record<string, unknown> = {}) {
const state = {
updateNodeData: mockUpdateNodeData,
regenerateNode: mockRegenerateNode,
removeEdge: mockRemoveEdge,
edges: [],
nodes: [],
isRunning: false,
currentNodeId: null,
groups: {},
getNodesWithComments: vi.fn(() => []),
markCommentViewed: vi.fn(),
setNavigationTarget: vi.fn(),
...overrides,
};
mockUseWorkflowStore.mockImplementation((selector: (s: typeof state) => unknown) => selector(state));
}
const createNodeData = (overrides: Partial<VideoStitchNodeData> = {}): VideoStitchNodeData => ({
clips: [],
clipOrder: [],
outputVideo: null,
status: "idle",
error: null,
progress: 0,
encoderSupported: true,
...overrides,
});
const createNodeProps = (data: Partial<VideoStitchNodeData> = {}) => ({
id: "test-stitch-1",
type: "videoStitch" as const,
data: createNodeData(data),
selected: false,
});
describe("VideoStitchNode", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCheckEncoderSupport.mockResolvedValue(true);
setMockStoreState();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Handle Rendering", () => {
it("should render video-0, video-1, audio, and output handles", () => {
const { container } = render(<VideoStitchNode {...createNodeProps()} />);
expect(container.querySelector('[data-handleid="video-0"]')).toBeInTheDocument();
expect(container.querySelector('[data-handleid="video-1"]')).toBeInTheDocument();
expect(container.querySelector('[data-handleid="audio"]')).toBeInTheDocument();
expect(container.querySelector('[data-handleid="video"]')).toBeInTheDocument();
const handles = container.querySelectorAll(".react-flow__handle");
expect(handles.length).toBeGreaterThanOrEqual(4);
handles.forEach((handle) => {
expect(handle.classList.contains("react-flow__handle-not-connectable")).toBe(false);
});
});
it("should grow dynamic handles when video edges exist", () => {
setMockStoreState({
edges: [
{ id: "e1", source: "gen1", target: "test-stitch-1", targetHandle: "video-0" },
{ id: "e2", source: "gen2", target: "test-stitch-1", targetHandle: "video-1" },
],
nodes: [
{ id: "gen1", type: "generateVideo", data: { outputVideo: null } },
{ id: "gen2", type: "generateVideo", data: { outputVideo: null } },
],
});
const { container } = render(<VideoStitchNode {...createNodeProps()} />);
expect(container.querySelector('[data-handleid="video-0"]')).toBeInTheDocument();
expect(container.querySelector('[data-handleid="video-1"]')).toBeInTheDocument();
expect(container.querySelector('[data-handleid="video-2"]')).toBeInTheDocument();
});
});
describe("Encoder Detection States", () => {
it("should show checking state when encoderSupported is null", () => {
render(<VideoStitchNode {...createNodeProps({ encoderSupported: null })} />);
expect(screen.getByText("Checking encoder...")).toBeInTheDocument();
});
it("should show unsupported message when encoderSupported is false", () => {
render(<VideoStitchNode {...createNodeProps({ encoderSupported: false })} />);
expect(screen.getByText("Your browser doesn't support video encoding.")).toBeInTheDocument();
});
it("should render handles in checking and unsupported states for connection stability", () => {
const { container: checking } = render(
<VideoStitchNode {...createNodeProps({ encoderSupported: null })} />
);
expect(checking.querySelectorAll(".react-flow__handle").length).toBeGreaterThanOrEqual(4);
const { container: unsupported } = render(
<VideoStitchNode {...createNodeProps({ encoderSupported: false })} />
);
expect(unsupported.querySelectorAll(".react-flow__handle").length).toBeGreaterThanOrEqual(4);
});
});
describe("Empty State", () => {
it("should show placeholder when no clips connected", () => {
render(<VideoStitchNode {...createNodeProps()} />);
expect(screen.getByText("Connect videos to stitch")).toBeInTheDocument();
});
});
describe("Stitch Button", () => {
it("should call regenerateNode when stitch button clicked", () => {
setMockStoreState({
edges: [
{ id: "e1", source: "gen1", target: "test-stitch-1", targetHandle: "video-0", data: { createdAt: 1 } },
{ id: "e2", source: "gen2", target: "test-stitch-1", targetHandle: "video-1", data: { createdAt: 2 } },
],
nodes: [
{ id: "gen1", type: "generateVideo", data: { outputVideo: "blob:video1" } },
{ id: "gen2", type: "generateVideo", data: { outputVideo: "blob:video2" } },
],
});
render(<VideoStitchNode {...createNodeProps({ clipOrder: ["e1", "e2"] })} />);
const stitchButton = screen.getByText("Stitch");
expect(stitchButton).not.toBeDisabled();
fireEvent.click(stitchButton);
expect(mockRegenerateNode).toHaveBeenCalledWith("test-stitch-1");
});
it("should disable stitch button when less than 2 clips", () => {
setMockStoreState({
edges: [
{ id: "e1", source: "gen1", target: "test-stitch-1", targetHandle: "video-0", data: { createdAt: 1 } },
],
nodes: [
{ id: "gen1", type: "generateVideo", data: { outputVideo: "blob:video1" } },
],
});
render(<VideoStitchNode {...createNodeProps({ clipOrder: ["e1"] })} />);
expect(screen.getByText("Stitch")).toBeDisabled();
});
});
});
Loading…
Cancel
Save