OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room / test / repo-base.test.js

73 lines · javascript ref:
import '../lib/bare-globals.js'
// RepoBase over an injected StateBaseV3-compatible KV log (mocked in-memory). Proves the repo-op
// convention: collection/rkey keying, put/get/delete round-trip, list + snapshot, reserved-key
// skipping, and input guards. No autobase/Bare needed — the substrate is injected.

import test from 'brittle'
import { RepoBase, recordKey, splitKey } from '../lib/repo-base.js'

// Minimal StateBaseV3-shaped KV log: async set/del/get/list, values are objects (StateBaseV3
// JSON-encodes internally but get/list return decoded objects — mirror that).
function mockStateBase () {
  const m = new Map()
  return {
    m,
    async set (k, v) { m.set(k, v) },
    async del (k) { m.delete(k) },
    async get (k) { return m.has(k) ? m.get(k) : null },
    async list (prefix) { const o = {}; for (const [k, v] of m) if (k.startsWith(prefix)) o[k] = v; return o }
  }
}

const POST = 'app.bsky.feed.post'
const PROFILE = 'app.bsky.actor.profile'

test('key helpers: collection/rkey ↔ key', (t) => {
  t.is(recordKey(POST, '3abc'), POST + '/3abc')
  t.alike(splitKey(POST + '/3abc'), { collection: POST, rkey: '3abc' })
  t.is(splitKey('__members__'), null, 'reserved (no separator) → null')
  // NSID keeps its dots; only the FIRST slash separates
  t.alike(splitKey('app.bsky.feed.post/3a'), { collection: 'app.bsky.feed.post', rkey: '3a' })
})

test('put → get → delete round-trip', async (t) => {
  const rb = new RepoBase(mockStateBase(), { did: 'did:plc:test' })
  const rec = { $type: POST, text: 'hello', createdAt: '2026-07-04T00:00:00.000Z' }
  t.alike(await rb.putRecord(POST, '3abc', rec), { collection: POST, rkey: '3abc' })
  t.alike(await rb.getRecord(POST, '3abc'), rec, 'record round-trips')
  t.is(await rb.getRecord(POST, 'nope'), null, 'missing → null')
  await rb.deleteRecord(POST, '3abc')
  t.is(await rb.getRecord(POST, '3abc'), null, 'deleted → null')
})

test('listRecords + snapshot; scope by collection; skip reserved keys', async (t) => {
  const sb = mockStateBase()
  // a reserved StateBase key that is NOT a repo record — must be skipped by list/snapshot
  await sb.set('__members__', { some: 'roster' })
  const rb = new RepoBase(sb)
  await rb.putRecord(POST, '3a', { $type: POST, text: 'a' })
  await rb.putRecord(POST, '3b', { $type: POST, text: 'b' })
  await rb.putRecord(PROFILE, 'self', { $type: PROFILE, displayName: 'x' })

  const all = await rb.listRecords()
  t.is(all.length, 3, 'lists 3 records, __members__ skipped')
  t.absent(all.some(r => r.collection === '__members__'), 'reserved key not surfaced')

  const posts = await rb.listRecords(POST)
  t.is(posts.length, 2, 'scoped to the post collection')
  t.ok(posts.every(r => r.collection === POST), 'only posts')

  const snap = await rb.snapshot()
  t.is(Object.keys(snap[POST]).length, 2, 'snapshot groups posts by rkey')
  t.is(snap[PROFILE].self.displayName, 'x', 'snapshot nests collection/rkey/record')
  t.absent(snap.__members__, 'snapshot excludes reserved keys')
})

test('input guards', async (t) => {
  const rb = new RepoBase(mockStateBase())
  await t.exception(rb.putRecord('a/b', 'r', { $type: 'x' }), /must not contain/, 'collection with "/" rejected')
  await t.exception(rb.putRecord(POST, 'r/x', { $type: 'x' }), /must not contain/, 'rkey with "/" rejected')
  await t.exception(rb.putRecord(POST, '3a', 'not-an-object'), /must be an object/, 'non-object record rejected')
  t.exception(() => new RepoBase(null), /statebase/, 'missing statebase rejected')
})