GET/PATCH /api/providers/config, subset POST /refresh, and
GET /api/providers/:id/diagnostic (JSON { diagnostic }, §6.4). PATCH order
is validate→save→reload→clear; a malformed body or invalid merged config
returns 422 without writing, and a save failure returns 500 without
reloading (no file/registry divergence). Web client + types extended.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
212 lines
7.3 KiB
TypeScript
212 lines
7.3 KiB
TypeScript
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();
|
|
});
|
|
});
|