OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / lists.test.mjs

99 lines · javascript ref:
// User lists — app.bsky.graph.list + listitem. Run: node test/lists.test.mjs
//
// The shape is the point: a list is a NAME, and membership lives in SEPARATE listitem records. So a list
// of 500 people is 501 records, and removing someone deletes THEIR listitem rather than editing the list.
import { listRecord, listitemRecord, isModlist, describePurpose, listUri, ownLists, membersOf, memberRkey, CURATE, MODLIST, subscriptionRkey, missingMembers } from '../lib/lists.js'

let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log('  ok -', m); pass++ }

// ── records ──────────────────────────────────────────────────────────────────────────────────────
const l = listRecord({ name: 'boats', description: 'people who post about boats' })
ok(l.$type === 'app.bsky.graph.list', 'a list record is typed')
ok(l.purpose === CURATE, 'and defaults to a CURATION list — never a modlist by accident')
ok(listRecord({ name: '   ' }) === null, 'a list needs a real name')
ok(listRecord({ name: 'x', purpose: 'nonsense' }).purpose === CURATE, 'an unknown purpose falls back to curation, not moderation')
// PURPOSE IS LOAD-BEARING: a modlist mutes or blocks everyone on it for subscribers.
const ml = listRecord({ name: 'bad actors', purpose: MODLIST })
ok(isModlist(ml) === true, 'a modlist is identifiable')
ok(describePurpose(ml).includes('mutes or blocks'), 'and describes its consequence, not just its name')
ok(describePurpose(l).includes('curation'), 'a curation list says what it is')

const li = listitemRecord({ subject: 'did:plc:bob', list: 'at://did:plc:me/app.bsky.graph.list/abc' })
ok(li.subject === 'did:plc:bob' && li.list.startsWith('at://'), 'a listitem binds a subject to a list')
ok(listitemRecord({ subject: 'notadid', list: 'at://x' }) === null, 'a non-did subject is refused')
ok(listitemRecord({ subject: 'did:plc:bob', list: 'notauri' }) === null, 'and a non-uri list')
ok(listUri('did:plc:me', 'abc') === 'at://did:plc:me/app.bsky.graph.list/abc', 'list uris are built consistently')

// ── reading, against a fake store shaped like OverlayRepoStore ───────────────────────────────────
const LIST_URI = listUri('did:plc:me', 'r1')
const fake = {
  data: {
    'app.bsky.graph.list': [{ rkey: 'r1', record: { $type: 'app.bsky.graph.list', name: 'boats', purpose: CURATE } }],
    'app.bsky.graph.listitem': [
      { rkey: 'i1', record: { $type: 'app.bsky.graph.listitem', subject: 'did:plc:bob', list: LIST_URI } },
      { rkey: 'i2', record: { $type: 'app.bsky.graph.listitem', subject: 'did:plc:carol', list: LIST_URI } },
      // A listitem for a DIFFERENT list must not leak into this one's membership.
      { rkey: 'i3', record: { $type: 'app.bsky.graph.listitem', subject: 'did:plc:dave', list: listUri('did:plc:me', 'other') } }
    ]
  },
  async * list (c) { for (const e of (this.data[c] || [])) yield e }
}

const ls = await ownLists(fake, 'did:plc:me')
ok(ls.length === 1 && ls[0].uri === LIST_URI, 'own lists are read straight from the store — no index needed')

const mem = await membersOf(fake, LIST_URI)
ok(mem.length === 2, 'membership scans listitems for this list')
ok(mem.some((m) => m.subject === 'did:plc:bob') && mem.some((m) => m.subject === 'did:plc:carol'), 'and finds the right people')
ok(!mem.some((m) => m.subject === 'did:plc:dave'), "another list's members do not leak in")

// The rkey is what removal needs — the membership RECORD is the thing deleted.
ok((await memberRkey(fake, LIST_URI, 'did:plc:bob')) === 'i1', 'a member resolves to their listitem rkey')
ok((await memberRkey(fake, LIST_URI, 'did:plc:nobody')) === null, 'a non-member resolves to null')

// An empty store is empty, not an error.
const bare = { async * list () {} }
ok((await ownLists(bare, 'did:plc:me')).length === 0, 'no lists yet is not an error')
ok((await membersOf(bare, LIST_URI)).length === 0, 'nor is an empty list')

console.log(`\nLISTS OK: ${pass} assertions`)


// ── converging one identity's subscriptions across its devices ───────────────────────────────────
// These two decisions are why a handset and a server can merge into one list without a winner. The bug
// they encode was only reproducible on a phone (hyper-social #53), so it is pinned here instead.
{
  // A REAL hash, not a stub: the rkey is a TRUNCATION, so a stub that does not diffuse its input makes
  // this test pass or fail for reasons production never sees. (A first attempt used
  // 'deadbeef' + hex(subject) and collided, because every did:plc: shares a prefix.)
  const { createHash } = await import('node:crypto')
  const hash = (s) => createHash('sha256').update(s).digest('hex')
  const A = 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa'
  const B = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'
  const SELF = 'did:plc:sssssssssssssssssssssss'

  // ⛔ Deterministic: two devices adding the same subject must produce the SAME record, or one
  // membership becomes two listitems that reads dedup and the repo keeps for ever.
  ok(subscriptionRkey(A, hash) === subscriptionRkey(A, hash), 'the same subject always yields the same rkey')
  ok(subscriptionRkey(A, hash) !== subscriptionRkey(B, hash), 'different subjects do not collide')
  ok(/^[A-Za-z0-9.:_~-]+$/.test(subscriptionRkey(A, hash)), 'the rkey stays inside the atproto rkey charset')
  ok(subscriptionRkey(A, hash).length <= 27, 'and stays short')
  let threw = false
  try { subscriptionRkey('', hash) } catch { threw = true }
  ok(threw, 'an empty subject is refused rather than hashed into a real rkey')

  // ⛔ Union, never replace: this is what stops "who booted first" deciding the whole roster.
  ok(JSON.stringify(missingMembers({ local: [A, B], listed: [A], selfDid: SELF })) === JSON.stringify([B]),
    'only what the list lacks is contributed')
  ok(missingMembers({ local: [A], listed: [A], selfDid: SELF }).length === 0,
    'a node with nothing new writes nothing — steady state is zero writes')
  ok(missingMembers({ local: [SELF, A], listed: [], selfDid: SELF }).length === 1,
    'a hosted DID never lists itself')
  ok(missingMembers({ local: [A, A], listed: [], selfDid: SELF }).length === 1,
    'a repeated local entry is contributed once')
  ok(missingMembers({ local: ['not-a-did', A], listed: [], selfDid: SELF }).length === 1,
    'junk in the local roster is not written into the repo')
  ok(missingMembers({}).length === 0, 'no local roster, nothing to contribute')
}