OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / record-source-contract.js

93 lines · javascript ref:
// record-source-contract.js — THE contract a record source must satisfy, stated once.
//
// ⛔ WHY THIS EXISTS. There are two sources — `RecordSource` (a DID's repo) and `FavelaSource` (a
// favela feed) — and everything above them is written as though they were interchangeable. They
// were not. FavelaSource implemented only `listNotes`; `getProfile`, `getNote` and
// `listInteractions` were simply absent, and the callers swallow a failure:
//
//     const profile = ctx.records ? await ctx.records.getProfile().catch(() => ({})) : {}
//
// So a missing method surfaced as THE WRONG PERSON'S DATA, never as an error. Live, ashatron's
// actor served the incumbent's display name, summary and avatar, and an empty outbox — and the only
// way to find out was to look at it.
//
// This is the same shape hyper-identity-core uses: one contract file, imported by both bindings
// rather than restated, so a binding cannot quietly implement a subset.
//
// Imported by BOTH source test files. A source that cannot pass this must not be handed to the
// router, because the router cannot tell.

const REQUIRED = ['getProfile', 'listNotes', 'getNote', 'listInteractions']

/**
 * @param test     brittle's test function
 * @param name     what to call this source in the output
 * @param make     async () => a source with at least one note authored
 */
export function recordSourceContract (test, name, make) {
  test(`⛔ ${name} — implements every method a caller uses`, async (t) => {
    const src = await make()
    for (const m of REQUIRED) {
      t.ok(typeof src[m] === 'function', `${m}() exists`)
    }
  })

  // ⚠ `{}` IS A VALID ANSWER — somebody with no profile record has no display name, and saying so
  // is honest. The live defect was never this returning empty; it was the CALLER substituting the
  // process-wide config when it did, which put the incumbent's name and avatar on another actor.
  // That assertion belongs where the substitution happens — see per-user-actor.test.js.
  test(`★★ ${name} — getProfile always resolves to an object, never throws`, async (t) => {
    const src = await make()
    const p = await src.getProfile()
    t.ok(p && typeof p === 'object', 'an object, so a caller need not guard — and need not .catch()')

    // ⛔ AND IN AS2'S SHAPE. buildActor reads `profile.name`; a source returning `displayName`
    // instead is fetched, ignored, and falls through to the config — indistinguishable from having
    // no getProfile at all. That shipped. Naming the method was never enough.
    for (const k of Object.keys(p)) {
      t.ok(['name', 'summary', 'icon', 'image'].includes(k), `"${k}" is an AS2 profile field`)
    }
  })

  test(`★★ ${name} — listNotes returns { notes, cursor }`, async (t) => {
    const src = await make()
    const res = await src.listNotes({ limit: 10 })
    t.ok(res && Array.isArray(res.notes), 'notes is an array')
    t.ok('cursor' in res, 'cursor is present even when null — its absence would read as "no more"')
    if (res.notes.length) {
      const n = res.notes[0]
      t.ok(n.id && n.type === 'Note', 'and a note is an AS2 Note with an id')
      t.ok(n.attributedTo, 'attributed to somebody')
    }
  })

  test(`★ ${name} — getNote round-trips a note listNotes returned`, async (t) => {
    const src = await make()
    const { notes } = await src.listNotes({ limit: 5 })
    if (!notes.length) { t.pass('no notes to round-trip'); return }
    const rkey = String(notes[0].id).split('/').pop()
    const one = await src.getNote(rkey)
    t.ok(one, 'the note is retrievable by the rkey its own id ends with')
    t.is(one.id, notes[0].id, 'and it is the same note')
    // ⛔ AND IT IS DEREFERENCEABLE. A Note inside a Create inherits the activity's @context; one
    // served at its own URI does not, and a JSON-LD document without one expands to nothing — every
    // AS2 term becomes an unmapped string. One source wrapped it and the other did not, and asking
    // only for "a note with an id" let that through.
    t.ok(one['@context'], 'carries its own @context — this is a document somebody will GET directly')
  })

  test(`★ ${name} — getNote is null for something that is not there`, async (t) => {
    const src = await make()
    t.is(await src.getNote('definitely-not-a-real-rkey'), null,
      'null, not a throw and not a different note')
  })

  test(`★ ${name} — listInteractions returns a list`, async (t) => {
    const src = await make()
    const res = await src.listInteractions({ limit: 10 })
    t.ok(res && Array.isArray(res.interactions), 'interactions is an array')
  })
}

export default { recordSourceContract, REQUIRED }