// inbound-notifications.test.js — projecting what the fediverse sent us into the shape favela
// already uses for notifications.
//
// ⛔ WHY THIS AND NOT "WRITE IT INTO THE PEER'S LOG". favela-peer has nowhere to put a record whose
// author has no feed: every record lives in its author's feed, `notifications()` is a SCAN of feeds
// this node holds (peer.js:640), and the doorbell is keyed by FEED KEY (peer.js:705) — which a
// Mastodon actor does not have. Putting an inbound Note in the log would mean the bridge authoring
// records as if it were the remote person, which is impersonation with extra steps.
//
// ★ But notifications are "a pure function from records to a view — derived on read, never stored,
// never replicated" (peer.js:623, SOCIAL-MODEL §3). A view deriving from TWO sources is still a
// projection. So the honest fix is to hand the view a second source in the same shape, and let the
// peer go on not knowing what a fediverse is.
import test from 'brittle'
import { toNotification, toNotifications } from '../lib/inbound-notifications.js'
const at = '2026-09-08T10:00:00Z'
const item = (over = {}) => ({
id: 'https://prsm.space/activities/1',
type: 'Create',
actor: 'https://prsm.space/users/fleeky',
objectId: 'https://prsm.space/notes/1',
inReplyTo: null,
content: '<p>hello</p>',
url: 'https://prsm.space/notes/1',
published: at,
receivedAt: at,
...over
})
test('★★ the row is the shape favela-peer already emits', async (t) => {
const n = toNotification(item({ inReplyTo: 'https://ap.7rnx.net/ap/objects/abc' }))
// peer.js:646 — kind is one of reaction · repost · reply · mention · follow · knock.
t.alike(Object.keys(n).sort(), ['at', 'author', 'body', 'kind', 'name', 'source', 'target', 'value'].sort())
t.is(n.kind, 'reply')
t.is(n.author, 'https://prsm.space/users/fleeky')
t.is(n.at, at)
t.is(n.body, '<p>hello</p>')
})
// ⛔ `author` here is an https actor URI. In a peer row it is a 64-char FEED KEY. A view that
// cannot tell them apart will try to resolve one as the other and render a broken profile link.
test('★★ every federated row says where it came from', async (t) => {
t.is(toNotification(item()).source, 'fediverse')
})
test('★ a Create with inReplyTo is a reply; without one it is a mention', async (t) => {
t.is(toNotification(item({ inReplyTo: 'https://ap.7rnx.net/ap/objects/abc' })).kind, 'reply')
t.is(toNotification(item({ inReplyTo: null })).kind, 'mention')
})
test('★ Like is a reaction, Announce is a repost, Follow is a follow', async (t) => {
t.is(toNotification(item({ type: 'Like' })).kind, 'reaction')
t.is(toNotification(item({ type: 'Announce' })).kind, 'repost')
t.is(toNotification(item({ type: 'Follow' })).kind, 'follow')
})
// A remote server can send anything. Rendering an unrecognised activity as some nearby kind tells
// the reader something that did not happen.
test('⛔ an unknown activity type is DROPPED, never guessed at', async (t) => {
t.is(toNotification(item({ type: 'Flag' })), null)
t.is(toNotification(item({ type: null })), null)
t.is(toNotification(null), null)
t.is(toNotifications([item({ type: 'Flag' }), item()]).length, 1, 'and it does not reach the list')
})
// A Like/Announce points at OUR post; a reply points at the post it answers. Either way the target
// is the thing on our side, which is what makes the row actionable.
test('★ the target is what it acted on', async (t) => {
t.is(toNotification(item({ type: 'Like', objectId: 'https://ap.7rnx.net/ap/objects/xyz' })).target,
'https://ap.7rnx.net/ap/objects/xyz')
t.is(toNotification(item({ inReplyTo: 'https://ap.7rnx.net/ap/objects/abc' })).target,
'https://ap.7rnx.net/ap/objects/abc')
t.is(toNotification(item({ type: 'Follow' })).target, null, 'a follow acts on you, not on a post')
})
test('★ newest first, and limited', async (t) => {
const rows = toNotifications([
item({ id: 'a', published: '2026-09-01T00:00:00Z' }),
item({ id: 'b', published: '2026-09-08T00:00:00Z' }),
item({ id: 'c', published: '2026-09-04T00:00:00Z' })
])
t.alike(rows.map((r) => r.at), ['2026-09-08T00:00:00Z', '2026-09-04T00:00:00Z', '2026-09-01T00:00:00Z'])
t.is(toNotifications([item(), item({ id: 'z' })], { limit: 1 }).length, 1)
})
// inbound-store.js: "Content is HTML from a remote server… NEVER rendered as trusted markup".
// This projection must not quietly sanitise it either — that would let a caller believe the string
// is safe when the guarantee lives in the renderer.
test('⛔ the body is carried VERBATIM — this layer makes no safety claim', async (t) => {
const nasty = '<p>hi</p><script>alert(1)</script>'
t.is(toNotification(item({ content: nasty })).body, nasty, 'unchanged, so the renderer must escape it')
})
test('a missing timestamp falls back to when we received it', async (t) => {
t.is(toNotification(item({ published: null, receivedAt: at })).at, at)
})