mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-06-02 07:16:45 +00:00
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)
This commit is contained in:
462
src/controllers/__tests__/llm-config.test.ts
Normal file
462
src/controllers/__tests__/llm-config.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
224
src/crypto/__tests__/secrets.test.ts
Normal file
224
src/crypto/__tests__/secrets.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
213
src/db/__tests__/model-role-repo.test.ts
Normal file
213
src/db/__tests__/model-role-repo.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
233
src/db/__tests__/provider-repo.test.ts
Normal file
233
src/db/__tests__/provider-repo.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
193
src/db/__tests__/secret-repo.test.ts
Normal file
193
src/db/__tests__/secret-repo.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
245
src/llm/__tests__/gateway.test.ts
Normal file
245
src/llm/__tests__/gateway.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
178
src/llm/__tests__/tool-converter.test.ts
Normal file
178
src/llm/__tests__/tool-converter.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user