Refactor paginated pages onto shared base, add property coverage

Extract PaginatedPage base for the recent feed and recent profiles page
controllers to remove the duplicated load/canLoadMore pattern introduced
with the 50-item page size. Refresh mutation manifests via the tool. Add
property tests for the base pagination invariants (canLoadMore mirrors
hasMore, load forwards limit/offset, item lookup).

By refactorer.
This commit is contained in:
Chris Troutner
2026-09-04 10:06:27 -07:00
parent 38f7a42b90
commit a9488d9ae2
4 changed files with 174 additions and 41 deletions
@@ -0,0 +1,41 @@
/*
Shared base for simple paginated page controllers.
A paginated page loads a single list from a MemoDb method, stores the list
and its pagination, and reports whether more items can be loaded. Subclasses
supply the memoDb method name, the result list field, and the error message
for the missing-client guard, then add their own item finder.
*/
class PaginatedPage {
constructor (deps = {}, { listField, loadMethod, errorMessage }) {
this.memoDb = deps.memoDb || null
this[listField] = []
this.pagination = null
this._listField = listField
this._loadMethod = loadMethod
this._errorMessage = errorMessage
}
async load ({ limit = 50, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error(this._errorMessage)
}
const data = await this.memoDb[this._loadMethod]({ limit, offset })
this[this._listField] = data[this._listField] || []
this.pagination = data.pagination || null
return { [this._listField]: this[this._listField], pagination: this.pagination }
}
canLoadMore () {
return this.pagination?.hasMore ?? false
}
}
module.exports = PaginatedPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-04T17:03:38.880Z","module_hash":"6ba99f826fd1a2bd4a090bedaabe3a8497d4ebe3694138b26090fa5c2f1f6efb","functions":[{"id":"func/PaginatedPage.constructor","name":"PaginatedPage.constructor","line":11,"end_line":18,"hash":"14d764e7220271146f33020326072e2ebb653e1a1372360348e552056d21e0c8"},{"id":"func/PaginatedPage.load","name":"PaginatedPage.load","line":20,"end_line":30,"hash":"b9c43d761b25e202dfbc16beee7ab90b06ee75dcb9ba0799ee014d513b2e9a5c"},{"id":"func/PaginatedPage.canLoadMore","name":"PaginatedPage.canLoadMore","line":32,"end_line":34,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"}]}
// mutate4javascript-manifest-end
@@ -10,34 +10,22 @@
boundary.
*/
const PaginatedPage = require('./paginated-page')
const RECENT_FEED_PATH = '/posts/recent'
class RecentFeedPage {
class RecentFeedPage extends PaginatedPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.posts = []
this.pagination = null
}
async load ({ limit = 50, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error('Recent feed page requires a memo db client.')
}
const data = await this.memoDb.getRecentPosts({ limit, offset })
this.posts = data.posts || []
this.pagination = data.pagination || null
return { posts: this.posts, pagination: this.pagination }
super(deps, {
listField: 'posts',
loadMethod: 'getRecentPosts',
errorMessage: 'Recent feed page requires a memo db client.'
})
}
getPost (txid) {
return this.posts.find((post) => post.txid === txid) || null
}
canLoadMore () {
return this.pagination?.hasMore ?? false
}
}
RecentFeedPage.RECENT_FEED_PATH = RECENT_FEED_PATH
@@ -45,5 +33,5 @@ RecentFeedPage.RECENT_FEED_PATH = RECENT_FEED_PATH
module.exports = RecentFeedPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-27T03:38:52.144Z","module_hash":"e2ee3beff9079c7f93d29043b1428c271fbd1a2b64f2635fbc2cc06fb52a3aab","functions":[{"id":"func/RecentFeedPage.constructor","name":"RecentFeedPage.constructor","line":16,"end_line":20,"hash":"0eb6faf1dfd73ad95b210473ab82fdae513ff1570e401820645661881a2f8d4b"},{"id":"func/RecentFeedPage.load","name":"RecentFeedPage.load","line":22,"end_line":32,"hash":"96e8f2c77aadf98e3a9e71e1f0f6ce98a3e47dd828ffb4626916f56ca32ed137"},{"id":"func/RecentFeedPage.getPost","name":"RecentFeedPage.getPost","line":34,"end_line":36,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// {"version":1,"tested_at":"2026-09-04T17:03:39.630Z","module_hash":"9c0c48d7b21d24fcf640bf461b9798b2377097687c433be64776495ab7166601","functions":[{"id":"func/RecentFeedPage.constructor","name":"RecentFeedPage.constructor","line":18,"end_line":24,"hash":"78adbb7be7ccdc136fb6bb9201a8447b0a484f525a7426f25f281b0ecd78c0ae"},{"id":"func/RecentFeedPage.getPost","name":"RecentFeedPage.getPost","line":26,"end_line":28,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// mutate4javascript-manifest-end
@@ -6,29 +6,17 @@
view can render them.
*/
const PaginatedPage = require('./paginated-page')
const RECENT_PROFILES_PATH = '/profile/recent'
class RecentProfilesPage {
class RecentProfilesPage extends PaginatedPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.profiles = []
this.pagination = null
}
async load ({ limit = 50, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error('Recent profiles page requires a memo db client.')
}
const data = await this.memoDb.getRecentProfiles({ limit, offset })
this.profiles = data.profiles || []
this.pagination = data.pagination || null
return { profiles: this.profiles, pagination: this.pagination }
}
canLoadMore () {
return this.pagination?.hasMore ?? false
super(deps, {
listField: 'profiles',
loadMethod: 'getRecentProfiles',
errorMessage: 'Recent profiles page requires a memo db client.'
})
}
getProfile (addr) {
@@ -39,3 +27,7 @@ class RecentProfilesPage {
RecentProfilesPage.RECENT_PROFILES_PATH = RECENT_PROFILES_PATH
module.exports = RecentProfilesPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-04T17:03:40.330Z","module_hash":"8a305e7c3c74e6ef33e57da9a0c1f3d3e0eff6ff2d6da99d1383d93874745b65","functions":[{"id":"func/RecentProfilesPage.constructor","name":"RecentProfilesPage.constructor","line":14,"end_line":20,"hash":"01a7876c30597e8eb8e37003290b8a9c9db2d3f2bc7305104e1b02659cc413c5"},{"id":"func/RecentProfilesPage.getProfile","name":"RecentProfilesPage.getProfile","line":22,"end_line":24,"hash":"c93f06a279f8976938fc8b91ce24e9e742c700ac6ff271328192dba9140ae195"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,112 @@
/*
Property tests for the shared PaginatedPage base controller.
The unit tests probe a few fixed pagination shapes. These properties cover
the load/`canLoadMore`/lookup invariants over broad random inputs so they
hold everywhere for the recent feed and recent profiles pages:
- has more: canLoadMore always mirrors pagination.hasMore.
- forwarding: the load limit and offset forwarded to the memo-db client
match exactly what the caller requested.
- lookup: the item finder returns a loaded item by key and null otherwise.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll, intGen } = require('./harness')
const PaginatedPage = require('../../src/services/paginated-page')
const rng = seededRandom(20260904)
// A minimal concrete subclass exercising the shared base logic.
class TestPage extends PaginatedPage {
constructor (deps = {}) {
super(deps, {
listField: 'items',
loadMethod: 'getItems',
errorMessage: 'Test page requires a memo db client.'
})
}
getItem (key) {
return this.items.find((item) => item.key === key) || null
}
}
function makeMemoDb (items, pagination) {
return {
async getItems ({ limit, offset }) {
return { items, pagination }
}
}
}
function fixtureGen () {
return () => {
const n = intGen(rng, 0, 8)()
const items = []
for (let i = 0; i < n; i++) {
items.push({ key: 'item-' + i, text: 'value ' + i })
}
return {
items,
pagination: { hasMore: rng() < 0.5 },
limit: intGen(rng, 1, 100)(),
offset: intGen(rng, 0, 200)()
}
}
}
test('canLoadMore always mirrors pagination.hasMore', async () => {
await forAll(
fixtureGen(),
async ({ items, pagination, limit, offset }) => {
const page = new TestPage({ memoDb: makeMemoDb(items, pagination) })
await page.load({ limit, offset })
return page.canLoadMore() === (pagination.hasMore === true) &&
page.pagination.hasMore === pagination.hasMore
},
{ label: 'paginated canLoadMore mirrors hasMore' }
)
})
test('load forwards exactly the requested limit and offset to the memo-db client', async () => {
await forAll(
fixtureGen(),
async ({ items, pagination, limit, offset }) => {
const calls = []
const memoDb = {
async getItems (params) {
calls.push(params)
return { items, pagination }
}
}
const page = new TestPage({ memoDb })
await page.load({ limit, offset })
return calls.length === 1 &&
calls[0].limit === limit &&
calls[0].offset === offset
},
{ label: 'paginated load forwards limit and offset' }
)
})
test('getItem returns a loaded item by key, otherwise null', async () => {
await forAll(
fixtureGen(),
async ({ items, pagination, limit, offset }) => {
const page = new TestPage({ memoDb: makeMemoDb(items, pagination) })
await page.load({ limit, offset })
if (items.length === 0) return true
const any = page.getItem(items[0].key)
if (!any || any.text !== items[0].text) return false
return page.getItem('does-not-exist') === null
},
{ label: 'paginated getItem lookup' }
)
})