OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge / test / project.test.js

280 lines · javascript ref:
// The projection pass, with no transport under it.
//
// ⛔ WHY IT IS EXTRACTED. This loop has to run in two places: on a desktop against unix sockets, and
// inside the handset worklet, because bsky records are authored and SIGNED next to the DID key and
// that key is on the phone. It lived inside index.js next to argv parsing, a socket path and
// process.exit, so the only way to run it on a phone was to write it a second time.
//
// These pin the behaviours that must survive the move — the ones where getting it wrong is silent.

import test from 'brittle'
import { projectCycle } from '../lib/project.js'
import { BskySink } from '../lib/bsky.js'
import { tidFor } from '../lib/tid.js'

const at = '2026-09-07T23:12:04.600Z'
const row = (name, body) => ({ name, body, createdAt: at })

function fakePeer ({ networks = ['bsky'], rows = [], profile = null, unknown = false, deleted = null } = {}) {
  return {
    // Only when given: a peer adapter without it (the desktop CLI's socket client) must still work.
    ...(deleted ? { ownDeleted: async () => deleted } : {}),
    reach: async () => (unknown ? { networks: [], credential: null, unknown: true } : { networks, credential: null }),
    ownPosts: async () => ({ feed: 'f'.repeat(64), rows }),
    call: async (cmd) => {
      if (cmd === 'profile') return profile
      return null
    }
  }
}

function fakeSink ({ has = [], haveNull = false, getFails = false } = {}) {
  const written = []
  const records = new Map()
  const client = {
    call: async (cmd, args = {}) => {
      if (cmd === 'get-state') {
        if (haveNull) throw new Error('cannot read')
        return { records: has.map((rkey) => ({ collection: 'app.bsky.feed.post', rkey })) }
      }
      if (cmd === 'apply-write') { written.push(args); records.set(args.collection + '/' + args.rkey, args.record); return { ok: true } }
      if (cmd === 'get-record') { if (getFails) throw new Error('cannot read'); return records.get(args.collection + '/' + args.rkey) || null }
      throw new Error('unexpected ' + cmd)
    }
  }
  return { sink: new BskySink(null, { connect: async () => client }), written, records }
}

test('★★★ consent gates every post — an identity with bsky off publishes nothing', async (t) => {
  const { sink, written } = fakeSink()
  const r = await projectCycle({
    peer: fakePeer({ networks: ['fediverse'], rows: [row('1788822724600', 'hello')] }),
    bsky: sink, send: true
  })
  t.is(r.withheld, 1, 'the post is withheld')
  t.is(r.sent, 0)
  t.is(written.length, 0, 'and nothing whatsoever was written')
})

test('★★★ an unreadable repo REFUSES a send rather than duplicating everything', async (t) => {
  // ⛔ `have()` answering null means "could not find out", which is NOT an empty repo. Treating the
  // two alike re-sends every post the repo already holds.
  const { sink, written } = fakeSink({ haveNull: true })
  const r = await projectCycle({
    peer: fakePeer({ rows: [row('1788822724600', 'hello')] }),
    bsky: sink, send: true
  })
  t.ok(r.refused, 'the run refuses: ' + r.refused)
  t.is(written.length, 0, 'and writes nothing')
})

test('★★ a post already in the repo is not sent twice', async (t) => {
  const name = '1788822724600'
  const { sink, written } = fakeSink({ has: [tidFor(name)] })
  const r = await projectCycle({ peer: fakePeer({ rows: [row(name, 'hello')] }), bsky: sink, send: true })
  t.is(r.skipped, 1, 'recognised as already there')
  t.is(r.sent, 0)
  t.is(written.filter((w) => w.collection === 'app.bsky.feed.post').length, 0, 'no post re-sent (the feed link may be written once)')
})

test('★★ a public post is sent, under a TID rkey', async (t) => {
  const name = '1788822724600'
  const { sink, written } = fakeSink()
  const r = await projectCycle({ peer: fakePeer({ rows: [row(name, 'hello')] }), bsky: sink, send: true })
  t.is(r.sent, 1)
  // The DID's feed-link record (net.favela.feed/self, I2a) is also written on this first pass; count POSTS.
  const posts = written.filter((w) => w.collection === 'app.bsky.feed.post')
  t.is(posts.length, 1)
  t.is(posts[0].collection, 'app.bsky.feed.post')
  t.is(posts[0].rkey, tidFor(name), 'the AppView silently drops posts whose rkey is not a TID')
  t.is(posts[0].record.text, 'hello')
})

test('★★ a dry run writes nothing but still reports what it would do', async (t) => {
  const { sink, written } = fakeSink()
  const r = await projectCycle({ peer: fakePeer({ rows: [row('1788822724600', 'hello')] }), bsky: sink, send: false })
  t.is(written.length, 0, 'nothing published')
  t.is(r.sent, 0)
})

test('★★★ a members room reaches nothing, even with bsky switched on', async (t) => {
  // The reach lattice, not the switch, is what makes this safe: consent says where the owner WANTS to
  // publish; the room says what may leave at all. A private room must not escape a switched-on network.
  const { sink, written } = fakeSink()
  const r = await projectCycle({
    peer: fakePeer({ networks: ['bsky'], rows: [row('1788822724600', 'secret')] }),
    bsky: sink, send: true, roomType: 'members'
  })
  t.is(r.withheld, 1, 'withheld by the room, not by the switch')
  t.is(written.length, 0, 'and nothing left the device')
})

test('★★ the profile is projected, and only when it differs', async (t) => {
  const { sink, written } = fakeSink()
  const peer = fakePeer({ rows: [], profile: { name: 'Fleeky', bio: 'hello' } })

  const first = await projectCycle({ peer, bsky: sink, send: true })
  t.ok(first.profile.done, 'written on the first pass')
  const profileWrites = written.filter((w) => w.collection === 'app.bsky.actor.profile')
  t.is(profileWrites.length, 1)
  t.is(profileWrites[0].record.displayName, 'Fleeky')

  const second = await projectCycle({ peer, bsky: sink, send: true })
  t.absent(second.profile.done, 'and NOT rewritten when it already matches — each write is a repo version')
})

test('★★★ edges are projected after the posts, and only with a resolver', async (t) => {
  // ⛔ AFTER, ALWAYS. A subject resolves only once the post it points at is in the repo, so edges
  // first would withhold every one on the first pass and quietly fix itself on the second.
  const name = '1788822724600'
  const { sink, written } = fakeSink()
  const peer = fakePeer({ rows: [row(name, 'hello')] })
  peer.call = async (cmd, args = {}) => {
    if (cmd === 'edges-by' && args.collection === 'reaction') {
      return [{ name: 'e1', collection: 'reaction', value: 'like', createdAt: '2026-09-07T00:00:00.000Z', target: { feed: 'f'.repeat(64), collection: 'post', name } }]
    }
    return cmd === 'profile' ? null : []
  }

  // Without a resolver nothing edge-shaped happens — the desktop CLI cannot compute a subject CID.
  const plain = await projectCycle({ peer, bsky: sink, send: true })
  t.absent(plain.edges && plain.edges.sent, 'no resolver, no edges')

  const SUBJ = { uri: 'at://did:plc:me/app.bsky.feed.post/3mv', cid: 'bafy1' }
  const r = await projectCycle({ peer, bsky: sink, send: true, resolveSubject: () => SUBJ })
  t.is(r.edges.sent, 1, 'the like was projected')

  const like = written.find((w) => w.collection === 'app.bsky.feed.like')
  t.ok(like, 'as an app.bsky.feed.like')
  t.alike(like.record.subject, SUBJ, 'with uri and cid')

  const post = written.find((w) => w.collection === 'app.bsky.feed.post')
  t.ok(post, 'and the post it points at went too')
})

test('★★★ an edge already in the repo is not written again', async (t) => {
  // ⛔ Each write is a repo version. A like nobody changed must not append one every pass.
  const name = '1788822724600'
  const { sink, written, records } = fakeSink()
  records.set('app.bsky.feed.like/' + tidFor('e1'), { $type: 'app.bsky.feed.like' })
  const peer = fakePeer({ rows: [] })
  peer.call = async (cmd, args = {}) => (cmd === 'edges-by' && args.collection === 'reaction')
    ? [{ name: 'e1', collection: 'reaction', value: 'like', createdAt: '2026-09-07T00:00:00.000Z', target: { feed: 'f'.repeat(64), collection: 'post', name } }]
    : (cmd === 'profile' ? null : [])

  const r = await projectCycle({ peer, bsky: sink, send: true, resolveSubject: () => ({ uri: 'at://x/y/z', cid: 'c' }) })
  t.is(r.edges.skipped, 1, 'recognised as already there')
  t.is(written.filter((w) => w.collection === 'app.bsky.feed.like').length, 0, 'and nothing was written')
})

test('★★★ with bsky switched off, no edge is projected either', async (t) => {
  const { sink, written } = fakeSink()
  const peer = fakePeer({ networks: ['fediverse'], rows: [] })
  peer.call = async (cmd, args = {}) => (cmd === 'edges-by' && args.collection === 'reaction')
    ? [{ name: 'e1', collection: 'reaction', value: 'like', createdAt: '2026-09-07T00:00:00.000Z', target: { feed: 'f'.repeat(64), collection: 'post', name: 'p' } }]
    : (cmd === 'profile' ? null : [])

  const r = await projectCycle({ peer, bsky: sink, send: true, resolveSubject: () => ({ uri: 'at://x/y/z', cid: 'c' }) })
  t.absent(r.edges.sent, 'consent gates edges exactly as it gates posts')
  t.is(written.length, 0)
})

// ── the DID names its favela feed back (hyper-social #82, I2a / D4a) ───────────────────────────────────────────
//
// ⛔ TWO-SIDED, OR NOT AT ALL. The identity's reach already names its DID (credential). This is the other half: the
// DID's own repo, signed by the DID key, names the favela feed. A gateway binds a DID to an identity only when both
// agree, so nobody can attach someone else's DID to their feed. Published content, so it waits for bsky consent
// exactly like the profile; reconciled against what the repo holds, never written on a failed read.
const FEED_CLAIM = 'net.favela.feed'
const claimOf = (records) => records.get(FEED_CLAIM + '/self') || null

test('★★ with bsky on, the DID repo gets net.favela.feed/self naming this feed', async (t) => {
  const { sink, written, records } = fakeSink()
  await projectCycle({ peer: fakePeer(), bsky: sink, send: true })
  const c = claimOf(records)
  t.ok(c, 'written')
  t.alike({ type: c.$type, feed: c.feed }, { type: FEED_CLAIM, feed: 'f'.repeat(64) })
  t.ok(written.some((w) => w.collection === FEED_CLAIM && w.rkey === 'self'))
})

test('the same claim is not rewritten', async (t) => {
  const { sink, written } = fakeSink()
  await projectCycle({ peer: fakePeer(), bsky: sink, send: true })
  const n = written.filter((w) => w.collection === FEED_CLAIM).length
  await projectCycle({ peer: fakePeer(), bsky: sink, send: true })
  t.is(written.filter((w) => w.collection === FEED_CLAIM).length, n, 'a second pass writes nothing')
})

test('⛔ no claim without bsky consent, on a dry run, or when the repo cannot be read', async (t) => {
  const off = fakeSink()
  await projectCycle({ peer: fakePeer({ networks: ['fediverse'] }), bsky: off.sink, send: true })
  t.absent(claimOf(off.records), 'bsky off')
  const dry = fakeSink()
  await projectCycle({ peer: fakePeer(), bsky: dry.sink, send: false })
  t.absent(claimOf(dry.records), 'dry run')
  const blind = fakeSink({ getFails: true })
  await projectCycle({ peer: fakePeer(), bsky: blind.sink, send: true })
  t.absent(blind.written.some((w) => w.collection === FEED_CLAIM), 'could not read is not absent')
})

// ── deletes ──────────────────────────────────────────────────────────────────────────────────────
//
// ⛔ A deleted favela post is retracted from bsky ONLY on positive evidence — a tombstone the peer
// reports — and only where the repo still holds it. A name merely missing from ownPosts() is never
// grounds to delete anything: "not replicated yet" looks identical.

const deletes = (written) => written.filter((w) => w.action === 'delete')

test('★★★ a deleted post that bsky holds is deleted there, at its TID rkey', async (t) => {
  const name = '1788822724600'
  const { sink, written } = fakeSink({ has: [tidFor(name)] })
  const r = await projectCycle({ peer: fakePeer({ deleted: { post: [name], reply: [] } }), bsky: sink, send: true })
  t.alike(deletes(written), [{ action: 'delete', collection: 'app.bsky.feed.post', rkey: tidFor(name) }], 'exactly that record')
  t.is(r.deleted, 1)
})

test('★★ a deleted reply is retracted the same way — replies are app.bsky.feed.post too', async (t) => {
  const name = '1788822724601'
  const { sink, written } = fakeSink({ has: [tidFor(name)] })
  await projectCycle({ peer: fakePeer({ deleted: { post: [], reply: [name] } }), bsky: sink, send: true })
  t.is(deletes(written).length, 1)
  t.is(deletes(written)[0].rkey, tidFor(name))
})

test('★★ a deleted post bsky never had is not touched', async (t) => {
  const { sink, written } = fakeSink({ has: [] })
  const r = await projectCycle({ peer: fakePeer({ deleted: { post: ['1788822724600'], reply: [] } }), bsky: sink, send: true })
  t.is(deletes(written).length, 0, 'nothing to retract')
  t.is(r.deleted, 0)
})

test('★★ a dry run deletes nothing', async (t) => {
  const name = '1788822724600'
  const { sink, written } = fakeSink({ has: [tidFor(name)] })
  const said = []
  await projectCycle({ peer: fakePeer({ deleted: { post: [name], reply: [] } }), bsky: sink, send: false, log: (l) => said.push(l) })
  t.is(deletes(written).length, 0)
  t.ok(said.some((l) => /would DELETE/.test(l)), 'but says it would')
})

test('★★ retraction is not gated on consent — switching bsky off must not strand a deleted post there', async (t) => {
  const name = '1788822724600'
  const { sink, written } = fakeSink({ has: [tidFor(name)] })
  await projectCycle({ peer: fakePeer({ networks: [], deleted: { post: [name], reply: [] } }), bsky: sink, send: true })
  t.is(deletes(written).length, 1, 'deleting publishes nothing, so it does not need consent to publish')
})

test('★★★ an unreadable repo deletes nothing either', async (t) => {
  const { sink, written } = fakeSink({ haveNull: true })
  await projectCycle({ peer: fakePeer({ deleted: { post: ['1788822724600'], reply: [] } }), bsky: sink, send: true })
  t.is(deletes(written).length, 0)
})

test('a peer with no ownDeleted (the desktop socket client) projects as before', async (t) => {
  const { sink, written } = fakeSink({ has: [] })
  const r = await projectCycle({ peer: fakePeer({ rows: [row('1788822724600', 'hello')] }), bsky: sink, send: true })
  t.is(r.sent, 1)
  t.is(deletes(written).length, 0)
  t.is(r.deleted, 0)
})