Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
196 lines
6.4 KiB
TypeScript
196 lines
6.4 KiB
TypeScript
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<typeof vi.fn> {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
/** Every URL the client actually requested, across all calls. */
|
|
function requestedUrls(fetchMock: ReturnType<typeof vi.fn>): 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');
|
|
}
|
|
});
|
|
});
|