import { afterEach, describe, expect, it, vi } from 'vitest'; import { fetchCatalog, fetchHarnesses, fetchPersistedSelection, persistSelection, } from './chat-api'; function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }); } function stubFetch(): ReturnType { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); return fetchMock; } /** Every URL the client actually requested, across all calls. */ function requestedUrls(fetchMock: ReturnType): string[] { return fetchMock.mock.calls.map((call) => String(call[0])); } describe('chat-api', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('fetchHarnesses GETs /api/harnesses and returns typed summaries (harness id separate from provider)', async () => { const fetchMock = stubFetch(); fetchMock.mockResolvedValue( json([ { id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] }, { id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] }, ]), ); const harnesses = await fetchHarnesses(); expect(fetchMock).toHaveBeenCalledOnce(); expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses'); expect(harnesses).toEqual([ { id: 'pi', displayName: 'Pi', capabilities: ['chat', 'tools'] }, { id: 'openai', displayName: 'OpenAI', capabilities: ['chat'] }, ]); }); it('fetchCatalog GETs the harness-scoped catalog and returns only its model entries', async () => { const fetchMock = stubFetch(); fetchMock.mockResolvedValue( json({ harnessId: 'pi', version: '2026-08-11', fingerprint: 'abc123', models: [ { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5', displayName: 'GPT-5', reasoningCapability: true, inputTypes: ['text'], authState: 'ready', availability: 'available', }, ], }), ); const result = await fetchCatalog('pi'); expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/harnesses/pi/catalog'); expect(result.ok).toBe(true); if (!result.ok) throw new Error('expected ok catalog'); expect(result.catalog.harnessId).toBe('pi'); expect(result.catalog.models).toHaveLength(1); expect(result.catalog.models[0]).toMatchObject({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5', availability: 'available', }); }); it('normalizes a catalog 404 into a typed catalog_unavailable result without surfacing the raw body', async () => { const fetchMock = stubFetch(); fetchMock.mockResolvedValue( json( { code: 'adapter_unavailable', message: 'raw gateway detail that must not leak verbatim', harnessId: 'attacker-echo', extra: { hostile: 'blob' }, }, 404, ), ); const result = await fetchCatalog('ghost'); expect(result.ok).toBe(false); if (result.ok) throw new Error('expected unavailable result'); expect(result.code).toBe('catalog_unavailable'); // harnessId comes from the request, never the (untrusted) response body. expect(result.harnessId).toBe('ghost'); expect(typeof result.message).toBe('string'); // The raw response body is never rendered/returned verbatim. expect(JSON.stringify(result)).not.toContain('hostile'); expect(JSON.stringify(result)).not.toContain('attacker-echo'); }); it('fetchPersistedSelection returns the stored tuple, or null when unset', async () => { const fetchMock = stubFetch(); fetchMock.mockResolvedValueOnce( json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }), ); await expect(fetchPersistedSelection()).resolves.toEqual({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5', }); expect(String(fetchMock.mock.calls[0]?.[0])).toBe('/api/chat/preferences/selection'); fetchMock.mockResolvedValueOnce(json({ selection: null })); await expect(fetchPersistedSelection()).resolves.toBeNull(); }); it('persistSelection PUTs the structured tuple (not free text) and returns the confirmed selection', async () => { const fetchMock = stubFetch(); fetchMock.mockResolvedValue( json({ selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' } }), ); const result = await persistSelection({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5', }); expect(result.ok).toBe(true); const call = fetchMock.mock.calls[0]; expect(String(call?.[0])).toBe('/api/chat/preferences/selection'); const init = call?.[1] as RequestInit; expect(String(init.method).toUpperCase()).toBe('PUT'); // The body is exactly the structured tuple — harness/provider/model kept distinct. expect(JSON.parse(String(init.body))).toEqual({ harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5', }); }); it('normalizes a selection 422 into a typed error preserving the requested tuple exactly', async () => { const fetchMock = stubFetch(); fetchMock.mockResolvedValue( json( { code: 'model_unavailable', message: 'raw detail that must not leak', selection: { harnessId: 'x', providerId: 'y', modelId: 'z' }, }, 422, ), ); const requested = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' }; const result = await persistSelection(requested); expect(result.ok).toBe(false); if (result.ok) throw new Error('expected failed persist'); expect(['selection_invalid', 'model_unavailable']).toContain(result.code); // The requested tuple is preserved unchanged — not replaced by the body's echo. expect(result.requested).toEqual(requested); expect(JSON.stringify(result)).not.toContain('raw detail'); }); it('never requests any /api/providers* endpoint', async () => { const fetchMock = stubFetch(); fetchMock.mockResolvedValue(json([])); await fetchHarnesses(); fetchMock.mockResolvedValue( json({ harnessId: 'pi', version: '1', fingerprint: 'f', models: [] }), ); await fetchCatalog('pi'); fetchMock.mockResolvedValue(json({ selection: null })); await fetchPersistedSelection(); for (const url of requestedUrls(fetchMock)) { expect(url).not.toContain('/api/providers'); } }); });