From 9985c634ff6e2c96d8e95c13ecf26f22394ce47f Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 12:04:20 +1300 Subject: [PATCH] test(quick-012): add unit tests for prompt passthrough with dynamicInputs - 3 tests per provider (Replicate, fal.ai): prompt included with dynamicInputs, no duplication when dynamicInputs already has prompt, existing non-dynamicInputs path works - Mock fetch intercepts provider API calls and captures request bodies - Verifies the fix from the previous commit prevents "prompt is required" errors --- .../__tests__/fal-prompt-passthrough.test.ts | 177 ++++++++++++++++++ .../replicate-prompt-passthrough.test.ts | 147 +++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts create mode 100644 src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts diff --git a/src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts b/src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts new file mode 100644 index 00000000..c87eaf4f --- /dev/null +++ b/src/app/api/generate/providers/__tests__/fal-prompt-passthrough.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { generateWithFalQueue, clearFalInputMappingCache } from "../fal"; +import type { GenerationInput } from "@/lib/providers/types"; + +/** + * Tests for prompt passthrough when dynamicInputs are present. + * + * Bug: When a generate node has both a prompt AND image input connected, + * the dynamicInputs code path processes images but never reads input.prompt, + * causing "prompt is required" errors from the fal.ai API. + */ + +// Captured request bodies from fetch calls +let capturedQueueBody: Record | null = null; + +function makeInput(overrides: Partial = {}): GenerationInput { + return { + model: { + id: "fal-ai/test-model", + name: "Test Model", + description: null, + provider: "fal", + capabilities: ["text-to-image"], + }, + prompt: "a photo of a cat", + images: [], + parameters: {}, + ...overrides, + }; +} + +/** + * Create a mock fetch that intercepts fal.ai API calls. + * - Schema request: returns OpenAPI spec with prompt + image_url properties + * - Queue submission: captures body, returns request_id + * - Status poll: returns COMPLETED + * - Result fetch: returns images array + * - Media fetch: returns fake image + */ +function createMockFetch() { + return vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + + // Schema request (fal.ai model search API with OpenAPI expansion) + if (urlStr.includes("api.fal.ai/v1/models")) { + return new Response( + JSON.stringify({ + models: [ + { + endpoint_id: "fal-ai/test-model", + openapi: { + paths: { + "/": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + properties: { + prompt: { type: "string", description: "Text prompt" }, + image_url: { type: "string", description: "Input image URL" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }), + { status: 200 } + ); + } + + // Queue submission + if (urlStr.includes("queue.fal.run/fal-ai/test-model") && init?.method === "POST") { + capturedQueueBody = JSON.parse(init.body as string); + return new Response( + JSON.stringify({ + request_id: "test-123", + status_url: "https://queue.fal.run/fal-ai/test-model/requests/test-123/status", + response_url: "https://queue.fal.run/fal-ai/test-model/requests/test-123", + }), + { status: 200 } + ); + } + + // Status poll + if (urlStr.includes("/requests/test-123/status")) { + return new Response( + JSON.stringify({ status: "COMPLETED" }), + { status: 200 } + ); + } + + // Result fetch + if (urlStr.includes("/requests/test-123") && !urlStr.includes("/status")) { + return new Response( + JSON.stringify({ + images: [{ url: "https://cdn.fal.ai/test/image.png" }], + }), + { status: 200 } + ); + } + + // Media fetch (output image) + if (urlStr.includes("cdn.fal.ai")) { + return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + + return new Response("Not Found", { status: 404 }); + }); +} + +describe("fal.ai prompt passthrough with dynamicInputs", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + capturedQueueBody = null; + clearFalInputMappingCache(); + mockFetch = createMockFetch(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("includes prompt when dynamicInputs has image_url but no prompt", async () => { + const input = makeInput({ + prompt: "a photo of a cat", + dynamicInputs: { + image_url: "https://cdn.example.com/img.png", + }, + }); + + await generateWithFalQueue("test-req", "test-api-key", input); + + expect(capturedQueueBody).not.toBeNull(); + expect(capturedQueueBody!.prompt).toBe("a photo of a cat"); + expect(capturedQueueBody!.image_url).toBe("https://cdn.example.com/img.png"); + }); + + it("does not duplicate prompt when dynamicInputs already contains prompt", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: { + prompt: "a dog", + image_url: "https://cdn.example.com/img.png", + }, + }); + + await generateWithFalQueue("test-req", "test-api-key", input); + + expect(capturedQueueBody).not.toBeNull(); + // dynamicInputs value wins - not overwritten by input.prompt + expect(capturedQueueBody!.prompt).toBe("a dog"); + }); + + it("works without dynamicInputs (existing behavior)", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: undefined, + }); + + await generateWithFalQueue("test-req", "test-api-key", input); + + expect(capturedQueueBody).not.toBeNull(); + expect(capturedQueueBody!.prompt).toBe("a cat"); + }); +}); diff --git a/src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts b/src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts new file mode 100644 index 00000000..bad9e722 --- /dev/null +++ b/src/app/api/generate/providers/__tests__/replicate-prompt-passthrough.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { generateWithReplicate } from "../replicate"; +import type { GenerationInput } from "@/lib/providers/types"; + +/** + * Tests for prompt passthrough when dynamicInputs are present. + * + * Bug: When a generate node has both a prompt AND image input connected, + * the dynamicInputs code path processes images but never reads input.prompt, + * causing "prompt is required" errors from the Replicate API. + */ + +// Captured request bodies from fetch calls +let capturedPredictionBody: Record | null = null; + +function makeInput(overrides: Partial = {}): GenerationInput { + return { + model: { + id: "owner/test-model", + name: "Test Model", + description: null, + provider: "replicate", + capabilities: ["text-to-image"], + }, + prompt: "a photo of a cat", + images: [], + parameters: {}, + ...overrides, + }; +} + +/** + * Create a mock fetch that intercepts Replicate API calls. + * - Model info: returns schema with prompt + image_input properties + * - Prediction creation: captures body, returns succeeded + * - Media fetch: returns fake image + */ +function createMockFetch() { + return vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + + // Model info request + if (urlStr.includes("/models/owner/test-model") && !urlStr.includes("/predictions")) { + return new Response( + JSON.stringify({ + latest_version: { + id: "abc123", + openapi_schema: { + components: { + schemas: { + Input: { + properties: { + prompt: { type: "string", description: "Text prompt" }, + image_input: { type: "string", description: "Input image URL" }, + }, + }, + }, + }, + }, + }, + }), + { status: 200 } + ); + } + + // Prediction creation + if (urlStr.includes("/predictions") && init?.method === "POST") { + const body = JSON.parse(init.body as string); + capturedPredictionBody = body.input; + return new Response( + JSON.stringify({ + id: "pred-123", + status: "succeeded", + output: ["https://replicate.delivery/test/image.png"], + }), + { status: 201 } + ); + } + + // Media fetch (output image) + if (urlStr.includes("replicate.delivery")) { + return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }); + } + + return new Response("Not Found", { status: 404 }); + }); +} + +describe("Replicate prompt passthrough with dynamicInputs", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + capturedPredictionBody = null; + mockFetch = createMockFetch(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("includes prompt when dynamicInputs has image_input but no prompt", async () => { + const input = makeInput({ + prompt: "a photo of a cat", + dynamicInputs: { + image_input: "https://cdn.example.com/img.png", + }, + }); + + await generateWithReplicate("test-req", "test-api-key", input); + + expect(capturedPredictionBody).not.toBeNull(); + expect(capturedPredictionBody!.prompt).toBe("a photo of a cat"); + expect(capturedPredictionBody!.image_input).toBe("https://cdn.example.com/img.png"); + }); + + it("does not duplicate prompt when dynamicInputs already contains prompt", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: { + prompt: "a dog", + image_input: "https://cdn.example.com/img.png", + }, + }); + + await generateWithReplicate("test-req", "test-api-key", input); + + expect(capturedPredictionBody).not.toBeNull(); + // dynamicInputs value wins - not overwritten by input.prompt + expect(capturedPredictionBody!.prompt).toBe("a dog"); + }); + + it("works without dynamicInputs (existing behavior)", async () => { + const input = makeInput({ + prompt: "a cat", + dynamicInputs: undefined, + }); + + await generateWithReplicate("test-req", "test-api-key", input); + + expect(capturedPredictionBody).not.toBeNull(); + expect(capturedPredictionBody!.prompt).toBe("a cat"); + }); +});