import { describe, it, expect, vi, afterEach } from 'vitest'; import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { registerLlmRoutes } from '../src/routes/llms.js'; import { LlmService } from '../src/services/llm.service.js'; import { errorHandler } from '../src/middleware/error-handler.js'; import type { ILlmRepository } from '../src/repositories/llm.repository.js'; import type { Llm, Secret } from '@prisma/client'; let app: FastifyInstance; function makeLlm(overrides: Partial = {}): Llm { return { id: 'llm-1', name: 'claude', type: 'anthropic', model: 'claude-3-5-sonnet-20241022', url: '', tier: 'heavy', description: '', apiKeySecretId: null, apiKeySecretKey: null, extraConfig: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } function mockRepo(initial: Llm[] = []): ILlmRepository { const rows = new Map(initial.map((r) => [r.id, r])); return { findAll: vi.fn(async () => [...rows.values()]), findById: vi.fn(async (id: string) => rows.get(id) ?? null), findByName: vi.fn(async (name: string) => { for (const r of rows.values()) if (r.name === name) return r; return null; }), findByTier: vi.fn(async () => []), create: vi.fn(async (data) => { const row = makeLlm({ id: 'new-id', name: data.name, type: data.type, model: data.model }); rows.set(row.id, row); return row; }), update: vi.fn(async (id, data) => { const existing = rows.get(id)!; const next: Llm = { ...existing, ...(data.model !== undefined ? { model: data.model } : {}), }; rows.set(id, next); return next; }), delete: vi.fn(async (id) => { rows.delete(id); }), }; } function mockSecretService() { const sec: Secret = { id: 'sec-1', name: 'anthropic-key', backendId: 'b', data: {}, externalRef: '', version: 1, createdAt: new Date(), updatedAt: new Date(), }; return { getById: vi.fn(async (id: string) => { if (id === sec.id) return sec; throw new Error('not found'); }), getByName: vi.fn(async (name: string) => { if (name === sec.name) return sec; throw new Error('not found'); }), resolveData: vi.fn(async () => ({ token: 'sk-ant-xyz' })), }; } afterEach(async () => { if (app) await app.close(); }); async function createApp(repo: ILlmRepository): Promise { app = Fastify({ logger: false }); app.setErrorHandler(errorHandler); // eslint-disable-next-line @typescript-eslint/no-explicit-any const service = new LlmService(repo, mockSecretService() as any); registerLlmRoutes(app, service); await app.ready(); return app; } describe('Llm Routes', () => { it('GET /api/v1/llms returns a list', async () => { await createApp(mockRepo([makeLlm()])); const res = await app.inject({ method: 'GET', url: '/api/v1/llms' }); expect(res.statusCode).toBe(200); const body = res.json>(); expect(body).toHaveLength(1); expect(body[0]!.name).toBe('claude'); }); it('GET /api/v1/llms/:id returns 404 when missing', async () => { await createApp(mockRepo()); const res = await app.inject({ method: 'GET', url: '/api/v1/llms/missing' }); expect(res.statusCode).toBe(404); }); it('POST /api/v1/llms creates and returns 201', async () => { await createApp(mockRepo()); const res = await app.inject({ method: 'POST', url: '/api/v1/llms', payload: { name: 'ollama-local', type: 'ollama', model: 'llama3', url: 'http://localhost:11434', }, }); expect(res.statusCode).toBe(201); expect(res.json<{ name: string }>().name).toBe('ollama-local'); }); it('POST /api/v1/llms rejects bad input with 400', async () => { await createApp(mockRepo()); const res = await app.inject({ method: 'POST', url: '/api/v1/llms', payload: { name: '', type: 'anthropic', model: 'x' }, }); expect(res.statusCode).toBe(400); }); it('POST /api/v1/llms returns 409 when name exists', async () => { await createApp(mockRepo([makeLlm({ name: 'claude' })])); const res = await app.inject({ method: 'POST', url: '/api/v1/llms', payload: { name: 'claude', type: 'anthropic', model: 'x' }, }); expect(res.statusCode).toBe(409); }); it('PUT /api/v1/llms/:id updates model', async () => { await createApp(mockRepo([makeLlm({ id: 'llm-1' })])); const res = await app.inject({ method: 'PUT', url: '/api/v1/llms/llm-1', payload: { model: 'claude-3-opus' }, }); expect(res.statusCode).toBe(200); expect(res.json<{ model: string }>().model).toBe('claude-3-opus'); }); it('PUT /api/v1/llms/:id returns 404 when missing', async () => { await createApp(mockRepo()); const res = await app.inject({ method: 'PUT', url: '/api/v1/llms/missing', payload: { model: 'x' }, }); expect(res.statusCode).toBe(404); }); it('DELETE /api/v1/llms/:id returns 204', async () => { await createApp(mockRepo([makeLlm({ id: 'llm-1' })])); const res = await app.inject({ method: 'DELETE', url: '/api/v1/llms/llm-1' }); expect(res.statusCode).toBe(204); }); it('DELETE /api/v1/llms/:id returns 404 when missing', async () => { await createApp(mockRepo()); const res = await app.inject({ method: 'DELETE', url: '/api/v1/llms/missing' }); expect(res.statusCode).toBe(404); }); });