import { PersonalDataServer } from '@pdsjs/core/pds'; import { describe, expect, it } from 'vitest'; import { createMemoryBlobContext, createMemorySpaceStorage, } from '../src/memory-storage.js'; import { createSpaceRoutes } from '../src/routes.js'; const AUTHORITY = 'did:plc:aaaa'; const SPACE = `at://${AUTHORITY}/space/com.example.forum/general`; const DID = 'did:plc:writer'; const COL = 'com.example.post'; // A space is reachable only from a scope naming it, so these carry the grant a // real client would hold rather than a blanket one. const AUTH = { did: DID, scope: `space:com.example.forum?authority=${AUTHORITY}&collection=${COL}`, }; function setup() { const spaceStorage = createMemorySpaceStorage(); return { spaceStorage, routes: createSpaceRoutes({ spaceStorage, ...createMemoryBlobContext(), getDid: async () => DID, getKemKey: async () => null, getSigner: async () => ({ sign: async () => new Uint8Array(64) }), resolveDid: async () => null, verifier: { verify: async () => false }, }), }; } /** * Invoke a route the way PersonalDataServer.handleRequest does. * @param {any} route * @param {any} body * @param {{auth?: any, query?: string}} [opts] */ function call(route, body, opts = {}) { const url = new URL( `https://pds.example/xrpc/x${opts.query ? `?${opts.query}` : ''}`, ); const request = new Request(url, { method: body === undefined ? 'GET' : 'POST', ...(body === undefined ? {} : { body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, }), }); return route.handler(request, url, 'auth' in opts ? opts.auth : AUTH); } describe('space route registration', () => { it('is absent unless space routes are supplied', () => { const pds = new PersonalDataServer(/** @type {any} */ ({})); expect(pds.findRoute('/xrpc/com.atproto.space.createRecord')).toBeNull(); // The existing table is untouched. expect(pds.findRoute('/xrpc/com.atproto.repo.createRecord')).not.toBeNull(); }); it('merges in when supplied, without displacing existing routes', () => { const { routes } = setup(); const pds = new PersonalDataServer( /** @type {any} */ ({ extensions: [() => ({ name: 'spaces', routes })], }), ); expect( pds.findRoute('/xrpc/com.atproto.space.createRecord'), ).not.toBeNull(); expect(pds.findRoute('/xrpc/com.atproto.repo.createRecord')).not.toBeNull(); }); it('does not leak the merged table into other instances', () => { const { routes } = setup(); new PersonalDataServer( /** @type {any} */ ({ extensions: [() => ({ name: 'spaces', routes })], }), ); const plain = new PersonalDataServer(/** @type {any} */ ({})); expect(plain.findRoute('/xrpc/com.atproto.space.createRecord')).toBeNull(); }); it('requires a storage port', () => { expect(() => createSpaceRoutes(/** @type {any} */ ({}))).toThrow( /spaceStorage/, ); expect(() => createSpaceRoutes(/** @type {any} */ ({ spaceStorage: {} })), ).toThrow(/getDid/); }); }); describe('createRecord', () => { it('creates a record and returns its space URI', async () => { const { routes, spaceStorage } = setup(); const res = await call(routes['/xrpc/com.atproto.space.createRecord'], { space: SPACE, repo: DID, collection: COL, rkey: 'a', record: { $type: COL, text: 'hi' }, }); expect(res.status).toBe(200); const body = await res.json(); expect(body.uri).toBe(`${SPACE}/${DID}/${COL}/a`); expect(body.cid).toMatch(/^b[a-z2-7]+$/); const stored = await spaceStorage.getSpaceRecord(SPACE, COL, 'a'); expect(stored?.cid).toBe(body.cid); }); it('generates an rkey when none is given', async () => { const { routes } = setup(); const res = await call(routes['/xrpc/com.atproto.space.createRecord'], { space: SPACE, repo: DID, collection: COL, record: { $type: COL }, }); const body = await res.json(); expect(body.uri.startsWith(`${SPACE}/${DID}/${COL}/`)).toBe(true); expect(body.uri.split('/').pop()).toHaveLength(13); }); it('refuses to write another account’s repo', async () => { const { routes } = setup(); const res = await call(routes['/xrpc/com.atproto.space.createRecord'], { space: SPACE, repo: 'did:plc:someone-else', collection: COL, record: {}, }); expect(res.status).toBe(403); expect((await res.json()).error).toBe('Forbidden'); }); it('rejects a non-space URI as a request error, not a 500', async () => { const { routes } = setup(); const res = await call(routes['/xrpc/com.atproto.space.createRecord'], { space: 'at://did:plc:aaaa/com.example.post/a', repo: DID, collection: COL, record: {}, }); expect(res.status).toBe(400); expect((await res.json()).error).toBe('InvalidSpaceUri'); }); it('surfaces a duplicate create as RecordAlreadyExists', async () => { const { routes } = setup(); const input = { space: SPACE, repo: DID, collection: COL, rkey: 'a', record: {}, }; await call(routes['/xrpc/com.atproto.space.createRecord'], input); const res = await call( routes['/xrpc/com.atproto.space.createRecord'], input, ); expect(res.status).toBe(400); expect((await res.json()).error).toBe('RecordAlreadyExists'); }); it('rejects a malformed body', async () => { const { routes } = setup(); const url = new URL('https://pds.example/xrpc/x'); const handler = /** @type {any} */ ( routes['/xrpc/com.atproto.space.createRecord'] ).handler; const res = await handler( new Request(url, { method: 'POST', body: 'not json' }), url, AUTH, ); expect(res.status).toBe(400); }); }); describe('putRecord and deleteRecord', () => { it('put creates then updates', async () => { const { routes } = setup(); const input = (/** @type {number} */ v) => ({ space: SPACE, repo: DID, collection: COL, rkey: 'a', record: { v }, }); const first = await ( await call(routes['/xrpc/com.atproto.space.putRecord'], input(1)) ).json(); const second = await ( await call(routes['/xrpc/com.atproto.space.putRecord'], input(2)) ).json(); expect(second.cid).not.toBe(first.cid); }); it('delete removes the record', async () => { const { routes, spaceStorage } = setup(); await call(routes['/xrpc/com.atproto.space.createRecord'], { space: SPACE, repo: DID, collection: COL, rkey: 'a', record: {}, }); const res = await call(routes['/xrpc/com.atproto.space.deleteRecord'], { space: SPACE, repo: DID, collection: COL, rkey: 'a', }); expect(res.status).toBe(200); expect(await spaceStorage.getSpaceRecord(SPACE, COL, 'a')).toBeNull(); }); it('deleting a missing record is RecordNotFound', async () => { const { routes } = setup(); const res = await call(routes['/xrpc/com.atproto.space.deleteRecord'], { space: SPACE, repo: DID, collection: COL, rkey: 'nope', }); expect(res.status).toBe(400); expect((await res.json()).error).toBe('RecordNotFound'); }); }); describe('applyWrites', () => { it('applies a batch as one commit and returns typed results', async () => { const { routes, spaceStorage } = setup(); const res = await call(routes['/xrpc/com.atproto.space.applyWrites'], { space: SPACE, repo: DID, writes: [ { $type: 'com.atproto.space.applyWrites#create', collection: COL, rkey: 'a', value: { v: 1 }, }, { $type: 'com.atproto.space.applyWrites#create', collection: COL, rkey: 'b', value: { v: 2 }, }, ], }); expect(res.status).toBe(200); const body = await res.json(); expect(body.results.map((/** @type {any} */ r) => r.$type)).toEqual([ 'com.atproto.space.applyWrites#createResult', 'com.atproto.space.applyWrites#createResult', ]); const ops = await spaceStorage.listSpaceOps(SPACE, null, 50); expect(new Set(ops.map((o) => o.rev)).size).toBe(1); }); it('emits a bare deleteResult', async () => { const { routes } = setup(); await call(routes['/xrpc/com.atproto.space.createRecord'], { space: SPACE, repo: DID, collection: COL, rkey: 'a', record: {}, }); const res = await call(routes['/xrpc/com.atproto.space.applyWrites'], { space: SPACE, repo: DID, writes: [ { $type: 'com.atproto.space.applyWrites#delete', collection: COL, rkey: 'a', }, ], }); expect((await res.json()).results).toEqual([ { $type: 'com.atproto.space.applyWrites#deleteResult' }, ]); }); it('rejects an unrecognised write type', async () => { const { routes } = setup(); const res = await call(routes['/xrpc/com.atproto.space.applyWrites'], { space: SPACE, repo: DID, writes: [{ $type: 'com.example.nope', collection: COL, rkey: 'a' }], }); expect(res.status).toBe(400); }); }); describe('listSpaces', () => { it('lists spaces the user has written to', async () => { const { routes } = setup(); await call(routes['/xrpc/com.atproto.space.createRecord'], { space: SPACE, repo: DID, collection: COL, record: {}, }); const res = await call( routes['/xrpc/com.atproto.space.listSpaces'], undefined, ); const body = await res.json(); expect(body.spaces).toEqual([{ uri: SPACE, isOwner: false }]); }); it('filters by space type', async () => { const { routes } = setup(); await call(routes['/xrpc/com.atproto.space.createRecord'], { space: SPACE, repo: DID, collection: COL, record: {}, }); const miss = await ( await call(routes['/xrpc/com.atproto.space.listSpaces'], undefined, { query: 'spaceType=com.other.kind', }) ).json(); expect(miss.spaces).toEqual([]); const hit = await ( await call(routes['/xrpc/com.atproto.space.listSpaces'], undefined, { query: 'spaceType=com.example.forum', }) ).json(); expect(hit.spaces).toHaveLength(1); }); it('filters by space type under its earlier name too', async () => { const { routes } = setup(); await call(routes['/xrpc/com.atproto.space.createRecord'], { space: SPACE, repo: DID, collection: COL, record: {}, }); const miss = await ( await call(routes['/xrpc/com.atproto.space.listSpaces'], undefined, { query: 'type=com.other.kind', }) ).json(); expect(miss.spaces).toEqual([]); const hit = await ( await call(routes['/xrpc/com.atproto.space.listSpaces'], undefined, { query: 'type=com.example.forum', }) ).json(); expect(hit.spaces).toHaveLength(1); }); it('leaves out a deleted space', async () => { const { routes, spaceStorage } = setup(); await call(routes['/xrpc/com.atproto.space.createRecord'], { space: SPACE, repo: DID, collection: COL, record: {}, }); await spaceStorage.deleteSpace(SPACE, '2026-11-01T00:00:00.000Z'); const res = await call( routes['/xrpc/com.atproto.space.listSpaces'], undefined, ); expect((await res.json()).spaces).toEqual([]); }); it('rejects an out-of-range limit', async () => { const { routes } = setup(); const res = await call( routes['/xrpc/com.atproto.space.listSpaces'], undefined, { query: 'limit=0' }, ); expect(res.status).toBe(400); }); });