OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / test / appview-author-filter.test.mjs

100 lines · javascript ref:
// favela appview — getAuthorFeed `filter`, the query behind the profile tabs.
//
// The values are app.bsky.feed.getAuthorFeed's knownValues verbatim (verified against the lexicon
// 2026-07-30), so a tab means here what it means everywhere else in atproto.
//
// The property most worth pinning is PAGE FILLING: filtering happens over the reverse-chron stream, so a
// page returns `limit` MATCHES. The naive version — take limit from the index, then drop non-matches —
// returns "limit scanned, some discarded", which makes a reply-heavy profile look nearly empty under
// posts_no_replies. The interleaved fixture below fails that way if the filter is applied after the read.
//
// Run: `bare test/appview-author-filter.test.mjs`
import '../lib/bare-globals.js'
import { storeDir } from './_store-dir.mjs'
import Corestore from 'corestore'
import { Appview, AUTHOR_FILTERS } 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-author-filter-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 ts = (n) => new Date(Date.UTC(2026, 0, 1) + n * 60000).toISOString()
const put = (did, rkey, record) => av.applyCommit(did, [{ action: 'create', collection: 'app.bsky.feed.post', rkey, record }])
const base = (text, createdAt, extra = {}) => ({ $type: 'app.bsky.feed.post', text, createdAt, ...extra })
const images = (n = 1) => ({ embed: { $type: 'app.bsky.embed.images', images: Array.from({ length: n }, () => ({ alt: '', image: { $type: 'blob', ref: { $link: 'bafyimg' } } })) } })

console.log('\n# the filter set matches the lexicon')
for (const v of ['posts_with_replies', 'posts_no_replies', 'posts_with_media', 'posts_and_author_threads', 'posts_with_video']) {
  assert(AUTHOR_FILTERS.has(v), `knownValue ${v} is supported`)
}

// A's own root post, so alice has something to self-thread off.
const ROOT = uri(A, 'root')
await put(A, 'root', base('alice root', ts(0)))
const BROOT = uri(B, 'broot')
await put(B, 'broot', base('bob root', ts(1)))

// INTERLEAVED so a "read limit then discard" implementation cannot pass by accident: alternating
// top-level post / reply-to-bob, 10 of each.
for (let i = 0; i < 10; i++) {
  await put(A, 'top' + i, base('top ' + i, ts(10 + i * 2)))
  await put(A, 'rep' + i, base('reply ' + i, ts(11 + i * 2), { reply: { root: ref(BROOT), parent: ref(BROOT) } }))
}
// Self-thread replies (alice replying to her OWN root) and a couple of image posts.
await put(A, 'self1', base('self thread 1', ts(60), { reply: { root: ref(ROOT), parent: ref(ROOT) } }))
await put(A, 'self2', base('self thread 2', ts(61), { reply: { root: ref(ROOT), parent: ref(uri(A, 'self1')) } }))
await put(A, 'pic1', base('with a picture', ts(70), images(1)))
await put(A, 'pic2', base('another picture', ts(71), images(3)))

const rkeys = (r) => r.posts.map((p) => p.rkey)

console.log('\n# posts_with_replies — everything (the lexicon default)')
const all = await av.getAuthorFeed(A, { limit: 100, filter: 'posts_with_replies' })
assert(all.posts.length === 25, 'all 25 of alice\'s posts (1 root + 10 top + 10 replies + 2 self + 2 pics)')

console.log('\n# posts_no_replies — top-level only')
const noReplies = await av.getAuthorFeed(A, { limit: 100, filter: 'posts_no_replies' })
assert(!rkeys(noReplies).some((k) => k.startsWith('rep')), 'no replies-to-others')
assert(!rkeys(noReplies).some((k) => k.startsWith('self')), 'no self-thread replies either — a reply is a reply')
assert(noReplies.posts.length === 13, 'leaves the 13 top-level posts (root + 10 top + 2 pics)')

console.log('\n# PAGE FILLING — the property a post-hoc filter gets wrong')
const page = await av.getAuthorFeed(A, { limit: 5, filter: 'posts_no_replies' })
assert(page.posts.length === 5, 'a limit-5 page returns 5 MATCHES, not 5-scanned-minus-discards')
assert(page.cursor, 'and a cursor, since more remain')
const page2 = await av.getAuthorFeed(A, { limit: 5, cursor: page.cursor, filter: 'posts_no_replies' })
assert(page2.posts.length === 5, 'the next page also returns 5 matches')
const overlap = rkeys(page).filter((k) => rkeys(page2).includes(k))
assert(overlap.length === 0, 'and does not repeat the first page')

console.log('\n# posts_and_author_threads — self-threads count, conversations do not')
const threads = await av.getAuthorFeed(A, { limit: 100, filter: 'posts_and_author_threads' })
const tk = rkeys(threads)
assert(tk.includes('self1') && tk.includes('self2'), 'replies to her OWN posts are included')
assert(!tk.some((k) => k.startsWith('rep')), 'replies to BOB are excluded — that is a conversation, not her thread')
assert(tk.includes('root') && tk.includes('top0'), 'top-level posts are still included')

console.log('\n# posts_with_media — images only')
const media = await av.getAuthorFeed(A, { limit: 100, filter: 'posts_with_media' })
assert(rkeys(media).sort().join(',') === 'pic1,pic2', 'exactly the two posts carrying an images embed')

console.log('\n# posts_with_video — none on this node, and that is not an error')
const video = await av.getAuthorFeed(A, { limit: 100, filter: 'posts_with_video' })
assert(video.posts.length === 0, 'our composer only writes app.bsky.embed.images, so this is empty')

console.log('\n# an unknown filter degrades, it does not throw')
const bogus = await av.getAuthorFeed(A, { limit: 100, filter: 'posts_with_haiku' })
assert(bogus.posts.length === 25, 'a hand-edited query string falls back to the default rather than 500ing')

console.log('\n# the filter is per-author')
const bobs = await av.getAuthorFeed(B, { limit: 100, filter: 'posts_no_replies' })
assert(rkeys(bobs).join(',') === 'broot', "bob's feed is unaffected by alice's replies to him")

console.log('\nall author-filter assertions passed')