Refactor notifications query: cut CRAP, share test double, add membership property

Reduce the notifications-query collector cyclomatic complexity below the CRAP
threshold by extracting prefix-range, txid-fallback, height-selection, and
actor-suppression helpers, and by sharing one per-post index scan between the
like and reply collectors. Reuse the shared FakeDb in the unit and property
suites instead of two local LevelDB doubles, cover every not-found branch, and
add a property test asserting returned notifications map to exactly one
generated interaction.

By refactorer.
This commit is contained in:
Chris Troutner
2026-09-18 10:19:26 -07:00
parent f45d63cd70
commit 45a7b34fad
4 changed files with 272 additions and 105 deletions
+97 -52
View File
@@ -33,12 +33,48 @@ function padHeight (blockHeight) {
return String(blockHeight ?? 0).padStart(HEIGHT_PAD, '0') return String(blockHeight ?? 0).padStart(HEIGHT_PAD, '0')
} }
// The half-open key range covering one prefix's records at or above cutoff:
// `${prefix}<paddedCutoff>` .. `${prefix}\uffff`. A null cutoff starts at the
// prefix itself, so the whole history is in range.
function prefixRange (prefix, cutoff) {
const start = cutoff === null ? prefix : `${prefix}${padHeight(cutoff)}`
return { start, end: `${prefix}\uffff` }
}
// The txid carried by a per-post index entry, falling back to the final
// colon-delimited key segment when the value omits it.
function txidFromKey (key) {
return String(key).slice(String(key).lastIndexOf(':') + 1)
}
// The first value that is neither null nor undefined, else 0. Used to pick the
// most specific block height across a record, its parent, and the post.
function firstDefined (...values) {
for (const value of values) {
if (value !== null && value !== undefined) return value
}
return 0
}
// An actor never notifies themselves, and muted actors never notify.
function isSuppressedActor (actorAddr, addr, mutedAddrs) {
return actorAddr === addr || mutedAddrs.has(actorAddr)
}
function isNotFoundError (err) {
return Boolean(
err?.notFound ||
err?.code === 'LEVEL_NOT_FOUND' ||
err?.response?.status === 404
)
}
// Read a record by key, returning null when it does not exist. // Read a record by key, returning null when it does not exist.
async function getRecordOrNull (db, key) { async function getRecordOrNull (db, key) {
try { try {
return await db.get(key) return await db.get(key)
} catch (err) { } catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND' || err.response?.status === 404) return null if (isNotFoundError(err)) return null
throw err throw err
} }
} }
@@ -91,7 +127,9 @@ class NotificationsQuery {
this.listNotifications = this.listNotifications.bind(this) this.listNotifications = this.listNotifications.bind(this)
this._windowCutoff = this._windowCutoff.bind(this) this._windowCutoff = this._windowCutoff.bind(this)
this._scanViewerPosts = this._scanViewerPosts.bind(this) this._scanViewerPosts = this._scanViewerPosts.bind(this)
this._scanPostIndex = this._scanPostIndex.bind(this)
this._collectFollowNotifications = this._collectFollowNotifications.bind(this) this._collectFollowNotifications = this._collectFollowNotifications.bind(this)
this._followNotifications = this._followNotifications.bind(this)
this._collectLikeNotifications = this._collectLikeNotifications.bind(this) this._collectLikeNotifications = this._collectLikeNotifications.bind(this)
this._collectReplyNotifications = this._collectReplyNotifications.bind(this) this._collectReplyNotifications = this._collectReplyNotifications.bind(this)
this._followerFromKey = this._followerFromKey.bind(this) this._followerFromKey = this._followerFromKey.bind(this)
@@ -111,40 +149,58 @@ class NotificationsQuery {
// Prefix-scan the viewer's posts within the window from addrPostHeights, // Prefix-scan the viewer's posts within the window from addrPostHeights,
// newest-first is not required because likes/replies are scanned per txid. // newest-first is not required because likes/replies are scanned per txid.
async _scanViewerPosts (addr, cutoff) { async _scanViewerPosts (addr, cutoff) {
const prefix = `${addr}:` const { start, end } = prefixRange(`${addr}:`, cutoff)
const start = cutoff === null ? prefix : `${addr}:${padHeight(cutoff)}`
const end = `${addr}:\uffff`
const posts = [] const posts = []
for await (const [key, value] of this.addrPostHeightsDb.iterator({ gte: start, lte: end })) { for await (const [key, value] of this.addrPostHeightsDb.iterator({ gte: start, lte: end })) {
const txid = value?.txid || key.slice(key.lastIndexOf(':') + 1) const txid = value?.txid || txidFromKey(key)
if (!txid) continue if (!txid) continue
posts.push({ txid, blockHeight: value?.blockHeight ?? 0 }) posts.push({ txid, blockHeight: value?.blockHeight })
} }
return posts return posts
} }
// Walk a per-post index keyed `${postTxid}:${childKey}` for the viewer's
// posts only, resolving each entry's txid. The index is never scanned
// globally, so this bounds likes and replies to the viewer's content.
async _scanPostIndex (db, posts, txidField) {
const entries = []
for (const post of posts) {
const prefix = `${post.txid}:`
for await (const [key, value] of db.iterator({ gte: prefix, lte: `${prefix}\uffff` })) {
const txid = value?.[txidField] || txidFromKey(key)
if (txid) entries.push({ txid, value, post })
}
}
return entries
}
// Collect the viewer's follows from the followeeHeights index within the // Collect the viewer's follows from the followeeHeights index within the
// window, keeping the newest entry per follower and ignoring unfollows. // window, keeping the newest entry per follower and ignoring unfollows.
async _collectFollowNotifications (addr, cutoff, mutedAddrs) { async _collectFollowNotifications (addr, cutoff, mutedAddrs) {
const myHash160 = this.bchjs.Address.toHash160(addr) const myHash160 = this.bchjs.Address.toHash160(addr)
const start = cutoff === null ? `${myHash160}:` : `${myHash160}:${padHeight(cutoff)}` const { start, end } = prefixRange(`${myHash160}:`, cutoff)
const end = `${myHash160}:\uffff`
const newestByFollower = new Map() const newestByFollower = new Map()
for await (const [key, record] of this.followeeHeightsDb.iterator({ gte: start, lte: end })) { for await (const [key, record] of this.followeeHeightsDb.iterator({ gte: start, lte: end })) {
const followerAddr = record?.followerAddr || this._followerFromKey(key) const followerAddr = record?.followerAddr || this._followerFromKey(key)
if (!followerAddr) continue if (followerAddr) newestByFollower.set(followerAddr, record)
// Ascending height order means the last entry for a follower wins.
newestByFollower.set(followerAddr, record)
} }
return this._followNotifications(newestByFollower, addr, mutedAddrs)
}
// Turn the newest follow entry per follower into follow notifications,
// dropping unfollows, self-follows, and muted followers.
_followNotifications (newestByFollower, addr, mutedAddrs) {
const notifications = [] const notifications = []
for (const [followerAddr, record] of newestByFollower) { for (const [followerAddr, record] of newestByFollower) {
if (record.unfollow === true) continue if (record.unfollow === true) continue
if (followerAddr === addr) continue if (isSuppressedActor(followerAddr, addr, mutedAddrs)) continue
if (mutedAddrs.has(followerAddr)) continue
notifications.push({ notifications.push({
type: 'follow', type: 'follow',
@@ -161,28 +217,22 @@ class NotificationsQuery {
// Prefix-scan postLikes for each viewer post and load the like record to get // Prefix-scan postLikes for each viewer post and load the like record to get
// the actor and height. The global likes store is never iterated. // the actor and height. The global likes store is never iterated.
async _collectLikeNotifications (posts, addr, mutedAddrs) { async _collectLikeNotifications (posts, addr, mutedAddrs) {
const entries = await this._scanPostIndex(this.postLikesDb, posts, 'txid')
const notifications = [] const notifications = []
for (const post of posts) { for (const { txid, post } of entries) {
const prefix = `${post.txid}:` const like = await getRecordOrNull(this.likesDb, txid)
for await (const [key, value] of this.postLikesDb.iterator({ gte: prefix, lte: `${post.txid}:\uffff` })) { if (!like) continue
const likeTxid = value?.txid || key.slice(key.lastIndexOf(':') + 1) if (isSuppressedActor(like.addr, addr, mutedAddrs)) continue
if (!likeTxid) continue
const like = await getRecordOrNull(this.likesDb, likeTxid) notifications.push({
if (!like) continue type: 'like',
if (like.addr === addr) continue txid,
if (mutedAddrs.has(like.addr)) continue addr: like.addr,
postTxid: post.txid,
notifications.push({ blockHeight: firstDefined(like.blockHeight, post.blockHeight),
type: 'like', seen: like.seen ?? 0
txid: likeTxid, })
addr: like.addr,
postTxid: post.txid,
blockHeight: like.blockHeight ?? post.blockHeight ?? 0,
seen: like.seen ?? 0
})
}
} }
return notifications return notifications
@@ -192,30 +242,25 @@ class NotificationsQuery {
// the actor and text. postChildren is only read per viewer post, never // the actor and text. postChildren is only read per viewer post, never
// full-scanned. // full-scanned.
async _collectReplyNotifications (posts, addr, mutedAddrs) { async _collectReplyNotifications (posts, addr, mutedAddrs) {
const entries = await this._scanPostIndex(this.postChildrenDb, posts, 'childTxid')
const notifications = [] const notifications = []
for (const post of posts) { for (const { txid: childTxid, value: child, post } of entries) {
const prefix = `${post.txid}:` if (child?.parentTxid && child.parentTxid !== post.txid) continue
for await (const [key, child] of this.postChildrenDb.iterator({ gte: prefix, lte: `${post.txid}:\uffff` })) {
const childTxid = child?.childTxid || key.slice(key.lastIndexOf(':') + 1)
if (!childTxid) continue
if (child?.parentTxid && child.parentTxid !== post.txid) continue
const childPost = await getPostOrNull(this.postsDb, childTxid) const childPost = await getPostOrNull(this.postsDb, childTxid)
if (!childPost) continue if (!childPost) continue
if (childPost.addr === addr) continue if (isSuppressedActor(childPost.addr, addr, mutedAddrs)) continue
if (mutedAddrs.has(childPost.addr)) continue
notifications.push({ notifications.push({
type: 'reply', type: 'reply',
txid: childTxid, txid: childTxid,
addr: childPost.addr, addr: childPost.addr,
postTxid: post.txid, postTxid: post.txid,
text: childPost.text, text: childPost.text,
blockHeight: child?.blockHeight ?? childPost.blockHeight ?? post.blockHeight ?? 0, blockHeight: firstDefined(child?.blockHeight, childPost.blockHeight, post.blockHeight),
seen: childPost.seen ?? 0 seen: childPost.seen ?? 0
}) })
}
} }
return notifications return notifications
@@ -20,6 +20,7 @@
import test from 'node:test' import test from 'node:test'
import { seededRandom, forAll, intGen } from './harness.js' import { seededRandom, forAll, intGen } from './harness.js'
import NotificationsQuery from '../../src/adapters/notifications-query.js' import NotificationsQuery from '../../src/adapters/notifications-query.js'
import { FakeDb } from '../support/level-double.js'
const rng = seededRandom(20260918) const rng = seededRandom(20260918)
@@ -31,28 +32,7 @@ const VIEWER_POSTS = ['vp1', 'vp2', 'vp3', 'vp4']
const PAD = 12 const PAD = 12
const pad = (h) => String(h).padStart(PAD, '0') const pad = (h) => String(h).padStart(PAD, '0')
function makeDb (entries = []) { const makeDb = (entries = []) => new FakeDb(entries)
const map = new Map(entries)
return {
map,
async get (key) {
if (!map.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return map.get(key)
},
iterator (opts = {}) {
let keys = [...map.keys()].sort()
if (opts.gte !== undefined) keys = keys.filter((key) => key >= opts.gte)
if (opts.lte !== undefined) keys = keys.filter((key) => key <= opts.lte)
return (async function * () {
for (const key of keys) yield [key, map.get(key)]
}())
}
}
}
function buildQuery () { function buildQuery () {
// statusDb null => no window; every generated interaction is in scope. // statusDb null => no window; every generated interaction is in scope.
@@ -97,10 +77,11 @@ function fixtureGen () {
const follower = OTHERS[Math.floor(rng() * OTHERS.length)] const follower = OTHERS[Math.floor(rng() * OTHERS.length)]
const n = randomNotification() const n = randomNotification()
const unfollow = rng() < 0.25 const unfollow = rng() < 0.25
events.push({ follower, ...n, unfollow }) const followTxid = `follow${i}`
events.push({ follower, txid: followTxid, ...n, unfollow })
followeeHeightEntries.push([ followeeHeightEntries.push([
`${MY_HASH}:${pad(n.blockHeight)}:${follower}`, `${MY_HASH}:${pad(n.blockHeight)}:${follower}`,
{ followerAddr: follower, followeePkHash: MY_HASH, unfollow, txid: `follow${i}`, seen: n.seen, blockHeight: n.blockHeight } { followerAddr: follower, followeePkHash: MY_HASH, unfollow, txid: followTxid, seen: n.seen, blockHeight: n.blockHeight }
]) ])
} }
@@ -112,7 +93,7 @@ function fixtureGen () {
const actor = OTHERS[Math.floor(rng() * OTHERS.length)] const actor = OTHERS[Math.floor(rng() * OTHERS.length)]
const n = randomNotification() const n = randomNotification()
const likeTxid = `like${i}` const likeTxid = `like${i}`
likeRecords.push({ likeTxid, addr: actor, ...n }) likeRecords.push({ likeTxid, txid: likeTxid, addr: actor, postTxid, ...n })
postLikesEntries.push([`${postTxid}:${likeTxid}`, { postTxid, txid: likeTxid }]) postLikesEntries.push([`${postTxid}:${likeTxid}`, { postTxid, txid: likeTxid }])
likesEntries.push([likeTxid, { addr: actor, postTxid, blockHeight: n.blockHeight, seen: n.seen }]) likesEntries.push([likeTxid, { addr: actor, postTxid, blockHeight: n.blockHeight, seen: n.seen }])
} }
@@ -125,7 +106,7 @@ function fixtureGen () {
const childTxid = `child${i}` const childTxid = `child${i}`
const actor = OTHERS[Math.floor(rng() * OTHERS.length)] const actor = OTHERS[Math.floor(rng() * OTHERS.length)]
const n = randomNotification() const n = randomNotification()
replyRecords.push({ childTxid, addr: actor, ...n }) replyRecords.push({ childTxid, txid: childTxid, addr: actor, parentTxid, ...n })
postChildrenEntries.push([`${parentTxid}:${childTxid}`, { parentTxid, childTxid, blockHeight: n.blockHeight }]) postChildrenEntries.push([`${parentTxid}:${childTxid}`, { parentTxid, childTxid, blockHeight: n.blockHeight }])
postsDbEntries.push([childTxid, { addr: actor, text: 'reply', blockHeight: n.blockHeight, seen: n.seen }]) postsDbEntries.push([childTxid, { addr: actor, text: 'reply', blockHeight: n.blockHeight, seen: n.seen }])
} }
@@ -183,6 +164,29 @@ function buildExpected ({ events, likeRecords, replyRecords, limit, offset }) {
return { total: out.length, page: out.slice(offset, offset + limit) } return { total: out.length, page: out.slice(offset, offset + limit) }
} }
// The identity of a notification: the txid of the active follow, like, or
// reply that produced it. Two notifications with the same identity cannot be
// distinct.
function identityOf (notification) {
return notification.txid
}
// The set of txids the adapter is allowed to return, derived directly from the
// generated input. It mirrors the newest-follow-wins rule.
function expectedIdentities ({ events, likeRecords, replyRecords }) {
const ids = new Set()
const newestByFollower = new Map()
for (const event of [...events].sort((a, b) => a.blockHeight - b.blockHeight)) {
newestByFollower.set(event.follower, event)
}
for (const event of newestByFollower.values()) {
if (!event.unfollow) ids.add(event.txid)
}
for (const like of likeRecords) ids.add(like.txid)
for (const reply of replyRecords) ids.add(reply.txid)
return ids
}
test('notifications are sorted newest-first with seen tie-break and exact pagination', async () => { test('notifications are sorted newest-first with seen tie-break and exact pagination', async () => {
await forAll( await forAll(
fixtureGen(), fixtureGen(),
@@ -226,3 +230,24 @@ test('the returned page is globally ordered by blockHeight then seen descending'
{ label: 'notifications global ordering invariant' } { label: 'notifications global ordering invariant' }
) )
}) })
test('every returned notification maps to exactly one generated interaction', async () => {
await forAll(
fixtureGen(),
async (input) => {
const query = populate(input)
const { notifications } = await query.listNotifications(VIEWER, { limit: 1000, offset: 0 })
const expected = expectedIdentities(input)
const actual = new Set(notifications.map(identityOf))
// No duplicate identities and no missing or spurious notifications.
if (actual.size !== notifications.length) return false
if (actual.size !== expected.size) return false
for (const id of actual) {
if (!expected.has(id)) return false
}
return true
},
{ label: 'notification membership matches generated interactions' }
)
})
@@ -1,6 +1,7 @@
import { assert } from 'chai' import { assert } from 'chai'
import sinon from 'sinon' import sinon from 'sinon'
import NotificationsQuery from '../../../src/adapters/notifications-query.js' import NotificationsQuery from '../../../src/adapters/notifications-query.js'
import { FakeDb } from '../../support/level-double.js'
describe('#NotificationsQuery', () => { describe('#NotificationsQuery', () => {
let sandbox let sandbox
@@ -15,33 +16,9 @@ describe('#NotificationsQuery', () => {
const HEIGHT_PAD = 12 const HEIGHT_PAD = 12
const pad = (h) => String(h).padStart(HEIGHT_PAD, '0') const pad = (h) => String(h).padStart(HEIGHT_PAD, '0')
// A minimal LevelDB double that honors gte/lte range options and records // The adapter only needs get and range iteration, both provided by the
// iteration calls so tests can assert that a store was never scanned. // shared in-memory LevelDB double.
function makeDb (entries = []) { const makeDb = (entries = []) => new FakeDb(entries)
const map = new Map(entries)
const iteratorCalls = []
return {
map,
iteratorCalls,
async get (key) {
if (!map.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return map.get(key)
},
iterator (opts = {}) {
iteratorCalls.push(opts)
let keys = [...map.keys()].sort()
if (opts.gte !== undefined) keys = keys.filter((key) => key >= opts.gte)
if (opts.lte !== undefined) keys = keys.filter((key) => key <= opts.lte)
return (async function * () {
for (const key of keys) yield [key, map.get(key)]
}())
}
}
}
function addrPostHeight (addr, height, txid) { function addrPostHeight (addr, height, txid) {
return [`${addr}:${pad(height)}:${txid}`, { txid, addr, blockHeight: height }] return [`${addr}:${pad(height)}:${txid}`, { txid, addr, blockHeight: height }]
@@ -327,6 +304,52 @@ describe('#NotificationsQuery', () => {
assert.equal(result.notifications[0].addr, FOLLOWER) assert.equal(result.notifications[0].addr, FOLLOWER)
}) })
it('should recover per-post index txids from keys when values omit them', async () => {
const postsDb = makeDb([
['reply-keyed', { addr: REPLIER, text: 'reply' }]
])
// addrPostHeights value omits txid; the key's final segment supplies it.
const addrPostHeightsDb = makeDb([
[`${VIEWER}:${pad(690000)}:post-recent`, { addr: VIEWER, blockHeight: 690000 }]
])
// postLikes value omits txid and postChildren value omits childTxid.
const postLikesDb = makeDb([
['post-recent:like-keyed', { postTxid: 'post-recent' }]
])
const likesDb = makeDb([
['like-keyed', { addr: LIKER, postTxid: 'post-recent', blockHeight: 690100 }]
])
const postChildrenDb = makeDb([
['post-recent:reply-keyed', { parentTxid: 'post-recent' }]
])
const uut = buildQuery({ postsDb, addrPostHeightsDb, postLikesDb, likesDb, postChildrenDb })
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
assert.equal(result.total, 2)
assert.deepEqual(result.notifications.map((n) => n.type).sort(), ['like', 'reply'])
})
it('should default a notification height to 0 when no source records one', async () => {
const postsDb = makeDb([
['reply-bare', { addr: REPLIER, text: 'reply' }]
])
const addrPostHeightsDb = makeDb([
[`${VIEWER}:${pad(690000)}:post-recent`, { txid: 'post-recent', addr: VIEWER }]
])
const postLikesDb = makeDb([postLike('post-recent', 'like-bare')])
const likesDb = makeDb([['like-bare', { addr: LIKER, postTxid: 'post-recent' }]])
const postChildrenDb = makeDb([
['post-recent:reply-bare', { childTxid: 'reply-bare' }]
])
const uut = buildQuery({ postsDb, addrPostHeightsDb, postLikesDb, likesDb, postChildrenDb })
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
assert.equal(result.total, 2)
assert.deepEqual(result.notifications.map((n) => n.blockHeight), [0, 0])
})
it('should skip a missing like record', async () => { it('should skip a missing like record', async () => {
const postsDb = makeDb([['post-recent', { addr: VIEWER, text: 'hi' }]]) const postsDb = makeDb([['post-recent', { addr: VIEWER, text: 'hi' }]])
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 690000, 'post-recent')]) const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 690000, 'post-recent')])
@@ -338,6 +361,58 @@ describe('#NotificationsQuery', () => {
assert.equal(result.total, 0) assert.equal(result.total, 0)
}) })
it('should skip a like whose record reports LEVEL_NOT_FOUND', async () => {
const postsDb = makeDb([['post-recent', { addr: VIEWER, text: 'hi' }]])
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 690000, 'post-recent')])
const postLikesDb = makeDb([postLike('post-recent', 'like-missing')])
const likesDb = makeDb()
likesDb.get = async () => {
const err = new Error('level not found')
err.code = 'LEVEL_NOT_FOUND'
throw err
}
const uut = buildQuery({ postsDb, addrPostHeightsDb, postLikesDb, likesDb })
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
assert.equal(result.total, 0)
})
it('should skip a like whose record reports an HTTP 404', async () => {
const postsDb = makeDb([['post-recent', { addr: VIEWER, text: 'hi' }]])
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 690000, 'post-recent')])
const postLikesDb = makeDb([postLike('post-recent', 'like-missing')])
const likesDb = makeDb()
likesDb.get = async () => {
const err = new Error('not found')
err.response = { status: 404 }
throw err
}
const uut = buildQuery({ postsDb, addrPostHeightsDb, postLikesDb, likesDb })
const result = await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
assert.equal(result.total, 0)
})
it('should rethrow a non-not-found error from the likes store', async () => {
const postsDb = makeDb([['post-recent', { addr: VIEWER, text: 'hi' }]])
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 690000, 'post-recent')])
const postLikesDb = makeDb([postLike('post-recent', 'like-recent')])
const likesDb = makeDb()
likesDb.get = async () => { throw new Error('boom') }
const uut = buildQuery({ postsDb, addrPostHeightsDb, postLikesDb, likesDb })
let error
try {
await uut.listNotifications(VIEWER, { limit: 100, offset: 0 })
} catch (err) {
error = err
}
assert.equal(error?.message, 'boom')
})
it('should default notificationBlockWindow to 25000', async () => { it('should default notificationBlockWindow to 25000', async () => {
const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 1, 'post-old')]) const addrPostHeightsDb = makeDb([addrPostHeight(VIEWER, 1, 'post-old')])
const uut = buildQuery({ addrPostHeightsDb, statusDb: makeDb([['status', { chainBlockHeight: 700000 }]]) }) const uut = buildQuery({ addrPostHeightsDb, statusDb: makeDb([['status', { chainBlockHeight: 700000 }]]) })
@@ -80,6 +80,28 @@ describe('#backfillFolloweeIndex', () => {
assert.deepEqual(db.store, before) assert.deepEqual(db.store, before)
}) })
it('should pad a missing height to zero', () => {
assert.equal(
followeeHeightKey(VIEWER_HASH, undefined, FOLLOWER_A),
`${VIEWER_HASH}:${'0'.repeat(12)}:${FOLLOWER_A}`
)
})
it('should skip records that cannot yield a follower and default missing fields', async () => {
const db = new FakeDb([
[':deadbeef', {}],
[`${FOLLOWER_A}:${VIEWER_HASH}`, { txid: 'follow-a' }]
])
const followeeHeightsDb = new FakeDb()
const result = await backfillFolloweeIndex({ followsDb: db, followeeHeightsDb })
assert.equal(result.follows, 1)
const stored = followeeHeightsDb.store.get(followeeHeightKey(VIEWER_HASH, 0, FOLLOWER_A))
assert.equal(stored.unfollow, false)
assert.equal(stored.blockHeight, 0)
})
it('should recover the follower and followee from the key when fields are missing', async () => { it('should recover the follower and followee from the key when fields are missing', async () => {
const db = new FakeDb([ const db = new FakeDb([
[`${FOLLOWER_A}:${VIEWER_HASH}`, { unfollow: false, blockHeight: 690400 }] [`${FOLLOWER_A}:${VIEWER_HASH}`, { unfollow: false, blockHeight: 690400 }]