Files
simple-ai-provider/tests/gemini.test.ts
Jan-Marlon Leibl a0d181787c test: repair test suite after R1-R3 refactors and add Claude Code tests
The R1-R3 refactors removed the per-provider handle*Error methods,
consolidated validators into validateNumberInRange, renamed Gemini's
buildGenerationConfig to buildConfig, and split OpenWebUI's message
conversion and response formatting into openwebui-strategies.ts.
44 of 91 existing tests broke as a result.

Changes:

claude.test.ts (8 -> 13 passing)
- Switch error mapping tests from handleAnthropicError to the base
  normalizeError method.
- Update temperature error-message expectation to match the new
  validateNumberInRange wording.

openai.test.ts (16 -> 22 passing)
- Same handleOpenAIError -> normalizeError switch.
- Add a regression test for the 429+"quota" path that goes through
  mapProviderError instead of the base mapping.

gemini.test.ts (17 -> 27 passing)
- Update getInfo expectations to the new default model (gemini-2.5)
  and provider version 2.0.0.
- Switch to normalizeError.
- Rename buildGenerationConfig -> buildConfig (renamed during R2).
- Update formatCompletionResponse mocks: the @google/genai SDK
  exposes response.text as a getter rather than walking
  candidates[0].content.parts.

openwebui.test.ts (4 -> 19 passing)
- Drop tests for convertMessages / convertMessagesToPrompt /
  formatChatResponse / formatOllamaResponse / makeRequest: these
  moved into openwebui-strategies.ts and openwebui-http.ts during R3
  and warrant their own test files.
- Add error-mapping tests covering the new mapProviderError +
  providerErrorMessages plumbing.

claude-code.test.ts (new, 18 tests)
- Constructor accepts subscription mode (empty config).
- getInfo advertises subscriptionAuth + requiresLocalCli.
- mapProviderError catches ENOENT and "not logged in".
- errorForSdkAssistantError covers each documented SDK error code.
- buildPrompt handles single-turn pass-through, multi-system
  combination, multi-turn flattening with role labels, and rejects
  empty turn lists.

Full suite: 100 pass / 0 fail across 5 files.
2026-05-21 14:37:33 +02:00

372 lines
12 KiB
TypeScript

/**
* Tests for Gemini Provider
*/
import { describe, it, expect, beforeEach, jest } from 'bun:test';
import { GeminiProvider, createResponseType, AIProviderError, AIErrorType } from '../src/index.js';
describe('GeminiProvider', () => {
let provider: GeminiProvider;
beforeEach(() => {
provider = new GeminiProvider({
apiKey: 'test-api-key',
defaultModel: 'gemini-2.5-flash'
});
});
describe('constructor', () => {
it('should create provider with valid config', () => {
expect(provider).toBeInstanceOf(GeminiProvider);
expect(provider.isInitialized()).toBe(false);
});
it('should throw error for missing API key', () => {
expect(() => {
new GeminiProvider({ apiKey: '' });
}).toThrow(AIProviderError);
});
it('should set default model', () => {
const customProvider = new GeminiProvider({
apiKey: 'test-key',
defaultModel: 'gemini-1.5-pro'
});
expect(customProvider).toBeInstanceOf(GeminiProvider);
});
it('should handle safety settings and generation config', () => {
const customProvider = new GeminiProvider({
apiKey: 'test-key',
safetySettings: [],
generationConfig: {
temperature: 0.8,
topP: 0.9,
topK: 40
}
});
expect(customProvider).toBeInstanceOf(GeminiProvider);
});
});
describe('getInfo', () => {
it('should return provider information', () => {
const info = provider.getInfo();
expect(info.name).toBe('Gemini');
expect(info.version).toBe('2.0.0');
expect(info.supportsStreaming).toBe(true);
expect(info.models).toContain('gemini-2.5-flash');
expect(info.models).toContain('gemini-2.5-pro');
expect(info.models).toContain('gemini-1.5-pro');
expect(info.maxContextLength).toBe(1048576);
expect(info.capabilities).toHaveProperty('vision', true);
expect(info.capabilities).toHaveProperty('functionCalling', true);
expect(info.capabilities).toHaveProperty('systemMessages', true);
expect(info.capabilities).toHaveProperty('multimodal', true);
});
});
describe('validation', () => {
it('should validate temperature range', async () => {
// Mock initialization to avoid API call
(provider as any).initialized = true;
(provider as any).client = {};
(provider as any).model = {};
await expect(
provider.complete({
messages: [{ role: 'user', content: 'test' }],
temperature: 1.5
})
).rejects.toThrow(/temperature must be at most 1/);
});
it('should validate top_p range', async () => {
(provider as any).initialized = true;
(provider as any).client = {};
(provider as any).model = {};
await expect(
provider.complete({
messages: [{ role: 'user', content: 'test' }],
topP: 1.5
})
).rejects.toThrow(/topP must be at most 1/);
});
it('should validate message format', async () => {
(provider as any).initialized = true;
(provider as any).client = {};
(provider as any).model = {};
await expect(
provider.complete({
messages: [{ role: 'invalid' as any, content: 'test' }]
})
).rejects.toThrow('Message at index 0 has invalid role \'invalid\'. Must be: system, user, assistant');
});
it('should validate empty content', async () => {
(provider as any).initialized = true;
(provider as any).client = {};
(provider as any).model = {};
await expect(
provider.complete({
messages: [{ role: 'user', content: '' }]
})
).rejects.toThrow('Message at index 0 must have non-empty string content');
});
it('should require initialization before use', async () => {
await expect(
provider.complete({
messages: [{ role: 'user', content: 'test' }]
})
).rejects.toThrow('Provider must be initialized before use');
});
});
describe('error handling', () => {
const mapError = (err: any) => (provider as any).normalizeError(err);
it('should handle authentication errors', () => {
const error = new Error('API key invalid');
const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.AUTHENTICATION);
expect(providerError.message).toContain('Invalid Google API key');
});
it('should handle rate limit errors', () => {
const error = new Error('quota exceeded');
const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.RATE_LIMIT);
expect(providerError.message).toContain('API quota exceeded');
});
it('should handle model not found errors', () => {
const error = new Error('model not found');
const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.MODEL_NOT_FOUND);
expect(providerError.message).toContain('Model not found');
});
it('should handle invalid request errors', () => {
const error = new Error('invalid length request parameters');
const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.INVALID_REQUEST);
});
it('should handle network errors', () => {
const error = new Error('network connection failed');
const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.NETWORK);
});
it('should handle timeout errors', () => {
const error = new Error('request timeout');
const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.TIMEOUT);
});
it('should handle unknown errors', () => {
const error = new Error('Unknown error');
const providerError = mapError(error);
expect(providerError).toBeInstanceOf(AIProviderError);
expect(providerError.type).toBe(AIErrorType.UNKNOWN);
});
});
describe('message conversion', () => {
it('should convert messages to Gemini format', () => {
const messages = [
{ role: 'system' as const, content: 'You are helpful' },
{ role: 'user' as const, content: 'Hello' },
{ role: 'assistant' as const, content: 'Hi there' }
];
const result = (provider as any).convertMessages(messages);
expect(result.systemInstruction).toBe('You are helpful');
expect(result.contents).toHaveLength(2);
expect(result.contents[0]).toEqual({
role: 'user',
parts: [{ text: 'Hello' }]
});
expect(result.contents[1]).toEqual({
role: 'model',
parts: [{ text: 'Hi there' }]
});
});
it('should handle multiple system messages', () => {
const messages = [
{ role: 'system' as const, content: 'You are helpful' },
{ role: 'user' as const, content: 'Hello' },
{ role: 'system' as const, content: 'Be concise' }
];
const result = (provider as any).convertMessages(messages);
expect(result.systemInstruction).toBe('You are helpful\n\nBe concise');
expect(result.contents).toHaveLength(1);
expect(result.contents[0].role).toBe('user');
});
it('should handle messages without system prompts', () => {
const messages = [
{ role: 'user' as const, content: 'Hello' },
{ role: 'assistant' as const, content: 'Hi there' }
];
const result = (provider as any).convertMessages(messages);
expect(result.systemInstruction).toBeUndefined();
expect(result.contents).toHaveLength(2);
});
it('should convert assistant role to model role', () => {
const messages = [
{ role: 'assistant' as const, content: 'I am an assistant' }
];
const result = (provider as any).convertMessages(messages);
expect(result.contents[0].role).toBe('model');
expect(result.contents[0].parts[0].text).toBe('I am an assistant');
});
});
describe('generation config', () => {
it('should build generation config from completion params', () => {
const params = {
messages: [{ role: 'user' as const, content: 'test' }],
temperature: 0.8,
topP: 0.9,
maxTokens: 500,
stopSequences: ['STOP', 'END']
};
const result = (provider as any).buildConfig(params);
expect(result.temperature).toBe(0.8);
expect(result.topP).toBe(0.9);
expect(result.maxOutputTokens).toBe(500);
expect(result.stopSequences).toEqual(['STOP', 'END']);
});
it('should use default temperature when not provided', () => {
const params = {
messages: [{ role: 'user' as const, content: 'test' }]
};
const result = (provider as any).buildConfig(params);
expect(result.temperature).toBe(0.7);
expect(result.maxOutputTokens).toBe(1000);
});
it('should attach systemInstruction when passed', () => {
const params = { messages: [{ role: 'user' as const, content: 'test' }] };
const result = (provider as any).buildConfig(params, 'be helpful');
expect(result.systemInstruction).toBe('be helpful');
});
});
describe('response formatting', () => {
// The @google/genai SDK exposes `text` as a getter on GenerateContentResponse,
// so the mock provides it directly rather than reconstructing the candidate->parts tree.
it('should format completion response correctly', () => {
const mockResponse = {
text: 'Hello there!',
candidates: [{
finishReason: 'STOP',
safetyRatings: [],
citationMetadata: null
}],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 20,
totalTokenCount: 30
}
};
const result = (provider as any).formatCompletionResponse(mockResponse, 'gemini-2.5-flash');
expect(result.content).toBe('Hello there!');
expect(result.model).toBe('gemini-2.5-flash');
expect(result.usage.promptTokens).toBe(10);
expect(result.usage.completionTokens).toBe(20);
expect(result.usage.totalTokens).toBe(30);
expect(result.metadata.finishReason).toBe('STOP');
});
it('should throw error for empty response', () => {
const mockResponse = {
text: '',
candidates: [],
usageMetadata: {
promptTokenCount: 5,
candidatesTokenCount: 0,
totalTokenCount: 5
}
};
expect(() => {
(provider as any).formatCompletionResponse(mockResponse, 'gemini-2.5-flash');
}).toThrow('No text content found in Gemini response');
});
});
describe('structured responses', () => {
it('should handle structured responses correctly', async () => {
const provider = new GeminiProvider({ apiKey: 'test-key' });
const mockResponse = {
content: JSON.stringify({ name: 'John Doe', age: 30 }),
model: 'gemini-1.5-pro',
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
id: 'test-id'
};
(provider as any).doComplete = jest.fn().mockResolvedValue(mockResponse);
(provider as any).initialized = true;
const responseType = createResponseType<{ name: string; age: number }>
(`{ name: string; age: number }`,
'A user profile'
);
const response = await provider.complete<{ name: string; age: number }>({
messages: [{ role: 'user', content: 'test' }],
responseType
});
expect(response.content).toEqual({ name: 'John Doe', age: 30 });
expect(response.rawContent).toBe(JSON.stringify({ name: 'John Doe', age: 30 }));
expect((provider as any).doComplete).toHaveBeenCalled();
});
});
});