lamplighter-mcp
Version:
An intelligent context engine for AI-assisted software development
274 lines (229 loc) • 9.91 kB
text/typescript
import 'openai/shims/node'; // Add node fetch shim
import { AIService, IAIService, AIConfig } from '../../src/services/aiService';
import OpenAI from 'openai';
// --- Mocks ---
// Mock OpenAI
jest.mock('openai');
const mockedOpenAI = jest.mocked(OpenAI);
const mockCreate = jest.fn();
mockedOpenAI.mockImplementation(() => ({
chat: {
completions: {
create: mockCreate,
},
},
} as unknown as OpenAI));
// Mock fetch for Google Gemini
global.fetch = jest.fn();
const mockedFetch = jest.mocked(global.fetch);
// --- Environment Variable Management ---
const originalEnv = process.env;
beforeEach(() => {
// Reset mocks and environment variables before each test
jest.clearAllMocks();
process.env = { ...originalEnv }; // Start with original env
// Set default required keys for tests, can be overridden
process.env.OPENAI_API_KEY = 'test-openai-key';
process.env.GOOGLE_API_KEY = 'test-google-key';
});
afterAll(() => {
// Restore original environment variables
process.env = originalEnv;
});
// --- Test Suites ---
describe('AIService Factory (getInstance)', () => {
beforeEach(() => {
// Reset the singleton and mocks before each test
(AIService as any).instance = undefined;
mockedOpenAI.mockClear();
});
it('should return OpenAIService by default', () => {
delete process.env.AI_PROVIDER;
const instance = AIService.getInstance();
// Check if the instance is an OpenAIService (difficult directly, check constructor side effect)
expect(mockedOpenAI).toHaveBeenCalled();
});
it('should return OpenAIService when AI_PROVIDER is openai', () => {
process.env.AI_PROVIDER = 'openai';
const instance = AIService.getInstance();
expect(mockedOpenAI).toHaveBeenCalled();
});
it('should return GoogleGeminiService when AI_PROVIDER is google', () => {
process.env.AI_PROVIDER = 'google';
const instance = AIService.getInstance();
// Check constructor side-effect (no direct OpenAI mock call)
expect(mockedOpenAI).not.toHaveBeenCalled();
// Check if it implements IAIService (basic check)
expect(instance.generateText).toBeDefined();
});
it('should return the same instance on subsequent calls (singleton)', () => {
process.env.AI_PROVIDER = 'google';
const instance1 = AIService.getInstance();
const instance2 = AIService.getInstance();
expect(instance1).toBe(instance2);
});
it('should re-initialize if instance is manually reset (for testing purposes)', () => {
// This test demonstrates how to reset the singleton if needed between test suites
mockedOpenAI.mockClear(); // Clear mock counts first
process.env.AI_PROVIDER = 'openai';
const instance1 = AIService.getInstance();
expect(mockedOpenAI).toHaveBeenCalledTimes(1);
// Manually reset internal state
(AIService as any).instance = undefined;
process.env.AI_PROVIDER = 'google';
const instance2 = AIService.getInstance();
expect(mockedOpenAI).toHaveBeenCalledTimes(1); // OpenAI should not be called again
expect(instance1).not.toBe(instance2);
});
});
describe('OpenAIService', () => {
let openAIService: IAIService;
beforeEach(() => {
// Ensure we get an OpenAIService instance
(AIService as any).instance = undefined; // Reset singleton
process.env.AI_PROVIDER = 'openai';
openAIService = AIService.getInstance();
// Reset OpenAI mock function calls specifically for this suite
mockCreate.mockClear();
});
it('should throw if OPENAI_API_KEY is missing', () => {
delete process.env.OPENAI_API_KEY;
(AIService as any).instance = undefined; // Reset to force re-creation
expect(() => AIService.getInstance()).toThrow('OpenAI API key is required');
});
it('should call OpenAI create with correct parameters', async () => {
const prompt = 'Test prompt';
const config: AIConfig = { temperature: 0.5, maxTokens: 100 };
const mockResponse = { choices: [{ message: { content: 'OpenAI response' } }] };
mockCreate.mockResolvedValue(mockResponse);
await openAIService.generateText(prompt, config);
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockCreate).toHaveBeenCalledWith({
model: process.env.AI_MODEL || 'gpt-4', // Check default or env model
messages: [{ role: 'user', content: prompt }],
temperature: config.temperature,
max_tokens: config.maxTokens,
top_p: 1, // Default top_p
});
});
it('should use default config values if not provided', async () => {
const prompt = 'Test prompt defaults';
const mockResponse = { choices: [{ message: { content: 'Default response' } }] };
mockCreate.mockResolvedValue(mockResponse);
await openAIService.generateText(prompt); // No config provided
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockCreate).toHaveBeenCalledWith({
model: process.env.AI_MODEL || 'gpt-4',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7, // Default temperature
max_tokens: undefined, // Default max_tokens
top_p: 1, // Default top_p
});
});
it('should return the content from the response', async () => {
const expectedContent = 'Successful OpenAI response';
const mockResponse = { choices: [{ message: { content: expectedContent } }] };
mockCreate.mockResolvedValue(mockResponse);
const result = await openAIService.generateText('Get response');
expect(result).toBe(expectedContent);
});
it('should handle OpenAI API errors', async () => {
const apiError = new Error('Invalid API Key');
mockCreate.mockRejectedValue(apiError);
await expect(openAIService.generateText('Trigger error')).rejects.toThrow(
`Failed to generate text with OpenAI: ${apiError.message}`
);
});
});
describe('GoogleGeminiService', () => {
let googleService: IAIService;
beforeEach(() => {
// Ensure we get a GoogleGeminiService instance
(AIService as any).instance = undefined; // Reset singleton
process.env.AI_PROVIDER = 'google';
googleService = AIService.getInstance();
// Reset fetch mock specifically for this suite
mockedFetch.mockClear();
});
it('should throw if GOOGLE_API_KEY is missing', () => {
delete process.env.GOOGLE_API_KEY;
(AIService as any).instance = undefined; // Reset to force re-creation
expect(() => AIService.getInstance()).toThrow('Google API key is required');
});
it('should call fetch with correct parameters', async () => {
const prompt = 'Google test prompt';
const config: AIConfig = { temperature: 0.6, maxTokens: 150, topP: 0.9 };
const expectedContent = 'Google response';
const mockResponse = { candidates: [{ content: { parts: [{ text: expectedContent }] } }] };
mockedFetch.mockResolvedValue({
ok: true,
json: async () => mockResponse,
text: async () => JSON.stringify(mockResponse),
} as Response);
await googleService.generateText(prompt, config);
expect(mockedFetch).toHaveBeenCalledTimes(1);
const fetchCall = mockedFetch.mock.calls[0];
const url = fetchCall[0];
const options = fetchCall[1];
const expectedModel = process.env.AI_MODEL || 'gemini-pro';
expect(url).toContain(`https://generativelanguage.googleapis.com/v1/models/${expectedModel}:generateContent`);
expect(url).toContain(`key=${process.env.GOOGLE_API_KEY}`);
expect(options?.method).toBe('POST');
expect(options?.headers).toEqual({ 'Content-Type': 'application/json' });
expect(JSON.parse(options?.body as string)).toEqual({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: config.temperature,
topP: config.topP,
maxOutputTokens: config.maxTokens,
},
});
});
it('should use default config values if not provided', async () => {
const prompt = 'Google defaults';
const mockResponse = { candidates: [{ content: { parts: [{ text: 'Default google' }] } }] };
mockedFetch.mockResolvedValue({
ok: true,
json: async () => mockResponse,
text: async () => JSON.stringify(mockResponse),
} as Response);
await googleService.generateText(prompt); // No config
expect(mockedFetch).toHaveBeenCalledTimes(1);
const options = mockedFetch.mock.calls[0][1];
expect(JSON.parse(options?.body as string).generationConfig).toEqual({
temperature: 0.7, // Default temperature
topP: 1, // Default topP
maxOutputTokens: 1024, // Default maxOutputTokens
});
});
it('should return the content from the response', async () => {
const expectedContent = 'Successful Google response';
const mockResponse = { candidates: [{ content: { parts: [{ text: expectedContent }] } }] };
mockedFetch.mockResolvedValue({
ok: true,
json: async () => mockResponse,
text: async () => JSON.stringify(mockResponse),
} as Response);
const result = await googleService.generateText('Get response');
expect(result).toBe(expectedContent);
});
it('should handle Google API errors (non-OK response)', async () => {
const errorText = 'Invalid API key or resource exhausted.';
mockedFetch.mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: { message: errorText } }),
text: async () => errorText,
} as Response);
await expect(googleService.generateText('Trigger error')).rejects.toThrow(
`Failed to generate text with Google Gemini: Google API error: 400 ${errorText}`
);
});
it('should handle network errors during fetch', async () => {
const networkError = new Error('Fetch failed');
mockedFetch.mockRejectedValue(networkError);
await expect(googleService.generateText('Trigger network error')).rejects.toThrow(
`Failed to generate text with Google Gemini: ${networkError.message}`
);
});
});