// network followers — DESIGN §7.6 Tier C: referenced, never retained.
// Run: node test/followers.test.mjs
//
// The two assertions that matter are the ones about honesty rather than mechanics: network and local
// counts must stay SEPARATE (different denominators — a sum means nothing), and an unverified handle
// must not render as a name. A follower list is precisely where impersonation would pay.
import { createFollowerLens } from '../lib/followers.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
const WJ62 = 'did:plc:wj62ebbusln6gxo5azo6ogzo'
const FLEEKY = 'did:plc:y5caotnrle4kxlfez4azaf33'
const SOURCE = 'app.bsky.graph.follow:subject'
const stubConstellation = ({ count = 4, dids = [FLEEKY, 'did:plc:aaa', 'did:plc:bbb'], calls = {} } = {}) => ({
async countBacklinks (subject, source) { calls.count = { subject, source }; return count },
async getBacklinks (subject, source, opts) {
calls.list = { subject, source, opts }
return { total: count, pointers: dids.map((d) => ({ did: d, collection: 'app.bsky.graph.follow', rkey: 'rk' })), cursor: null }
}
})
const stubHandles = (map) => ({ async resolve (did) { return map[did] || null } })
// ── the query shape ──────────────────────────────────────────────────────────────────────────────
{
const calls = {}
const lens = createFollowerLens({ constellation: stubConstellation({ calls }) })
const r = await lens.followersOf(WJ62)
ok(calls.count.source === SOURCE && calls.list.source === SOURCE,
'followers come from app.bsky.graph.follow:subject — the follow record AUTHOR is the follower')
ok(calls.count.subject === WJ62, 'subject is the DID being followed')
ok(r.count === 4, 'the count is the NETWORK total from the index, not what we happen to index locally')
ok(r.followers.length === 3 && r.shown === 3, 'and the resolved list is the displayable prefix')
ok(r.truncated === true, 'truncation is stated, so the UI never implies it is showing everyone')
}
{
const lens = createFollowerLens({ constellation: stubConstellation({ count: 3, dids: [FLEEKY, 'did:plc:aaa', 'did:plc:bbb'] }) })
ok((await lens.followersOf(WJ62)).truncated === false, 'a complete list is not marked truncated')
}
// ── handles are VERIFIED, never claimed ──────────────────────────────────────────────────────────
{
const lens = createFollowerLens({
constellation: stubConstellation({ dids: [FLEEKY, 'did:plc:impostor'] }),
handles: stubHandles({
[FLEEKY]: { handle: 'fleeky.bsky.social', verified: true },
'did:plc:impostor': { handle: 'fleeky.bsky.social', verified: false } // claims the same handle
})
})
const r = await lens.followersOf(WJ62)
const real = r.followers.find((f) => f.did === FLEEKY)
const fake = r.followers.find((f) => f.did === 'did:plc:impostor')
ok(real.handle === 'fleeky.bsky.social' && real.verified === true, 'a verified follower shows its handle')
ok(fake.handle === null && fake.verified === false,
'an UNVERIFIED claim of the same handle shows no handle at all — a follower list is exactly where impersonation would pay')
}
{
const lens = createFollowerLens({ constellation: stubConstellation({ dids: [FLEEKY] }) })
const r = await lens.followersOf(WJ62)
ok(r.followers[0].handle === null, 'with no resolver wired, DIDs render unresolved rather than as a raw claim')
}
// ── dedup + fail-soft ────────────────────────────────────────────────────────────────────────────
{
const lens = createFollowerLens({ constellation: stubConstellation({ count: 2, dids: [FLEEKY, FLEEKY] }) })
ok((await lens.followersOf(WJ62)).followers.length === 1, 'one DID following twice is one follower')
}
{
const lens = createFollowerLens({ constellation: { async countBacklinks () { throw new Error('down') }, async getBacklinks () { return null } } })
ok((await lens.followersOf(WJ62)) === null, 'an index failure is null — a follower list must never break a profile page')
}
{
const lens = createFollowerLens({ constellation: stubConstellation() })
ok((await lens.followersOf('not-a-did')) === null, 'non-DIDs are refused')
}
{
const lens = createFollowerLens({
constellation: stubConstellation(),
handles: { async resolve () { throw new Error('plc down') } }
})
const r = await lens.followersOf(WJ62)
ok(r !== null && r.followers.every((f) => f.handle === null),
'a handle-resolution failure degrades to an unresolved DID, it does not lose the follower')
}
// ── the render path must not hang (measured: 24 serial resolutions took >90s) ────────────────────
{
const slow = { async resolve () { await new Promise((r) => setTimeout(r, 5000)); return { handle: 'never.seen', verified: true } } }
const many = Array.from({ length: 20 }, (_, i) => `did:plc:slow${i}`)
const lens = createFollowerLens({
constellation: stubConstellation({ count: 20, dids: many }),
handles: slow,
deadlineMs: 60,
concurrency: 8
})
const t0 = Date.now()
const r = await lens.followersOf(WJ62)
const ms = Date.now() - t0
ok(ms < 1500, `resolution is deadline-bounded (${ms}ms) — a slow host must delay one NAME, never the page`)
ok(r.followers.length === 20, 'every follower is still returned')
ok(r.followers.every((f) => f.handle === null), 'unresolved ones render as DIDs rather than blocking')
}
// ── caching ──────────────────────────────────────────────────────────────────────────────────────
{
let n = 0
const lens = createFollowerLens({
constellation: { async countBacklinks () { n++; return 1 }, async getBacklinks () { return { total: 1, pointers: [{ did: FLEEKY }], cursor: null } } }
})
await lens.followersOf(WJ62); await lens.followersOf(WJ62)
ok(n === 1, 'reference data is cached — a profile render must not re-query the index every time')
}
{
let n = 0
const lens = createFollowerLens({
constellation: { async countBacklinks () { n++; throw new Error('x') }, async getBacklinks () { return null } }
})
await lens.followersOf(WJ62); await lens.followersOf(WJ62)
ok(n === 1, 'and negatives are cached briefly so an outage cannot be amplified into a query storm')
}
// ── NOTHING IS RETAINED ──────────────────────────────────────────────────────────────────────────
{
// The lens is constructed with no store, no index and no write hook of any kind — that is the point.
// A read-only path cannot corrupt hpp's canonical records, which is not a theoretical property.
const lens = createFollowerLens({ constellation: stubConstellation() })
ok(typeof lens.followersOf === 'function' && typeof lens.countOf === 'function', 'the surface is read-only: two queries, no writes')
ok(await lens.countOf(WJ62) === 4, 'countOf answers from the same cached result')
}
console.log(`\nFOLLOWERS OK: ${pass} assertions`)