diff --git a/psf-memo-client/src/services/memo-db.js b/psf-memo-client/src/services/memo-db.js index cccf80a..6dca18e 100644 --- a/psf-memo-client/src/services/memo-db.js +++ b/psf-memo-client/src/services/memo-db.js @@ -52,17 +52,8 @@ class MemoDb { return this.getRecent('/topics', 'getTopics', {}) } - async getTopicPosts (room, { limit = 100, offset = 0 } = {}) { - try { - const result = await this.axios.get( - `${config.backend}/topics/${encodeURIComponent(room)}/posts`, - { params: { limit, offset } } - ) - return result.data - } catch (err) { - console.error('Error in getTopicPosts()') - throw err - } + async getTopicPosts (room, opts = {}) { + return this.getPage(`/topics/${encodeURIComponent(room)}/posts`, 'getTopicPosts', opts) } // GET a paginated 'recent' listing endpoint. @@ -79,6 +70,20 @@ class MemoDb { } } + // GET a paginated resource page at a full path. + async getPage (path, name, { limit = 100, offset = 0 } = {}) { + try { + const result = await this.axios.get(`${config.backend}${path}`, { + params: { limit, offset } + }) + + return result.data + } catch (err) { + console.error(`Error in ${name}()`) + throw err + } + } + // GET a level endpoint that resolves an address. Returns null on 404. async getLevelResource (endpoint, addr, name) { try { @@ -95,18 +100,8 @@ class MemoDb { } } - async getPostsByAddr (addr, { limit = 100, offset = 0 } = {}) { - try { - const result = await this.axios.get( - `${config.backend}/posts/by/${encodeURIComponent(addr)}`, - { params: { limit, offset } } - ) - - return result.data - } catch (err) { - console.error('Error in getPostsByAddr()') - throw err - } + async getPostsByAddr (addr, opts = {}) { + return this.getPage(`/posts/by/${encodeURIComponent(addr)}`, 'getPostsByAddr', opts) } async getPostThread (txid) { diff --git a/psf-memo-client/test/property/topic-services.property.test.js b/psf-memo-client/test/property/topic-services.property.test.js new file mode 100644 index 0000000..fa5b1c0 --- /dev/null +++ b/psf-memo-client/test/property/topic-services.property.test.js @@ -0,0 +1,80 @@ +/* + Property tests for the topic discovery and topic feed page controllers. + + The unit tests probe getTopic / getPost at a few fixed fixtures. These + properties pin down the lookup invariants over broad random inputs: + + - TopicDiscoveryPage.getTopic finds any topic that was loaded. + - TopicFeedPage.getPost finds any post that was loaded. + - Lookups are stable: a loaded item is always found, and an unknown key + returns null. +*/ + +'use strict' + +const test = require('node:test') +const { seededRandom, forAll, intGen } = require('./harness') +const TopicDiscoveryPage = require('../../src/services/topic-discovery-page') +const TopicFeedPage = require('../../src/services/topic-feed-page') + +const rng = seededRandom(20260828) + +function randomRoom () { + return 'room-' + Math.floor(rng() * 1e6) +} + +function randomTxid () { + return 't' + rng().toString(36).slice(2, 10) + Math.floor(rng() * 1e6) +} + +test('TopicDiscoveryPage.getTopic finds any loaded topic', async () => { + await forAll( + (i) => { + const n = intGen(rng, 0, 20)() + const topics = [] + for (let j = 0; j < n; j++) { + topics.push({ room: randomRoom(), postCount: intGen(rng, 0, 1000)() }) + } + return topics + }, + async (topics) => { + const memoDb = { async getTopics () { return { topics } } } + const page = new TopicDiscoveryPage({ memoDb }) + await page.load() + + for (const topic of topics) { + const found = page.getTopic(topic.room) + if (!found || found.room !== topic.room) return false + } + if (page.getTopic('does-not-exist') !== null) return false + return true + }, + { label: 'topic discovery lookup completeness' } + ) +}) + +test('TopicFeedPage.getPost finds any loaded post', async () => { + await forAll( + (i) => { + const n = intGen(rng, 0, 20)() + const posts = [] + for (let j = 0; j < n; j++) { + posts.push({ txid: randomTxid(), text: 'post ' + j }) + } + return posts + }, + async (posts) => { + const memoDb = { async getTopicPosts () { return { posts, pagination: { total: posts.length } } } } + const page = new TopicFeedPage({ memoDb, room: 'bitcoin' }) + await page.load() + + for (const post of posts) { + const found = page.getPost(post.txid) + if (!found || found.txid !== post.txid) return false + } + if (page.getPost('does-not-exist') !== null) return false + return true + }, + { label: 'topic feed lookup completeness' } + ) +}) diff --git a/psf-memo-db/test/property/topic-query.property.test.js b/psf-memo-db/test/property/topic-query.property.test.js new file mode 100644 index 0000000..fbba33a --- /dev/null +++ b/psf-memo-db/test/property/topic-query.property.test.js @@ -0,0 +1,159 @@ +/* + Property tests for the TopicQuery adapter. + + The unit tests probe listTopics and getTopicPostTxids at a few fixed + fixtures. These properties pin down invariants that hold over broad random + inputs: + + - listTopics conservation: the sum of postCounts equals the number of + post entries in the rooms store, each room's count matches its own post + entries, and the topics are returned sorted by room name. + - getTopicPostTxids ordering + pagination: posts are returned newest-first + by block height, the total matches the room's post entries, and the + offset/limit slice is exact. + - roomFromKey / txidFromKey round-trip: the room and txid are recovered + from a `${room}:${txid}` key. +*/ + +import test from 'node:test' + +import { seededRandom, forAll, intGen, txidGen } from './harness.js' +import TopicQuery from '../../src/adapters/topic-query.js' + +const rng = seededRandom(20260828) + +// In-memory rooms store mirroring the LevelDB iterator contract (gte/lte +// prefix bounds over the key string). +function makeRoomsDb (entries) { + const store = new Map(entries.map((e) => [e.key, e.value])) + return { + async * iterator (opts = {}) { + const { gte, lte } = opts + let keys = Array.from(store.keys()).sort() + if (gte !== undefined) keys = keys.filter((k) => k >= gte) + if (lte !== undefined) keys = keys.filter((k) => k <= lte) + for (const key of keys) { + yield [key, store.get(key)] + } + } + } +} + +function makeQuery (entries) { + return new TopicQuery({ + roomsDb: makeRoomsDb(entries), + postsDb: {} + }) +} + +function fixtureGen () { + return () => { + const roomCount = intGen(rng, 0, 5)() + const rooms = [] + const entries = [] + for (let i = 0; i < roomCount; i++) { + const room = `room-${i}` + rooms.push(room) + + const postCount = intGen(rng, 0, 8)() + for (let j = 0; j < postCount; j++) { + entries.push({ + key: `${room}:${txidGen(rng)}`, + value: { + type: 'post', + txid: txidGen(rng), + blockHeight: intGen(rng, 0, 9000000)(), + room + } + }) + } + + const followCount = intGen(rng, 0, 3)() + for (let j = 0; j < followCount; j++) { + entries.push({ + key: `${room}:${txidGen(rng)}`, + value: { type: 'follow', room } + }) + } + } + + return { + entries, + rooms, + limit: intGen(rng, 1, 10)(), + offset: intGen(rng, 0, 8)() + } + } +} + +test('listTopics conserves post counts and returns rooms sorted by name', async () => { + await forAll( + fixtureGen(), + async ({ entries, rooms }) => { + const query = makeQuery(entries) + const topics = await query.listTopics() + + const postEntries = entries.filter((e) => e.value.type === 'post') + const totalPosts = topics.reduce((sum, t) => sum + t.postCount, 0) + if (totalPosts !== postEntries.length) return false + + const expectedRooms = [...new Set(entries.map((e) => e.value.room))].sort((a, b) => a.localeCompare(b)) + if (JSON.stringify(topics.map((t) => t.room)) !== JSON.stringify(expectedRooms)) return false + + for (const topic of topics) { + const roomPosts = postEntries.filter((e) => e.value.room === topic.room).length + if (topic.postCount !== roomPosts) return false + } + return true + }, + { label: 'listTopics conservation and ordering' } + ) +}) + +test('getTopicPostTxids returns posts newest-first with an exact total and slice', async () => { + await forAll( + fixtureGen(), + async ({ entries, rooms, limit, offset }) => { + if (rooms.length === 0) return true + const room = rooms[0] + const query = makeQuery(entries) + + const { txids, total } = await query.getTopicPostTxids(room, { limit, offset }) + + const roomPosts = entries + .filter((e) => e.value.type === 'post' && e.value.room === room) + .sort((a, b) => b.value.blockHeight - a.value.blockHeight) + + if (total !== roomPosts.length) return false + + const expectedTxids = roomPosts.slice(offset, offset + limit).map((e) => e.value.txid) + if (JSON.stringify(txids) !== JSON.stringify(expectedTxids)) return false + + // Newest-first ordering invariant. + for (let i = 1; i < roomPosts.length; i++) { + if (roomPosts[i - 1].value.blockHeight < roomPosts[i].value.blockHeight) return false + } + return true + }, + { label: 'getTopicPostTxids ordering and pagination' } + ) +}) + +test('roomFromKey and txidFromKey round-trip a room:txid key', async () => { + const query = makeQuery([]) + + await forAll( + fixtureGen(), + ({ entries }) => { + for (const e of entries) { + const room = query.roomFromKey(e.key, undefined) + const txid = query.txidFromKey(e.key) + const [expectedRoom, expectedTxid] = e.key.split(':') + if (room !== expectedRoom) return false + if (txid !== expectedTxid) return false + } + return true + }, + { label: 'roomFromKey/txidFromKey round-trip' } + ) +}) diff --git a/psf-memo-db/test/unit/controllers/topics.controller.unit.js b/psf-memo-db/test/unit/controllers/topics.controller.unit.js index aa4d53c..4f8293d 100644 --- a/psf-memo-db/test/unit/controllers/topics.controller.unit.js +++ b/psf-memo-db/test/unit/controllers/topics.controller.unit.js @@ -58,4 +58,31 @@ describe('#TopicsRESTController', () => { assert.equal(ctx.body.posts.length, 1) assert.equal(ctx.body.posts[0].txid, 'post-300') }) + + it('should throw a 500 when the use case fails without a status', async () => { + uut.useCases.listTopics.execute = sandbox.stub().rejects(new Error('boom')) + const ctx = { body: null, throw: sandbox.stub() } + + await uut.getTopics(ctx) + + assert.equal(ctx.throw.callCount, 1) + assert.equal(ctx.throw.firstCall.args[0], 500) + }) + + it('should preserve the status when the use case throws a statused error', async () => { + const err = new Error('room is required') + err.status = 400 + uut.useCases.listTopicPosts.execute = sandbox.stub().rejects(err) + const ctx = { + params: {}, + query: {}, + body: null, + throw: sandbox.stub() + } + + await uut.getTopicPosts(ctx) + + assert.equal(ctx.throw.callCount, 1) + assert.equal(ctx.throw.firstCall.args[0], 400) + }) })