Browse Source
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 <noreply@anthropic.com>handoff-20260429-1057
1 changed files with 310 additions and 0 deletions
@ -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<string, string> |
||||
|
): 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, |
||||
|
}, |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue