From 3937c678f3a0e989a67c11dd7dd887afa8eb0575 Mon Sep 17 00:00:00 2001 From: jeffusion Date: Thu, 5 Mar 2026 00:32:53 +0800 Subject: [PATCH] test(llm): add backend unit tests for LLM provider feature (113 tests) Comprehensive test coverage for the entire LLM provider backend: - secrets.test.ts: AES-256-GCM encrypt/decrypt, master key lifecycle (14) - tool-converter.test.ts: Cross-provider tool format conversion (10) - gateway.test.ts: Role routing, error handling, cache invalidation (12) - provider-repo.test.ts: Provider CRUD, filtering, timestamps (18) - model-role-repo.test.ts: Role assignments, FK constraints (15) - secret-repo.test.ts: Encrypted storage, CASCADE delete (13) - llm-config.test.ts: Full REST API integration tests (31) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) --- src/controllers/__tests__/llm-config.test.ts | 462 +++++++++++++++++++ src/crypto/__tests__/secrets.test.ts | 224 +++++++++ src/db/__tests__/model-role-repo.test.ts | 213 +++++++++ src/db/__tests__/provider-repo.test.ts | 233 ++++++++++ src/db/__tests__/secret-repo.test.ts | 193 ++++++++ src/llm/__tests__/gateway.test.ts | 245 ++++++++++ src/llm/__tests__/tool-converter.test.ts | 178 +++++++ 7 files changed, 1748 insertions(+) create mode 100644 src/controllers/__tests__/llm-config.test.ts create mode 100644 src/crypto/__tests__/secrets.test.ts create mode 100644 src/db/__tests__/model-role-repo.test.ts create mode 100644 src/db/__tests__/provider-repo.test.ts create mode 100644 src/db/__tests__/secret-repo.test.ts create mode 100644 src/llm/__tests__/gateway.test.ts create mode 100644 src/llm/__tests__/tool-converter.test.ts diff --git a/src/controllers/__tests__/llm-config.test.ts b/src/controllers/__tests__/llm-config.test.ts new file mode 100644 index 0000000..f6d2b77 --- /dev/null +++ b/src/controllers/__tests__/llm-config.test.ts @@ -0,0 +1,462 @@ +// @ts-expect-error bun:test is provided by Bun at runtime +declare module 'bun:test' { + export const describe: any; + export const test: any; + export const expect: any; + export const beforeEach: any; + export const afterEach: any; +} + +// @ts-expect-error bun:test is provided by Bun at runtime +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Hono } from 'hono'; +import { initMasterKey } from '../../crypto/secrets'; +import { closeDatabase, initDatabase } from '../../db/database'; +import { modelRoleRepo } from '../../db/repositories/model-role-repo'; +import { providerRepo } from '../../db/repositories/provider-repo'; +import { secretRepo } from '../../db/repositories/secret-repo'; +import { llmConfigRouter } from '../llm-config'; + +/** + * Create a test Hono app with the LLM config router mounted. + */ +function createTestApp(): Hono { + const app = new Hono(); + app.route('/llm', llmConfigRouter); + return app; +} + +/** + * Helper to make JSON requests to the test app. + */ +async function jsonRequest( + app: Hono, + method: string, + path: string, + body?: unknown +): Promise<{ status: number; data: any }> { + const init: RequestInit = { + method, + headers: { 'Content-Type': 'application/json' }, + }; + if (body !== undefined) { + init.body = JSON.stringify(body); + } + const res = await app.request(`http://localhost/llm${path}`, init); + // Handle non-JSON responses (e.g. 500 errors that return HTML) + const text = await res.text(); + try { + const data = JSON.parse(text); + return { status: res.status, data }; + } catch { + return { status: res.status, data: { _raw: text } }; + } +} + +describe('llm-config controller', () => { + let dbPath: string; + let keyPath: string; + let app: Hono; + const savedDbPath = process.env.DATABASE_PATH; + const savedKeyPath = process.env.MASTER_KEY_PATH; + + beforeEach(() => { + const tmpDir = join(tmpdir(), `ctrl-test-${randomUUID()}`); + mkdirSync(tmpDir, { recursive: true }); + dbPath = join(tmpDir, 'test.db'); + keyPath = join(tmpDir, 'master.key'); + process.env.DATABASE_PATH = dbPath; + process.env.MASTER_KEY_PATH = keyPath; + + initMasterKey(); + initDatabase(); + app = createTestApp(); + }); + + afterEach(() => { + closeDatabase(); + if (savedDbPath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = savedDbPath; + } + if (savedKeyPath === undefined) { + delete process.env.MASTER_KEY_PATH; + } else { + process.env.MASTER_KEY_PATH = savedKeyPath; + } + try { + if (existsSync(dbPath)) unlinkSync(dbPath); + } catch { + /* ok */ + } + try { + if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`); + } catch { + /* ok */ + } + try { + if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`); + } catch { + /* ok */ + } + try { + if (existsSync(keyPath)) unlinkSync(keyPath); + } catch { + /* ok */ + } + }); + + // ─── Provider CRUD ──────────────────────────────────────────────── + + describe('GET /providers', () => { + test('returns empty array when no providers', async () => { + const { status, data } = await jsonRequest(app, 'GET', '/providers'); + expect(status).toBe(200); + expect(data).toEqual([]); + }); + + test('returns all providers with formatted fields', async () => { + providerRepo.create({ + name: 'Test', + type: 'openai_compatible', + baseUrl: 'https://api.example.com/v1', + defaultModel: 'gpt-4o-mini', + }); + + const { status, data } = await jsonRequest(app, 'GET', '/providers'); + expect(status).toBe(200); + expect(data).toHaveLength(1); + expect(data[0]).toHaveProperty('id'); + expect(data[0].name).toBe('Test'); + expect(data[0].isEnabled).toBe(true); + expect(data[0].hasKey).toBe(false); + }); + }); + + describe('POST /providers', () => { + test('creates a provider successfully', async () => { + const { status, data } = await jsonRequest(app, 'POST', '/providers', { + name: 'New Provider', + type: 'anthropic', + defaultModel: 'claude-3-5-sonnet-20241022', + }); + + expect(status).toBe(201); + expect(data.name).toBe('New Provider'); + expect(data.type).toBe('anthropic'); + expect(data.defaultModel).toBe('claude-3-5-sonnet-20241022'); + expect(data.isEnabled).toBe(true); + }); + + test('creates provider with API key', async () => { + const { status, data } = await jsonRequest(app, 'POST', '/providers', { + name: 'With Key', + type: 'openai_responses', + defaultModel: 'gpt-4o', + apiKey: 'sk-test-123', + }); + + expect(status).toBe(201); + expect(data.hasKey).toBe(true); + }); + + test('auto-binds all roles when first provider is created', async () => { + await jsonRequest(app, 'POST', '/providers', { + name: 'First Provider', + type: 'gemini', + defaultModel: 'gemini-pro', + apiKey: 'test-key', + }); + + const { data: roles } = await jsonRequest(app, 'GET', '/roles'); + const assignedRoles = roles.filter((r: any) => r.providerId !== null); + expect(assignedRoles).toHaveLength(5); // All 5 roles bound + }); + + test('rejects missing required fields', async () => { + const { status, data } = await jsonRequest(app, 'POST', '/providers', { + name: 'Missing Type', + }); + expect(status).toBe(400); + expect(data.message).toContain('Missing required fields'); + }); + + test('rejects invalid provider type', async () => { + const { status, data } = await jsonRequest(app, 'POST', '/providers', { + name: 'Bad Type', + type: 'invalid_type', + defaultModel: 'model', + }); + expect(status).toBe(400); + expect(data.message).toContain('Invalid type'); + }); + + test('requires baseUrl for openai_compatible', async () => { + const { status, data } = await jsonRequest(app, 'POST', '/providers', { + name: 'No URL', + type: 'openai_compatible', + defaultModel: 'model', + }); + expect(status).toBe(400); + expect(data.message).toContain('baseUrl is required'); + }); + }); + + describe('GET /providers/:id', () => { + test('returns provider by ID', async () => { + const created = providerRepo.create({ + name: 'FindMe', + type: 'gemini', + defaultModel: 'gemini-pro', + }); + + const { status, data } = await jsonRequest(app, 'GET', `/providers/${created.id}`); + expect(status).toBe(200); + expect(data.name).toBe('FindMe'); + }); + + test('returns 404 for non-existent provider', async () => { + const { status } = await jsonRequest(app, 'GET', '/providers/non-existent'); + expect(status).toBe(404); + }); + }); + + describe('PUT /providers/:id', () => { + test('updates provider fields', async () => { + const created = providerRepo.create({ + name: 'Original', + type: 'openai_compatible', + baseUrl: 'https://api.example.com/v1', + defaultModel: 'gpt-4o-mini', + }); + + const { status, data } = await jsonRequest(app, 'PUT', `/providers/${created.id}`, { + name: 'Updated', + defaultModel: 'gpt-4o', + }); + + expect(status).toBe(200); + expect(data.name).toBe('Updated'); + expect(data.defaultModel).toBe('gpt-4o'); + }); + + test('returns 404 for non-existent provider', async () => { + const { status } = await jsonRequest(app, 'PUT', '/providers/non-existent', { + name: 'x', + }); + expect(status).toBe(404); + }); + }); + + describe('DELETE /providers/:id', () => { + test('deletes provider without role assignments', async () => { + const created = providerRepo.create({ + name: 'ToDelete', + type: 'anthropic', + defaultModel: 'claude-3', + }); + + const { status, data } = await jsonRequest(app, 'DELETE', `/providers/${created.id}`); + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.removedRoleAssignments).toEqual([]); + }); + + test('returns 404 for non-existent provider', async () => { + const { status } = await jsonRequest(app, 'DELETE', '/providers/non-existent'); + expect(status).toBe(404); + }); + }); + + // ─── API Key Management ────────────────────────────────────────── + + describe('PUT /providers/:id/key', () => { + test('sets API key for provider', async () => { + const created = providerRepo.create({ + name: 'NeedsKey', + type: 'anthropic', + defaultModel: 'claude-3', + }); + + const { status, data } = await jsonRequest(app, 'PUT', `/providers/${created.id}/key`, { + apiKey: 'sk-new-key', + }); + expect(status).toBe(200); + expect(data.success).toBe(true); + + // Verify key is stored + expect(secretRepo.has(created.id)).toBe(true); + expect(secretRepo.get(created.id)).toBe('sk-new-key'); + }); + + test('returns 400 when apiKey is missing', async () => { + const created = providerRepo.create({ + name: 'Test', + type: 'anthropic', + defaultModel: 'claude-3', + }); + + const { status, data } = await jsonRequest(app, 'PUT', `/providers/${created.id}/key`, {}); + expect(status).toBe(400); + expect(data.message).toContain('apiKey is required'); + }); + + test('returns 404 for non-existent provider', async () => { + const { status } = await jsonRequest(app, 'PUT', '/providers/non-existent/key', { + apiKey: 'sk-test', + }); + expect(status).toBe(404); + }); + }); + + describe('DELETE /providers/:id/key', () => { + test('deletes API key', async () => { + const created = providerRepo.create({ + name: 'HasKey', + type: 'anthropic', + defaultModel: 'claude-3', + }); + secretRepo.set(created.id, 'sk-key'); + + const { status, data } = await jsonRequest(app, 'DELETE', `/providers/${created.id}/key`); + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(secretRepo.has(created.id)).toBe(false); + }); + + test('returns 404 for non-existent provider', async () => { + const { status } = await jsonRequest(app, 'DELETE', '/providers/non-existent/key'); + expect(status).toBe(404); + }); + }); + + // ─── Role Assignments ──────────────────────────────────────────── + + describe('GET /roles', () => { + test('returns all MODEL_ROLES with null assignments when unassigned', async () => { + const { status, data } = await jsonRequest(app, 'GET', '/roles'); + expect(status).toBe(200); + expect(data).toHaveLength(5); // 5 roles + expect(data[0]).toHaveProperty('role'); + expect(data[0]).toHaveProperty('providerId'); + }); + + test('returns assigned role info when set', async () => { + const provider = providerRepo.create({ + name: 'RoleTest', + type: 'openai_compatible', + baseUrl: 'https://api.example.com/v1', + defaultModel: 'gpt-4o-mini', + }); + modelRoleRepo.set('legacy', provider.id, 'gpt-4o'); + + const { data } = await jsonRequest(app, 'GET', '/roles'); + const legacy = data.find((r: any) => r.role === 'legacy'); + expect(legacy.providerId).toBe(provider.id); + expect(legacy.providerName).toBe('RoleTest'); + expect(legacy.model).toBe('gpt-4o'); + }); + }); + + describe('PUT /roles/:role', () => { + test('assigns a role to a provider+model', async () => { + const provider = providerRepo.create({ + name: 'AssignTarget', + type: 'anthropic', + defaultModel: 'claude-3', + }); + + const { status, data } = await jsonRequest(app, 'PUT', '/roles/planner', { + providerId: provider.id, + model: 'claude-3-5-sonnet', + }); + + expect(status).toBe(200); + expect(data.role).toBe('planner'); + expect(data.providerId).toBe(provider.id); + expect(data.model).toBe('claude-3-5-sonnet'); + }); + + test('rejects invalid role name', async () => { + const { status, data } = await jsonRequest(app, 'PUT', '/roles/invalid_role', { + providerId: 'some-id', + model: 'model', + }); + expect(status).toBe(400); + expect(data.message).toContain('Invalid role'); + }); + + test('rejects missing providerId or model', async () => { + const { status, data } = await jsonRequest(app, 'PUT', '/roles/legacy', { + providerId: 'some-id', + }); + expect(status).toBe(400); + expect(data.message).toContain('providerId and model are required'); + }); + + test('returns 404 for non-existent provider', async () => { + const { status } = await jsonRequest(app, 'PUT', '/roles/legacy', { + providerId: 'non-existent', + model: 'model', + }); + expect(status).toBe(404); + }); + }); + + // ─── Connection Test ───────────────────────────────────────────── + + describe('POST /providers/:id/test', () => { + test('returns error when no API key configured', async () => { + const provider = providerRepo.create({ + name: 'NoKey', + type: 'anthropic', + defaultModel: 'claude-3', + }); + + const { status, data } = await jsonRequest(app, 'POST', `/providers/${provider.id}/test`); + expect(status).toBe(200); + expect(data.success).toBe(false); + expect(data.error).toContain('No API key'); + }); + + test('returns 404 for non-existent provider', async () => { + const { status } = await jsonRequest(app, 'POST', '/providers/non-existent/test'); + expect(status).toBe(404); + }); + }); + + // ─── System Settings ───────────────────────────────────────────── + + describe('GET /settings', () => { + test('returns empty array when no settings', async () => { + const { status, data } = await jsonRequest(app, 'GET', '/settings'); + expect(status).toBe(200); + expect(data).toEqual([]); + }); + }); + + describe('PUT /settings', () => { + test('saves and retrieves settings', async () => { + const { status } = await jsonRequest(app, 'PUT', '/settings', [ + { key: 'theme', value: 'dark' }, + { key: 'lang', value: 'zh-CN' }, + ]); + expect(status).toBe(200); + + const { data } = await jsonRequest(app, 'GET', '/settings'); + expect(data).toHaveLength(2); + expect(data.find((s: any) => s.key === 'theme')?.value).toBe('dark'); + }); + + test('rejects non-array body', async () => { + const { status, data } = await jsonRequest(app, 'PUT', '/settings', { key: 'x' }); + expect(status).toBe(400); + expect(data.message).toContain('array'); + }); + }); +}); diff --git a/src/crypto/__tests__/secrets.test.ts b/src/crypto/__tests__/secrets.test.ts new file mode 100644 index 0000000..752a77e --- /dev/null +++ b/src/crypto/__tests__/secrets.test.ts @@ -0,0 +1,224 @@ +// @ts-expect-error bun:test is provided by Bun at runtime +declare module 'bun:test' { + export const describe: any; + export const test: any; + export const expect: any; + export const beforeEach: any; + export const afterEach: any; +} + +// @ts-expect-error bun:test is provided by Bun at runtime +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { randomUUID } from 'node:crypto'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Import a fresh secrets module to bypass module cache (same pattern as config-manager.test.ts). + * Each import gets its own masterKey singleton. + */ +async function importFresh() { + return await import(`../../crypto/secrets.ts?t=${Date.now()}-${randomUUID()}`); +} + +describe('secrets — AES-256-GCM encryption', () => { + let tmpDir: string; + let keyPath: string; + const savedMasterKeyPath = process.env.MASTER_KEY_PATH; + + beforeEach(() => { + tmpDir = join(tmpdir(), `secrets-test-${randomUUID()}`); + mkdirSync(tmpDir, { recursive: true }); + keyPath = join(tmpDir, 'master.key'); + process.env.MASTER_KEY_PATH = keyPath; + }); + + afterEach(() => { + if (savedMasterKeyPath === undefined) { + delete process.env.MASTER_KEY_PATH; + } else { + process.env.MASTER_KEY_PATH = savedMasterKeyPath; + } + // Cleanup temp files + try { + if (existsSync(keyPath)) unlinkSync(keyPath); + } catch { + /* ok */ + } + }); + + // ─── Master Key Init ───────────────────────────────────────────────── + + describe('initMasterKey()', () => { + test('generates a new 32-byte key file when none exists', async () => { + const secrets = await importFresh(); + expect(existsSync(keyPath)).toBe(false); + + secrets.initMasterKey(); + + expect(existsSync(keyPath)).toBe(true); + const raw = readFileSync(keyPath); + expect(raw.length).toBe(32); + expect(secrets.isMasterKeyReady()).toBe(true); + }); + + test('loads an existing key file without overwriting', async () => { + // Pre-create a 32-byte key + const existingKey = Buffer.from(crypto.getRandomValues(new Uint8Array(32))); + writeFileSync(keyPath, existingKey, { mode: 0o600 }); + + const secrets = await importFresh(); + secrets.initMasterKey(); + + const loaded = readFileSync(keyPath); + expect(Buffer.compare(loaded, existingKey)).toBe(0); + expect(secrets.isMasterKeyReady()).toBe(true); + }); + + test('throws if key file is wrong length', async () => { + writeFileSync(keyPath, Buffer.alloc(16)); // Wrong length + + const secrets = await importFresh(); + expect(() => secrets.initMasterKey()).toThrow('16 bytes, expected 32'); + }); + + test('creates parent directories if needed', async () => { + const nestedPath = join(tmpDir, 'nested', 'deep', 'master.key'); + process.env.MASTER_KEY_PATH = nestedPath; + + const secrets = await importFresh(); + secrets.initMasterKey(); + + expect(existsSync(nestedPath)).toBe(true); + expect(readFileSync(nestedPath).length).toBe(32); + }); + }); + + // ─── isMasterKeyReady ──────────────────────────────────────────────── + + describe('isMasterKeyReady()', () => { + test('returns false before initMasterKey', async () => { + const secrets = await importFresh(); + expect(secrets.isMasterKeyReady()).toBe(false); + }); + + test('returns true after initMasterKey', async () => { + const secrets = await importFresh(); + secrets.initMasterKey(); + expect(secrets.isMasterKeyReady()).toBe(true); + }); + }); + + // ─── Encrypt / Decrypt ────────────────────────────────────────────── + + describe('encrypt() / decrypt()', () => { + test('throws when master key not initialized', async () => { + const secrets = await importFresh(); + expect(() => secrets.encrypt('test')).toThrow('Master key not initialized'); + }); + + test('roundtrip: encrypt then decrypt recovers plaintext', async () => { + const secrets = await importFresh(); + secrets.initMasterKey(); + + const plaintext = 'sk-abc123-super-secret-api-key'; + const encrypted = secrets.encrypt(plaintext); + const decrypted = secrets.decrypt(encrypted); + + expect(decrypted).toBe(plaintext); + }); + + test('encrypts empty string', async () => { + const secrets = await importFresh(); + secrets.initMasterKey(); + + const encrypted = secrets.encrypt(''); + expect(secrets.decrypt(encrypted)).toBe(''); + }); + + test('encrypts unicode content', async () => { + const secrets = await importFresh(); + secrets.initMasterKey(); + + const unicode = '密钥🔑テスト키'; + const encrypted = secrets.encrypt(unicode); + expect(secrets.decrypt(encrypted)).toBe(unicode); + }); + + test('encrypts long content', async () => { + const secrets = await importFresh(); + secrets.initMasterKey(); + + const long = 'A'.repeat(10000); + const encrypted = secrets.encrypt(long); + expect(secrets.decrypt(encrypted)).toBe(long); + }); + + test('each encryption produces unique IV (different ciphertext)', async () => { + const secrets = await importFresh(); + secrets.initMasterKey(); + + const plaintext = 'same-content'; + const enc1 = secrets.encrypt(plaintext); + const enc2 = secrets.encrypt(plaintext); + + // IVs should differ + expect(Buffer.compare(enc1.iv, enc2.iv)).not.toBe(0); + // Both decrypt to same plaintext + expect(secrets.decrypt(enc1)).toBe(plaintext); + expect(secrets.decrypt(enc2)).toBe(plaintext); + }); + + test('encrypted payload has expected structure', async () => { + const secrets = await importFresh(); + secrets.initMasterKey(); + + const encrypted = secrets.encrypt('test'); + + expect(encrypted).toHaveProperty('ciphertext'); + expect(encrypted).toHaveProperty('iv'); + expect(encrypted).toHaveProperty('authTag'); + expect(Buffer.isBuffer(encrypted.ciphertext)).toBe(true); + expect(Buffer.isBuffer(encrypted.iv)).toBe(true); + expect(Buffer.isBuffer(encrypted.authTag)).toBe(true); + expect(encrypted.iv.length).toBe(12); // GCM nonce + expect(encrypted.authTag.length).toBe(16); // GCM tag + }); + + test('tampered ciphertext fails decryption', async () => { + const secrets = await importFresh(); + secrets.initMasterKey(); + + const encrypted = secrets.encrypt('secret'); + // Tamper with ciphertext + encrypted.ciphertext[0] ^= 0xff; + + expect(() => secrets.decrypt(encrypted)).toThrow(); + }); + + test('tampered auth tag fails decryption', async () => { + const secrets = await importFresh(); + secrets.initMasterKey(); + + const encrypted = secrets.encrypt('secret'); + encrypted.authTag[0] ^= 0xff; + + expect(() => secrets.decrypt(encrypted)).toThrow(); + }); + + test('wrong master key fails decryption', async () => { + const secrets1 = await importFresh(); + secrets1.initMasterKey(); + const encrypted = secrets1.encrypt('secret'); + + // Create a different key + unlinkSync(keyPath); + const secrets2 = await importFresh(); + secrets2.initMasterKey(); // Generates a new key + + expect(() => secrets2.decrypt(encrypted)).toThrow(); + }); + }); +}); diff --git a/src/db/__tests__/model-role-repo.test.ts b/src/db/__tests__/model-role-repo.test.ts new file mode 100644 index 0000000..919c7b9 --- /dev/null +++ b/src/db/__tests__/model-role-repo.test.ts @@ -0,0 +1,213 @@ +// @ts-expect-error bun:test is provided by Bun at runtime +declare module 'bun:test' { + export const describe: any; + export const test: any; + export const expect: any; + export const beforeEach: any; + export const afterEach: any; +} + +// @ts-expect-error bun:test is provided by Bun at runtime +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { closeDatabase, initDatabase } from '../database'; +import { modelRoleRepo } from '../repositories/model-role-repo'; +import type { ModelRole } from '../repositories/model-role-repo'; +import { providerRepo } from '../repositories/provider-repo'; +import type { CreateProviderInput } from '../repositories/provider-repo'; + +describe('model-role-repo', () => { + let dbPath: string; + let providerId: string; + const savedDbPath = process.env.DATABASE_PATH; + + const providerInput: CreateProviderInput = { + name: 'Test Provider', + type: 'openai_compatible', + baseUrl: 'https://api.example.com/v1', + defaultModel: 'gpt-4o-mini', + }; + + beforeEach(() => { + const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`); + mkdirSync(tmpDir, { recursive: true }); + dbPath = join(tmpDir, 'test.db'); + process.env.DATABASE_PATH = dbPath; + initDatabase(); + + const created = providerRepo.create(providerInput); + providerId = created.id; + }); + + afterEach(() => { + closeDatabase(); + if (savedDbPath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = savedDbPath; + } + try { + if (existsSync(dbPath)) unlinkSync(dbPath); + } catch { + /* ok */ + } + try { + if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`); + } catch { + /* ok */ + } + try { + if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`); + } catch { + /* ok */ + } + }); + + // ─── Set (upsert) ───────────────────────────────────────────────── + + describe('set()', () => { + test('creates a new role assignment', () => { + modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini'); + + const assignment = modelRoleRepo.getByRole('legacy'); + expect(assignment).not.toBeNull(); + expect(assignment!.role).toBe('legacy'); + expect(assignment!.provider_id).toBe(providerId); + expect(assignment!.model).toBe('gpt-4o-mini'); + }); + + test('upserts: updates existing role assignment', () => { + modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini'); + modelRoleRepo.set('legacy', providerId, 'gpt-4o'); + + const assignment = modelRoleRepo.getByRole('legacy'); + expect(assignment!.model).toBe('gpt-4o'); + }); + + test('can assign different roles', () => { + const roles: ModelRole[] = ['legacy', 'planner', 'specialist', 'judge', 'embedding']; + for (const role of roles) { + modelRoleRepo.set(role, providerId, `model-for-${role}`); + } + + for (const role of roles) { + const a = modelRoleRepo.getByRole(role); + expect(a!.model).toBe(`model-for-${role}`); + } + }); + }); + + // ─── GetByRole ──────────────────────────────────────────────────── + + describe('getByRole()', () => { + test('returns null when no assignment exists', () => { + expect(modelRoleRepo.getByRole('legacy')).toBeNull(); + }); + + test('returns the correct assignment', () => { + modelRoleRepo.set('planner', providerId, 'gpt-4o'); + const a = modelRoleRepo.getByRole('planner'); + expect(a!.provider_id).toBe(providerId); + expect(a!.model).toBe('gpt-4o'); + }); + }); + + // ─── List ───────────────────────────────────────────────────────── + + describe('list()', () => { + test('returns empty array when no assignments exist', () => { + expect(modelRoleRepo.list()).toEqual([]); + }); + + test('returns all assignments with provider info (JOIN)', () => { + modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini'); + modelRoleRepo.set('planner', providerId, 'gpt-4o'); + + const all = modelRoleRepo.list(); + expect(all).toHaveLength(2); + + expect(all[0].provider_name).toBe('Test Provider'); + expect(all[0].provider_type).toBe('openai_compatible'); + }); + + test('results are ordered by role', () => { + modelRoleRepo.set('specialist', providerId, 'model-a'); + modelRoleRepo.set('embedding', providerId, 'model-b'); + modelRoleRepo.set('legacy', providerId, 'model-c'); + + const all = modelRoleRepo.list(); + const roles = all.map((a) => a.role); + expect(roles).toEqual([...roles].sort()); + }); + }); + + // ─── Delete ─────────────────────────────────────────────────────── + + describe('delete()', () => { + test('deletes existing assignment, returns true', () => { + modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini'); + expect(modelRoleRepo.delete('legacy')).toBe(true); + expect(modelRoleRepo.getByRole('legacy')).toBeNull(); + }); + + test('returns false for non-existent role', () => { + expect(modelRoleRepo.delete('legacy')).toBe(false); + }); + }); + + // ─── GetRolesByProvider ─────────────────────────────────────────── + + describe('getRolesByProvider()', () => { + test('returns empty array when no roles assigned', () => { + expect(modelRoleRepo.getRolesByProvider(providerId)).toEqual([]); + }); + + test('returns all roles assigned to a provider', () => { + modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini'); + modelRoleRepo.set('planner', providerId, 'gpt-4o'); + modelRoleRepo.set('judge', providerId, 'gpt-4o'); + + const roles = modelRoleRepo.getRolesByProvider(providerId); + expect(roles).toHaveLength(3); + expect(roles).toContain('legacy'); + expect(roles).toContain('planner'); + expect(roles).toContain('judge'); + }); + + test('does not return roles assigned to other providers', () => { + const p2 = providerRepo.create({ + ...providerInput, + name: 'Other Provider', + type: 'anthropic', + }); + modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini'); + modelRoleRepo.set('planner', p2.id, 'claude-3-5-sonnet'); + + const roles1 = modelRoleRepo.getRolesByProvider(providerId); + expect(roles1).toEqual(['legacy']); + + const roles2 = modelRoleRepo.getRolesByProvider(p2.id); + expect(roles2).toEqual(['planner']); + }); + }); + + // ─── CASCADE on provider delete ─────────────────────────────────── + + describe('foreign key constraint', () => { + test('cannot delete provider while role assignments exist (no CASCADE)', () => { + modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini'); + modelRoleRepo.set('planner', providerId, 'gpt-4o'); + + // FK constraint prevents delete — must remove assignments first + expect(() => providerRepo.delete(providerId)).toThrow(); + + // Clean up assignments first, then delete succeeds + modelRoleRepo.delete('legacy'); + modelRoleRepo.delete('planner'); + expect(providerRepo.delete(providerId)).toBe(true); + }); + }); +}); diff --git a/src/db/__tests__/provider-repo.test.ts b/src/db/__tests__/provider-repo.test.ts new file mode 100644 index 0000000..34aa103 --- /dev/null +++ b/src/db/__tests__/provider-repo.test.ts @@ -0,0 +1,233 @@ +// @ts-expect-error bun:test is provided by Bun at runtime +declare module 'bun:test' { + export const describe: any; + export const test: any; + export const expect: any; + export const beforeEach: any; + export const afterEach: any; +} + +// @ts-expect-error bun:test is provided by Bun at runtime +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { closeDatabase, initDatabase } from '../database'; +import { providerRepo } from '../repositories/provider-repo'; +import type { CreateProviderInput } from '../repositories/provider-repo'; + +describe('provider-repo', () => { + let dbPath: string; + const savedDbPath = process.env.DATABASE_PATH; + + beforeEach(() => { + const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`); + mkdirSync(tmpDir, { recursive: true }); + dbPath = join(tmpDir, 'test.db'); + process.env.DATABASE_PATH = dbPath; + initDatabase(); + }); + + afterEach(() => { + closeDatabase(); + if (savedDbPath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = savedDbPath; + } + try { + if (existsSync(dbPath)) unlinkSync(dbPath); + } catch { + /* ok */ + } + try { + if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`); + } catch { + /* ok */ + } + try { + if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`); + } catch { + /* ok */ + } + }); + + const sampleInput: CreateProviderInput = { + name: 'Test OpenAI', + type: 'openai_compatible', + baseUrl: 'https://api.openai.com/v1', + defaultModel: 'gpt-4o-mini', + extraConfig: { org: 'test-org' }, + }; + + // ─── Create ──────────────────────────────────────────────────────── + + describe('create()', () => { + test('creates a provider and returns it with auto-generated ID', () => { + const created = providerRepo.create(sampleInput); + + expect(created.id).toBeTruthy(); + expect(typeof created.id).toBe('string'); + expect(created.name).toBe('Test OpenAI'); + expect(created.type).toBe('openai_compatible'); + expect(created.base_url).toBe('https://api.openai.com/v1'); + expect(created.default_model).toBe('gpt-4o-mini'); + expect(created.is_enabled).toBe(1); + expect(JSON.parse(created.extra_config)).toEqual({ org: 'test-org' }); + expect(created.created_at).toBeTruthy(); + expect(created.updated_at).toBeTruthy(); + }); + + test('creates provider with null baseUrl', () => { + const input: CreateProviderInput = { + name: 'Anthropic', + type: 'anthropic', + defaultModel: 'claude-3-5-sonnet-20241022', + }; + const created = providerRepo.create(input); + expect(created.base_url).toBeNull(); + }); + + test('creates provider with default empty extraConfig', () => { + const input: CreateProviderInput = { + name: 'Gemini', + type: 'gemini', + defaultModel: 'gemini-pro', + }; + const created = providerRepo.create(input); + expect(JSON.parse(created.extra_config)).toEqual({}); + }); + + test('each create generates unique IDs', () => { + const p1 = providerRepo.create({ ...sampleInput, name: 'Provider 1' }); + const p2 = providerRepo.create({ ...sampleInput, name: 'Provider 2' }); + expect(p1.id).not.toBe(p2.id); + }); + }); + + // ─── List ────────────────────────────────────────────────────────── + + describe('list()', () => { + test('returns empty array when no providers exist', () => { + expect(providerRepo.list()).toEqual([]); + }); + + test('returns all providers ordered by created_at', () => { + providerRepo.create({ ...sampleInput, name: 'First' }); + providerRepo.create({ ...sampleInput, name: 'Second' }); + providerRepo.create({ ...sampleInput, name: 'Third' }); + + const all = providerRepo.list(); + expect(all).toHaveLength(3); + expect(all[0].name).toBe('First'); + expect(all[2].name).toBe('Third'); + }); + + test('enabledOnly=true filters disabled providers', () => { + providerRepo.create({ ...sampleInput, name: 'Enabled' }); + const p2 = providerRepo.create({ ...sampleInput, name: 'Disabled' }); + providerRepo.update(p2.id, { isEnabled: false }); + + const enabled = providerRepo.list(true); + expect(enabled).toHaveLength(1); + expect(enabled[0].name).toBe('Enabled'); + }); + }); + + // ─── GetById ─────────────────────────────────────────────────────── + + describe('getById()', () => { + test('returns provider by ID', () => { + const created = providerRepo.create(sampleInput); + const fetched = providerRepo.getById(created.id); + + expect(fetched).not.toBeNull(); + expect(fetched!.id).toBe(created.id); + expect(fetched!.name).toBe(created.name); + }); + + test('returns null for non-existent ID', () => { + expect(providerRepo.getById('non-existent')).toBeNull(); + }); + }); + + // ─── Update ──────────────────────────────────────────────────────── + + describe('update()', () => { + test('updates name only', () => { + const created = providerRepo.create(sampleInput); + const updated = providerRepo.update(created.id, { name: 'Updated Name' }); + + expect(updated!.name).toBe('Updated Name'); + expect(updated!.type).toBe(created.type); + expect(updated!.default_model).toBe(created.default_model); + }); + + test('updates multiple fields at once', () => { + const created = providerRepo.create(sampleInput); + const updated = providerRepo.update(created.id, { + name: 'New Name', + defaultModel: 'gpt-4o', + isEnabled: false, + baseUrl: 'https://new-url.com', + extraConfig: { newKey: 'newVal' }, + }); + + expect(updated!.name).toBe('New Name'); + expect(updated!.default_model).toBe('gpt-4o'); + expect(updated!.is_enabled).toBe(0); + expect(updated!.base_url).toBe('https://new-url.com'); + expect(JSON.parse(updated!.extra_config)).toEqual({ newKey: 'newVal' }); + }); + + test('returns null for non-existent ID', () => { + expect(providerRepo.update('non-existent', { name: 'x' })).toBeNull(); + }); + + test('returns existing row when no fields provided', () => { + const created = providerRepo.create(sampleInput); + const updated = providerRepo.update(created.id, {}); + expect(updated!.name).toBe(created.name); + }); + + test('updates updated_at timestamp', () => { + const created = providerRepo.create(sampleInput); + const updated = providerRepo.update(created.id, { name: 'Changed' }); + expect(updated!.updated_at).toBeTruthy(); + }); + }); + + // ─── Delete ──────────────────────────────────────────────────────── + + describe('delete()', () => { + test('deletes existing provider, returns true', () => { + const created = providerRepo.create(sampleInput); + expect(providerRepo.delete(created.id)).toBe(true); + expect(providerRepo.getById(created.id)).toBeNull(); + }); + + test('returns false for non-existent ID', () => { + expect(providerRepo.delete('non-existent')).toBe(false); + }); + + test('deleting provider does not affect other providers', () => { + const p1 = providerRepo.create({ ...sampleInput, name: 'Keep' }); + const p2 = providerRepo.create({ ...sampleInput, name: 'Delete' }); + + providerRepo.delete(p2.id); + + expect(providerRepo.list()).toHaveLength(1); + expect(providerRepo.list()[0].name).toBe('Keep'); + }); + }); + + // ─── hasKey ──────────────────────────────────────────────────────── + + describe('hasKey()', () => { + test('returns false when no secret exists', () => { + const created = providerRepo.create(sampleInput); + expect(providerRepo.hasKey(created.id)).toBe(false); + }); + }); +}); diff --git a/src/db/__tests__/secret-repo.test.ts b/src/db/__tests__/secret-repo.test.ts new file mode 100644 index 0000000..65b5202 --- /dev/null +++ b/src/db/__tests__/secret-repo.test.ts @@ -0,0 +1,193 @@ +// @ts-expect-error bun:test is provided by Bun at runtime +declare module 'bun:test' { + export const describe: any; + export const test: any; + export const expect: any; + export const beforeEach: any; + export const afterEach: any; +} + +// @ts-expect-error bun:test is provided by Bun at runtime +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { initMasterKey } from '../../crypto/secrets'; +import { closeDatabase, initDatabase } from '../database'; +import { providerRepo } from '../repositories/provider-repo'; +import type { CreateProviderInput } from '../repositories/provider-repo'; +import { secretRepo } from '../repositories/secret-repo'; + +describe('secret-repo', () => { + let dbPath: string; + let keyPath: string; + let providerId: string; + const savedDbPath = process.env.DATABASE_PATH; + const savedKeyPath = process.env.MASTER_KEY_PATH; + + const providerInput: CreateProviderInput = { + name: 'Test Provider', + type: 'openai_compatible', + baseUrl: 'https://api.example.com/v1', + defaultModel: 'gpt-4o-mini', + }; + + beforeEach(() => { + const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`); + mkdirSync(tmpDir, { recursive: true }); + dbPath = join(tmpDir, 'test.db'); + keyPath = join(tmpDir, 'master.key'); + process.env.DATABASE_PATH = dbPath; + process.env.MASTER_KEY_PATH = keyPath; + + initMasterKey(); + initDatabase(); + + const created = providerRepo.create(providerInput); + providerId = created.id; + }); + + afterEach(() => { + closeDatabase(); + if (savedDbPath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = savedDbPath; + } + if (savedKeyPath === undefined) { + delete process.env.MASTER_KEY_PATH; + } else { + process.env.MASTER_KEY_PATH = savedKeyPath; + } + try { + if (existsSync(dbPath)) unlinkSync(dbPath); + } catch { + /* ok */ + } + try { + if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`); + } catch { + /* ok */ + } + try { + if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`); + } catch { + /* ok */ + } + try { + if (existsSync(keyPath)) unlinkSync(keyPath); + } catch { + /* ok */ + } + }); + + // ─── Set ────────────────────────────────────────────────────────── + + describe('set()', () => { + test('stores an encrypted API key', () => { + secretRepo.set(providerId, 'sk-test-key-123'); + expect(secretRepo.has(providerId)).toBe(true); + }); + + test('upserts: overwrites existing key', () => { + secretRepo.set(providerId, 'old-key'); + secretRepo.set(providerId, 'new-key'); + + const retrieved = secretRepo.get(providerId); + expect(retrieved).toBe('new-key'); + }); + }); + + // ─── Get ────────────────────────────────────────────────────────── + + describe('get()', () => { + test('returns null when no key exists', () => { + expect(secretRepo.get(providerId)).toBeNull(); + }); + + test('retrieves and decrypts the stored key', () => { + const apiKey = 'sk-super-secret-api-key-abc123'; + secretRepo.set(providerId, apiKey); + + const retrieved = secretRepo.get(providerId); + expect(retrieved).toBe(apiKey); + }); + + test('handles unicode API keys', () => { + const apiKey = 'key-with-特殊字符-🔑'; + secretRepo.set(providerId, apiKey); + expect(secretRepo.get(providerId)).toBe(apiKey); + }); + + test('returns null for non-existent provider', () => { + expect(secretRepo.get('non-existent')).toBeNull(); + }); + }); + + // ─── Has ────────────────────────────────────────────────────────── + + describe('has()', () => { + test('returns false when no key stored', () => { + expect(secretRepo.has(providerId)).toBe(false); + }); + + test('returns true when key is stored', () => { + secretRepo.set(providerId, 'sk-key'); + expect(secretRepo.has(providerId)).toBe(true); + }); + + test('returns false for non-existent provider', () => { + expect(secretRepo.has('non-existent')).toBe(false); + }); + }); + + // ─── Delete ─────────────────────────────────────────────────────── + + describe('delete()', () => { + test('deletes existing key, returns true', () => { + secretRepo.set(providerId, 'sk-key'); + expect(secretRepo.delete(providerId)).toBe(true); + expect(secretRepo.has(providerId)).toBe(false); + expect(secretRepo.get(providerId)).toBeNull(); + }); + + test('returns false when no key exists', () => { + expect(secretRepo.delete(providerId)).toBe(false); + }); + }); + + // ─── CASCADE on provider delete ─────────────────────────────────── + + describe('CASCADE behavior', () => { + test('deleting provider removes its secret', () => { + secretRepo.set(providerId, 'sk-will-be-deleted'); + expect(secretRepo.has(providerId)).toBe(true); + + providerRepo.delete(providerId); + + expect(secretRepo.has(providerId)).toBe(false); + }); + }); + + // ─── Multiple providers ────────────────────────────────────────── + + describe('multiple providers', () => { + test('each provider has independent key storage', () => { + const p2 = providerRepo.create({ + ...providerInput, + name: 'Second Provider', + type: 'anthropic', + }); + + secretRepo.set(providerId, 'key-1'); + secretRepo.set(p2.id, 'key-2'); + + expect(secretRepo.get(providerId)).toBe('key-1'); + expect(secretRepo.get(p2.id)).toBe('key-2'); + + secretRepo.delete(providerId); + expect(secretRepo.get(p2.id)).toBe('key-2'); + }); + }); +}); diff --git a/src/llm/__tests__/gateway.test.ts b/src/llm/__tests__/gateway.test.ts new file mode 100644 index 0000000..d518d2a --- /dev/null +++ b/src/llm/__tests__/gateway.test.ts @@ -0,0 +1,245 @@ +// @ts-expect-error bun:test is provided by Bun at runtime +declare module 'bun:test' { + export const describe: any; + export const test: any; + export const expect: any; + export const beforeEach: any; + export const afterEach: any; +} + +// @ts-expect-error bun:test is provided by Bun at runtime +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { initMasterKey } from '../../crypto/secrets'; +import { closeDatabase, initDatabase } from '../../db/database'; +import { modelRoleRepo } from '../../db/repositories/model-role-repo'; +import { providerRepo } from '../../db/repositories/provider-repo'; +import type { CreateProviderInput } from '../../db/repositories/provider-repo'; +import { secretRepo } from '../../db/repositories/secret-repo'; +import { LLMGateway } from '../gateway'; + +describe('LLMGateway', () => { + let dbPath: string; + let keyPath: string; + let gateway: LLMGateway; + let providerId: string; + const savedDbPath = process.env.DATABASE_PATH; + const savedKeyPath = process.env.MASTER_KEY_PATH; + + const providerInput: CreateProviderInput = { + name: 'Test OpenAI', + type: 'openai_compatible', + baseUrl: 'https://api.openai.com/v1', + defaultModel: 'gpt-4o-mini', + }; + + beforeEach(() => { + const tmpDir = join(tmpdir(), `gw-test-${randomUUID()}`); + mkdirSync(tmpDir, { recursive: true }); + dbPath = join(tmpDir, 'test.db'); + keyPath = join(tmpDir, 'master.key'); + process.env.DATABASE_PATH = dbPath; + process.env.MASTER_KEY_PATH = keyPath; + + initMasterKey(); + initDatabase(); + + // Create provider with key + const created = providerRepo.create(providerInput); + providerId = created.id; + secretRepo.set(providerId, 'sk-test-key'); + + gateway = new LLMGateway(); + }); + + afterEach(() => { + closeDatabase(); + if (savedDbPath === undefined) { + delete process.env.DATABASE_PATH; + } else { + process.env.DATABASE_PATH = savedDbPath; + } + if (savedKeyPath === undefined) { + delete process.env.MASTER_KEY_PATH; + } else { + process.env.MASTER_KEY_PATH = savedKeyPath; + } + try { + if (existsSync(dbPath)) unlinkSync(dbPath); + } catch { + /* ok */ + } + try { + if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`); + } catch { + /* ok */ + } + try { + if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`); + } catch { + /* ok */ + } + try { + if (existsSync(keyPath)) unlinkSync(keyPath); + } catch { + /* ok */ + } + }); + + // ─── chatForRole: Error Cases ────────────────────────────────────── + + describe('chatForRole() — error handling', () => { + test('throws LLMNoProviderError when role is not assigned', async () => { + try { + await gateway.chatForRole('legacy', { + messages: [{ role: 'user', content: 'hello' }], + }); + expect(true).toBe(false); // Should not reach + } catch (e: any) { + expect(e.name).toBe('LLMNoProviderError'); + expect(e.role).toBe('legacy'); + } + }); + + test('throws LLMError when provider is disabled', async () => { + providerRepo.update(providerId, { isEnabled: false }); + modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini'); + + try { + await gateway.chatForRole('legacy', { + messages: [{ role: 'user', content: 'hello' }], + }); + expect(true).toBe(false); + } catch (e: any) { + expect(e.name).toBe('LLMError'); + expect(e.message).toContain('disabled'); + } + }); + + test('throws LLMAuthError when no API key configured', async () => { + secretRepo.delete(providerId); + modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini'); + + try { + await gateway.chatForRole('legacy', { + messages: [{ role: 'user', content: 'hello' }], + }); + expect(true).toBe(false); + } catch (e: any) { + expect(e.name).toBe('LLMAuthError'); + expect(e.message).toContain('No API key'); + } + }); + + test('throws LLMError when provider not found after role assignment manually deleted', async () => { + modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini'); + // Must remove assignments before deleting provider (no CASCADE on model_role_assignments) + modelRoleRepo.delete('legacy'); + secretRepo.delete(providerId); + providerRepo.delete(providerId); + + // Re-create assignment pointing to non-existent provider + // (simulating stale data) + try { + // No assignment exists now, so this throws LLMNoProviderError + await gateway.chatForRole('legacy', { + messages: [{ role: 'user', content: 'hello' }], + }); + expect(true).toBe(false); + } catch (e: any) { + expect(e.name).toBe('LLMNoProviderError'); + } + }); + }); + + // ─── chatDirect: Error Cases ────────────────────────────────────── + + describe('chatDirect() — error handling', () => { + test('throws LLMError for non-existent provider ID', async () => { + try { + await gateway.chatDirect('non-existent', { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello' }], + }); + expect(true).toBe(false); + } catch (e: any) { + expect(e.name).toBe('LLMError'); + expect(e.message).toContain('not found'); + } + }); + }); + + // ─── embedForRole: Error Cases ──────────────────────────────────── + + describe('embedForRole() — error handling', () => { + test('throws LLMNoProviderError when embedding role not assigned', async () => { + try { + await gateway.embedForRole(['text']); + expect(true).toBe(false); + } catch (e: any) { + expect(e.name).toBe('LLMNoProviderError'); + expect(e.role).toBe('embedding'); + } + }); + }); + + // ─── Cache invalidation ────────────────────────────────────────── + + describe('cache management', () => { + test('invalidateProvider is callable without error', () => { + gateway.invalidateProvider(providerId); + // No-op if not cached — should not throw + }); + + test('invalidateAll is callable without error', () => { + gateway.invalidateAll(); + }); + }); + + // ─── Provider creation/routing (integration) ───────────────────── + + describe('getProviderInstance()', () => { + test('creates provider instance for valid config', () => { + const instance = gateway.getProviderInstance(providerId); + expect(instance).toBeTruthy(); + expect(instance.type).toBe('openai_compatible'); + }); + + test('caches provider instance on repeated access', () => { + const inst1 = gateway.getProviderInstance(providerId); + const inst2 = gateway.getProviderInstance(providerId); + expect(inst1).toBe(inst2); // Same reference + }); + + test('returns fresh instance after invalidation', () => { + const inst1 = gateway.getProviderInstance(providerId); + gateway.invalidateProvider(providerId); + const inst2 = gateway.getProviderInstance(providerId); + expect(inst1).not.toBe(inst2); // Different reference + }); + + test('creates provider for all supported types', () => { + const types = [ + { type: 'openai_responses' as const, baseUrl: undefined }, + { type: 'anthropic' as const, baseUrl: undefined }, + { type: 'gemini' as const, baseUrl: undefined }, + ]; + + for (const { type, baseUrl } of types) { + const p = providerRepo.create({ + name: `Test ${type}`, + type, + baseUrl, + defaultModel: 'test-model', + }); + secretRepo.set(p.id, 'sk-test'); + + const instance = gateway.getProviderInstance(p.id); + expect(instance.type).toBe(type); + } + }); + }); +}); diff --git a/src/llm/__tests__/tool-converter.test.ts b/src/llm/__tests__/tool-converter.test.ts new file mode 100644 index 0000000..a3edbac --- /dev/null +++ b/src/llm/__tests__/tool-converter.test.ts @@ -0,0 +1,178 @@ +// @ts-expect-error bun:test is provided by Bun at runtime +declare module 'bun:test' { + export const describe: any; + export const test: any; + export const expect: any; +} + +// @ts-expect-error bun:test is provided by Bun at runtime +import { describe, expect, test } from 'bun:test'; +import { toAnthropicTools, toGeminiTools, toOpenAITools } from '../tool-converter'; +import type { LLMToolDefinition } from '../types'; + +const SAMPLE_TOOLS: LLMToolDefinition[] = [ + { + name: 'get_weather', + description: 'Get current weather for a location', + parameters: { + type: 'object', + properties: { + location: { type: 'string', description: 'City name' }, + unit: { type: 'string', enum: ['celsius', 'fahrenheit'] }, + }, + required: ['location'], + }, + }, + { + name: 'search_code', + description: 'Search codebase for a pattern', + parameters: { + type: 'object', + properties: { + query: { type: 'string' }, + path: { type: 'string' }, + }, + required: ['query'], + }, + }, +]; + +describe('tool-converter', () => { + // ─── toOpenAITools ────────────────────────────────────────────────── + + describe('toOpenAITools()', () => { + test('wraps each tool in { type: "function", function: {...} }', () => { + const result = toOpenAITools(SAMPLE_TOOLS); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + type: 'function', + function: { + name: 'get_weather', + description: 'Get current weather for a location', + parameters: SAMPLE_TOOLS[0].parameters, + }, + }); + expect(result[1]).toEqual({ + type: 'function', + function: { + name: 'search_code', + description: 'Search codebase for a pattern', + parameters: SAMPLE_TOOLS[1].parameters, + }, + }); + }); + + test('returns empty array for empty input', () => { + expect(toOpenAITools([])).toEqual([]); + }); + + test('preserves nested parameter schema', () => { + const tools: LLMToolDefinition[] = [ + { + name: 'complex', + description: 'Complex params', + parameters: { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { + deep: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + ]; + const result = toOpenAITools(tools) as any; + expect(result[0].function.parameters.properties.nested.properties.deep.type).toBe('array'); + }); + }); + + // ─── toAnthropicTools ─────────────────────────────────────────────── + + describe('toAnthropicTools()', () => { + test('maps to { name, description, input_schema }', () => { + const result = toAnthropicTools(SAMPLE_TOOLS); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + name: 'get_weather', + description: 'Get current weather for a location', + input_schema: SAMPLE_TOOLS[0].parameters, + }); + expect(result[1]).toEqual({ + name: 'search_code', + description: 'Search codebase for a pattern', + input_schema: SAMPLE_TOOLS[1].parameters, + }); + }); + + test('returns empty array for empty input', () => { + expect(toAnthropicTools([])).toEqual([]); + }); + + test('uses input_schema (not parameters)', () => { + const result = toAnthropicTools(SAMPLE_TOOLS) as any; + expect(result[0]).toHaveProperty('input_schema'); + expect(result[0]).not.toHaveProperty('parameters'); + }); + }); + + // ─── toGeminiTools ────────────────────────────────────────────────── + + describe('toGeminiTools()', () => { + test('wraps all tools in a single functionDeclarations array', () => { + const result = toGeminiTools(SAMPLE_TOOLS); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('functionDeclarations'); + + const decls = (result[0] as any).functionDeclarations; + expect(decls).toHaveLength(2); + expect(decls[0]).toEqual({ + name: 'get_weather', + description: 'Get current weather for a location', + parameters: SAMPLE_TOOLS[0].parameters, + }); + expect(decls[1]).toEqual({ + name: 'search_code', + description: 'Search codebase for a pattern', + parameters: SAMPLE_TOOLS[1].parameters, + }); + }); + + test('returns single-element array even for one tool', () => { + const result = toGeminiTools([SAMPLE_TOOLS[0]]); + expect(result).toHaveLength(1); + expect((result[0] as any).functionDeclarations).toHaveLength(1); + }); + + test('returns single-element with empty declarations for empty input', () => { + const result = toGeminiTools([]); + expect(result).toHaveLength(1); + expect((result[0] as any).functionDeclarations).toHaveLength(0); + }); + }); + + // ─── Cross-format comparison ──────────────────────────────────────── + + describe('cross-format consistency', () => { + test('all formats preserve tool name and description', () => { + const openai = toOpenAITools(SAMPLE_TOOLS) as any; + const anthropic = toAnthropicTools(SAMPLE_TOOLS) as any; + const gemini = (toGeminiTools(SAMPLE_TOOLS)[0] as any).functionDeclarations; + + for (let i = 0; i < SAMPLE_TOOLS.length; i++) { + expect(openai[i].function.name).toBe(SAMPLE_TOOLS[i].name); + expect(anthropic[i].name).toBe(SAMPLE_TOOLS[i].name); + expect(gemini[i].name).toBe(SAMPLE_TOOLS[i].name); + + expect(openai[i].function.description).toBe(SAMPLE_TOOLS[i].description); + expect(anthropic[i].description).toBe(SAMPLE_TOOLS[i].description); + expect(gemini[i].description).toBe(SAMPLE_TOOLS[i].description); + } + }); + }); +});