// community.lexicon.service.describe is the only way a client learns whether an // optional feature is served here. atproto publishes no method list, so without // this a caller has to invoke a space method and read the failure, where an // absent feature and a broken one look the same. import { describe, expect, it } from 'vitest'; import { PersonalDataServer } from '../packages/core/src/pds.js'; const PATH = '/xrpc/community.lexicon.service.describe'; /** @param {import('../packages/core/src/pds.js').Routes} [spaceRoutes] */ function createPds(spaceRoutes) { // Nothing here reaches storage, so the ports can be bare stubs. return new PersonalDataServer({ actorStorage: /** @type {any} */ ({}), sharedStorage: /** @type {any} */ ({}), blobs: /** @type {any} */ ({}), jwtSecret: 'test-secret', extensions: spaceRoutes ? [() => ({ name: 'spaces', routes: spaceRoutes })] : undefined, }); } /** @param {PersonalDataServer} pds */ async function describeBody(pds) { const response = await pds.fetch( new Request(`https://pds.example.com${PATH}`), ); expect(response.status).toBe(200); return response.json(); } describe('community.lexicon.service.describe', () => { it('answers unauthenticated, naming the pds role', async () => { const body = await describeBody(createPds()); expect(body.roles).toEqual(['pds']); }); it('names each method with its union member', async () => { const body = await describeBody(createPds()); for (const entry of body.methods) { expect(entry.$type).toBe('community.lexicon.service.describe#nsid'); expect(entry.value).toMatch(/^[a-z][a-z0-9.]*\.[a-zA-Z]+$/); } }); it('describes exactly the routed methods, sorted', async () => { const pds = createPds(); const body = await describeBody(pds); const routed = Object.keys(pds.routes) .filter((p) => p.startsWith('/xrpc/')) .map((p) => p.slice('/xrpc/'.length)) .filter((n) => n.includes('.')) .sort(); expect(body.methods.map((/** @type {any} */ m) => m.value)).toEqual(routed); }); it('describes itself', async () => { const body = await describeBody(createPds()); expect(body.methods.map((/** @type {any} */ m) => m.value)).toContain( 'community.lexicon.service.describe', ); }); it('omits _health, which is an endpoint rather than a method', async () => { const body = await describeBody(createPds()); const values = body.methods.map((/** @type {any} */ m) => m.value); expect(values).not.toContain('_health'); expect(values.some((/** @type {string} */ v) => v.startsWith('_'))).toBe( false, ); }); it('omits the proxied app.bsky methods it does not route', async () => { const body = await describeBody(createPds()); const values = body.methods.map((/** @type {any} */ m) => m.value); // Served locally, so declared. expect(values).toContain('app.bsky.actor.getPreferences'); // Forwarded to the AppView; claiming it would describe the AppView. expect(values).not.toContain('app.bsky.feed.getTimeline'); }); it('names no space method while spaces are off', async () => { const body = await describeBody(createPds()); const values = body.methods.map((/** @type {any} */ m) => m.value); expect(values.some((/** @type {string} */ v) => v.includes('space'))).toBe( false, ); }); it('names the space methods once spaces are on', async () => { const body = await describeBody( createPds( /** @type {any} */ ({ '/xrpc/com.atproto.space.listSpaces': { handler: async () => new Response(), }, '/xrpc/com.atproto.space.createSpace': { handler: async () => new Response(), }, }), ), ); const values = body.methods.map((/** @type {any} */ m) => m.value); expect(values).toContain('com.atproto.space.listSpaces'); expect(values).toContain('com.atproto.space.createSpace'); }); });