OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge / test / plan.test.js

115 lines · javascript ref:
// ⛔ ONE REACH DECISION, ONE PROJECTION PER NETWORK.
//
// `favela-markdown/lib/reach.js` says why this component exists at all: "Written here once, this
// check would otherwise be written three times — favela-web, hyper-proto-peer, ap-bridge — and the
// third one would eventually be written wrong." The same applies one level up. If hpp projects to
// bsky and ap-bridge projects to the fediverse, two things read the same record and each decides
// independently what it may reach. This plans once and fans out.
//
// ⛔ AND EACH NETWORK DERIVES FROM THE MARKDOWN, never from another projection. Measured: today the
// AP path takes `record.text` — the STRIPPED atproto text — and ap-bridge's textToHtml wraps it in
// <p>. So a heading and a list reach Mastodon as literal bullet characters, because the fediverse
// projection is derived from the bsky one and bsky's text is plain by protocol. A projection of a
// projection loses what the second one never had.

import test from 'brittle'
import { planRecord, selectImages, MAX_IMAGE_BYTES } from '../lib/plan.js'

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

test('★★ a public post reaches bsky and the fediverse', async (t) => {
  const p = planRecord(row('hello'), { roomType: 'public', verb: 'post' })
  t.alike(p.networks.sort(), ['bsky', 'favela', 'fediverse'])
  t.ok(p.bsky.project, 'bsky gets a post')
  t.ok(p.fediverse.project, 'the fediverse gets one too')
})

test('⛔ a followers room reaches the fediverse ONLY — atproto has no addressing', async (t) => {
  // reach.js: "atproto has NO addressing at all, so a projection it cannot restrict is a leak, and
  // the only safe answer is to withhold."
  const p = planRecord(row('hello'), { roomType: 'followers', verb: 'post' })
  t.absent(p.networks.includes('bsky'))
  t.ok(p.networks.includes('fediverse'))
  t.absent(p.bsky.project, 'and the bsky plan refuses')
  t.ok(p.bsky.losses.some((l) => /reach|address|withheld/i.test(l)), p.bsky.losses.join('; '))
})

test('⛔ a members room and a dm reach NOTHING but favela', async (t) => {
  for (const roomType of ['members', 'dm']) {
    const p = planRecord(row('secret'), { roomType, verb: 'post' })
    t.alike(p.networks, ['favela'], roomType + ' stays local')
    t.absent(p.bsky.project, roomType + ' does not project to bsky')
    t.absent(p.fediverse.project, roomType + ' does not project to the fediverse')
  }
})

test('⛔ an UNKNOWN room type fails closed', async (t) => {
  // Deny by default. An unrecognised container must never widen an audience.
  const p = planRecord(row('hello'), { roomType: 'wat', verb: 'post' })
  t.alike(p.networks, ['favela'])
})

test('★★ the fediverse gets STRUCTURE, bsky gets plain text', async (t) => {
  // The whole reason each network derives from the markdown separately.
  const md = '# Heading\n\n* one\n* two'
  const p = planRecord(row(md), { roomType: 'public', verb: 'post' })
  t.is(p.bsky.record.text.indexOf('#'), -1, 'bsky text carries no markdown syntax')
  t.ok(/<h1|<h2/.test(p.fediverse.content), 'the fediverse gets a real heading: ' + p.fediverse.content.slice(0, 60))
  t.ok(/<ul|<li/.test(p.fediverse.content), 'and a real list')
})

test('⛔ too long for bsky is WITHHELD there and still sent to the fediverse', async (t) => {
  // 300 graphemes is bsky's limit, not the fediverse's. One network's ceiling must not silence the
  // other — which is exactly what a single shared projection would have done.
  const p = planRecord(row('x'.repeat(400)), { roomType: 'public', verb: 'post' })
  t.absent(p.bsky.project)
  t.ok(p.bsky.losses.some((l) => /300/.test(l)))
  t.ok(p.fediverse.project, 'the fediverse has no such limit')
})

test('⛔ losses are reported on a SUCCESSFUL projection too', async (t) => {
  // A mention still cannot be projected — resolving favela:// to a did:plc: is unbuilt — and the
  // post goes anyway with the loss named beside it.
  const p = planRecord(row('hello [@alice](favela://' + 'ab'.repeat(32) + ')'), { roomType: 'public', verb: 'post' })
  t.ok(p.bsky.project, 'the post still goes')
  t.ok(p.bsky.losses.length > 0, 'and the loss is named: ' + p.bsky.losses.join('; '))
})

// ⛔ THIS USED TO BE A LOSS. "attachment(s) not projected — bsky needs an uploaded blob ref, which
// is unbuilt" was reported for every post carrying a picture, and the picture reached the fediverse
// and never bsky. The oids are named here; the caller uploads them and attaches the embed.
test('★ a post image is NAMED for projection rather than reported as a loss', async (t) => {
  const oid = 'cd'.repeat(32)
  const p = planRecord(row('look ![a cat](favela://blob/' + oid + ')'), { roomType: 'public', verb: 'post' })
  t.is(p.bsky.images.length, 1, 'the picture is offered for upload')
  t.is(p.bsky.images[0].oid, oid)
  t.is(p.bsky.images[0].alt, 'a cat', '⛔ alt text is an accessibility obligation, not a nicety')
  t.absent(p.bsky.losses.some((l) => l.includes('unbuilt')), 'and is no longer reported as unbuilt')
})

test('at most four images, and the rest are named as dropped', async (t) => {
  const imgs = Array.from({ length: 6 }, (_, i) => '![' + i + '](favela://blob/' + String(i).repeat(64) + ')').join(' ')
  const p = planRecord(row('many ' + imgs), { roomType: 'public', verb: 'post' })
  t.is(p.bsky.images.length, 4, 'bsky takes four')
  t.ok(p.bsky.losses.some((l) => l.includes('only the first 4 of 6')), 'and says so')
})

test('⛔ selectImages WITHHOLDS an oversized picture rather than resizing it', async (t) => {
  const { images, losses } = selectImages([
    { oid: 'a', path: '/tmp/a.png', mimeType: 'image/png', size: 500 },
    { oid: 'b', path: '/tmp/b.png', mimeType: 'image/png', size: MAX_IMAGE_BYTES + 1 },
    { oid: 'c', path: '/tmp/c.pdf', mimeType: 'application/pdf', size: 10 }
  ])
  t.is(images.length, 1, 'only the one bsky will take')
  t.ok(losses.some((l) => l.includes('against bsky')), 'the oversized one is named, not shrunk')
  t.ok(losses.some((l) => l.includes('image/*')), 'and so is the non-image')
})

test('⛔ planRecord NEVER throws — it runs on the projection path', async (t) => {
  for (const bad of [undefined, null, {}, { body: null }, { body: 123 }]) {
    t.execution(() => planRecord(bad, { roomType: 'public', verb: 'post' }), JSON.stringify(bad))
  }
  t.execution(() => planRecord(row('hi'), null), 'and with no context at all')
})