import { describe, expect, it, vi, type Mock } from 'vitest'; import { ClientResponseError, createBobbinClient, type BobbinContext } from './client'; import { jsonGet, rawGet } from './_request'; const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }); const makeCtx = ( fetchMock: typeof globalThis.fetch, serviceUrl = 'https://bobbin.test' ): BobbinContext => createBobbinClient({ serviceUrl, fetch: fetchMock }); const fetchedUrl = (mock: Mock, n = 0): URL => new URL(String(mock.mock.calls[n][0])); describe('createBobbinClient', () => { it('normalizes trailing slashes off the service url', () => { const ctx = makeCtx(vi.fn(), 'https://bobbin.test///'); expect(ctx.serviceUrl).toBe('https://bobbin.test'); }); }); describe('jsonGet URL construction', () => { it('appends an array param as repeated keys', async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: 1 })); await jsonGet(makeCtx(fetchMock), 'sh.tangled.repo.getRepos', { repos: ['a', 'b'] }); const url = fetchedUrl(fetchMock); expect(url.searchParams.getAll('repos')).toEqual(['a', 'b']); expect(url.search).toBe('?repos=a&repos=b'); }); it('omits undefined params entirely', async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: 1 })); await jsonGet(makeCtx(fetchMock), 'sh.tangled.x', { keep: 'yes', drop: undefined }); const url = fetchedUrl(fetchMock); expect(url.searchParams.has('drop')).toBe(false); expect(url.searchParams.get('keep')).toBe('yes'); }); it('resolves the query against the normalized origin', async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: 1 })); await jsonGet(makeCtx(fetchMock, 'https://bobbin.test/'), 'sh.tangled.x', { a: '1' }); expect(fetchedUrl(fetchMock).href).toBe('https://bobbin.test/xrpc/sh.tangled.x?a=1'); }); }); describe('jsonGet request headers & signal', () => { it('sends accept: application/json and merges caller headers + signal', async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: 1 })); const controller = new AbortController(); await jsonGet(makeCtx(fetchMock), 'sh.tangled.x', undefined, { headers: { 'x-custom': '1' }, signal: controller.signal }); const init = fetchMock.mock.calls[0][1] as RequestInit; expect(init.headers).toMatchObject({ accept: 'application/json', 'x-custom': '1' }); expect(init.signal).toBe(controller.signal); }); }); describe('jsonGet responses', () => { it('throws ClientResponseError carrying status + error/description for a JSON error body', async () => { const fetchMock = vi .fn() .mockResolvedValue( jsonResponse({ error: 'RecordNotFound', message: 'no such repo' }, { status: 404 }) ); const err = await jsonGet(makeCtx(fetchMock), 'sh.tangled.x').catch((e: unknown) => e); expect(err).toBeInstanceOf(ClientResponseError); const cre = err as ClientResponseError; expect(cre.status).toBe(404); expect(cre.error).toBe('RecordNotFound'); expect(cre.description).toBe('no such repo'); }); it('falls back to the status line for a non-JSON error body (does not hang)', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response('502 Bad Gateway', { status: 502, statusText: 'Bad Gateway' }) ); const err = await jsonGet(makeCtx(fetchMock), 'sh.tangled.x').catch((e: unknown) => e); expect(err).toBeInstanceOf(ClientResponseError); const cre = err as ClientResponseError; expect(cre.status).toBe(502); expect(cre.error).toBe('XRPCError'); expect(cre.description).toBe('Bad Gateway'); }); }); describe('rawGet', () => { it('throws ClientResponseError on a non-2xx status', async () => { const fetchMock = vi .fn() .mockResolvedValue( jsonResponse({ error: 'UpstreamFailed', message: 'knot down' }, { status: 502 }) ); const err = await rawGet(makeCtx(fetchMock), 'sh.tangled.repo.archive').catch( (e: unknown) => e ); expect(err).toBeInstanceOf(ClientResponseError); expect((err as ClientResponseError).status).toBe(502); }); });