diff --git a/src/app/api/llm/__tests__/route.test.ts b/src/app/api/llm/__tests__/route.test.ts index e5e421da..fcb6903f 100644 --- a/src/app/api/llm/__tests__/route.test.ts +++ b/src/app/api/llm/__tests__/route.test.ts @@ -46,8 +46,12 @@ vi.mock("@/utils/logger", () => ({ import { POST } from "../route"; -// Store original env +// Store original env and fetch const originalEnv = { ...process.env }; +const originalFetch = global.fetch; + +// Mock fetch for OpenAI API +const mockFetch = vi.fn(); // Helper to create mock NextRequest for POST function createMockPostRequest( @@ -70,6 +74,7 @@ describe("/api/llm route", () => { afterEach(() => { process.env = originalEnv; + global.fetch = originalFetch; }); describe("Google provider", () => { @@ -307,4 +312,275 @@ describe("/api/llm route", () => { }); }); }); + + describe("OpenAI provider", () => { + beforeEach(() => { + global.fetch = mockFetch; + }); + + it("should generate text successfully with OpenAI", async () => { + process.env.OPENAI_API_KEY = "test-openai-key"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "OpenAI response text" } }], + }), + }); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "openai", + model: "gpt-4.1-mini", + temperature: 0.7, + maxTokens: 1024, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.text).toBe("OpenAI response text"); + + // Verify fetch was called with correct parameters + expect(mockFetch).toHaveBeenCalledWith( + "https://api.openai.com/v1/chat/completions", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test-openai-key", + }, + body: JSON.stringify({ + model: "gpt-4.1-mini", + messages: [{ role: "user", content: "Test prompt" }], + temperature: 0.7, + max_tokens: 1024, + }), + }) + ); + }); + + it("should handle vision input (images + prompt)", async () => { + process.env.OPENAI_API_KEY = "test-openai-key"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "Image description from OpenAI" } }], + }), + }); + + const request = createMockPostRequest({ + prompt: "Describe this image", + images: ["data:image/png;base64,iVBORw0KGgo="], + provider: "openai", + model: "gpt-4.1-mini", + temperature: 0.7, + maxTokens: 1024, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.text).toBe("Image description from OpenAI"); + + // Verify fetch was called with vision content structure + expect(mockFetch).toHaveBeenCalledWith( + "https://api.openai.com/v1/chat/completions", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + model: "gpt-4.1-mini", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe this image" }, + { type: "image_url", image_url: { url: "data:image/png;base64,iVBORw0KGgo=" } }, + ], + }, + ], + temperature: 0.7, + max_tokens: 1024, + }), + }) + ); + }); + + it("should reject unknown provider", async () => { + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "unknown-provider", + model: "some-model", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Unknown provider: unknown-provider"); + }); + + it("should reject missing OpenAI API key", async () => { + delete process.env.OPENAI_API_KEY; + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "openai", + model: "gpt-4.1-mini", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toContain("OPENAI_API_KEY not configured"); + }); + + it("should use X-OpenAI-API-Key header over env var", async () => { + process.env.OPENAI_API_KEY = "env-openai-key"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: "Response with header key" } }], + }), + }); + + const request = createMockPostRequest( + { + prompt: "Test prompt", + provider: "openai", + model: "gpt-4.1-mini", + }, + { "X-OpenAI-API-Key": "header-openai-key" } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + + // Verify fetch was called with header key (takes precedence) + expect(mockFetch).toHaveBeenCalledWith( + "https://api.openai.com/v1/chat/completions", + expect.objectContaining({ + headers: { + "Content-Type": "application/json", + Authorization: "Bearer header-openai-key", + }, + }) + ); + }); + + it("should return 429 on rate limit errors", async () => { + process.env.OPENAI_API_KEY = "test-openai-key"; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + json: () => + Promise.resolve({ + error: { message: "429 Rate limit exceeded" }, + }), + }); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "openai", + model: "gpt-4.1-mini", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(429); + expect(data.success).toBe(false); + expect(data.error).toBe("Rate limit reached. Please wait and try again."); + }); + + it("should handle OpenAI API error responses", async () => { + process.env.OPENAI_API_KEY = "test-openai-key"; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: () => + Promise.resolve({ + error: { message: "Invalid API key" }, + }), + }); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "openai", + model: "gpt-4.1-mini", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe("Invalid API key"); + }); + + it("should handle OpenAI API error without message", async () => { + process.env.OPENAI_API_KEY = "test-openai-key"; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.resolve({}), + }); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "openai", + model: "gpt-4.1-mini", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe("OpenAI API error: 500"); + }); + + it("should handle no text in OpenAI response", async () => { + process.env.OPENAI_API_KEY = "test-openai-key"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: null } }], + }), + }); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "openai", + model: "gpt-4.1-mini", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe("No text in OpenAI response"); + }); + }); });