diff --git a/psf-memo-client/src/services/paginated-page.js b/psf-memo-client/src/services/paginated-page.js new file mode 100644 index 0000000..653765e --- /dev/null +++ b/psf-memo-client/src/services/paginated-page.js @@ -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 diff --git a/psf-memo-client/src/services/recent-feed-page.js b/psf-memo-client/src/services/recent-feed-page.js index 396d83b..9349549 100644 --- a/psf-memo-client/src/services/recent-feed-page.js +++ b/psf-memo-client/src/services/recent-feed-page.js @@ -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 diff --git a/psf-memo-client/src/services/recent-profiles-page.js b/psf-memo-client/src/services/recent-profiles-page.js index 2f0f526..15a3d38 100644 --- a/psf-memo-client/src/services/recent-profiles-page.js +++ b/psf-memo-client/src/services/recent-profiles-page.js @@ -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 diff --git a/psf-memo-client/test/property/paginated-page.property.test.js b/psf-memo-client/test/property/paginated-page.property.test.js new file mode 100644 index 0000000..57f02e2 --- /dev/null +++ b/psf-memo-client/test/property/paginated-page.property.test.js @@ -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' } + ) +})