// The dev.pdsjs.git client over a fake fetch: describe asked once, params // encoded, a refusal named, a procedure posted as JSON. import { describe, expect, it } from 'vitest'; import { GitClient } from '../src/client.js'; import { XrpcError } from '../src/xrpc.js'; const SERVICE = 'https://pds.example.com'; /** * A fetch that records every request and answers by path. * @param {(url: URL, init: RequestInit|undefined) => Response} answer */ function fakeFetch(answer) { /** @type {Array<{url: URL, init: RequestInit|undefined}>} */ const calls = []; /** @type {typeof fetch} */ const carrier = async (input, init) => { const url = new URL(String(input)); calls.push({ url, init }); return answer(url, init); }; return { calls, carrier }; } const describing = (/** @type {string[]} */ methods) => Response.json({ roles: ['pds'], methods: methods.map((value) => ({ $type: 'community.lexicon.service.describe#nsid', value, })), }); describe('GitClient', () => { it('asks describe once and reads what the server serves', async () => { const { calls, carrier } = fakeFetch(() => describing(['dev.pdsjs.git.listRepos', 'dev.pdsjs.git.getTree']), ); const client = new GitClient({ service: `${SERVICE}/`, fetch: carrier }); expect(await client.serves('getTree')).toBe(true); expect(await client.serves('dev.pdsjs.git.listRepos')).toBe(true); expect(await client.serves('getFile')).toBe(false); expect(await client.servesReads()).toBe(false); expect(calls).toHaveLength(1); expect(calls[0].url.href).toBe( `${SERVICE}/xrpc/community.lexicon.service.describe`, ); }); it('reads a server that cannot describe itself as serving nothing', async () => { const { carrier } = fakeFetch(() => new Response('nope', { status: 404 })); const client = new GitClient({ service: SERVICE, fetch: carrier }); expect(await client.serves('listRepos')).toBe(false); const failing = new GitClient({ service: SERVICE, fetch: async () => { throw new Error('offline'); }, }); expect(await failing.servesReads()).toBe(false); }); it('serves every read once the server lists them all', async () => { const { carrier } = fakeFetch(() => describing([ 'dev.pdsjs.git.getTree', 'dev.pdsjs.git.listFiles', 'dev.pdsjs.git.getFile', 'dev.pdsjs.git.getRaw', 'dev.pdsjs.git.listCommits', 'dev.pdsjs.git.getCommit', 'dev.pdsjs.git.getLastCommit', ]), ); const client = new GitClient({ service: SERVICE, fetch: carrier }); expect(await client.servesReads()).toBe(true); }); it('encodes the parameters a read names and leaves the empty ones out', async () => { const { calls, carrier } = fakeFetch(() => Response.json({ ref: 'refs/heads/main', sha: 'a', path: '', entries: [], }), ); const client = new GitClient({ service: SERVICE, fetch: carrier, headers: { Authorization: 'Bearer t' }, }); const tree = await client.getTree({ repo: 'proj', ref: undefined, path: '', cid: 'bafyrepo', }); expect(tree.entries).toEqual([]); expect(calls[0].url.pathname).toBe('/xrpc/dev.pdsjs.git.getTree'); expect([...calls[0].url.searchParams]).toEqual([ ['repo', 'proj'], ['cid', 'bafyrepo'], ]); const sent = /** @type {Record} */ ( calls[0].init?.headers ?? {} ); expect(sent.Authorization).toBe('Bearer t'); await client.listCommits({ repo: 'proj', ref: 'main', limit: 20 }); expect(calls[1].url.searchParams.get('limit')).toBe('20'); }); it('throws the name a refusal carries', async () => { const { carrier } = fakeFetch(() => Response.json( { error: 'RepoNotFound', message: 'No repository named x.' }, { status: 404 }, ), ); const client = new GitClient({ service: SERVICE, fetch: carrier }); const failure = await client.getRepo({ repo: 'x' }).catch((err) => err); expect(failure).toBeInstanceOf(XrpcError); expect(failure.status).toBe(404); expect(failure.error).toBe('RepoNotFound'); expect(failure.message).toBe('No repository named x.'); }); it('names an unserved method from a bare 501', async () => { const { carrier } = fakeFetch(() => new Response(null, { status: 501 })); const client = new GitClient({ service: SERVICE, fetch: carrier }); const failure = await client.getTree({ repo: 'x' }).catch((err) => err); expect(failure.error).toBe('MethodNotImplemented'); }); it('answers raw bytes as the response itself', async () => { const { carrier } = fakeFetch( () => new Response('hi', { headers: { 'Content-Type': 'text/plain' } }), ); const client = new GitClient({ service: SERVICE, fetch: carrier }); const res = await client.getRaw({ repo: 'proj', path: 'a.txt' }); expect(await res.text()).toBe('hi'); }); it('posts a procedure as JSON', async () => { const { calls, carrier } = fakeFetch(() => Response.json({ name: 'proj', space: null }), ); const client = new GitClient({ service: SERVICE, fetch: carrier }); expect( await client.createRepo({ name: 'proj', description: 'A project.' }), ).toEqual({ name: 'proj', space: null, }); expect(calls[0].init?.method).toBe('POST'); expect(calls[0].init?.body).toBe( '{"name":"proj","description":"A project."}', ); const sent = /** @type {Record} */ ( calls[0].init?.headers ?? {} ); expect(sent['Content-Type']).toBe('application/json'); }); });