// favela-web feed reactor — reconcile the SERVED feed set to the FeedRegistry's entries (community-ux.md
// §120). Pure, injected stubs (a fake feedgen + a recording wireRoster). Run: node test/feed-reactor.test.mjs
import { makeFeedReactor } from '../lib/feed-reactor.js'
const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
// minimal feedgen stub with the same (communityDid, rkey) keying as lib/feedgen.js
function fakeFeedgen () {
const m = new Map()
const k = (d, r) => `${d}\x00${r}`
return {
added: [], removed: [],
addFeed ({ communityDid, rkey, roomKey, displayName }) { m.set(k(communityDid, rkey), { communityDid, rkey, roomKey, displayName }); this.added.push(`${communityDid}/${rkey}`) },
removeFeed (communityDid, rkey) { this.removed.push(`${communityDid}/${rkey}`); return m.delete(k(communityDid, rkey)) },
has (d, r) { return m.has(k(d, r)) },
get feeds () { return [...m.values()] }
}
}
const A = 'did:plc:aaaa2222222222222222222222', B = 'did:plc:bbbb2222222222222222222222'
const ROOM_A = 'a'.repeat(64), ROOM_B = 'b'.repeat(64)
const REG1 = '1'.repeat(64), REG2 = '2'.repeat(64)
const entry = (did, rkey, roomKey, name) => ({ did, rkey, roomKey, name, curator: 'roster', params: {} })
// ── add: a roster feed entry → served + roster wired ──
{
const fg = fakeFeedgen()
const wired = []
const react = makeFeedReactor({ feedgen: fg, wireRoster: async (id, rk) => wired.push([id, rk]), log: () => {} })
await react(REG1, [entry(A, 'loser-club', ROOM_A, 'loser club')])
assert(fg.has(A, 'loser-club'), 'add-feed → feedgen serves the feed')
assert(wired.length === 1 && wired[0][1] === ROOM_A, 'add-feed → roster wired once (index + scope)')
assert(react.served().length === 1 && react.served()[0].did === A, 'served() reports the feed')
// re-fire, unchanged → idempotent (no duplicate roster wiring, still served)
await react(REG1, [entry(A, 'loser-club', ROOM_A, 'loser club')])
assert(wired.length === 1, 're-fire unchanged → roster NOT re-wired (idempotent per room)')
assert(fg.has(A, 'loser-club'), 're-fire unchanged → still served')
}
// ── light feed (no room) → skipped, reported to onLight ──
{
const fg = fakeFeedgen()
const light = []
const react = makeFeedReactor({ feedgen: fg, onLight: (e) => light.push(e.rkey) })
await react(REG1, [{ did: A, rkey: 'my-tag', curator: 'subject', params: { tag: 'p2p' } }]) // no roomKey
assert(!fg.has(A, 'my-tag'), 'light feed (no room) NOT served (roster-first)')
assert(light.length === 1 && light[0] === 'my-tag', 'light feed reported to onLight')
}
// ── remove: an entry that vanishes → de-served ──
{
const fg = fakeFeedgen()
const react = makeFeedReactor({ feedgen: fg })
await react(REG1, [entry(A, 'loser-club', ROOM_A), entry(B, 'pets', ROOM_B)])
assert(fg.has(A, 'loser-club') && fg.has(B, 'pets'), 'both feeds served')
await react(REG1, [entry(A, 'loser-club', ROOM_A)]) // pets removed from the registry
assert(fg.has(A, 'loser-club') && !fg.has(B, 'pets'), 'remove-feed → the vanished feed is de-served')
assert(fg.removed.includes(`${B}/pets`), 'feedgen.removeFeed was called for the removed feed')
}
// ── multi-registry: a feed listed in TWO registries stays served until BOTH drop it ──
{
const fg = fakeFeedgen()
const react = makeFeedReactor({ feedgen: fg })
await react(REG1, [entry(A, 'loser-club', ROOM_A)])
await react(REG2, [entry(A, 'loser-club', ROOM_A)]) // same feed, second allow-set
assert(react.served()[0].registries.length === 2, 'feed is claimed by both registries')
await react(REG1, []) // REG1 drops it — but REG2 still lists it
assert(fg.has(A, 'loser-club'), 'still served while ONE registry lists it (no premature de-serve)')
await react(REG2, []) // REG2 drops it too → now de-served
assert(!fg.has(A, 'loser-club'), 'de-served only after the LAST registry drops it')
}
console.log('\nFEED-REACTOR OK: add serves + wires roster (idempotent), light feeds skipped, remove de-serves, multi-registry ref-counted')