import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { registerProviderRoutes } from '../providers.js'; import { load } from '../../services/provider-config.js'; import { loadProviderConfig } from '../../services/provider-config-registry.js'; import { clearProviderSnapshotCache } from '../../services/provider-snapshot.js'; import type { Config } from '../../config.js'; import type { Sql } from '../../db.js'; /** Minimal sql stub: available_agents reads return []. */ function mockSql(): Sql { return vi.fn((strings: TemplateStringsArray) => { const q = strings.join(''); if (q.includes('available_agents')) return Promise.resolve([]); return Promise.resolve([]); }) as unknown as Sql; } let tmpCounter = 0; function freshPath(): string { tmpCounter += 1; return join(tmpdir(), `coder-providers-routes-${process.pid}-${tmpCounter}.json`); } function buildApp(providersPath: string): FastifyInstance { const app = Fastify(); // Mirror index.ts: tolerate empty JSON bodies. app.removeContentTypeParser(['application/json']); app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { const str = (body as string) ?? ''; if (str.trim().length === 0) return done(null, {}); try { done(null, JSON.parse(str)); } catch (err) { done(err as Error, undefined); } }); const config = { CODER_PROVIDERS_PATH: providersPath, LLAMA_SWAP_URL: 'http://llama-swap.test', PROVIDER_PROBE_TTL_MS: 86_400_000, } as unknown as Config; registerProviderRoutes(app, mockSql(), config); return app; } const JSON_HEADERS = { 'content-type': 'application/json' }; const createdPaths: string[] = []; beforeEach(() => { clearProviderSnapshotCache(); loadProviderConfig('/nonexistent-coder-providers.json'); // reset registry to built-ins vi.restoreAllMocks(); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network in test'))); }); afterEach(() => { for (const p of createdPaths.splice(0)) { try { rmSync(p, { force: true }); } catch { /* ignore */ } } }); describe('GET /api/providers/config', () => { it('returns the current config file (built-ins-only when missing)', async () => { const path = freshPath(); createdPaths.push(path); const app = buildApp(path); const res = await app.inject({ method: 'GET', url: '/api/providers/config' }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ providers: {} }); await app.close(); }); it('reflects an existing file', async () => { const path = freshPath(); createdPaths.push(path); writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false } } })); const app = buildApp(path); const res = await app.inject({ method: 'GET', url: '/api/providers/config' }); expect(res.json()).toEqual({ providers: { goose: { enabled: false } } }); await app.close(); }); }); describe('PATCH /api/providers/config', () => { it('valid patch → 200, writes the merged file (order: validate→save→reload→clear)', async () => { const path = freshPath(); createdPaths.push(path); writeFileSync(path, JSON.stringify({ providers: { goose: { label: 'Goose' } } })); const app = buildApp(path); const res = await app.inject({ method: 'PATCH', url: '/api/providers/config', headers: JSON_HEADERS, payload: JSON.stringify({ providers: { opencode: { enabled: false } } }), }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ ok: true }); // File written + merged (goose untouched, opencode added). const onDisk = load(path); expect(onDisk.providers).toEqual({ goose: { label: 'Goose' }, opencode: { enabled: false }, }); await app.close(); }); it('null value deletes the override', async () => { const path = freshPath(); createdPaths.push(path); writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false }, opencode: { enabled: false } } })); const app = buildApp(path); const res = await app.inject({ method: 'PATCH', url: '/api/providers/config', headers: JSON_HEADERS, payload: JSON.stringify({ providers: { goose: null } }), }); expect(res.statusCode).toBe(200); expect(load(path).providers).toEqual({ opencode: { enabled: false } }); await app.close(); }); it('INVALID body → 422 and the file is NOT written (validate before save)', async () => { const path = freshPath(); createdPaths.push(path); const before = JSON.stringify({ providers: { goose: { enabled: true } } }); writeFileSync(path, before); const app = buildApp(path); const res = await app.inject({ method: 'PATCH', url: '/api/providers/config', headers: JSON_HEADERS, payload: JSON.stringify({ providers: { goose: { enabled: 'yes' } } }), // bad type }); expect(res.statusCode).toBe(422); // File must be byte-for-byte unchanged — nothing written on a 422. expect(readFileSync(path, 'utf8')).toBe(before); await app.close(); }); it('save failure → 500 and the file is NOT created (no state divergence)', async () => { const path = join(tmpdir(), `no-such-dir-${process.pid}-${Date.now()}`, 'coder-providers.json'); const app = buildApp(path); const res = await app.inject({ method: 'PATCH', url: '/api/providers/config', headers: JSON_HEADERS, payload: JSON.stringify({ providers: { goose: { enabled: false } } }), }); expect(res.statusCode).toBe(500); expect(existsSync(path)).toBe(false); await app.close(); }); }); describe('POST /api/providers/refresh', () => { it('no body → refreshes all registered providers', async () => { const app = buildApp(freshPath()); const res = await app.inject({ method: 'POST', url: '/api/providers/refresh' }); expect(res.statusCode).toBe(200); expect(res.json().refreshed).toBeGreaterThan(0); await app.close(); }); it('subset body → refreshed count reflects only the requested providers', async () => { const app = buildApp(freshPath()); const res = await app.inject({ method: 'POST', url: '/api/providers/refresh', headers: JSON_HEADERS, payload: JSON.stringify({ providers: ['boocode'] }), }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ refreshed: 1 }); await app.close(); }); }); describe('GET /api/providers/:id/diagnostic', () => { it('known provider → 200 JSON { diagnostic }', async () => { const app = buildApp(freshPath()); const res = await app.inject({ method: 'GET', url: '/api/providers/boocode/diagnostic' }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('application/json'); expect(res.json().diagnostic).toContain('provider: boocode'); await app.close(); }); it('unknown provider → 404', async () => { const app = buildApp(freshPath()); const res = await app.inject({ method: 'GET', url: '/api/providers/nope/diagnostic' }); expect(res.statusCode).toBe(404); await app.close(); }); });