// The member projector: a community's roster → `<feed>-member` labels (hyper-social #80 step 4).
//
// The gateway stays keyless: it STATES intents on the co-located labeler DID's hpp socket (label-intent,
// idempotent) and hpp signs them. On every roster change it asserts each member and negates each labelled
// DID that is no longer a member.
//
// ⛔ Two guards, each against a real way to strip every badge at once:
// - an EMPTY roster negates nothing. At boot the room has not replicated yet and reads empty; treating
// that as "everyone left" would negate every member.
// - the socket must serve the community's DID (get-state). A socket configured for the wrong DID would
// write another account's labels.
import test from 'brittle'
import { createMemberLabels } from '../lib/member-labels.js'
const LC = 'did:plc:wqnwulql7ndcz6xmtvaovl6u'
const WJ = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
const E2 = 'did:plc:e2jevhuurrhlm36ufk7fklpg'
const OLD = 'did:plc:oldmemberoldmemberoldmem'
// A fake hpp socket holding label intents, as label-intent/label-list answer them.
function fakeHpp ({ did = LC, intents = [] } = {}) {
const map = new Map(intents.map((i) => [i.uri + '|' + i.val, i]))
const calls = []
return {
calls,
map,
connect: async () => ({
call: async (method, p = {}) => {
calls.push([method, p])
if (method === 'get-state') return { did }
if (method === 'label-list') return { intents: [...map.values()], signed: map.size }
if (method === 'label-intent') {
const k = p.uri + '|' + p.val
const cur = map.get(k)
if (cur ? !!cur.neg === !!p.neg : p.neg) return { intent: cur || null, changed: false }
map.set(k, { uri: p.uri, val: p.val, neg: !!p.neg, cts: 'now' })
return { changed: true }
}
throw new Error('unknown method: ' + method)
},
close: async () => {}
})
}
}
const feed = (members, communityDid = LC) => ({ id: 'loser-club', communityDid, members })
const active = (hpp) => [...hpp.map.values()].filter((i) => !i.neg && i.val === 'loser-club-member').map((i) => i.uri).sort()
test('★ each member gets `<feed>-member`; a member who left is negated', async (t) => {
const hpp = fakeHpp({ intents: [{ uri: OLD, val: 'loser-club-member', neg: false }, { uri: WJ, val: 'spam', neg: false }] })
const p = createMemberLabels({ connect: hpp.connect, log: () => {} })
const r = await p.apply(feed([WJ, E2]))
t.alike(active(hpp), [E2, WJ].sort(), 'members labelled, the old member not')
t.is(hpp.map.get(OLD + '|loser-club-member').neg, true, 'the old member is negated, not deleted')
t.is(hpp.map.get(WJ + '|spam').neg, false, 'labels of other values are not touched')
t.alike({ added: r.added, negated: r.negated }, { added: 2, negated: 1 })
})
test('idempotent: the same roster again writes nothing', async (t) => {
const hpp = fakeHpp()
const p = createMemberLabels({ connect: hpp.connect, log: () => {} })
await p.apply(feed([WJ, E2]))
const r = await p.apply(feed([WJ, E2]))
t.alike({ added: r.added, negated: r.negated }, { added: 0, negated: 0 })
})
test('⛔ an empty roster negates nothing (the room has not replicated yet)', async (t) => {
const hpp = fakeHpp({ intents: [{ uri: WJ, val: 'loser-club-member', neg: false }] })
const p = createMemberLabels({ connect: hpp.connect, log: () => {} })
const r = await p.apply(feed([]))
t.is(r.skipped, 'empty roster')
t.alike(active(hpp), [WJ])
t.absent(hpp.calls.some(([m]) => m === 'label-intent'), 'no write at all')
})
test('⛔ a socket serving a different DID is refused — no write', async (t) => {
const hpp = fakeHpp({ did: 'did:plc:someoneelse' })
const p = createMemberLabels({ connect: hpp.connect, log: () => {} })
const r = await p.apply(feed([WJ]))
t.ok(/serves did:plc:someoneelse/.test(r.skipped), 'skipped, naming the DID it found')
t.absent(hpp.calls.some(([m]) => m === 'label-intent'))
t.ok(/someoneelse/.test(p.metrics().feeds['loser-club'].error), 'and the metrics say why')
})
test('no community DID yet: skipped, nothing called', async (t) => {
const hpp = fakeHpp()
const p = createMemberLabels({ connect: hpp.connect, log: () => {} })
const r = await p.apply(feed([WJ], null))
t.ok(r.skipped)
t.is(hpp.calls.length, 0)
})
test('the socket being down is reported, not thrown', async (t) => {
const p = createMemberLabels({ connect: async () => { throw new Error('connect ENOENT') }, log: () => {} })
const r = await p.apply(feed([WJ]))
t.ok(/ENOENT/.test(r.error))
t.ok(/ENOENT/.test(p.metrics().feeds['loser-club'].error))
})