Refactor topic actions: reduce CRAP/DRY, add property coverage, complete acceptance

- Add unit coverage for Profiles topic follow state to bring CRAP to 4.
- DRY Profiles set/get follow state via shared nested-map helpers.
- Add TopicQuery topic-follow read-side property tests and MemoTopicPost
  byte-accounting property tests.
- Complete client and DB acceptance step handlers and mocks for the
  topic-follow and topic-post features.

By refactorer.
This commit is contained in:
Chris Troutner
2026-08-28 11:13:03 -07:00
parent 50e287ed0f
commit d6f56780be
7 changed files with 601 additions and 21 deletions
+202 -5
View File
@@ -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 "?(<topic>|[^"]+)"?$/,
pattern: /^I open the topic feed for (?:the topic )?"?(<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 (<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 (<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 (<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 (<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 (<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 (<count>)$/,
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}.`)
}
}
}
]
+20 -15
View File
@@ -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
@@ -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' }
)
})
@@ -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()
@@ -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'