diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 73f7164..898336d 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -38,6 +38,9 @@ const ProfilePage = require('../../src/services/profile-page') const ThreadPage = require('../../src/services/thread-page') const TopicDiscoveryPage = require('../../src/services/topic-discovery-page') const TopicFeedPage = require('../../src/services/topic-feed-page') +const MemoTopicFollow = require('../../src/services/memo-topic-follow') +const MemoTopicPost = require('../../src/services/memo-topic-post') +const TopicPostPage = require('../../src/services/topic-post-page') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX @@ -47,6 +50,9 @@ const MEMO_SET_AVATAR_URL_PREFIX = MemoSetAvatarUrl.MEMO_SET_AVATAR_URL_PREFIX const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX const MEMO_FOLLOW_PREFIX = MemoFollow.MEMO_FOLLOW_PREFIX const MEMO_UNFOLLOW_PREFIX = MemoFollow.MEMO_UNFOLLOW_PREFIX +const MEMO_TOPIC_MESSAGE_PREFIX = MemoTopicPost.MEMO_TOPIC_MESSAGE_PREFIX +const MEMO_TOPIC_FOLLOW_PREFIX = MemoTopicFollow.MEMO_TOPIC_FOLLOW_PREFIX +const MEMO_TOPIC_UNFOLLOW_PREFIX = MemoTopicFollow.MEMO_TOPIC_UNFOLLOW_PREFIX // Default author address used by Gherkin steps that refer to "the author address". const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc' @@ -98,11 +104,13 @@ function makeProfiles () { const bios = {} const avatarUrls = {} const following = {} + const topicFollowing = {} return { names, bios, avatarUrls, following, + topicFollowing, setName: (addr, name) => { names[addr] = name }, getName: (addr) => names[addr] || null, setBio: (addr, bio) => { bios[addr] = bio }, @@ -113,7 +121,12 @@ function makeProfiles () { if (!following[selfAddr]) following[selfAddr] = {} following[selfAddr][targetAddr] = isFollowing }, - getFollowState: (selfAddr, targetAddr) => following[selfAddr]?.[targetAddr] || false + getFollowState: (selfAddr, targetAddr) => following[selfAddr]?.[targetAddr] || false, + setTopicFollowState: (selfAddr, room, isFollowing) => { + if (!topicFollowing[selfAddr]) topicFollowing[selfAddr] = {} + topicFollowing[selfAddr][room] = isFollowing + }, + getTopicFollowState: (selfAddr, room) => topicFollowing[selfAddr]?.[room] || false } } @@ -136,6 +149,7 @@ function makeMemoDb () { const topics = [] const topicPosts = {} const topicCounts = new Map() + const topicFollow = new Map() return { posts, @@ -171,6 +185,20 @@ function makeMemoDb () { setFollowState (followerAddr, followeeAddr, following) { followState[`${followerAddr}:${followeeAddr}`] = following }, + setTopicFollowState (addr, room, following) { + if (!topicFollow.has(room)) topicFollow.set(room, new Map()) + topicFollow.get(room).set(addr, following) + }, + async getTopicFollowState (room, addr) { + return topicFollow.get(room)?.get(addr) || false + }, + async getTopicFollowers (room) { + const addrs = [] + for (const [addr, following] of (topicFollow.get(room) || new Map()).entries()) { + if (following) addrs.push(addr) + } + return addrs + }, async getRecentPosts ({ limit = 100, offset = 0 } = {}) { const page = posts.slice(offset, offset + limit) return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } } @@ -212,6 +240,7 @@ function createWorld () { const memoReply = new MemoReply({ wallet, thread }) const memoLike = new MemoLike({ wallet, feed }) const memoFollow = new MemoFollow({ wallet, profiles }) + const memoTopicFollow = new MemoTopicFollow({ wallet, profiles }) const memoDb = makeMemoDb() const world = { @@ -222,6 +251,7 @@ function createWorld () { memoReply, memoLike, memoFollow, + memoTopicFollow, memoDb, currentPath: null, menuOpen: false, @@ -326,6 +356,13 @@ function findDisplayedPost (txid, world) { return null } +// True when the currently displayed page is a topic feed (used to dispatch the +// shared "Follow/Unfollow button" steps between the profile page and the topic +// feed page). +function isTopicFeedActive (world) { + return Boolean(world.currentPath && String(world.currentPath).startsWith('/topics/')) +} + // Handler registry. Each entry: { pattern, run }. // run receives (match, exampleStore, world, step). const handlers = [ @@ -1340,14 +1377,22 @@ const handlers = [ name: 'click Follow button', pattern: /^I click the Follow button$/, async run (m, example, world) { - await world.profilePage.follow() + if (isTopicFeedActive(world)) { + await world.topicFeedPage.follow() + } else { + await world.profilePage.follow() + } } }, { name: 'click Unfollow button', pattern: /^I click the Unfollow button$/, async run (m, example, world) { - await world.profilePage.unfollow() + if (isTopicFeedActive(world)) { + await world.topicFeedPage.unfollow() + } else { + await world.profilePage.unfollow() + } } }, { @@ -1471,12 +1516,23 @@ const handlers = [ }, { name: 'open topic feed', - pattern: /^I open the topic feed for "?(|[^"]+)"?$/, + pattern: /^I open the topic feed for (?:the topic )?"?(|[^"]+)"?$/, async run (m, example, world) { const room = resolveParam(m[1], example) - world.topicFeedPage = new TopicFeedPage({ memoDb: world.memoDb, room }) + const myAddr = world.wallet.walletInfo.cashAddress + world.topicFeedPage = new TopicFeedPage({ + memoDb: world.memoDb, + room, + myAddr, + memoTopicFollow: world.memoTopicFollow + }) await world.topicFeedPage.load() world.currentPath = TopicFeedPage.topicFeedPath(room) + + // Set up the topic post composer for this room so topic messages can be + // composed and broadcast, reflecting new posts onto the shared feed. + const memoTopicPost = new MemoTopicPost({ wallet: world.wallet, room, feed: world.feed }) + world.topicPostPage = new TopicPostPage({ memoTopicPost }) } }, { @@ -1503,6 +1559,147 @@ const handlers = [ throw new Error(`Expected topic feed to be empty, but found ${Array.isArray(posts) ? posts.length : 'non-array'} posts.`) } } + }, + { + name: 'API reports I do not follow topic', + pattern: /^the psf-memo-db API reports that I do not follow the topic ()$/, + run (m, example, world) { + const room = resolveParam(m[1], example) + const myAddr = world.wallet.walletInfo.cashAddress + world.memoDb.setTopicFollowState(myAddr, room, false) + } + }, + { + name: 'API reports I follow topic', + pattern: /^the psf-memo-db API reports that I follow the topic ()$/, + run (m, example, world) { + const room = resolveParam(m[1], example) + const myAddr = world.wallet.walletInfo.cashAddress + world.memoDb.setTopicFollowState(myAddr, room, true) + } + }, + { + name: 'topic feed page shows Follow button', + pattern: /^the topic feed page shows a Follow button$/, + run (m, example, world) { + if (!world.topicFeedPage) throw new Error('No topic feed page is loaded.') + if (!world.topicFeedPage.canFollow()) throw new Error('Topic feed cannot show a Follow button.') + if (world.topicFeedPage.isFollowing()) throw new Error('Topic feed shows Unfollow, but Follow was expected.') + } + }, + { + name: 'topic feed page shows Unfollow button', + pattern: /^the topic feed page shows an Unfollow button$/, + run (m, example, world) { + if (!world.topicFeedPage) throw new Error('No topic feed page is loaded.') + if (!world.topicFeedPage.canFollow()) throw new Error('Topic feed cannot show an Unfollow button.') + if (!world.topicFeedPage.isFollowing()) throw new Error('Topic feed shows Follow, but Unfollow was expected.') + } + }, + { + name: 'broadcasts topic-follow prefix', + pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-follow prefix for the topic ()$/, + run (m, example, world) { + const room = resolveParam(m[1], example) + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.') + const last = broadcasts[broadcasts.length - 1] + if (last.prefix !== MEMO_TOPIC_FOLLOW_PREFIX) { + throw new Error(`Expected Memo topic-follow prefix ${MEMO_TOPIC_FOLLOW_PREFIX}, got "${last.prefix}".`) + } + if (last.msg !== room) { + throw new Error(`Broadcast topic-follow payload did not match topic ${room}.`) + } + } + }, + { + name: 'broadcasts topic-unfollow prefix', + pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-unfollow prefix for the topic ()$/, + run (m, example, world) { + const room = resolveParam(m[1], example) + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.') + const last = broadcasts[broadcasts.length - 1] + if (last.prefix !== MEMO_TOPIC_UNFOLLOW_PREFIX) { + throw new Error(`Expected Memo topic-unfollow prefix ${MEMO_TOPIC_UNFOLLOW_PREFIX}, got "${last.prefix}".`) + } + if (last.msg !== room) { + throw new Error(`Broadcast topic-unfollow payload did not match topic ${room}.`) + } + } + }, + { + name: 'compose topic message', + pattern: /^I compose a topic message with the text "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + if (!(param in example)) throw new Error(`Missing example value for "${param}"`) + world.topicPostPage.setInput(example[param]) + } + }, + { + name: 'submit topic message', + pattern: /^I submit the topic message$/, + async run (m, example, world) { + await world.topicPostPage.submit() + } + }, + { + name: 'broadcasts topic-message prefix', + pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-message prefix for the topic ()$/, + run (m, example, world) { + const room = resolveParam(m[1], example) + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.') + const last = broadcasts[broadcasts.length - 1] + if (last.prefix !== MEMO_TOPIC_MESSAGE_PREFIX) { + throw new Error(`Expected Memo topic-message prefix ${MEMO_TOPIC_MESSAGE_PREFIX}, got "${last.prefix}".`) + } + const expectedPayload = room + world.topicPostPage.input + if (last.msg !== expectedPayload) { + throw new Error(`Broadcast topic-message payload did not match ${room} + input.`) + } + } + }, + { + name: 'topic feed shows new post from my address', + pattern: /^the topic feed shows a new post from my address with the text "(.+)"$/, + run (m, example, world) { + const expectedText = resolveParam(m[1], example) + const myAddress = world.wallet.walletInfo.cashAddress + const found = world.feed.posts.find((p) => p.text === expectedText && p.address === myAddress) + if (!found) throw new Error(`Topic feed does not show the new post with text "${expectedText}".`) + } + }, + { + name: 'topic post composer shows validation error', + pattern: /^the topic post composer shows a validation error$/, + run (m, example, world) { + if (world.topicPostPage.submitError !== 'topic_post_validation') { + throw new Error(`Expected topic_post_validation, got ${world.topicPostPage.submitError}.`) + } + } + }, + { + name: 'topic post composer shows length error', + pattern: /^the topic post composer shows a length error$/, + run (m, example, world) { + if (world.topicPostPage.submitError !== 'topic_post_length') { + throw new Error(`Expected topic_post_length, got ${world.topicPostPage.submitError}.`) + } + } + }, + { + name: 'topic post composer remaining byte count', + pattern: /^the topic post composer shows a remaining byte count of ()$/, + run (m, example, world) { + const expected = parseInt(resolveParam(m[1], example), 10) + if (Number.isNaN(expected)) throw new Error(`Invalid expected count for "${m[1]}".`) + const actual = world.topicPostPage.remainingCount() + if (actual !== expected) { + throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`) + } + } } ] diff --git a/psf-memo-client/src/services/profiles.js b/psf-memo-client/src/services/profiles.js index b439387..bce0e04 100644 --- a/psf-memo-client/src/services/profiles.js +++ b/psf-memo-client/src/services/profiles.js @@ -50,35 +50,40 @@ class Profiles { // Track whether the current wallet follows a given address. setFollowState (selfAddr, targetAddr, isFollowing) { - if (!selfAddr || !targetAddr) return - if (!this.following.has(selfAddr)) { - this.following.set(selfAddr, new Map()) - } - this.following.get(selfAddr).set(targetAddr, isFollowing) + this._setMapState(this.following, selfAddr, targetAddr, isFollowing) } getFollowState (selfAddr, targetAddr) { - if (!selfAddr || !targetAddr) return false - return this.following.get(selfAddr)?.get(targetAddr) || false + return this._getMapState(this.following, selfAddr, targetAddr) } // Track whether the current wallet follows a given topic. setTopicFollowState (selfAddr, room, isFollowing) { - if (!selfAddr || !room) return - if (!this.topicFollowing.has(selfAddr)) { - this.topicFollowing.set(selfAddr, new Map()) - } - this.topicFollowing.get(selfAddr).set(room, isFollowing) + this._setMapState(this.topicFollowing, selfAddr, room, isFollowing) } getTopicFollowState (selfAddr, room) { - if (!selfAddr || !room) return false - return this.topicFollowing.get(selfAddr)?.get(room) || false + return this._getMapState(this.topicFollowing, selfAddr, room) + } + + // Set a boolean value on a per-self-address nested map. + _setMapState (map, selfAddr, key, value) { + if (!selfAddr || !key) return + if (!map.has(selfAddr)) { + map.set(selfAddr, new Map()) + } + map.get(selfAddr).set(key, value) + } + + // Read a boolean value from a per-self-address nested map. + _getMapState (map, selfAddr, key) { + if (!selfAddr || !key) return false + return map.get(selfAddr)?.get(key) || false } } module.exports = Profiles // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-27T17:52:37.432Z","module_hash":"f729d4125eea29c45c4a0d7bd691a5f590512922f2ba78c85756643638fb0859","functions":[{"id":"func/Profiles.constructor","name":"Profiles.constructor","line":13,"end_line":18,"hash":"574f0b3772f783646c283c6ddc4d5ff200ca1f9918ab1fa668205003f885d4d3"},{"id":"func/Profiles.setName","name":"Profiles.setName","line":20,"end_line":23,"hash":"5a36c6e237798608de0bedd8744b75000c0eec5c0a6a64c870a25bcdf20aed21"},{"id":"func/Profiles.getName","name":"Profiles.getName","line":25,"end_line":28,"hash":"2fcb9d84687ea0f3b24f3e34c0a72eda55a4e869b87e08a7f4551321a4166198"},{"id":"func/Profiles.setBio","name":"Profiles.setBio","line":30,"end_line":33,"hash":"3825665ead1ba9bb217694c45a17bb2e11c78ffa053e5e90111a4b9208183b56"},{"id":"func/Profiles.getBio","name":"Profiles.getBio","line":35,"end_line":38,"hash":"2e9708249db4a0e4fa642cbe52e6216144ec91283281c61dee2ff3cc3d8f1572"},{"id":"func/Profiles.setAvatarUrl","name":"Profiles.setAvatarUrl","line":40,"end_line":43,"hash":"e4ccb0164fee4ab6cc3a1290fb4bf85e4a79f2227ca72872b1f10bc616926fd9"},{"id":"func/Profiles.getAvatarUrl","name":"Profiles.getAvatarUrl","line":45,"end_line":48,"hash":"0aa58dc15fd519e553136d04d1954e18084f79a6cd9349dc89b1136cf0a22a2b"},{"id":"func/Profiles.setFollowState","name":"Profiles.setFollowState","line":51,"end_line":57,"hash":"aa527936b6ca92a502e93e8241803318700c528e05f68ff7fb6fac1d410c197c"},{"id":"func/Profiles.getFollowState","name":"Profiles.getFollowState","line":59,"end_line":62,"hash":"299a44adb289026bf3b89a21947fc23bf05f5a40f07abb7f1bbd311c0479f562"}]} +// {"version":1,"tested_at":"2026-08-28T18:00:08.907Z","module_hash":"1cec5125fb228d43d47e4ec171eb43c492f2bd773ca315748df78a16071e2802","functions":[{"id":"func/Profiles.constructor","name":"Profiles.constructor","line":13,"end_line":19,"hash":"247804207ec0220b6e6507f6beb6f86023a7c08b50737a52a0dbb99d262993d3"},{"id":"func/Profiles.setName","name":"Profiles.setName","line":21,"end_line":24,"hash":"5a36c6e237798608de0bedd8744b75000c0eec5c0a6a64c870a25bcdf20aed21"},{"id":"func/Profiles.getName","name":"Profiles.getName","line":26,"end_line":29,"hash":"2fcb9d84687ea0f3b24f3e34c0a72eda55a4e869b87e08a7f4551321a4166198"},{"id":"func/Profiles.setBio","name":"Profiles.setBio","line":31,"end_line":34,"hash":"3825665ead1ba9bb217694c45a17bb2e11c78ffa053e5e90111a4b9208183b56"},{"id":"func/Profiles.getBio","name":"Profiles.getBio","line":36,"end_line":39,"hash":"2e9708249db4a0e4fa642cbe52e6216144ec91283281c61dee2ff3cc3d8f1572"},{"id":"func/Profiles.setAvatarUrl","name":"Profiles.setAvatarUrl","line":41,"end_line":44,"hash":"e4ccb0164fee4ab6cc3a1290fb4bf85e4a79f2227ca72872b1f10bc616926fd9"},{"id":"func/Profiles.getAvatarUrl","name":"Profiles.getAvatarUrl","line":46,"end_line":49,"hash":"0aa58dc15fd519e553136d04d1954e18084f79a6cd9349dc89b1136cf0a22a2b"},{"id":"func/Profiles.setFollowState","name":"Profiles.setFollowState","line":52,"end_line":54,"hash":"805cafb46052f91bfa0a123842c25ff8ee0f7f4a7e661f51d289b0487738dbd4"},{"id":"func/Profiles.getFollowState","name":"Profiles.getFollowState","line":56,"end_line":58,"hash":"48fdf5a632825946c391146628cd6f51b4eb0169c7a6d6b89c795ff554e62b07"},{"id":"func/Profiles.setTopicFollowState","name":"Profiles.setTopicFollowState","line":61,"end_line":63,"hash":"54ab980e42018d64fbacee7d80347d530791b7ba21d8fac46f5ed85f33903f98"},{"id":"func/Profiles.getTopicFollowState","name":"Profiles.getTopicFollowState","line":65,"end_line":67,"hash":"90ce88799dda5477e20ce4de17404dace55abf1d2f2c1c87671e94fc2f270205"},{"id":"func/Profiles._setMapState","name":"Profiles._setMapState","line":70,"end_line":76,"hash":"13d2cad84be961b89482970f55f107d93fa1bdc10636dc64844c6a9263e88583"},{"id":"func/Profiles._getMapState","name":"Profiles._getMapState","line":79,"end_line":82,"hash":"c1c8966a797533614d35c7a13deb8f757572127ebcbde1d234c7d966346d89a8"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/test/property/topic-post.property.test.js b/psf-memo-client/test/property/topic-post.property.test.js new file mode 100644 index 0000000..6138b24 --- /dev/null +++ b/psf-memo-client/test/property/topic-post.property.test.js @@ -0,0 +1,79 @@ +/* + Property tests for the Memo topic-post byte accounting. + + The unit tests probe isTooLong and remainingBytes at a few fixed fixtures. + These properties pin down invariants that hold over broad random UTF-8 + inputs (mixed byte widths): + + - byte accounting: remainingBytes(msg) always equals the combined byte + budget minus the room and message byte lengths. + - consistency: isTooLong(msg) is true exactly when remainingBytes(msg) + is negative. + - monotonicity: appending bytes never flips a too-long message back to + being accepted. +*/ + +'use strict' + +const test = require('node:test') +const { seededRandom, forAll } = require('./harness') +const MemoTopicPost = require('../../src/services/memo-topic-post') +const { byteLength } = require('../../src/services/utf8') + +const rng = seededRandom(20260901) +const MAX = MemoTopicPost.MAX_TOPIC_MESSAGE_BYTES + +// Characters with distinct UTF-8 byte widths: 1, 1, 1, 2, 3, and 4 bytes. +const CHARS = ['a', 'b', ' ', '\u00e9', '\u20ac', '\ud83d\ude00'] + +function textGen () { + const len = Math.floor(rng() * 140) + let out = '' + for (let i = 0; i < len; i++) { + out += CHARS[Math.floor(rng() * CHARS.length)] + } + return out +} + +function postInputGen () { + return () => ({ room: textGen(), msg: textGen() }) +} + +test('remainingBytes equals the byte budget minus room and message bytes', async () => { + await forAll( + postInputGen(), + ({ room, msg }) => { + const post = new MemoTopicPost({ room }) + return post.remainingBytes(msg) === MAX - byteLength(room) - byteLength(msg) + }, + { label: 'remainingBytes byte accounting' } + ) +}) + +test('isTooLong is true exactly when remainingBytes is negative', async () => { + await forAll( + postInputGen(), + ({ room, msg }) => { + const post = new MemoTopicPost({ room }) + return post.isTooLong(msg) === (post.remainingBytes(msg) < 0) + }, + { label: 'isTooLong / remainingBytes consistency' } + ) +}) + +test('isTooLong is monotonic as message bytes are appended', async () => { + await forAll( + () => { + const { room, msg } = postInputGen()() + const extra = textGen() + return { room, msg, extra } + }, + ({ room, msg, extra }) => { + const post = new MemoTopicPost({ room }) + // Appending bytes can only make a message longer, so a too-long + // message must remain too-long once more bytes are added. + return !(post.isTooLong(msg) && !post.isTooLong(msg + extra)) + }, + { label: 'isTooLong monotonicity' } + ) +}) diff --git a/psf-memo-client/test/property/topic-services.property.test.js b/psf-memo-client/test/property/topic-services.property.test.js index fa5b1c0..2d7dc6d 100644 --- a/psf-memo-client/test/property/topic-services.property.test.js +++ b/psf-memo-client/test/property/topic-services.property.test.js @@ -64,7 +64,10 @@ test('TopicFeedPage.getPost finds any loaded post', async () => { return posts }, async (posts) => { - const memoDb = { async getTopicPosts () { return { posts, pagination: { total: posts.length } } } } + const memoDb = { + async getTopicPosts () { return { posts, pagination: { total: posts.length } } }, + async getTopicFollowers () { return [] } + } const page = new TopicFeedPage({ memoDb, room: 'bitcoin' }) await page.load() diff --git a/psf-memo-client/test/unit/profiles.test.js b/psf-memo-client/test/unit/profiles.test.js index 531a0bb..4998316 100644 --- a/psf-memo-client/test/unit/profiles.test.js +++ b/psf-memo-client/test/unit/profiles.test.js @@ -134,6 +134,64 @@ test('follow state storage is independent per self address', () => { assert.equal(profiles.getFollowState(otherSelfAddr, targetAddr), false) }) +test('setTopicFollowState stores and getTopicFollowState retrieves topic follow state', () => { + const profiles = new Profiles() + const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + const room = 'bitcoin' + + profiles.setTopicFollowState(selfAddr, room, true) + + assert.equal(profiles.getTopicFollowState(selfAddr, room), true) +}) + +test('getTopicFollowState returns false for an unknown topic relationship', () => { + const profiles = new Profiles() + const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + assert.equal(profiles.getTopicFollowState(selfAddr, 'bitcoin'), false) +}) + +test('setTopicFollowState with no self address does nothing', () => { + const profiles = new Profiles() + + profiles.setTopicFollowState('', 'bitcoin', true) + + assert.equal(profiles.getTopicFollowState('', 'bitcoin'), false) +}) + +test('setTopicFollowState with no room does nothing', () => { + const profiles = new Profiles() + const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + profiles.setTopicFollowState(selfAddr, '', true) + + assert.equal(profiles.getTopicFollowState(selfAddr, ''), false) +}) + +test('topic follow state storage is independent per self address', () => { + const profiles = new Profiles() + const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + const otherSelfAddr = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a' + + profiles.setTopicFollowState(selfAddr, 'bitcoin', true) + profiles.setTopicFollowState(otherSelfAddr, 'bitcoin', false) + + assert.equal(profiles.getTopicFollowState(selfAddr, 'bitcoin'), true) + assert.equal(profiles.getTopicFollowState(otherSelfAddr, 'bitcoin'), false) +}) + +test('topic follow state storage is independent of address follow state', () => { + const profiles = new Profiles() + const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + const targetAddr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy' + + profiles.setFollowState(selfAddr, targetAddr, true) + profiles.setTopicFollowState(selfAddr, 'bitcoin', true) + + assert.equal(profiles.getFollowState(selfAddr, targetAddr), true) + assert.equal(profiles.getTopicFollowState(selfAddr, 'bitcoin'), true) +}) + test('avatar URL storage is independent of name and bio storage', () => { const profiles = new Profiles() const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' diff --git a/psf-memo-db/acceptance/lib/handlers.js b/psf-memo-db/acceptance/lib/handlers.js index 78c20ea..124442e 100644 --- a/psf-memo-db/acceptance/lib/handlers.js +++ b/psf-memo-db/acceptance/lib/handlers.js @@ -20,6 +20,8 @@ import ListFollowing from '../../src/use-cases/list-following.js' import ListFollowers from '../../src/use-cases/list-followers.js' import ListTopics from '../../src/use-cases/list-topics.js' import ListTopicPosts from '../../src/use-cases/list-topic-posts.js' +import TopicFollowState from '../../src/use-cases/topic-follow-state.js' +import ListTopicFollowers from '../../src/use-cases/list-topic-followers.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance') @@ -96,6 +98,8 @@ async function createWorld () { const listFollowers = new ListFollowers({ adapters }) const listTopics = new ListTopics({ adapters }) const listTopicPosts = new ListTopicPosts({ adapters }) + const topicFollowState = new TopicFollowState({ adapters }) + const listTopicFollowers = new ListTopicFollowers({ adapters }) let lastResponse = null @@ -109,6 +113,8 @@ async function createWorld () { listFollowers, listTopics, listTopicPosts, + topicFollowState, + listTopicFollowers, postHeightsIteratorCounter, addrPostHeightsIteratorCounter, postChildrenIteratorCounter, @@ -153,6 +159,11 @@ async function loadFixture (world, name) { return } + if (name === 'topic-follows') { + await loadTopicFollows(world) + return + } + if (name !== 'three-top-level-posts-and-one-reply') { throw new Error(`Unknown fixture: ${name}`) } @@ -364,6 +375,19 @@ async function loadTopicsWithPosts (world) { } } +async function loadTopicFollows (world) { + const entries = [ + { key: 'bitcoin:addr-a', room: 'bitcoin', addr: 'addr-a', type: 'follow', unfollow: false }, + { key: 'bitcoin:addr-b', room: 'bitcoin', addr: 'addr-b', type: 'follow', unfollow: false }, + { key: 'bitcoin:addr-c', room: 'bitcoin', addr: 'addr-c', type: 'follow', unfollow: true }, + { key: 'cash:addr-a', room: 'cash', addr: 'addr-a', type: 'follow', unfollow: false } + ] + + for (const entry of entries) { + await world.adapters.level.roomsDb.put(entry.key, entry) + } +} + const handlers = [ { name: 'db instance with posts and postHeights stores', @@ -780,6 +804,64 @@ const handlers = [ throw new Error(`Expected no posts, got ${Array.isArray(posts) ? posts.length : 'non-array'}`) } } + }, + { + name: 'db instance with rooms store', + pattern: /^a psf-memo-db instance with a rooms store$/, + async run () { + // World is already created with the rooms store. + } + }, + { + name: 'load fixture into rooms store', + pattern: /^the fixture "(.+)" is loaded into the rooms store$/, + async run (m, example, world) { + await loadFixture(world, m[1]) + } + }, + { + name: 'request topic follow state', + pattern: /^the client requests the topic follow state for room (<[A-Za-z0-9_]+>) and address (<[A-Za-z0-9_]+>)$/, + async run (m, example, world) { + const room = resolveParam(m[1], example) + const addr = resolveParam(m[2], example) + const resp = await world.topicFollowState.execute({ room, addr }) + world.setLastResponse(resp) + } + }, + { + name: 'topic follow state reports following', + pattern: /^the topic follow state reports following ()$/, + run (m, example, world) { + const expected = resolveParam(m[1], example) === 'true' + const actual = world.getLastResponse().following + if (actual !== expected) { + throw new Error(`Expected following ${expected}, got ${actual}`) + } + } + }, + { + name: 'request topic followers list', + pattern: /^the client requests the topic followers list for room (<[A-Za-z0-9_]+>)$/, + async run (m, example, world) { + const room = resolveParam(m[1], example) + const resp = await world.listTopicFollowers.execute({ room }) + world.setLastResponse(resp) + } + }, + { + name: 'topic followers list contains addresses', + pattern: /^the topic followers list contains the addresses ()$/, + run (m, example, world) { + const raw = resolveParam(m[1], example).trim() + const expected = raw.length === 0 ? [] : raw.split(',').map((s) => s.trim()) + const actual = world.getLastResponse().followers + const expectedSet = new Set(expected) + const actualSet = new Set(actual) + if (expectedSet.size !== actualSet.size || !expectedSet.isSubsetOf(actualSet)) { + throw new Error(`Expected topic followers ${expected.join(',')}, got ${actual.join(',')}`) + } + } } ] diff --git a/psf-memo-db/test/property/topic-follow-query.property.test.js b/psf-memo-db/test/property/topic-follow-query.property.test.js new file mode 100644 index 0000000..3e0b3eb --- /dev/null +++ b/psf-memo-db/test/property/topic-follow-query.property.test.js @@ -0,0 +1,156 @@ +/* + Property tests for the TopicQuery topic-follow read side. + + The unit tests probe isFollowingRoom and listRoomFollowers at a few fixed + fixtures. These properties pin down invariants that hold over broad random + follow-record sets: + + - listRoomFollowers conservation: for every room the returned set equals + exactly the active (non-unfollowed) follow addresses for that room, + regardless of surrounding post/follow records. + - isFollowingRoom round trip: an address is reported following a room + exactly when an active follow record for that room:addr exists. + - followAddrFromValue recovery: the follower address is recovered from a + `${room}:${addr}` key. +*/ + +import test from 'node:test' + +import { seededRandom, forAll, intGen } from './harness.js' +import TopicQuery from '../../src/adapters/topic-query.js' + +const rng = seededRandom(20260831) + +// In-memory rooms store mirroring the LevelDB contract TopicQuery relies on: +// both an iterator over key/value entries (listRoomFollowers, listTopics) and +// a point get that throws notFound (isFollowingRoom). +function makeRoomsDb (entries) { + const store = new Map(entries.map((e) => [e.key, e.value])) + return { + async get (key) { + if (!store.has(key)) { + const err = new Error('not found') + err.notFound = true + throw err + } + return store.get(key) + }, + 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: {} + }) +} + +// Random set of rooms and topic follow records. Each room has some candidate +// addresses; not every address follows, and a follow may later be unfollowed. +function followSetGen () { + return () => { + const roomCount = intGen(rng, 1, 4)() + const rooms = [] + const entries = [] + for (let i = 0; i < roomCount; i++) { + const room = `room-${i}` + rooms.push(room) + + const addrCount = intGen(rng, 1, 6)() + for (let j = 0; j < addrCount; j++) { + const addr = `bitcoincash:q${i}-${j}` + if (rng() < 0.35) continue + entries.push({ + key: `${room}:${addr}`, + value: { + type: 'follow', + room, + addr, + unfollow: rng() < 0.4 + } + }) + } + + // A stray post entry in the same room must not count as a follower. + if (rng() < 0.5) { + entries.push({ + key: `${room}:post-${i}`, + value: { type: 'post', room } + }) + } + } + return { rooms, entries } + } +} + +test('listRoomFollowers returns exactly the active follow addresses for a room', async () => { + await forAll( + followSetGen(), + async ({ rooms, entries }) => { + const query = makeQuery(entries) + + for (const room of rooms) { + const got = await query.listRoomFollowers(room) + const expected = Array.from( + new Set( + entries + .filter((e) => e.value.type === 'follow' && e.value.room === room && e.value.unfollow !== true && e.value.addr) + .map((e) => e.value.addr) + ) + ) + const want = expected.sort((a, b) => a.localeCompare(b)) + const received = got.slice().sort((a, b) => a.localeCompare(b)) + if (JSON.stringify(received) !== JSON.stringify(want)) return false + } + return true + }, + { label: 'listRoomFollowers conservation' } + ) +}) + +test('isFollowingRoom is true exactly for active follow records', async () => { + await forAll( + followSetGen(), + async ({ entries }) => { + const query = makeQuery(entries) + + for (const e of entries) { + if (e.value.type !== 'follow') continue + const { room, addr } = e.value + const following = await query.isFollowingRoom(addr, room) + if (following !== (e.value.unfollow !== true)) return false + } + + // A room:addr never recorded is never following. + const unseen = await query.isFollowingRoom('bitcoincash:qunseen', 'room-99') + if (unseen !== false) return false + return true + }, + { label: 'isFollowingRoom round trip' } + ) +}) + +test('followAddrFromValue recovers the address from a room:addr record', async () => { + await forAll( + followSetGen(), + ({ entries }) => { + const query = makeQuery([]) + for (const e of entries) { + if (e.value.type !== 'follow') continue + const addr = query.followAddrFromValue(e.value, e.key) + if (addr !== e.value.addr) return false + } + return true + }, + { label: 'followAddrFromValue recovery' } + ) +})