OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / appview-thread.test.mjs

117 lines · javascript ref:
// favela appview — getPost (point lookup) and getPostThread (both directions).
//
// Two things are proven here, and the first is a REGRESSION control.
//
// 1. The permalink used to be served by scanning getAuthorFeed(did, { limit: 100 }) and matching rkey, so
//    a post older than the author's last 100 404'd on its own permalink. The control below builds 105
//    posts and shows the old approach missing the oldest one while getPost finds it — otherwise "it works
//    now" is unfalsifiable, since both approaches agree on any small fixture.
//
// 2. A thread walks BOTH directions, mirroring app.bsky.feed.getPostThread: `depth` down, `parentHeight`
//    up (lexicon defaults 6 / 80). The reply index's own comment claimed bsky only walks down and used
//    that to justify not indexing root; bsky takes a parentHeight, so ancestors are a first-class view.
//    Ancestors come off `_replyOf`, one hop at a time.
//
// Run: `bare test/appview-thread.test.mjs`
import '../lib/bare-globals.js'
import { storeDir } from './_store-dir.mjs'
import Corestore from 'corestore'
import { Appview, threadPostList } from '../lib/appview.js'
import { mkGetRecordStore } from './mk-record-store.mjs'

const assert = (cond, msg) => { if (!cond) throw new Error('FAIL: ' + msg); console.log('  ok -', msg) }

const dir = storeDir('appview-thread-test')
const av = await new Appview(new Corestore(dir), { getRecordStore: mkGetRecordStore(dir + '/rec') }).ready()

const A = 'did:plc:alice'
const B = 'did:plc:bob'
const uri = (did, rkey) => `at://${did}/app.bsky.feed.post/${rkey}`
const ref = (u) => ({ uri: u, cid: 'bafyreiaaa' })
const post = (text, createdAt, reply = null) => ({ $type: 'app.bsky.feed.post', text, createdAt, ...(reply ? { reply } : {}) })
const put = (did, rkey, record) => av.applyCommit(did, [{ action: 'create', collection: 'app.bsky.feed.post', rkey, record }])
const ts = (n) => new Date(Date.UTC(2026, 0, 1, 0, 0, 0) + n * 60000).toISOString()

// ── 1. point lookup, and the permalink control ──────────────────────────────────────────────────
console.log('\n# getPost is a point lookup with no horizon')
for (let i = 0; i < 105; i++) await put(A, 'p' + i, post('post ' + i, ts(i)))

const oldest = uri(A, 'p0')
const viaIndex = await av.getPost(oldest)
assert(!!viaIndex, 'getPost finds the OLDEST of 105 posts')
assert(viaIndex.rkey === 'p0' && viaIndex.record.text === 'post 0', 'and it is the right record, hydrated')

// CONTROL: exactly what the route used to do.
const feed = await av.getAuthorFeed(A, { limit: 100 })
const viaScan = (feed.posts || []).find((x) => x.rkey === 'p0') || null
assert(viaScan === null, 'CONTROL: the old last-100 scan does NOT find it — this is the 404 that was fixed')

assert((await av.getPost('not-a-uri')) === null, 'a malformed uri is null, not a throw')
assert((await av.getPost(uri(A, 'nope'))) === null, 'an unknown rkey is null')

// ── 2. ancestors ────────────────────────────────────────────────────────────────────────────────
console.log('\n# ancestors: the walk UP')
const ROOT = uri(A, 'troot')
await put(A, 'troot', post('root of thread', ts(200)))
await put(B, 't1', post('first reply', ts(201), { root: ref(ROOT), parent: ref(ROOT) }))
await put(A, 't2', post('second', ts(202), { root: ref(ROOT), parent: ref(uri(B, 't1')) }))
await put(B, 't3', post('third', ts(203), { root: ref(ROOT), parent: ref(uri(A, 't2')) }))

const deep = await av.getPostThread(uri(B, 't3'))
assert(deep.post.rkey === 't3', 'the focused post is the one asked for')
assert(deep.ancestors.length === 3, 'three ancestors are walked')
assert(deep.ancestors.map((a) => a.rkey).join(',') === 'troot,t1,t2', 'and they are OLDEST-FIRST (root, t1, t2)')

const capped = await av.getPostThread(uri(B, 't3'), { parentHeight: 1 })
assert(capped.ancestors.length === 1 && capped.ancestors[0].rkey === 't2', 'parentHeight caps the upward walk at the immediate parent')

// ── 3. descendants, nested ──────────────────────────────────────────────────────────────────────
console.log('\n# descendants: the walk DOWN is a TREE, not one level')
const t = await av.getPostThread(ROOT)
assert(t.replies.length === 1, 'root has ONE direct reply')
assert(t.replies[0].post.rkey === 't1', 'which is t1')
assert(t.replies[0].replies[0].post.rkey === 't2', 'and t2 is nested UNDER t1, not flattened beside it')
assert(t.replies[0].replies[0].replies[0].post.rkey === 't3', 'and t3 under t2 — three levels deep')

const shallow = await av.getPostThread(ROOT, { depth: 1 })
assert(shallow.replies.length === 1 && shallow.replies[0].replies.length === 0, 'depth:1 returns direct replies only')

// ── 4. a parent outside this node's window ──────────────────────────────────────────────────────
console.log('\n# a missing ancestor is REPORTED, not silently dropped')
const foreign = 'at://did:plc:stranger/app.bsky.feed.post/xyz'
await put(A, 'orphan', post('replying to someone we never indexed', ts(300), { root: ref(foreign), parent: ref(foreign) }))
const orphan = await av.getPostThread(uri(A, 'orphan'))
assert(orphan.ancestors.length === 1, 'the gap is represented')
assert(orphan.ancestors[0].missing === true && orphan.ancestors[0].uri === foreign, 'as { missing, uri } — the hole a lens import would fill')

// ── 5. guards ───────────────────────────────────────────────────────────────────────────────────
console.log('\n# guards')
const selfUri = uri(A, 'loop')
await put(A, 'loop', post('a post that replies to itself', ts(400), { root: ref(selfUri), parent: ref(selfUri) }))
const looped = await av.getPostThread(selfUri)
assert(looped.ancestors.length === 0, 'a self-referential reply does not loop forever — the seen-set breaks it')

const wide = uri(A, 'wide')
await put(A, 'wide', post('wide thread', ts(500)))
for (let i = 0; i < 12; i++) await put(B, 'w' + i, post('r' + i, ts(501 + i), { root: ref(wide), parent: ref(wide) }))
const bounded = await av.getPostThread(wide, { maxNodes: 5 })
assert(bounded.replies.length === 5, 'maxNodes bounds the TOTAL nodes walked')
assert(bounded.truncated === true, 'and says so, rather than pretending the thread is small')

assert((await av.getPostThread(uri(A, 'ghost'))) === null, 'a thread for an unknown post is null')

// ── 6. flattening for moderation ────────────────────────────────────────────────────────────────
console.log('\n# threadPostList feeds moderation')
const flat = threadPostList(t)
assert(flat.length === 4, 'the whole tree flattens to 4 posts (root + 3 nested)')
assert(flat.every((p) => p && p.uri), 'every entry is a real post')
assert(threadPostList(orphan).every((p) => !p.missing), 'missing markers are NOT posts and are skipped')
// The point of flattening: withMod mutates in place, so the TREE sees the moderation.
flat[3].treatment = 'warn'
const nested = t.replies[0].replies[0].replies[0].post
assert(nested.treatment === 'warn' || flat.includes(nested), 'the flat list holds the SAME objects as the tree — in-place moderation reaches nested replies')

assert(threadPostList(null).length === 0, 'null thread flattens to nothing')

console.log('\nall thread assertions passed')