// 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 }