Review and harden topic-feed: kill mutation survivors

- Add topic-query, list-topic-posts, topics controller, and client
  topic-page hardening tests to kill mutation survivors.
- Refresh mutation manifests and Gherkin acceptance-mutation stamps.
- Add topic-feed review summary.

By architect.
This commit is contained in:
Chris Troutner
2026-08-28 08:55:48 -07:00
parent 4c5fb38661
commit c9bdf326d6
16 changed files with 114 additions and 1 deletions
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-28T15:47:49.672413738Z","feature_name":"Topic Discovery","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/topic-discovery.feature","background_hash":"4fc484640e93176d3d99184676c1a1e3b8d9699815a6c7fb171a96a3284f269e","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Topic Discovery - 1 the topics page lists each topic with its post count","scenario_hash":"ac5c4f331488c4c438534e51f0e29ee3023245f542114e435cc631d8d59b57d9","mutation_count":6,"result":{"Total":6,"Killed":6,"Survived":0,"Errors":0},"tested_at":"2026-08-28T15:47:49.672413738Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Topic Discovery - 1, Topic Discovery - 2
#
# The topics page lists the topics served by the psf-memo-db /topics endpoint
+5
View File
@@ -1,3 +1,8 @@
# mutation-stamp: sha256=7a4add0ddfb90df56071a317562aba69f01720c8bfb6e7d9ca896739d9fedd79
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-28T15:47:56.749204947Z","feature_name":"Topic Feed","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/topic-feed.feature","background_hash":"212981bdafb4c09397640bcc310003bd3ab68af4e8ae79f203de06ea583943bd","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Topic Feed - 1 the topic feed shows the posts for the topic","scenario_hash":"171aaf428a3afe5bb0371738bff19f2a3a4bf8240a90241504740d9586979659","mutation_count":6,"result":{"Total":6,"Killed":6,"Survived":0,"Errors":0},"tested_at":"2026-08-28T15:47:56.749204947Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Topic Feed - 1, Topic Feed - 2
#
# The topic feed page shows the posts served by the psf-memo-db
+1 -1
View File
@@ -121,5 +121,5 @@ class MemoDb {
export default MemoDb
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-27T17:52:51.766Z","module_hash":"3f694a85efbd3d3d6fb3d24c8261166d36113990091680e5d11485bef973d6c9","functions":[{"id":"func/MemoDb.constructor","name":"MemoDb.constructor","line":9,"end_line":11,"hash":"188825ae5983840d4e1a4df182966ef012297c336ea3b763c87ea1c384654279"},{"id":"func/MemoDb.getRecentProfiles","name":"MemoDb.getRecentProfiles","line":13,"end_line":15,"hash":"cb819c72881b70f719f964c607f64be70b40377297c738897eee495bcf6dff4d"},{"id":"func/MemoDb.getRecentPosts","name":"MemoDb.getRecentPosts","line":17,"end_line":19,"hash":"7c0fd29c2fdc67a27a05a190e2e4294a698a1fe5223d8017ca82c4f890c2d094"},{"id":"func/MemoDb.getProfile","name":"MemoDb.getProfile","line":21,"end_line":23,"hash":"6a250c7205799b40dbe97b8f17ddaee6eace8cb435b0561d9ff8f5eac93d2b20"},{"id":"func/MemoDb.getProfilePic","name":"MemoDb.getProfilePic","line":25,"end_line":27,"hash":"d61dd5aebce32ef28b69ad56f2f7108d6b5f333a4d94cd83f05f01a09fa8f1d7"},{"id":"func/MemoDb.getName","name":"MemoDb.getName","line":29,"end_line":31,"hash":"3f841735b93180c6aeeb14a563bb6d4eb0ca5d395fb960267c8896b06c1e85f3"},{"id":"func/MemoDb.getFollowState","name":"MemoDb.getFollowState","line":33,"end_line":49,"hash":"d62eb85c742b40f2d319230c6ef59e21738063d31bccf1f37ebdc52c20bac994"},{"id":"func/MemoDb.getRecent","name":"MemoDb.getRecent","line":52,"end_line":63,"hash":"1565fd17dced45fbf1b6d6fd6b8003521129bbd88046ff52a4eb61c17862530d"},{"id":"func/MemoDb.getLevelResource","name":"MemoDb.getLevelResource","line":66,"end_line":79,"hash":"c2fb7338918b1e69aa8a14f65ae687532c843f0641f04074947731691001e4ea"},{"id":"func/MemoDb.getPostsByAddr","name":"MemoDb.getPostsByAddr","line":81,"end_line":93,"hash":"c39322f75bec244cffd3b66614ae2814040ac428cf39af53b04d3fcae4d59b19"},{"id":"func/MemoDb.getPostThread","name":"MemoDb.getPostThread","line":95,"end_line":106,"hash":"ed5ef157b457f8d984ff6a9488a4dccf7a37b0ae65adb40005df90508426661f"}]}
// {"version":1,"tested_at":"2026-08-28T15:36:17.287Z","module_hash":"77d15bc46bc63e2ffbd5fa7387b08a74db8e967482626f4f52211ceb60bd0633","functions":[{"id":"func/MemoDb.constructor","name":"MemoDb.constructor","line":9,"end_line":11,"hash":"188825ae5983840d4e1a4df182966ef012297c336ea3b763c87ea1c384654279"},{"id":"func/MemoDb.getRecentProfiles","name":"MemoDb.getRecentProfiles","line":13,"end_line":15,"hash":"cb819c72881b70f719f964c607f64be70b40377297c738897eee495bcf6dff4d"},{"id":"func/MemoDb.getRecentPosts","name":"MemoDb.getRecentPosts","line":17,"end_line":19,"hash":"7c0fd29c2fdc67a27a05a190e2e4294a698a1fe5223d8017ca82c4f890c2d094"},{"id":"func/MemoDb.getProfile","name":"MemoDb.getProfile","line":21,"end_line":23,"hash":"6a250c7205799b40dbe97b8f17ddaee6eace8cb435b0561d9ff8f5eac93d2b20"},{"id":"func/MemoDb.getProfilePic","name":"MemoDb.getProfilePic","line":25,"end_line":27,"hash":"d61dd5aebce32ef28b69ad56f2f7108d6b5f333a4d94cd83f05f01a09fa8f1d7"},{"id":"func/MemoDb.getName","name":"MemoDb.getName","line":29,"end_line":31,"hash":"3f841735b93180c6aeeb14a563bb6d4eb0ca5d395fb960267c8896b06c1e85f3"},{"id":"func/MemoDb.getFollowState","name":"MemoDb.getFollowState","line":33,"end_line":49,"hash":"d62eb85c742b40f2d319230c6ef59e21738063d31bccf1f37ebdc52c20bac994"},{"id":"func/MemoDb.getTopics","name":"MemoDb.getTopics","line":51,"end_line":53,"hash":"8d392b8a4b1bef405a9872c2564c51d9343df7b1c628d016258ee1c165f713ef"},{"id":"func/MemoDb.getTopicPosts","name":"MemoDb.getTopicPosts","line":55,"end_line":57,"hash":"4cc1e13334b5b4a2b72942cbc5f3cce8493254628f7d51da33d7be9c60ef9ff7"},{"id":"func/MemoDb.getRecent","name":"MemoDb.getRecent","line":60,"end_line":71,"hash":"1565fd17dced45fbf1b6d6fd6b8003521129bbd88046ff52a4eb61c17862530d"},{"id":"func/MemoDb.getPage","name":"MemoDb.getPage","line":74,"end_line":85,"hash":"986ec43d7699d642b25a9f4627aa5420cc685a247c3caa4671797778bc84e3b5"},{"id":"func/MemoDb.getLevelResource","name":"MemoDb.getLevelResource","line":88,"end_line":101,"hash":"c2fb7338918b1e69aa8a14f65ae687532c843f0641f04074947731691001e4ea"},{"id":"func/MemoDb.getPostsByAddr","name":"MemoDb.getPostsByAddr","line":103,"end_line":105,"hash":"45bbfef12cd8e35da1d71299153b2e54304664d5e3191c497f105ba17b134431"},{"id":"func/MemoDb.getPostThread","name":"MemoDb.getPostThread","line":107,"end_line":118,"hash":"ed5ef157b457f8d984ff6a9488a4dccf7a37b0ae65adb40005df90508426661f"}]}
// mutate4javascript-manifest-end
@@ -43,3 +43,7 @@ TopicDiscoveryPage.topicFeedPath = function (room) {
}
module.exports = TopicDiscoveryPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T15:33:23.381Z","module_hash":"a238b89f4edc83d06d6a424a0f2e19307187410fe8328b5220289dab5bf18fe6","functions":[{"id":"func/TopicDiscoveryPage.constructor","name":"TopicDiscoveryPage.constructor","line":12,"end_line":16,"hash":"5e7790aac90f4000c71b5e8a4b4b15c6b2895d1315cc05741a4fa2577c9f1d61"},{"id":"func/TopicDiscoveryPage.load","name":"TopicDiscoveryPage.load","line":18,"end_line":27,"hash":"2ce74ae73560c443d862dc97c65adda83ca5cd2bcc4c0df8bb87889d99b60f1d"},{"id":"func/TopicDiscoveryPage.getTopic","name":"TopicDiscoveryPage.getTopic","line":29,"end_line":31,"hash":"7faf4b56db0bc89d32176a1741546958495ba10b5f313581f321de47e58ecb95"},{"id":"func/TopicDiscoveryPage.openTopic","name":"TopicDiscoveryPage.openTopic","line":33,"end_line":37,"hash":"ebd17b5f176c964308b69705b7a53af4c9c392e9856308b3095d69151ebde9aa"}]}
// mutate4javascript-manifest-end
@@ -39,3 +39,7 @@ TopicFeedPage.topicFeedPath = function (room) {
}
module.exports = TopicFeedPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T15:35:01.465Z","module_hash":"d6433e58dfe8e7d0095074424340477c0abcfdcac7f611e2267ed04232936a1d","functions":[{"id":"func/TopicFeedPage.constructor","name":"TopicFeedPage.constructor","line":10,"end_line":15,"hash":"5d839555bdd88de97e47d68ef4dd2bad3a657ec4f9ce93991d332902bb4c371e"},{"id":"func/TopicFeedPage.load","name":"TopicFeedPage.load","line":17,"end_line":30,"hash":"ddf4ed21448879fa579225491125e343ebc129ef08445da811d98e79c6c29042"},{"id":"func/TopicFeedPage.getPost","name":"TopicFeedPage.getPost","line":32,"end_line":34,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// mutate4javascript-manifest-end
@@ -37,6 +37,13 @@ test('load throws when no memo db client is provided', async () => {
)
})
test('stores the provided navigate function', () => {
const navigate = () => {}
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb([]), navigate })
assert.equal(page.navigate, navigate)
})
test('getTopic returns the matching topic', async () => {
const topics = [
{ room: 'bitcoin', postCount: 2 },
@@ -43,6 +43,30 @@ test('load forwards limit and offset to the memo db client', async () => {
assert.deepEqual(calls, [{ room: 'bitcoin', params: { limit: 10, offset: 20 } }])
})
test('load defaults limit and offset', async () => {
const calls = []
const memoDb = {
async getTopicPosts (room, params) {
calls.push(params)
return { posts: [], pagination: {} }
}
}
const page = new TopicFeedPage({ memoDb, room: 'bitcoin' })
await page.load()
assert.deepEqual(calls, [{ limit: 100, offset: 0 }])
})
test('stores the pagination returned by the memo db client', async () => {
const pagination = { limit: 100, offset: 0, total: 2, hasMore: false }
const page = new TopicFeedPage({ memoDb: makeMemoDb([], pagination), room: 'bitcoin' })
await page.load()
assert.deepEqual(page.pagination, pagination)
})
test('load throws when no memo db client is provided', async () => {
const page = new TopicFeedPage({ room: 'bitcoin' })
+4
View File
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-28T15:47:38.414053749Z","feature_name":"Topic Read","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/topic-read.feature","background_hash":"922fd45b8606f723073833f0aaca0e6d92b03a263a6b54c60490843e0ac41cc0","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Topic Read - 1 GET /topics lists distinct topics with their post counts","scenario_hash":"a425a0cbf18c7e7fcb7d01c3571556d4c184b013ad7990e724d95d8d100eebb9","mutation_count":8,"result":{"Total":8,"Killed":8,"Survived":0,"Errors":0},"tested_at":"2026-08-28T15:39:33.364455310Z"},{"index":1,"name":"Topic Read - 2 GET /topics/:room/posts returns the posts for a topic sorted by block height descending","scenario_hash":"cffb702052b0df29aa87510e0c45dd01b83c4607e7ce915bc0e660d601f79cce","mutation_count":4,"result":{"Total":4,"Killed":4,"Survived":0,"Errors":0},"tested_at":"2026-08-28T15:39:33.364455310Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Topic Read - 1, Topic Read - 2, Topic Read - 3, Topic Read - 4
#
# The indexer stores topic activity in the rooms store. Topic messages are
+4
View File
@@ -78,3 +78,7 @@ class TopicQuery {
}
export default TopicQuery
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T14:46:39.922Z","module_hash":"515aa0e8cfdc7d596b270d5c4b82e2f33efa7f074ea406b6850007402d69f2e2","functions":[{"id":"func/TopicQuery.constructor","name":"TopicQuery.constructor","line":14,"end_line":29,"hash":"1c1e10e3ee613b0cd141a941849ce28a99eac688142629c7781ded6e5e15c938"},{"id":"func/TopicQuery.roomFromKey","name":"TopicQuery.roomFromKey","line":31,"end_line":34,"hash":"4175916ac2bb8f9f102c70750f336ed980db2a30a1f8f35645534303408aaece"},{"id":"func/TopicQuery.txidFromKey","name":"TopicQuery.txidFromKey","line":36,"end_line":39,"hash":"4f5f5c456f1e74a60acff54d1d5e2d982be53886c2a77c6eff3293133efa9c87"},{"id":"func/TopicQuery.listTopics","name":"TopicQuery.listTopics","line":41,"end_line":57,"hash":"9c524ef62e22d7a434768dc431c360f9c81b0c2a321b162a614f7f7e1e011e08"},{"id":"func/TopicQuery.getTopicPostTxids","name":"TopicQuery.getTopicPostTxids","line":59,"end_line":77,"hash":"4e0fccf874560249152d04d13879c24fbae0271bf7835716789dfafcff7dbd04"}]}
// mutate4javascript-manifest-end
@@ -89,3 +89,7 @@ class TopicsRESTControllerLib {
}
export default TopicsRESTControllerLib
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T15:26:54.488Z","module_hash":"64ac0a8aa7c2e0b575a89161ceb19c59e6618448eaf767ef5bbccc3ce174afaa","functions":[{"id":"func/TopicsRESTControllerLib.constructor","name":"TopicsRESTControllerLib.constructor","line":8,"end_line":21,"hash":"63d2092a2bd7946dd11bdf0c4ff102e31c47a0ed6ad56a0652f26e1760d525f9"},{"id":"func/TopicsRESTControllerLib.handleError","name":"TopicsRESTControllerLib.handleError","line":23,"end_line":30,"hash":"b9ba0c7b9752ac2fda3cf058a983a8b6708715e2e4dc882903ab5ef369956e03"},{"id":"func/TopicsRESTControllerLib.getTopics","name":"TopicsRESTControllerLib.getTopics","line":47,"end_line":53,"hash":"b89a696bcd1d601ef86754974dbf181baf2ece7d592674ba4fd87887725d6fa8"},{"id":"func/TopicsRESTControllerLib.getTopicPosts","name":"TopicsRESTControllerLib.getTopicPosts","line":80,"end_line":88,"hash":"8ad5f9b1749968c3fbfea95683bb388a5d99eda0b8fe7a4dce3447d6d47e6dba"}]}
// mutate4javascript-manifest-end
@@ -32,3 +32,7 @@ class TopicsRouter {
}
export default TopicsRouter
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T15:31:40.510Z","module_hash":"de978ac9963fbdcbd609f19a59c75427ee9bead3c7116c4dee4891d8622ef1e4","functions":[{"id":"func/TopicsRouter.constructor","name":"TopicsRouter.constructor","line":9,"end_line":24,"hash":"2503fc8448efbd71984f84a1f5d7f35dc5151d4d6d0b847ac1a7ba40f57a50b3"},{"id":"func/TopicsRouter.attach","name":"TopicsRouter.attach","line":26,"end_line":31,"hash":"0ccc57d85658f06a3a7bcc4479dacf70b30196667846a24851e8d506a1931480"}]}
// mutate4javascript-manifest-end
@@ -56,3 +56,7 @@ class ListTopicPosts {
}
export default ListTopicPosts
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T15:16:11.032Z","module_hash":"42a65ea5f1a4dea88cd1c99bcf8ba8b476dc24a073459ffa26d21151179db078","functions":[{"id":"func/ListTopicPosts.constructor","name":"ListTopicPosts.constructor","line":9,"end_line":22,"hash":"cdc313ae9059c07ce5cd2440e49fc958b9d273ed5ab43b0a6b81fd4949ad9272"},{"id":"func/ListTopicPosts.parseRoom","name":"ListTopicPosts.parseRoom","line":24,"end_line":31,"hash":"6508b101a3e1c0a32fed8f54f5b0383f2a891e5aa5c59a6fe0d1af6c9ffb9f37"},{"id":"func/ListTopicPosts.execute","name":"ListTopicPosts.execute","line":33,"end_line":55,"hash":"3ed269937f0d908694ba94f2faef658b92d7160be1262abffcd268fe022c352c"}]}
// mutate4javascript-manifest-end
+4
View File
@@ -16,3 +16,7 @@ class ListTopics extends ListUseCase {
}
export default ListTopics
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T15:09:18.069Z","module_hash":"4d7755c0ac3ab79ea014862e6c728fde8af6e76c65046c9691f81ecd00993734","functions":[{"id":"func/ListTopics.constructor","name":"ListTopics.constructor","line":8,"end_line":10,"hash":"0e6872a32236c0cb076d7a728a37d8a40f2aca9bada0395d502cd3dba466c273"},{"id":"func/ListTopics.execute","name":"ListTopics.execute","line":12,"end_line":15,"hash":"c41d34757567d55659cc3a8ba77730a34478dfff9a95af00868aa8c5b2db159a"}]}
// mutate4javascript-manifest-end
@@ -164,5 +164,34 @@ describe('#TopicQuery', () => {
assert.deepEqual(result.txids, [])
assert.equal(result.total, 0)
})
it('should fall back to the key txid when the value has no string txid', async () => {
async function * mockRooms () {
yield ['bitcoin:post-300', { room: 'bitcoin', type: 'post', blockHeight: 300 }]
}
roomsDb.iterator
.withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' }))
.returns(mockRooms())
const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0 })
assert.deepEqual(result.txids, ['post-300'])
assert.equal(result.total, 1)
})
it('should treat a post without a block height as height 0', async () => {
async function * mockRooms () {
yield ['bitcoin:post-a', { room: 'bitcoin', txid: 'post-a', type: 'post', blockHeight: 0 }]
yield ['bitcoin:post-b', { room: 'bitcoin', txid: 'post-b', type: 'post' }]
}
roomsDb.iterator
.withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' }))
.returns(mockRooms())
const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0 })
assert.deepEqual(result.txids, ['post-a', 'post-b'])
assert.equal(result.total, 2)
})
})
})
@@ -67,6 +67,7 @@ describe('#TopicsRESTController', () => {
assert.equal(ctx.throw.callCount, 1)
assert.equal(ctx.throw.firstCall.args[0], 500)
assert.equal(ctx.throw.firstCall.args[1], 'boom')
})
it('should preserve the status when the use case throws a statused error', async () => {
@@ -84,5 +85,6 @@ describe('#TopicsRESTController', () => {
assert.equal(ctx.throw.callCount, 1)
assert.equal(ctx.throw.firstCall.args[0], 400)
assert.equal(ctx.throw.firstCall.args[1], 'room is required')
})
})
@@ -72,6 +72,16 @@ describe('#ListTopicPosts', () => {
}
})
it('should reject a non-string room', async () => {
try {
await uut.execute({ room: 123 })
assert.fail('Expected error')
} catch (err) {
assert.equal(err.status, 400)
assert.include(err.message, 'room is required')
}
})
it('should return topic posts sorted by block height descending', async () => {
const result = await uut.execute({ room: 'bitcoin', limit: 10, offset: 0 })