OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / followers.test.mjs

133 lines · javascript ref:
// 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`)