OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / as2-project.test.js

143 lines · javascript ref:
import test from 'brittle'
import { projectNote, wrapCreate, standaloneNote, textToHtml, escapeHtml, PUBLIC_AUDIENCE } from '../lib/as2-project.js'

const ACTOR = 'https://ap.7rnx.net/ap/actor'
const FOLLOWERS = 'https://ap.7rnx.net/ap/followers'
const OBJ = 'https://ap.7rnx.net/ap/objects/3lab2c'
const URI = 'at://did:plc:wj62ebbusln6gxo5azo6ogzo/app.bsky.feed.post/3lab2c'
const CID = 'bafyreib2rxk3rybk3aobmv5cjuql3bm2twh4jo5uickrwmgqsdxtjfyvpm'

const base = { actorId: ACTOR, objectId: OBJ, followersId: FOLLOWERS, uri: URI, cid: CID }

// AS2 content is HTML. A record's text is plain — unescaped, a post containing markup breaks
// rendering on every receiving server.
test('text → HTML: escapes, paragraphs on blank lines, <br> on single', (t) => {
  t.is(escapeHtml('<script>&"'), '&lt;script&gt;&amp;&quot;')
  t.is(textToHtml('hello'), '<p>hello</p>')
  t.is(textToHtml('a\nb'), '<p>a<br />b</p>')
  t.is(textToHtml('a\n\nb'), '<p>a</p><p>b</p>')
  t.is(textToHtml('<b>x</b>'), '<p>&lt;b&gt;x&lt;/b&gt;</p>', 'markup in a post is text, not markup')
})

test('projects a plain post', (t) => {
  const note = projectNote({ ...base, record: { text: 'hello fediverse', createdAt: '2026-08-01T00:00:00.000Z' } })
  t.is(note.type, 'Note')
  t.is(note.id, OBJ)
  t.is(note.attributedTo, ACTOR)
  t.is(note.content, '<p>hello fediverse</p>')
  t.is(note.published, '2026-08-01T00:00:00.000Z')
  t.alike(note.to, [PUBLIC_AUDIENCE])
  t.alike(note.cc, [FOLLOWERS])
})

// The original plaintext, so receivers don't have to reverse our HTML to recover it.
test('carries source plaintext', (t) => {
  const note = projectNote({ ...base, record: { text: 'a\n\nb', createdAt: 'x' } })
  t.is(note.source.content, 'a\n\nb')
  t.is(note.source.mediaType, 'text/plain')
})

// ★ The cross-network dedup key.
test('★ carries atproto:uri and atproto:cid for cross-network dedup', (t) => {
  const note = projectNote({ ...base, record: { text: 'x', createdAt: 'y' } })
  t.is(note['atproto:uri'], URI)
  t.is(note['atproto:cid'], CID)

  // Both projections of this post derive from the same canonical bytes, so a viewer following us
  // on BOTH networks sees the same CID twice and can collapse them.
  const other = projectNote({ ...base, objectId: 'https://elsewhere/ap/objects/z', record: { text: 'x', createdAt: 'y' } })
  t.is(other['atproto:cid'], note['atproto:cid'], 'CID is network-independent — different object id, same content identity')
})

test('omits dedup keys cleanly when absent rather than emitting undefined', (t) => {
  const note = projectNote({ actorId: ACTOR, objectId: OBJ, record: { text: 'x', createdAt: 'y' } })
  t.absent('atproto:uri' in note)
  t.absent('atproto:cid' in note)
})

test('langs → contentMap', (t) => {
  const note = projectNote({ ...base, record: { text: 'hola', createdAt: 'x', langs: ['es', 'en'] } })
  t.alike(Object.keys(note.contentMap), ['es', 'en'])
  t.is(note.contentMap.es, '<p>hola</p>')
  t.absent(projectNote({ ...base, record: { text: 'x', createdAt: 'y' } }).contentMap)
})

// A wrong inReplyTo grafts the post onto the wrong thread on every receiving server — strictly
// worse than an orphaned reply. So an unresolvable parent must be omitted, never guessed.
test('★ reply — set when resolvable, OMITTED when not', (t) => {
  const parent = 'at://did:plc:other/app.bsky.feed.post/parent1'
  const record = { text: 'reply', createdAt: 'x', reply: { parent: { uri: parent } } }

  const resolved = projectNote({
    ...base, record, resolveObjectUri: (u) => u === parent ? 'https://mastodon.social/users/a/statuses/1' : null
  })
  t.is(resolved.inReplyTo, 'https://mastodon.social/users/a/statuses/1')

  const unresolved = projectNote({ ...base, record })
  t.absent('inReplyTo' in unresolved, 'unknown parent → omitted, never guessed')
})

test('refuses to project a record it cannot represent', (t) => {
  t.exception(() => projectNote({ ...base, record: {} }), /record.text is required/)
  t.exception(() => projectNote({ ...base, record: { text: 'x' }, objectId: null }), /objectId is required/)
  t.exception(() => projectNote({ ...base, record: { text: 'x' }, actorId: null }), /actorId is required/)
})

// Mismatched addressing between wrapper and object makes receivers disagree about audience.
test('Create wrapper mirrors the Note addressing and declares the context', (t) => {
  const note = projectNote({ ...base, record: { text: 'x', createdAt: '2026-08-01T00:00:00.000Z' } })
  const create = wrapCreate({ note, actorId: ACTOR, activityId: 'https://ap.7rnx.net/ap/activities/1' })

  t.is(create.type, 'Create')
  t.is(create.actor, ACTOR)
  t.is(create.object.id, OBJ)
  t.alike(create.to, note.to)
  t.alike(create.cc, note.cc)
  t.is(create.published, note.published)
  t.ok(create['@context'][0].includes('activitystreams'))
  t.is(create['@context'][1].atproto, 'https://atproto.com/ns#', 'the dedup extension is self-describing')
  t.absent(create.object['@context'], 'a nested object must not repeat @context')
})

test('standalone Note gets its own context', (t) => {
  const note = standaloneNote(projectNote({ ...base, record: { text: 'x', createdAt: 'y' } }))
  t.ok(note['@context'])
  t.is(note.type, 'Note')
})

// ── content that came from MARKDOWN ───────────────────────────────────────────────────────────
//
// ⛔ A PROJECTION OF A PROJECTION LOSES WHAT THE SECOND ONE NEVER HAD. `content` is built from
// `record.text` — the plain text an atproto post carries — so a heading and a list arrive at
// Mastodon as paragraphs containing literal • characters. AS2 `content` is HTML and Mastodon renders
// <h2>, <ul>, <a> and <strong>; the canonical favela record is markdown and has all of it.
//
// So a caller that already holds the markdown can hand over rendered HTML, and the text path is
// untouched for every caller that does not.

test('★★ projectNote uses supplied HTML when the caller has it', (t) => {
  const note = projectNote({
    record: { text: 'Heading\n\n• one\n• two', createdAt: '2026-09-07T00:00:00Z' },
    contentHtml: '<h2>Heading</h2><ul><li>one</li><li>two</li></ul>',
    objectId: 'https://h/ap/objects/x',
    actorId: 'https://h/ap/actor',
    followersId: 'https://h/ap/followers'
  })
  t.is(note.content, '<h2>Heading</h2><ul><li>one</li><li>two</li></ul>', 'structure survives')
  // ⛔ `source` must still be what the AUTHOR wrote, not the rendering. A receiver that offers an
  // edit box uses it, and handing back HTML there would round-trip their markdown into soup.
  t.is(note.source.content, 'Heading\n\n• one\n• two')
})

test('⚠ without supplied HTML nothing changes', (t) => {
  // The live actor federates through this path. It must be byte-identical for callers that do not
  // pass contentHtml.
  const note = projectNote({
    record: { text: 'plain post', createdAt: '2026-09-07T00:00:00Z' },
    objectId: 'https://h/ap/objects/x',
    actorId: 'https://h/ap/actor',
    followersId: 'https://h/ap/followers'
  })
  t.is(note.content, '<p>plain post</p>')
})