// favela community registry — resolves a viewer DID to their per-viewer author scope (the union of
// member DIDs across the communities they belong to). Pure/offline: the roster SOURCE is injected, so
// this proves the scoping + caching + failure semantics without any P2P. Run: node test/communities.test.mjs
import { Communities } from '../lib/communities.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
const eq = (a, b, m) => ok(a === b, m + ` (got ${JSON.stringify(a)})`)
const setEq = (set, arr, m) => ok(set && set.size === arr.length && arr.every((x) => set.has(x)), m + ` (got ${set ? JSON.stringify([...set]) : 'null'})`)
// Two communities. A = {viewer1, alice}; B = {viewer2, bob, alice}. alice is in both.
const ROSTERS = {
A: ['did:plc:viewer1', 'did:plc:alice'],
B: ['did:plc:viewer2', 'did:plc:bob', 'did:plc:alice']
}
const communities = [{ id: 'A', listUri: 'at://.../A' }, { id: 'B', listUri: 'at://.../B' }]
// ── scoping ──
{
const calls = {}
const resolve = async (c) => { calls[c.id] = (calls[c.id] || 0) + 1; return ROSTERS[c.id] }
const cm = new Communities({ communities, resolve })
const s1 = await cm.scopeFor('did:plc:viewer1') // only in A → members(A)
setEq(s1, ROSTERS.A, 'viewer in one community → that community\'s roster')
const s2 = await cm.scopeFor('did:plc:viewer2') // only in B → members(B)
setEq(s2, ROSTERS.B, 'viewer in the other community → that roster')
const sAlice = await cm.scopeFor('did:plc:alice') // in A and B → UNION
setEq(sAlice, ['did:plc:viewer1', 'did:plc:alice', 'did:plc:viewer2', 'did:plc:bob'], 'viewer in TWO communities → the UNION of both rosters')
eq(await cm.scopeFor('did:plc:stranger'), null, 'a viewer in NO community → null (caller falls back to global)')
eq(await cm.scopeFor(null), null, 'anonymous (no did) → null')
const cf = await cm.communitiesFor('did:plc:alice')
eq(cf.length, 2, 'communitiesFor lists every community the viewer belongs to')
setEq(await cm.allMemberDids(), ['did:plc:viewer1', 'did:plc:alice', 'did:plc:viewer2', 'did:plc:bob'], 'allMemberDids = union across ALL communities (what the node indexes)')
}
// ── caching: resolve is called once per community within the TTL, then re-resolved after it ──
{
let now = 1000
const calls = {}
const resolve = async (c) => { calls[c.id] = (calls[c.id] || 0) + 1; return ROSTERS[c.id] }
const cm = new Communities({ communities, resolve, ttlMs: 100, now: () => now })
await cm.scopeFor('did:plc:viewer1')
await cm.scopeFor('did:plc:viewer2')
eq(calls.A, 1, 'community A resolved once (cached across requests within TTL)')
eq(calls.B, 1, 'community B resolved once')
now = 1201 // past the TTL
await cm.scopeFor('did:plc:viewer1')
eq(calls.A, 2, 'community A re-resolved after the TTL expires')
}
// ── stale-serve: a resolve failure after a good cache keeps serving the last-good roster ──
{
let now = 0
let fail = false
const resolve = async (c) => { if (fail) throw new Error('P2P down'); return ROSTERS[c.id] }
const cm = new Communities({ communities: [{ id: 'A', listUri: 'x' }], resolve, ttlMs: 10, now: () => now })
setEq(await cm.scopeFor('did:plc:viewer1'), ROSTERS.A, 'warm the cache')
now = 100; fail = true // TTL expired + source now failing
setEq(await cm.scopeFor('did:plc:viewer1'), ROSTERS.A, 'stale-serve: a resolve failure keeps the last-good roster')
}
// ── a community that never resolves (fails cold) never breaks the view ──
{
const resolve = async () => { throw new Error('never up') }
const cm = new Communities({ communities: [{ id: 'A', listUri: 'x' }], resolve })
eq(await cm.scopeFor('did:plc:viewer1'), null, 'a cold-failing community yields no scope (empty roster → viewer is in nothing)')
}
// ── no communities configured → always null (global view for everyone) ──
{
const cm = new Communities({ communities: [], resolve: async () => [] })
eq(await cm.scopeFor('did:plc:viewer1'), null, 'no configured communities → null scope')
}
// ── constructor guard ──
try { new Communities({ communities }); ok(false, 'missing resolve should throw') } catch (e) { ok(/resolve/.test(e.message), 'constructor requires a resolve function') }
console.log(`\nCOMMUNITIES OK: ${pass} assertions`)