diff --git a/src/app/api/generate/__tests__/route.test.ts b/src/app/api/generate/__tests__/route.test.ts index 318388b2..9311f6fa 100644 --- a/src/app/api/generate/__tests__/route.test.ts +++ b/src/app/api/generate/__tests__/route.test.ts @@ -2637,5 +2637,165 @@ describe("/api/generate route", () => { expect(data.success).toBe(false); expect(data.error).toContain("No media URL in response"); }); + + it("should handle model_glb.url response format (Hunyuan 3D)", async () => { + // Schema fetch + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ models: [] }), + }); + + // Queue flow: submit → poll → result (model_glb format) → media fetch + // Queue submit + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ request_id: "test-req-id" }), + }); + // Status poll → COMPLETED + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ status: "COMPLETED" }), + }); + // Result fetch + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ model_glb: { url: "https://fal.media/hunyuan3d-output.glb" } }), + }); + // Media fetch (needed to reach is3DModel check) + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "model/gltf-binary" }), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(4096)), + }); + + const request = createMockPostRequest( + { + prompt: "A 3D model of a chair", + mediaType: "3d", + selectedModel: { + provider: "fal", + modelId: "fal-ai/hunyuan3d/mini", + displayName: "Hunyuan 3D Mini", + }, + }, + { "X-Fal-API-Key": "test-fal-key" } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.contentType).toBe("3d"); + expect(data.model3dUrl).toBe("https://fal.media/hunyuan3d-output.glb"); + }); + + it("should handle model_urls.glb.url response format (Hunyuan 3D variant)", async () => { + // Schema fetch + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ models: [] }), + }); + + // Queue flow: submit → poll → result (model_urls.glb format) → media fetch + // Queue submit + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ request_id: "test-req-id" }), + }); + // Status poll → COMPLETED + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ status: "COMPLETED" }), + }); + // Result fetch + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ model_urls: { glb: { url: "https://fal.media/hunyuan3d-v2-output.glb" } } }), + }); + // Media fetch (needed to reach is3DModel check) + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "model/gltf-binary" }), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(4096)), + }); + + const request = createMockPostRequest( + { + prompt: "A 3D model of a tree", + mediaType: "3d", + selectedModel: { + provider: "fal", + modelId: "fal-ai/hunyuan3d/standard", + displayName: "Hunyuan 3D Standard", + }, + }, + { "X-Fal-API-Key": "test-fal-key" } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.contentType).toBe("3d"); + expect(data.model3dUrl).toBe("https://fal.media/hunyuan3d-v2-output.glb"); + }); + + it("should include Result keys in error log when no media URL found", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + // Schema fetch + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ models: [] }), + }); + + // Queue submit + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ request_id: "test-req-id" }), + }); + // Status poll → COMPLETED + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ status: "COMPLETED" }), + }); + // Result fetch returns unrecognized format + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ unknown_field: "data", another: 123 }), + }); + + const request = createMockPostRequest( + { + prompt: "Test prompt", + selectedModel: { + provider: "fal", + modelId: "fal-ai/some-model", + displayName: "Some Model", + }, + }, + { "X-Fal-API-Key": "test-fal-key" } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toContain("No media URL in response"); + + // Verify the enhanced error log includes Result keys + const errorCalls = consoleSpy.mock.calls; + const resultKeysLog = errorCalls.find( + (call) => typeof call[0] === "string" && call[0].includes("Result keys:") + ); + expect(resultKeysLog).toBeDefined(); + expect(resultKeysLog![0]).toContain("unknown_field"); + expect(resultKeysLog![0]).toContain("another"); + + consoleSpy.mockRestore(); + }); }); }); diff --git a/src/app/api/open-file/__tests__/route.test.ts b/src/app/api/open-file/__tests__/route.test.ts new file mode 100644 index 00000000..16b85da7 --- /dev/null +++ b/src/app/api/open-file/__tests__/route.test.ts @@ -0,0 +1,276 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// Use vi.hoisted so mock fns are available during vi.mock() hoisting +const { mockExecFileAsync, mockStat, mockPlatform, mockHomedir } = vi.hoisted(() => ({ + mockExecFileAsync: vi.fn(), + mockStat: vi.fn(), + mockPlatform: vi.fn(), + mockHomedir: vi.fn(), +})); + +vi.mock(import("child_process"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFile: vi.fn(), + }; +}); + +vi.mock(import("util"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promisify: () => mockExecFileAsync, + }; +}); + +vi.mock(import("fs/promises"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + stat: (...args: unknown[]) => mockStat(...args), + }; +}); + +vi.mock(import("os"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual, + platform: () => mockPlatform(), + homedir: () => mockHomedir(), + }, + platform: () => mockPlatform(), + homedir: () => mockHomedir(), + }; +}); + +import { POST } from "../route"; + +// Helper to create mock NextRequest +function createMockRequest( + body: unknown, + headers?: Record +): NextRequest { + return { + json: vi.fn().mockResolvedValue(body), + headers: new Headers({ host: "localhost:3000", ...headers }), + } as unknown as NextRequest; +} + +describe("/api/open-file route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPlatform.mockReturnValue("darwin"); + mockHomedir.mockReturnValue("/Users/testuser"); + }); + + describe("localhost guard", () => { + it("should return 403 for non-localhost x-forwarded-for", async () => { + const request = createMockRequest( + { filePath: "/Users/testuser/file.glb" }, + { "x-forwarded-for": "203.0.113.50", host: "localhost:3000" } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("Forbidden: localhost only"); + }); + + it("should return 403 for non-localhost host header", async () => { + const request = createMockRequest( + { filePath: "/Users/testuser/file.glb" }, + { host: "example.com" } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("Forbidden: localhost only"); + }); + + it("should allow requests from 127.0.0.1 x-forwarded-for", async () => { + mockStat.mockResolvedValue({ isFile: () => true }); + mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); + + const request = createMockRequest( + { filePath: "/Users/testuser/file.glb" }, + { "x-forwarded-for": "127.0.0.1", host: "localhost:3000" } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + }); + + it("should allow requests from ::1 x-forwarded-for", async () => { + mockStat.mockResolvedValue({ isFile: () => true }); + mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); + + const request = createMockRequest( + { filePath: "/Users/testuser/file.glb" }, + { "x-forwarded-for": "::1", host: "localhost:3000" } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + }); + + it("should allow requests with localhost host header", async () => { + mockStat.mockResolvedValue({ isFile: () => true }); + mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); + + const request = createMockRequest( + { filePath: "/Users/testuser/file.glb" }, + { host: "localhost:3000" } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + }); + }); + + describe("input validation", () => { + it("should return 400 for missing filePath", async () => { + const request = createMockRequest({}); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("File path is required"); + }); + + it("should return 400 for empty filePath", async () => { + const request = createMockRequest({ filePath: "" }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("File path is required"); + }); + + it("should return 400 for non-string filePath", async () => { + const request = createMockRequest({ filePath: 12345 }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("File path is required"); + }); + }); + + describe("path restriction", () => { + it("should return 403 for path outside home directory", async () => { + const request = createMockRequest({ filePath: "/etc/passwd" }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("Path is outside allowed directory"); + }); + }); + + describe("file validation", () => { + it("should return 400 when path is a directory", async () => { + mockStat.mockResolvedValue({ isFile: () => false }); + + const request = createMockRequest({ + filePath: "/Users/testuser/generations", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("Path is not a file"); + }); + + it("should return 400 when file does not exist", async () => { + mockStat.mockRejectedValue(new Error("ENOENT")); + + const request = createMockRequest({ + filePath: "/Users/testuser/nonexistent.glb", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("File does not exist"); + }); + }); + + describe("platform commands", () => { + it("should call 'open -R' on macOS", async () => { + mockPlatform.mockReturnValue("darwin"); + mockStat.mockResolvedValue({ isFile: () => true }); + mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); + + const request = createMockRequest({ + filePath: "/Users/testuser/generations/model.glb", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockExecFileAsync).toHaveBeenCalledWith("open", [ + "-R", + "/Users/testuser/generations/model.glb", + ]); + }); + + it("should call 'xdg-open' with parent directory on Linux", async () => { + mockPlatform.mockReturnValue("linux"); + mockStat.mockResolvedValue({ isFile: () => true }); + mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" }); + + const request = createMockRequest({ + filePath: "/Users/testuser/generations/model.glb", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockExecFileAsync).toHaveBeenCalledWith("xdg-open", [ + "/Users/testuser/generations", + ]); + }); + + it("should return 500 when command execution fails", async () => { + mockStat.mockResolvedValue({ isFile: () => true }); + mockExecFileAsync.mockRejectedValue(new Error("Command not found")); + + const request = createMockRequest({ + filePath: "/Users/testuser/generations/model.glb", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.error).toBe("Failed to open file location"); + }); + }); +}); diff --git a/src/app/api/save-generation/__tests__/route.test.ts b/src/app/api/save-generation/__tests__/route.test.ts index 5999907d..4c5f3d58 100644 --- a/src/app/api/save-generation/__tests__/route.test.ts +++ b/src/app/api/save-generation/__tests__/route.test.ts @@ -27,7 +27,7 @@ vi.mock("@/utils/logger", () => ({ // Store original fetch const originalFetch = global.fetch; -import { POST } from "../route"; +import { POST, getExtensionFromUrl } from "../route"; // Helper to create mock NextRequest for POST function createMockPostRequest(body: unknown): NextRequest { @@ -526,3 +526,57 @@ describe("/api/save-generation route", () => { }); }); }); + +describe("getExtensionFromUrl", () => { + it("should extract .glb from a CDN URL", () => { + expect(getExtensionFromUrl("https://cdn.example.com/model.glb")).toBe("glb"); + }); + + it("should extract .obj extension", () => { + expect(getExtensionFromUrl("https://cdn.example.com/model.obj")).toBe("obj"); + }); + + it("should extract .fbx extension", () => { + expect(getExtensionFromUrl("https://cdn.example.com/model.fbx")).toBe("fbx"); + }); + + it("should extract .usdz extension", () => { + expect(getExtensionFromUrl("https://cdn.example.com/model.usdz")).toBe("usdz"); + }); + + it("should extract .stl extension", () => { + expect(getExtensionFromUrl("https://cdn.example.com/model.stl")).toBe("stl"); + }); + + it("should extract .gltf extension", () => { + expect(getExtensionFromUrl("https://cdn.example.com/model.gltf")).toBe("gltf"); + }); + + it("should return null for unrecognized extensions", () => { + expect(getExtensionFromUrl("https://cdn.example.com/file.xyz")).toBeNull(); + }); + + it("should return null for URLs without extensions", () => { + expect(getExtensionFromUrl("https://cdn.example.com/model")).toBeNull(); + }); + + it("should return null for invalid URLs", () => { + expect(getExtensionFromUrl("not-a-url")).toBeNull(); + }); + + it("should handle query strings correctly", () => { + expect(getExtensionFromUrl("https://cdn.example.com/model.glb?token=abc123")).toBe("glb"); + }); + + it("should handle fragments correctly", () => { + expect(getExtensionFromUrl("https://cdn.example.com/model.glb#section")).toBe("glb"); + }); + + it("should return null for URL ending with dot", () => { + expect(getExtensionFromUrl("https://cdn.example.com/model.")).toBeNull(); + }); + + it("should not recognize zip as a 3D extension", () => { + expect(getExtensionFromUrl("https://cdn.example.com/model.zip")).toBeNull(); + }); +});