From 6666b9274adc95fabab2c574ba5404f09ce39327 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 13 Jan 2026 14:53:16 +1300 Subject: [PATCH] test(18-02): add LLM route tests for Google provider Tests Google/Gemini provider path covering success, validation, rate limits, and API error handling. Uses vi.hoisted for proper mock class constructor. Co-Authored-By: Claude Opus 4.5 --- src/app/api/llm/__tests__/route.test.ts | 310 ++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 src/app/api/llm/__tests__/route.test.ts diff --git a/src/app/api/llm/__tests__/route.test.ts b/src/app/api/llm/__tests__/route.test.ts new file mode 100644 index 00000000..e5e421da --- /dev/null +++ b/src/app/api/llm/__tests__/route.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +// Use vi.hoisted to define mocks that work with hoisted vi.mock +const { mockGenerateContent, MockGoogleGenAI, mockGoogleGenAIInstance } = vi.hoisted(() => { + const mockGenerateContent = vi.fn(); + const mockGoogleGenAIInstance = { + models: { + generateContent: mockGenerateContent, + }, + }; + // Use a class to properly support `new` keyword + class MockGoogleGenAI { + apiKey: string; + models = mockGoogleGenAIInstance.models; + + constructor(config: { apiKey: string }) { + this.apiKey = config.apiKey; + // Track calls to constructor + MockGoogleGenAI.lastCalledWith = config; + MockGoogleGenAI.callCount++; + } + + static lastCalledWith: { apiKey: string } | null = null; + static callCount = 0; + static reset() { + MockGoogleGenAI.lastCalledWith = null; + MockGoogleGenAI.callCount = 0; + } + } + return { mockGenerateContent, MockGoogleGenAI, mockGoogleGenAIInstance }; +}); + +vi.mock("@google/genai", () => ({ + GoogleGenAI: MockGoogleGenAI, +})); + +// Mock logger to avoid console noise during tests +vi.mock("@/utils/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { POST } from "../route"; + +// Store original env +const originalEnv = { ...process.env }; + +// Helper to create mock NextRequest for POST +function createMockPostRequest( + body: unknown, + headers?: Record +): NextRequest { + return { + json: vi.fn().mockResolvedValue(body), + headers: new Headers(headers), + } as unknown as NextRequest; +} + +describe("/api/llm route", () => { + beforeEach(() => { + vi.clearAllMocks(); + MockGoogleGenAI.reset(); + // Reset env to original + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("Google provider", () => { + it("should generate text successfully with Google/Gemini", async () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; + + mockGenerateContent.mockResolvedValueOnce({ + text: "Generated response from Gemini", + }); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "google", + model: "gemini-2.5-flash", + 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("Generated response from Gemini"); + expect(mockGenerateContent).toHaveBeenCalledWith({ + model: "gemini-2.5-flash", + contents: "Test prompt", + config: { + temperature: 0.7, + maxOutputTokens: 1024, + }, + }); + }); + + it("should handle multimodal input (images + prompt)", async () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; + + mockGenerateContent.mockResolvedValueOnce({ + text: "Description of the image", + }); + + const request = createMockPostRequest({ + prompt: "Describe this image", + images: ["data:image/png;base64,iVBORw0KGgo="], + provider: "google", + model: "gemini-2.5-flash", + 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("Description of the image"); + + // Verify multimodal content structure + expect(mockGenerateContent).toHaveBeenCalledWith({ + model: "gemini-2.5-flash", + contents: [ + { + inlineData: { + mimeType: "image/png", + data: "iVBORw0KGgo=", + }, + }, + { text: "Describe this image" }, + ], + config: { + temperature: 0.7, + maxOutputTokens: 1024, + }, + }); + }); + + it("should reject missing prompt", async () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; + + const request = createMockPostRequest({ + provider: "google", + model: "gemini-2.5-flash", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Prompt is required"); + }); + + it("should reject missing API key (no env var, no header)", async () => { + delete process.env.GEMINI_API_KEY; + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "google", + model: "gemini-2.5-flash", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toContain("GEMINI_API_KEY not configured"); + }); + + it("should use X-Gemini-API-Key header over env var", async () => { + process.env.GEMINI_API_KEY = "env-gemini-key"; + + mockGenerateContent.mockResolvedValueOnce({ + text: "Response with header key", + }); + + const request = createMockPostRequest( + { + prompt: "Test prompt", + provider: "google", + model: "gemini-2.5-flash", + }, + { "X-Gemini-API-Key": "header-gemini-key" } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + + // Verify GoogleGenAI was called with header key (takes precedence) + expect(MockGoogleGenAI.lastCalledWith).toEqual({ apiKey: "header-gemini-key" }); + }); + + it("should return 429 on rate limit errors", async () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; + + mockGenerateContent.mockRejectedValueOnce( + new Error("429 Resource exhausted") + ); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "google", + model: "gemini-2.5-flash", + }); + + 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 return 500 on API errors", async () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; + + mockGenerateContent.mockRejectedValueOnce( + new Error("Internal server error") + ); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "google", + model: "gemini-2.5-flash", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe("Internal server error"); + }); + + it("should handle no text in Google AI response", async () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; + + mockGenerateContent.mockResolvedValueOnce({ + text: null, + }); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "google", + model: "gemini-2.5-flash", + }); + + 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 Google AI response"); + }); + + it("should handle image without data URL prefix", async () => { + process.env.GEMINI_API_KEY = "test-gemini-key"; + + mockGenerateContent.mockResolvedValueOnce({ + text: "Image description", + }); + + const request = createMockPostRequest({ + prompt: "Describe this", + images: ["iVBORw0KGgoAAAANSUhEUgAAAAUA"], // raw base64, no prefix + provider: "google", + model: "gemini-2.5-flash", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + + // Verify fallback to PNG mime type + expect(mockGenerateContent).toHaveBeenCalledWith({ + model: "gemini-2.5-flash", + contents: [ + { + inlineData: { + mimeType: "image/png", + data: "iVBORw0KGgoAAAANSUhEUgAAAAUA", + }, + }, + { text: "Describe this" }, + ], + config: { + temperature: 0.7, + maxOutputTokens: 1024, + }, + }); + }); + }); +});