// 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')
}