OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / notifications.test.mjs

134 lines · javascript ref:
// "Did anyone reply to me" — from this node's own knowledge, both networks, no second credential.
//
// The page this feeds used to be a shell that asked the browser for a bsky APP PASSWORD and fetched
// bsky's notification API client-side: a second credential for an account already controlled here, a
// view that was bsky's rather than ours, and total blindness to ActivityPub. hyper-social #45 listed
// it among the five gaps that decide whether favela is a product.
import { fromApItem, fromReply, mergeNotifications, actorHandle, htmlToText } from '../lib/notifications.js'

let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log('  ok -', m); pass++ }
const eq = (a, b, m) => ok(a === b, `${m} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`)

// A real item, copied from what ap-bridge actually stored when prsm.space replied on 2026-08-18.
const AP_REPLY = {
  id: 'https://prsm.space/users/fleeky/statuses/117118370829261804/activity',
  type: 'Create',
  actor: 'https://prsm.space/users/fleeky',
  objectId: 'https://prsm.space/users/fleeky/statuses/117118370829261804',
  inReplyTo: 'https://ap.7rnx.net/ap/objects/3mtewvmoqec22',
  content: '<p><span class="h-card"><a href="https://ap.7rnx.net/">@<span>p2p</span></a></span> this computer needs a can of air</p>',
  url: 'https://prsm.space/@fleeky/117118370829261804',
  published: '2026-08-18T20:21:55Z',
  receivedAt: 1787084518154
}

// — actor names —
eq(actorHandle('https://prsm.space/users/fleeky'), '@fleeky@prsm.space', 'a fediverse actor reads as the handle its owner knows')
eq(actorHandle('nonsense'), 'nonsense', 'an unparseable actor is passed through, not crashed on')

// — remote HTML is TEXT here —
// ⛔ ap-bridge stores content verbatim, which is right: it is not the store's job to decide how it
// renders. A notification line is not the place to reproduce someone else's markup.
eq(htmlToText('<p>hello<br />there</p>'), 'hello there', 'tags become spaces')
ok(!htmlToText(AP_REPLY.content).includes('<'), 'no markup survives into a notification line')
eq(htmlToText(AP_REPLY.content), '@p2p this computer needs a can of air', 'the words survive, and the mention reads as @p2p rather than being split by the h-card markup')

// — the AP reply —
{
  const n = fromApItem(AP_REPLY)
  eq(n.kind, 'reply', 'Create{Note} is a reply')
  eq(n.network, 'activitypub', 'and it is labelled as fediverse')
  eq(n.who, '@fleeky@prsm.space')
  eq(n.when, '2026-08-18T20:21:55Z', 'the author time, not when we happened to receive it')
  eq(n.subject, 'https://ap.7rnx.net/ap/objects/3mtewvmoqec22', 'which of OUR posts it answers')
  ok(n.text.includes('can of air'), 'and what they said')
}

// Likes and boosts carry no text and must not pretend to.
eq(fromApItem({ type: 'Like', actor: 'https://a.net/users/b', objectId: 'https://ap.7rnx.net/ap/objects/x' }).kind, 'like', 'Like')
eq(fromApItem({ type: 'Announce', actor: 'https://a.net/users/b', objectId: 'https://ap.7rnx.net/ap/objects/x' }).kind, 'boost', 'Announce')
eq(fromApItem({ type: 'Like', actor: 'https://a.net/users/b' }).text, '', 'a like has no text')
eq(fromApItem({ type: 'Follow', actor: 'https://a.net/users/b' }), null, 'a Follow is not presented here (yet) rather than mislabelled')
eq(fromApItem(null), null, 'nothing in, nothing out')

// — the atproto reply —
{
  const n = fromReply({ did: 'did:plc:alice', handle: 'alice.example', uri: 'at://did:plc:alice/app.bsky.feed.post/r1', record: { text: 'nice', createdAt: '2026-08-18T10:00:00Z' } }, 'at://did:plc:wj62/app.bsky.feed.post/p1')
  eq(n.kind, 'reply')
  eq(n.network, 'atproto', 'labelled as atproto — a reply from Mastodon and one from bsky are not interchangeable')
  eq(n.who, 'alice.example')
  eq(n.subject, 'at://did:plc:wj62/app.bsky.feed.post/p1')
}

// A handle-less author falls back to a SHORT did, never 32 raw characters.
{
  const n = fromReply({ did: 'did:plc:wj62ebbusln6gxo5azo6ogzo', uri: 'at://x/y/z', record: { text: 'hi' } }, 'at://s')
  eq(n.who, 'wj62ebbu…ogzo', 'readable, not decoration')
}

// — the merge —
{
  const merged = mergeNotifications({
    atproto: [fromReply({ did: 'did:plc:alice', handle: 'alice.example', uri: 'at://a/1', record: { text: 'older', createdAt: '2026-08-18T09:00:00Z' } }, 'at://s')],
    activitypub: [fromApItem(AP_REPLY)]
  })
  ok(merged.length === 2, 'both networks in ONE list — two inboxes is how you get a confused user')
  eq(merged[0].network, 'activitypub', 'newest first, regardless of source')
  eq(merged[1].when, '2026-08-18T09:00:00Z')
}

// ⛔ Your own replies are indexed exactly like anyone else's, so without this the page fills with
// your own voice.
{
  const mine = { ...fromReply({ did: 'did:plc:me', handle: 'me', uri: 'at://mine/1', record: { text: 'self', createdAt: '2026-08-18T23:00:00Z' } }, 'at://s'), authorDid: 'did:plc:me' }
  const theirs = { ...fromReply({ did: 'did:plc:alice', handle: 'alice', uri: 'at://a/2', record: { text: 'theirs', createdAt: '2026-08-18T22:00:00Z' } }, 'at://s'), authorDid: 'did:plc:alice' }
  const merged = mergeNotifications({ atproto: [mine, theirs], viewerDid: 'did:plc:me' })
  eq(merged.length, 1, 'your own reply is not a notification')
  eq(merged[0].who, 'alice')
}

// The same reply can arrive twice — a fediverse reply that also federates back, or both spacedust
// subject paths hitting one record.
{
  const a = fromApItem(AP_REPLY)
  const merged = mergeNotifications({ activitypub: [a, { ...a }] })
  eq(merged.length, 1, 'deduped by id')
}

// An undated item sorts last rather than vanishing: "we do not know when" is not "it did not happen".
{
  const undated = { kind: 'like', network: 'activitypub', who: '@x@y.z', when: null, id: 'u1' }
  const dated = fromApItem(AP_REPLY)
  const merged = mergeNotifications({ activitypub: [undated, dated] })
  eq(merged.length, 2)
  eq(merged[1].id, 'u1', 'undated last, still present')
}

// ── the AP inbox reader ─────────────────────────────────────────────────────────────────────────
//
// ⛔ null vs [] IS THE WHOLE CONTRACT. ap-bridge is single-instance, so a favela-web on any other
// host cannot read it. "You have no replies" and "this node cannot see the fediverse" must not
// render alike — the same distinction the AP reach reader already draws.
{
  const { createApInbox } = await import('../lib/ap-inbox.js')

  const okClient = { call: async () => ({ ok: true, items: [AP_REPLY] }), close: async () => {} }
  const got = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => okClient }).list({})
  eq(Array.isArray(got) && got.length, 1, 'items when the bridge answers')

  const none = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => ({ call: async () => ({ ok: true, items: [] }), close: async () => {} }) }).list({})
  eq(Array.isArray(none) && none.length, 0, 'EMPTY when it answered and had nothing')

  const noSock = await createApInbox({ sockPath: '/s', exists: () => false, connect: async () => okClient }).list({})
  eq(noSock, null, 'NULL when there is no ap-bridge here at all')

  const broke = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => { throw new Error('ECONNREFUSED') } }).list({})
  eq(broke, null, 'NULL when it is down — never a throw, the atproto half must still render')

  const oldVersion = await createApInbox({ sockPath: '/s', exists: () => true, connect: async () => ({ call: async () => { throw new Error('unknown method') }, close: async () => {} }) }).list({})
  eq(oldVersion, null, 'NULL against an ap-bridge too old to have the verb')
}

console.log(`\nNOTIFICATIONS OK: ${pass} assertions`)