From ace703831ff3d149f2caca10d540f4b1da021499 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sun, 20 Sep 2026 15:29:24 -0700 Subject: [PATCH] Harden profile-last-post with mutation-killing tests Add unit tests for the profileRecency backfill and recent-profiles read path that kill the language-mutation survivors: status-tip confirmation (no tip, exact tip), exclusion of addresses without a profile, replies, and polls, malformed addrPostHeight entries, missing optional stores, seen/height defaulting, stale-record removal, and the height/seen/address comparator tie-breakers. Tighten the indexer recency tests the same way and assert the set-profile text and max-size boundary. Refresh the mutate4javascript manifests and write the gherkin-mutator acceptance mutation metadata for the changed features. By architect. --- .../specs/backfill-profile-recency.feature | 5 + .../specs/recent-profile-identity.feature | 4 +- .../specs/recent-profile-ordering.feature | 4 + psf-memo-db/src/adapters/index.js | 2 +- psf-memo-db/src/adapters/level-db.js | 2 +- psf-memo-db/src/adapters/profile-query.js | 4 + .../rest-api/level/crud-handlers.js | 2 +- .../src/lib/backfill-profile-recency.js | 4 + .../src/use-cases/list-recent-profiles.js | 4 + .../test/unit/adapters/profile-query.unit.js | 51 ++++- .../unit/lib/backfill-profile-recency.unit.js | 118 ++++++++++ .../specs/profile-recency-indexing.feature | 4 + .../src/adapters/adapters-index.js | 2 +- .../src/use-cases/action-types/post.js | 4 + .../use-cases/action-types/profile-recency.js | 4 + .../src/use-cases/action-types/set-profile.js | 4 + .../action-types/profile-recency.unit.js | 216 +++++++++++++----- .../action-types/set-profile.unit.js | 42 ++++ 18 files changed, 414 insertions(+), 62 deletions(-) diff --git a/psf-memo-db/specs/backfill-profile-recency.feature b/psf-memo-db/specs/backfill-profile-recency.feature index bdb57a6..30c91af 100644 --- a/psf-memo-db/specs/backfill-profile-recency.feature +++ b/psf-memo-db/specs/backfill-profile-recency.feature @@ -1,3 +1,8 @@ +# mutation-stamp: sha256=010763f046b5bdd82c95d9992d35c34fa82155245f35148808d945fcef2a7d72 +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-09-20T22:26:06.447781649Z","feature_name":"Backfill profile recency","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/backfill-profile-recency.feature","background_hash":"9f29f1fd1af8398c20a9345845798f4d950d7074d138b4acd70ea111b8cf1017","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Backfill profile recency - 1 backfill records each profile's newest confirmed qualifying post","scenario_hash":"7926ea450d3754a5bfe456b5c8e61648d7ac7d1ada17288cb4c97b937e9c127a","mutation_count":6,"result":{"Total":6,"Killed":6,"Survived":0,"Errors":0},"tested_at":"2026-09-20T22:26:06.447781649Z"},{"index":2,"name":"Backfill profile recency - 3 backfill is idempotent","scenario_hash":"594c743f466022e0ff0008efce7f2134cfe2c12845c8da4eaebab2da510c4f6a","mutation_count":6,"result":{"Total":6,"Killed":6,"Survived":0,"Errors":0},"tested_at":"2026-09-20T22:26:06.447781649Z"}]} +# acceptance-mutation-manifest-end + # Scenarios: Backfill profile recency - 1, Backfill profile recency - 2, Backfill profile recency - 3 # # The profile recency backfill builds the profileRecency store from existing diff --git a/psf-memo-db/specs/recent-profile-identity.feature b/psf-memo-db/specs/recent-profile-identity.feature index 39def67..3e54d9f 100644 --- a/psf-memo-db/specs/recent-profile-identity.feature +++ b/psf-memo-db/specs/recent-profile-identity.feature @@ -1,6 +1,6 @@ -# mutation-stamp: sha256=f2672d16093128ab98f47d9bd32fb777fe6a860143e3a04492a6a8446314742b +# mutation-stamp: sha256=a9cdc032be8bad4a0ec047df3de8eb08b8ef47e305626e5e0f979d84f159d257 # acceptance-mutation-manifest-begin -# {"version":1,"tested_at":"2026-09-20T20:08:42.739895979Z","feature_name":"Recent Profile Identity","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/recent-profile-identity.feature","background_hash":"0289c6ddd1a0f1e7e23830863aec6d44f90f314d174423c44e8567da4e18f645","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Recent Profile Identity - 1 GET /profile/recent returns each profile's display name and avatar","scenario_hash":"ed4ba1b33ac244e193e9fb24bfd845d351f00cabe5bd507dfe735d73055ecece","mutation_count":9,"result":{"Total":9,"Killed":9,"Survived":0,"Errors":0},"tested_at":"2026-09-20T20:08:42.739895979Z"}]} +# {"version":1,"tested_at":"2026-09-20T22:26:26.477694996Z","feature_name":"Recent Profile Identity","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/recent-profile-identity.feature","background_hash":"f3df644832c7ba4bfe29da14ebe7c26c7ebc3d0e15db0b39854f6b69c593b670","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Recent Profile Identity - 1 GET /profile/recent returns each profile's display name and avatar","scenario_hash":"ed4ba1b33ac244e193e9fb24bfd845d351f00cabe5bd507dfe735d73055ecece","mutation_count":9,"result":{"Total":9,"Killed":9,"Survived":0,"Errors":0},"tested_at":"2026-09-20T22:26:26.477694996Z"}]} # acceptance-mutation-manifest-end # Scenarios: Recent Profile Identity - 1 diff --git a/psf-memo-db/specs/recent-profile-ordering.feature b/psf-memo-db/specs/recent-profile-ordering.feature index 3e0e61c..b76a2dc 100644 --- a/psf-memo-db/specs/recent-profile-ordering.feature +++ b/psf-memo-db/specs/recent-profile-ordering.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-09-20T22:25:45.967266375Z","feature_name":"Recent Profile Ordering","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/recent-profile-ordering.feature","background_hash":"8646bb1712dba3493a9703bf3fb32c011f7d4c1d7d9ed749868f9d9b247cdcd2","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Recent Profile Ordering - 2 each row reports the most recent post's block and seen","scenario_hash":"bbbb3e8a87dab0a3380f879cbb511728cf980ad52e46f6d3f411666a4d3b8d87","mutation_count":9,"result":{"Total":9,"Killed":9,"Survived":0,"Errors":0},"tested_at":"2026-09-20T22:25:45.967266375Z"}]} +# acceptance-mutation-manifest-end + # Scenarios: Recent Profile Ordering - 1, Recent Profile Ordering - 2, Recent Profile Ordering - 3, Recent Profile Ordering - 4 # # GET /profile/recent lists one row per profile address that has at least one diff --git a/psf-memo-db/src/adapters/index.js b/psf-memo-db/src/adapters/index.js index e4041f9..9a9bf60 100644 --- a/psf-memo-db/src/adapters/index.js +++ b/psf-memo-db/src/adapters/index.js @@ -94,5 +94,5 @@ class Adapters { export default Adapters // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-20T20:07:23.167Z","module_hash":"2f12ab15e729d2f35a9d092fd387e336a8ea21caa5c48c04e153b5d0f707947d","functions":[{"id":"func/Adapters.constructor","name":"Adapters.constructor","line":17,"end_line":22,"hash":"d5d1fd454ddfa112378f278773b65be5cb39aab75473dd57c04c9a385de1aa58"},{"id":"func/Adapters.openDatabases","name":"Adapters.openDatabases","line":24,"end_line":84,"hash":"6fe3d5ece4a1783936aa3c8f3fdd782206ae0bc417a06a048b1416a0dce8fb75"},{"id":"func/Adapters.start","name":"Adapters.start","line":86,"end_line":90,"hash":"f9e62a9199f0259f5c22913625e6e497548b3887cc24871bba475e73e52b3750"}]} +// {"version":1,"tested_at":"2026-09-20T21:56:16.308Z","module_hash":"7e2f08e8c4f5cbe9c883e29212a6391cd63a20f0875c29c192a0f078e1363458","functions":[{"id":"func/Adapters.constructor","name":"Adapters.constructor","line":17,"end_line":22,"hash":"d5d1fd454ddfa112378f278773b65be5cb39aab75473dd57c04c9a385de1aa58"},{"id":"func/Adapters.openDatabases","name":"Adapters.openDatabases","line":24,"end_line":85,"hash":"ed9027f054515a862e5eeaaaf5a996975cad3c910f2cd4e486c369a0a0282fa6"},{"id":"func/Adapters.start","name":"Adapters.start","line":87,"end_line":91,"hash":"f9e62a9199f0259f5c22913625e6e497548b3887cc24871bba475e73e52b3750"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-db/src/adapters/level-db.js b/psf-memo-db/src/adapters/level-db.js index 7084a8d..2feb7f7 100644 --- a/psf-memo-db/src/adapters/level-db.js +++ b/psf-memo-db/src/adapters/level-db.js @@ -84,5 +84,5 @@ export { DB_NAMES, dbDir } export default LevelDb // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-18T17:41:14.431Z","module_hash":"33eff30104aa55b918a55cbf874668dbc0c8f42d7bc4aced4ec1348917a73006","functions":[{"id":"func/LevelDb.constructor","name":"LevelDb.constructor","line":38,"end_line":45,"hash":"1c866e2484a08aa32503fb55bdfc2d711d2aedc0410e6fe749be66ec99e0f400"},{"id":"func/LevelDb.openDbs","name":"LevelDb.openDbs","line":47,"end_line":61,"hash":"1de94c7f46c342506f27b0066597f827bf1a57fa2caf2e1b6587701ae0e3c32f"},{"id":"func/LevelDb.getDbList","name":"LevelDb.getDbList","line":63,"end_line":65,"hash":"5a150ab18ab1e71f5a626c8fdd6f9dbe5d9a2406ad720f1875df32940de20976"},{"id":"func/LevelDb.closeDbs","name":"LevelDb.closeDbs","line":67,"end_line":72,"hash":"de21953bce0228ed9fffef541dc8326fe3cec9f8b3c85afd75eeb4671c15effe"},{"id":"func/LevelDb.ensureDirectories","name":"LevelDb.ensureDirectories","line":74,"end_line":79,"hash":"9ecaa7313531e06fab4148077786152467b83c19dee074a6218e8e43f4436b75"}]} +// {"version":1,"tested_at":"2026-09-20T21:57:39.141Z","module_hash":"5416df84f1fef398fd6ddd886f4467c5421e331aaa4299599cbfda271d2d7d7b","functions":[{"id":"func/LevelDb.constructor","name":"LevelDb.constructor","line":39,"end_line":46,"hash":"1c866e2484a08aa32503fb55bdfc2d711d2aedc0410e6fe749be66ec99e0f400"},{"id":"func/LevelDb.openDbs","name":"LevelDb.openDbs","line":48,"end_line":62,"hash":"1de94c7f46c342506f27b0066597f827bf1a57fa2caf2e1b6587701ae0e3c32f"},{"id":"func/LevelDb.getDbList","name":"LevelDb.getDbList","line":64,"end_line":66,"hash":"5a150ab18ab1e71f5a626c8fdd6f9dbe5d9a2406ad720f1875df32940de20976"},{"id":"func/LevelDb.closeDbs","name":"LevelDb.closeDbs","line":68,"end_line":73,"hash":"de21953bce0228ed9fffef541dc8326fe3cec9f8b3c85afd75eeb4671c15effe"},{"id":"func/LevelDb.ensureDirectories","name":"LevelDb.ensureDirectories","line":75,"end_line":80,"hash":"9ecaa7313531e06fab4148077786152467b83c19dee074a6218e8e43f4436b75"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-db/src/adapters/profile-query.js b/psf-memo-db/src/adapters/profile-query.js index bce246a..3a0b789 100644 --- a/psf-memo-db/src/adapters/profile-query.js +++ b/psf-memo-db/src/adapters/profile-query.js @@ -112,3 +112,7 @@ class ProfileQuery { } export default ProfileQuery + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-20T22:17:12.092Z","module_hash":"cd4a1178c4968e0a61acab324514fb4160e41e98535acd134fab994453484fcc","functions":[{"id":"func/ProfileQuery.constructor","name":"ProfileQuery.constructor","line":19,"end_line":32,"hash":"3ac4ed8e2d002256a3fcec017bc8ae0e783f29fe1de9e8b0eac04852b37ecc42"},{"id":"func/ProfileQuery.compareRecency","name":"ProfileQuery.compareRecency","line":37,"end_line":42,"hash":"891628bf31dbb0cf5d9a8560d9243328675f1f2278077eddc7d3d44836027b70"},{"id":"func/ProfileQuery.listRecencyEntries","name":"ProfileQuery.listRecencyEntries","line":46,"end_line":59,"hash":"618f0ae6b9c14a9fadfa6ffe97e818e81d14ca9c1c22fcc98a4d1f6c62444135"},{"id":"func/ProfileQuery.listRecentProfiles","name":"ProfileQuery.listRecentProfiles","line":64,"end_line":85,"hash":"a0c84485d1149689550216c16fda0c5312f6077118d306204b3837479d2cc978"},{"id":"func/ProfileQuery.getProfileIdentity","name":"ProfileQuery.getProfileIdentity","line":91,"end_line":101,"hash":"66105f1c77dadc97f32beec8c952184617e07a0401eae1af1128de70f47df070"},{"id":"func/ProfileQuery.getRecordOrNull","name":"ProfileQuery.getRecordOrNull","line":103,"end_line":111,"hash":"5604da1e9adc18abda5e58e4f366aa0bcc8e0dd417e2f6882c27289df7fb7a14"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js b/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js index 4153515..588253d 100644 --- a/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js +++ b/psf-memo-db/src/controllers/rest-api/level/crud-handlers.js @@ -59,5 +59,5 @@ export const ENTITY_CONFIG = [ ] // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-18T17:40:30.095Z","module_hash":"d37a0c9ebf7d94a345a2b129ffccaabb16ee06906c8f029efd2d7a4a8243936c","functions":[{"id":"func/makeCrudHandlers","name":"makeCrudHandlers","line":5,"end_line":35,"hash":"133b135aa115c058291b6df4a0b6b15152dde83c3163890e0eab0c3b018f8d4d"}]} +// {"version":1,"tested_at":"2026-09-20T21:59:36.940Z","module_hash":"471a9db88c0e95824a435ad338cd45277ac3dcd439175cd7fa084b87b227dd29","functions":[{"id":"func/makeCrudHandlers","name":"makeCrudHandlers","line":5,"end_line":35,"hash":"133b135aa115c058291b6df4a0b6b15152dde83c3163890e0eab0c3b018f8d4d"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-db/src/lib/backfill-profile-recency.js b/psf-memo-db/src/lib/backfill-profile-recency.js index ba0a327..edd30cc 100644 --- a/psf-memo-db/src/lib/backfill-profile-recency.js +++ b/psf-memo-db/src/lib/backfill-profile-recency.js @@ -139,3 +139,7 @@ export async function backfillProfileRecency (stores) { await removeStaleRecency(stores.profileRecencyDb, new Set(best.keys())) return { profiles: best.size } } + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-20T22:14:18.244Z","module_hash":"c6da57d385443b5883d925cdce874fb67ebbc1d4eecd78446dd992b538d7ce6c","functions":[{"id":"func/isNotFound","name":"isNotFound","line":21,"end_line":23,"hash":"3a97ac721c26cf8c2e1edae456afe59263be988cee566a87758f2cb013d8691c"},{"id":"func/getRecord","name":"getRecord","line":26,"end_line":34,"hash":"948bd0435cdce8acf433dbe3aebb9ede9929e6598610a888ec8443947e4cb5a8"},{"id":"func/readChainBlockHeight","name":"readChainBlockHeight","line":38,"end_line":41,"hash":"342991c6aa4dddf98ba8e8854f631a01a46d2f7bd4173ff78e9c601d75351f37"},{"id":"func/partsFromAddrPostHeightKey","name":"partsFromAddrPostHeightKey","line":46,"end_line":57,"hash":"1d48cbd481a5e21704ae4e352190c809b8620ee6c51c74eca4e41e2d25407afb"},{"id":"func/entryFields","name":"entryFields","line":61,"end_line":68,"hash":"a71641df77abbd4c553532b42f5fbf4cec4fd09ff809bc8b2264a1f91fcd050e"},{"id":"func/isConfirmedEntry","name":"isConfirmedEntry","line":72,"end_line":75,"hash":"e3d1fffa4b2ad3fcff3437d09f5ff1e9596ad921d60260db19385a2b3111fac2"},{"id":"func/isQualifyingPost","name":"isQualifyingPost","line":79,"end_line":84,"hash":"d58b96768f927594592576fde274d9a5f69d54df1b73184ecc38a5735ca5e252"},{"id":"func/qualifyingCandidate","name":"qualifyingCandidate","line":88,"end_line":95,"hash":"ec80fdecd67123dc9cb95f351933e6777b44b21b160cdc2d8e7cb0d8b9f9f072"},{"id":"func/isNewer","name":"isNewer","line":99,"end_line":103,"hash":"60db8ecaa7d252fb34515a16e4aa8388df6a4e2a0965425ec4c49df49cf0c32a"},{"id":"func/keepNewest","name":"keepNewest","line":107,"end_line":109,"hash":"17fd4a511b90bf952058a2ccf645b1f29fa68134aee1d4590bf7c375409fcfb3"},{"id":"func/collectBestRecency","name":"collectBestRecency","line":111,"end_line":118,"hash":"19618f9ad24d6b1740f1765d952a1b65cbc062319962051264193d24913349a1"},{"id":"func/writeRecency","name":"writeRecency","line":120,"end_line":124,"hash":"35ad5339afbd58f4df16ec5bc400b7a3e6e8136fc0d726dd6368a8be54d34b57"},{"id":"func/removeStaleRecency","name":"removeStaleRecency","line":126,"end_line":130,"hash":"494b04d530f09bc1ad5ff09c4b0b3cfe6a98366e4e4c21395f9df45bedb61f63"},{"id":"func/backfillProfileRecency","name":"backfillProfileRecency","line":135,"end_line":141,"hash":"a737e754befdc198a62ec6e6392e80ed4cde419019c9f04492212f971db42428"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-db/src/use-cases/list-recent-profiles.js b/psf-memo-db/src/use-cases/list-recent-profiles.js index 010c71e..d63ed08 100644 --- a/psf-memo-db/src/use-cases/list-recent-profiles.js +++ b/psf-memo-db/src/use-cases/list-recent-profiles.js @@ -36,3 +36,7 @@ class ListRecentProfiles extends ListUseCase { } export default ListRecentProfiles + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-20T21:53:30.844Z","module_hash":"ee68d093a6a1377f027641c165bf55ca0b8ca019a0f5449eb39c666b91b80bae","functions":[{"id":"func/ListRecentProfiles.constructor","name":"ListRecentProfiles.constructor","line":10,"end_line":12,"hash":"a2c7dd0696ac463cbc142fa7ccce4a3cadf8e246a872261733d199dbd4c153d1"},{"id":"func/ListRecentProfiles.execute","name":"ListRecentProfiles.execute","line":14,"end_line":35,"hash":"a629483fb61bd14c2a27880b667290c1dfbf2fb4e55faf14fe1010b2532b93bb"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-db/test/unit/adapters/profile-query.unit.js b/psf-memo-db/test/unit/adapters/profile-query.unit.js index 63c8d38..8d25465 100644 --- a/psf-memo-db/test/unit/adapters/profile-query.unit.js +++ b/psf-memo-db/test/unit/adapters/profile-query.unit.js @@ -40,10 +40,11 @@ describe('#ProfileQuery', () => { it('should order profiles by post height descending, then seen descending, then address ascending', async () => { stubRecency({ - 'bitcoincash:qaddr-alice': { addr: 'bitcoincash:qaddr-alice', blockHeight: 600300, seen: 300 }, - 'bitcoincash:qaddr-bob': { addr: 'bitcoincash:qaddr-bob', blockHeight: 600300, seen: 200 }, + // Deliberately unsorted so the comparator must do the work. + 'bitcoincash:qaddr-carol': { addr: 'bitcoincash:qaddr-carol', blockHeight: 600200, seen: 400 }, 'bitcoincash:qaddr-erin': { addr: 'bitcoincash:qaddr-erin', blockHeight: 600300, seen: 200 }, - 'bitcoincash:qaddr-carol': { addr: 'bitcoincash:qaddr-carol', blockHeight: 600200, seen: 400 } + 'bitcoincash:qaddr-alice': { addr: 'bitcoincash:qaddr-alice', blockHeight: 600300, seen: 300 }, + 'bitcoincash:qaddr-bob': { addr: 'bitcoincash:qaddr-bob', blockHeight: 600300, seen: 200 } }) stubProfiles({ 'bitcoincash:qaddr-alice': { text: 'alice bio', txid: 'profile-alice' }, @@ -63,6 +64,50 @@ describe('#ProfileQuery', () => { ]) }) + it('compareRecency ranks greater height and greater seen before lower values', () => { + const high = { addr: 'bitcoincash:qaddr-a', blockHeight: 600200, seen: 1 } + const low = { addr: 'bitcoincash:qaddr-b', blockHeight: 600100, seen: 1 } + assert.isBelow(uut.compareRecency(high, low), 0) + assert.isAbove(uut.compareRecency(low, high), 0) + + const newerSeen = { addr: 'bitcoincash:qaddr-c', blockHeight: 600200, seen: 5 } + const olderSeen = { addr: 'bitcoincash:qaddr-d', blockHeight: 600200, seen: 2 } + assert.isBelow(uut.compareRecency(newerSeen, olderSeen), 0) + assert.isAbove(uut.compareRecency(olderSeen, newerSeen), 0) + + const same = { addr: 'bitcoincash:qaddr-e', blockHeight: 600200, seen: 5 } + assert.equal(uut.compareRecency(same, { ...same }), 0) + + const addrA = { addr: 'bitcoincash:qaddr-a', blockHeight: 600200, seen: 5 } + const addrB = { addr: 'bitcoincash:qaddr-b', blockHeight: 600200, seen: 5 } + assert.isBelow(uut.compareRecency(addrA, addrB), 0) + assert.isAbove(uut.compareRecency(addrB, addrA), 0) + }) + + it('listRecencyEntries defaults a missing height and seen to 0', async () => { + stubRecency({ 'bitcoincash:qaddr-a': { addr: 'bitcoincash:qaddr-a' } }) + + const entries = await uut.listRecencyEntries() + + assert.deepEqual(entries, [{ addr: 'bitcoincash:qaddr-a', blockHeight: 0, seen: 0 }]) + }) + + it('should default limit to 100 and offset to 0', async () => { + stubRecency({ + 'bitcoincash:qaddr-a': { addr: 'bitcoincash:qaddr-a', blockHeight: 600300, seen: 3 }, + 'bitcoincash:qaddr-b': { addr: 'bitcoincash:qaddr-b', blockHeight: 600200, seen: 2 } + }) + stubProfiles({ + 'bitcoincash:qaddr-a': { text: 'a bio', txid: 'profile-a' }, + 'bitcoincash:qaddr-b': { text: 'b bio', txid: 'profile-b' } + }) + + const { profiles, total } = await uut.listRecentProfiles() + + assert.equal(total, 2) + assert.deepEqual(profiles.map((p) => p.addr), ['bitcoincash:qaddr-a', 'bitcoincash:qaddr-b']) + }) + it('should report the recency block height and seen, not the profile record values', async () => { stubRecency({ 'bitcoincash:qaddr-alice': { addr: 'bitcoincash:qaddr-alice', blockHeight: 600300, seen: 300 } diff --git a/psf-memo-db/test/unit/lib/backfill-profile-recency.unit.js b/psf-memo-db/test/unit/lib/backfill-profile-recency.unit.js index 41961f8..3a3bdb4 100644 --- a/psf-memo-db/test/unit/lib/backfill-profile-recency.unit.js +++ b/psf-memo-db/test/unit/lib/backfill-profile-recency.unit.js @@ -91,6 +91,116 @@ describe('#backfillProfileRecency', () => { assert.isFalse(stores.profileRecencyDb.store.has(NOPOST)) }) + + it('should rethrow an unexpected store error instead of treating it as not found', async () => { + const stores = fixtureStores() + stores.profilesDb.get = async () => { throw new Error('store boom') } + + let error + try { + await backfillProfileRecency(stores) + } catch (err) { + error = err + } + + assert.equal(error?.message, 'store boom') + }) + + it('should treat every entry as confirmed when the status store has no chain tip', async () => { + const stores = fixtureStores() + stores.statusDb = new FakeDb() + + await backfillProfileRecency(stores) + + // With no tip, Bob's unconfirmed post-b2 at 600500 is the newest. + assert.deepEqual(stores.profileRecencyDb.store.get(BOB), { + addr: BOB, blockHeight: 600500, seen: 250 + }) + }) + + it('should treat a post exactly at the chain tip as confirmed', async () => { + const stores = fixtureStores() + stores.statusDb = new FakeDb([['status', { chainBlockHeight: 600500 }]]) + + await backfillProfileRecency(stores) + + assert.deepEqual(stores.profileRecencyDb.store.get(BOB), { + addr: BOB, blockHeight: 600500, seen: 250 + }) + }) + + it('should not record a qualifying post from an address without a profile', async () => { + const stores = fixtureStores() + const noprofile = 'bitcoincash:qaddr-noprofile' + stores.addrPostHeightsDb.put(addrPostHeightKey(noprofile, 600100, 'post-np'), { + txid: 'post-np', addr: noprofile, blockHeight: 600100 + }) + stores.postsDb.put('post-np', { addr: noprofile, seen: 50, blockHeight: 600100 }) + + await backfillProfileRecency(stores) + + assert.isFalse(stores.profileRecencyDb.store.has(noprofile)) + }) + + it('should ignore an addrPostHeights entry with no txid', async () => { + const stores = fixtureStores() + stores.addrPostHeightsDb.put(addrPostHeightKey(ALICE, 600350, ''), null) + + await backfillProfileRecency(stores) + + // The malformed entry must not displace Alice's real post-a1. + assert.deepEqual(stores.profileRecencyDb.store.get(ALICE), { + addr: ALICE, blockHeight: 600100, seen: 100 + }) + }) + + it('should default the seen time to 0 when the post record is missing', async () => { + const stores = fixtureStores() + const ghost = 'bitcoincash:qaddr-ghost' + stores.profilesDb.put(ghost, { text: 'ghost bio', txid: 'profile-ghost' }) + stores.addrPostHeightsDb.put(addrPostHeightKey(ghost, 600100, 'post-ghost'), { + txid: 'post-ghost', addr: ghost, blockHeight: 600100 + }) + + await backfillProfileRecency(stores) + + assert.deepEqual(stores.profileRecencyDb.store.get(ghost), { + addr: ghost, blockHeight: 600100, seen: 0 + }) + }) + + it('should tolerate missing optional reply/poll stores', async () => { + const stores = fixtureStores() + delete stores.postParentsDb + delete stores.pollsDb + + await backfillProfileRecency(stores) + + // Without the reply store, reply-a1 at 600300 becomes Alice's newest. + assert.deepEqual(stores.profileRecencyDb.store.get(ALICE), { + addr: ALICE, blockHeight: 600300, seen: 150 + }) + }) + + it('should pick the greater seen time for two posts at equal height', async () => { + const stores = fixtureStores() + const tie = 'bitcoincash:qaddr-tie' + stores.profilesDb.put(tie, { text: 'tie bio', txid: 'profile-tie' }) + stores.addrPostHeightsDb.put(addrPostHeightKey(tie, 600100, 'post-t1'), { + txid: 'post-t1', addr: tie, blockHeight: 600100 + }) + stores.addrPostHeightsDb.put(addrPostHeightKey(tie, 600100, 'post-t2'), { + txid: 'post-t2', addr: tie, blockHeight: 600100 + }) + stores.postsDb.put('post-t1', { addr: tie, seen: 50, blockHeight: 600100 }) + stores.postsDb.put('post-t2', { addr: tie, seen: 80, blockHeight: 600100 }) + + await backfillProfileRecency(stores) + + assert.deepEqual(stores.profileRecencyDb.store.get(tie), { + addr: tie, blockHeight: 600100, seen: 80 + }) + }) }) describe('#partsFromAddrPostHeightKey', () => { @@ -101,4 +211,12 @@ describe('#partsFromAddrPostHeightKey', () => { txid: 'post-a1' }) }) + + it('should default a non-numeric height segment to 0', () => { + assert.deepEqual(partsFromAddrPostHeightKey(`${ALICE}::post-a1`), { + addr: ALICE, + blockHeight: 0, + txid: 'post-a1' + }) + }) }) diff --git a/psf-memo-indexer/specs/profile-recency-indexing.feature b/psf-memo-indexer/specs/profile-recency-indexing.feature index 5406bc2..031e381 100644 --- a/psf-memo-indexer/specs/profile-recency-indexing.feature +++ b/psf-memo-indexer/specs/profile-recency-indexing.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-09-20T22:26:28.547784872Z","feature_name":"Profile Recency Indexing","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-indexer/specs/profile-recency-indexing.feature","background_hash":"2b6c5e5cbd326861a7a295e201fdba1c1dbf847d7b65909821167cdb138f47ef","implementation_hash":"unknown","scenarios":[]} +# acceptance-mutation-manifest-end + # Scenarios: Profile Recency Indexing - 1, Profile Recency Indexing - 2, Profile Recency Indexing - 3, Profile Recency Indexing - 4, Profile Recency Indexing - 5, Profile Recency Indexing - 6, Profile Recency Indexing - 7, Profile Recency Indexing - 8, Profile Recency Indexing - 9, Profile Recency Indexing - 10 # # The indexer maintains a profileRecency store so the read side can list diff --git a/psf-memo-indexer/src/adapters/adapters-index.js b/psf-memo-indexer/src/adapters/adapters-index.js index abbbc1e..1893492 100644 --- a/psf-memo-indexer/src/adapters/adapters-index.js +++ b/psf-memo-indexer/src/adapters/adapters-index.js @@ -54,5 +54,5 @@ class Adapters { export default Adapters // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-29T03:35:13.645Z","module_hash":"242418b52e79c7a6f10aadb18839617f2135b16246db9a204d2888bc93c80d78","functions":[{"id":"func/Adapters.constructor","name":"Adapters.constructor","line":14,"end_line":42,"hash":"dc398f0bb823376f488088fc628f94913babcbe494cfd7dc299e230c08813836"},{"id":"func/Adapters.initAdapters","name":"Adapters.initAdapters","line":44,"end_line":47,"hash":"e3fd321225d51de1199b7ea45e4c9ab323a474c6fe99252041271e45daa59650"}]} +// {"version":1,"tested_at":"2026-09-20T22:03:25.604Z","module_hash":"7c072a095d3984b401d1eab04537690b78baff73211ad325364ddd4cb46379fa","functions":[{"id":"func/Adapters.constructor","name":"Adapters.constructor","line":14,"end_line":46,"hash":"fe2c96567b8ffaf157f7e64fff75e47ea33e2ddda13446048f37981976c4bb6f"},{"id":"func/Adapters.initAdapters","name":"Adapters.initAdapters","line":48,"end_line":51,"hash":"e3fd321225d51de1199b7ea45e4c9ab323a474c6fe99252041271e45daa59650"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-indexer/src/use-cases/action-types/post.js b/psf-memo-indexer/src/use-cases/action-types/post.js index edcb29d..95b63c5 100644 --- a/psf-memo-indexer/src/use-cases/action-types/post.js +++ b/psf-memo-indexer/src/use-cases/action-types/post.js @@ -48,3 +48,7 @@ export async function handlePost (ctx) { await recordProfileRecency(adapters, signerAddr, blockHeight, seen) } } + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-20T22:02:16.774Z","module_hash":"20468da5cbcbef84fea21769827508bd962fa39aaec7de52705b6f35def32191","functions":[{"id":"func/createIfMissing","name":"createIfMissing","line":6,"end_line":12,"hash":"d59cefaf87075a2bc41538961b609387e35393d4fbf02ecfc0633026bbfdca42"},{"id":"func/qualifiesForRecency","name":"qualifiesForRecency","line":17,"end_line":19,"hash":"865f41dcba571b02183d6bac2b8bdfbc0d43c444eb2fadc68b7aa35e0bb4b7fd"},{"id":"func/handlePost","name":"handlePost","line":21,"end_line":50,"hash":"8eae8f0e6003b114b4b043aa298d57ab6562095c74ead3589d503af3c0e82314"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-indexer/src/use-cases/action-types/profile-recency.js b/psf-memo-indexer/src/use-cases/action-types/profile-recency.js index bda2761..c5cc2af 100644 --- a/psf-memo-indexer/src/use-cases/action-types/profile-recency.js +++ b/psf-memo-indexer/src/use-cases/action-types/profile-recency.js @@ -108,3 +108,7 @@ export async function establishProfileRecency (adapters, addr) { if (!best) return null return upsertProfileRecency(adapters, addr, best.blockHeight, best.seen) } + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-20T22:21:12.817Z","module_hash":"4947b506b19056b74e5fa2b71a56977efc554e460f908123153701dffecb410d","functions":[{"id":"func/getChainBlockHeight","name":"getChainBlockHeight","line":21,"end_line":30,"hash":"496cbdf9c6911013be659fc4faf22c95e0871b989f9efe66cd6802b7c671bbc1"},{"id":"func/isConfirmed","name":"isConfirmed","line":34,"end_line":38,"hash":"3d803dc6482eefd4f09010edd1c4728ab5590cfe4d7e97e484324c28d08a8640"},{"id":"func/upsertProfileRecency","name":"upsertProfileRecency","line":42,"end_line":54,"hash":"465a6fc3736df2d1666b0bad083bc728ab19f704a664d53eb376875f4f9a8427"},{"id":"func/recordProfileRecency","name":"recordProfileRecency","line":58,"end_line":64,"hash":"4866765ec30a3c1b39bd68d1d51a9fd1ed2ea830182f9b7e45b55e948db67481"},{"id":"func/qualifyingCandidate","name":"qualifyingCandidate","line":68,"end_line":78,"hash":"c1595bafcea0d5ddc0c39d7ee30bea75250d3fabb46f9fd2925fc11dc182d827"},{"id":"func/newerCandidate","name":"newerCandidate","line":82,"end_line":86,"hash":"b9eabd80bee99447221c851694c9f1c0b9c8153e45e121580b72ce83cdfd766e"},{"id":"func/findNewestQualifyingPost","name":"findNewestQualifyingPost","line":88,"end_line":96,"hash":"e82f811d40c715d8c703837d4b0c0b40568d01622c902e5ebe504bf4dce56413"},{"id":"func/establishProfileRecency","name":"establishProfileRecency","line":103,"end_line":110,"hash":"bf58de2cfb1f41a13ce1964ec9fb5eb9939e6582307a6853d63ffe9603a7ffbc"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-indexer/src/use-cases/action-types/set-profile.js b/psf-memo-indexer/src/use-cases/action-types/set-profile.js index ecd3234..9644258 100644 --- a/psf-memo-indexer/src/use-cases/action-types/set-profile.js +++ b/psf-memo-indexer/src/use-cases/action-types/set-profile.js @@ -20,3 +20,7 @@ export async function handleSetProfile (ctx) { await adapters.profileDb.create(signerAddr, { text, txid, seen, addr: signerAddr, blockHeight }) await establishProfileRecency(adapters, signerAddr) } + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-20T22:12:37.670Z","module_hash":"527293404ff751265c80ca2abf4803b478e5786ad6bc3496616fb846668e4e02","functions":[{"id":"func/handleSetProfile","name":"handleSetProfile","line":5,"end_line":22,"hash":"4df97ed1a92e3d265b499abbb977e91ca80bd100b4d59138d4a3cd0d852fb25b"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/profile-recency.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/profile-recency.unit.js index 437e151..e3057bf 100644 --- a/psf-memo-indexer/test/unit/use-cases/action-types/profile-recency.unit.js +++ b/psf-memo-indexer/test/unit/use-cases/action-types/profile-recency.unit.js @@ -1,10 +1,17 @@ import { assert } from 'chai' import { makeMemoryDb } from '../../../support/memory-db.js' import { + isConfirmed, recordProfileRecency, establishProfileRecency } from '../../../../src/use-cases/action-types/profile-recency.js' +const ADDR = 'bitcoincash:qaddr-a' + +function pad (height) { + return String(height).padStart(12, '0') +} + function makeAdapters (overrides = {}) { return { profileDb: makeMemoryDb(), @@ -17,110 +24,159 @@ function makeAdapters (overrides = {}) { } } +// An adapter set with a stored profile for ADDR, the common precondition of +// the recordProfileRecency tests. +async function makeAdaptersWithProfile (overrides = {}) { + const adapters = makeAdapters(overrides) + await adapters.profileDb.update(ADDR, { addr: ADDR, text: 'bio' }) + return adapters +} + +function assertRecency (adapters, expected, addr = ADDR) { + assert.deepEqual(adapters.profileRecencyDb.store.get(addr), { addr, ...expected }) +} + +async function addAddrPost (adapters, txid, blockHeight, addr = ADDR) { + await adapters.addrPostHeightDb.update(`${addr}:${pad(blockHeight)}:${txid}`, { + txid, addr, blockHeight + }) +} + +async function addPost (adapters, txid, fields, addr = ADDR) { + await adapters.postDb.update(txid, { addr, ...fields }) +} + +describe('#isConfirmed', () => { + it('should treat a missing block height as unconfirmed', () => { + assert.isFalse(isConfirmed(null, 600100)) + assert.isFalse(isConfirmed(undefined, 600100)) + }) + + it('should treat a post at or below the chain tip as confirmed', () => { + assert.isTrue(isConfirmed(600100, 600100)) + assert.isTrue(isConfirmed(600099, 600100)) + assert.isFalse(isConfirmed(600101, 600100)) + }) + + it('should treat every post as confirmed when there is no chain tip', () => { + assert.isTrue(isConfirmed(600100, null)) + assert.isTrue(isConfirmed(600100, undefined)) + }) +}) + describe('#recordProfileRecency', () => { it('should record the recency for an author with a profile', async () => { - const adapters = makeAdapters() - await adapters.profileDb.update('bitcoincash:qaddr-a', { addr: 'bitcoincash:qaddr-a', text: 'bio' }) + const adapters = await makeAdaptersWithProfile() - await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 1000) + await recordProfileRecency(adapters, ADDR, 600100, 1000) - assert.deepEqual(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a'), { - addr: 'bitcoincash:qaddr-a', - blockHeight: 600100, - seen: 1000 - }) + assertRecency(adapters, { blockHeight: 600100, seen: 1000 }) }) it('should not record when the author has no profile', async () => { const adapters = makeAdapters() - await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 1000) + await recordProfileRecency(adapters, ADDR, 600100, 1000) assert.equal(adapters.profileRecencyDb.store.size, 0) }) it('should not record an unconfirmed post', async () => { - const adapters = makeAdapters({ + const adapters = await makeAdaptersWithProfile({ statusDb: { getStatus: async () => ({ chainBlockHeight: 600050 }) } }) - await adapters.profileDb.update('bitcoincash:qaddr-a', { addr: 'bitcoincash:qaddr-a', text: 'bio' }) - await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 1000) + await recordProfileRecency(adapters, ADDR, 600100, 1000) assert.equal(adapters.profileRecencyDb.store.size, 0) }) it('should keep the greatest height regardless of processing order', async () => { - const adapters = makeAdapters() - await adapters.profileDb.update('bitcoincash:qaddr-a', { addr: 'bitcoincash:qaddr-a', text: 'bio' }) + const adapters = await makeAdaptersWithProfile() - await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600200, 100) - await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 200) + await recordProfileRecency(adapters, ADDR, 600200, 100) + await recordProfileRecency(adapters, ADDR, 600100, 200) - assert.equal(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a').blockHeight, 600200) - assert.equal(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a').seen, 100) + assertRecency(adapters, { blockHeight: 600200, seen: 100 }) }) it('should keep the greatest seen at an equal height', async () => { - const adapters = makeAdapters() - await adapters.profileDb.update('bitcoincash:qaddr-a', { addr: 'bitcoincash:qaddr-a', text: 'bio' }) + const adapters = await makeAdaptersWithProfile() - await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600200, 100) - await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600200, 300) + await recordProfileRecency(adapters, ADDR, 600200, 100) + await recordProfileRecency(adapters, ADDR, 600200, 300) - assert.equal(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a').seen, 300) + assertRecency(adapters, { blockHeight: 600200, seen: 300 }) }) it('should be idempotent for the same post', async () => { - const adapters = makeAdapters() - await adapters.profileDb.update('bitcoincash:qaddr-a', { addr: 'bitcoincash:qaddr-a', text: 'bio' }) + const adapters = await makeAdaptersWithProfile() - await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 100) - await recordProfileRecency(adapters, 'bitcoincash:qaddr-a', 600100, 100) + await recordProfileRecency(adapters, ADDR, 600100, 100) + await recordProfileRecency(adapters, ADDR, 600100, 100) - assert.deepEqual(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a'), { - addr: 'bitcoincash:qaddr-a', - blockHeight: 600100, - seen: 100 - }) + assertRecency(adapters, { blockHeight: 600100, seen: 100 }) + }) + + it('should default a missing seen value to 0', async () => { + const adapters = await makeAdaptersWithProfile() + + await recordProfileRecency(adapters, ADDR, 600100, null) + + assertRecency(adapters, { blockHeight: 600100, seen: 0 }) + }) + + it('should treat a recency record missing blockHeight as height 0', async () => { + const adapters = await makeAdaptersWithProfile() + await adapters.profileRecencyDb.update(ADDR, { addr: ADDR }) + + await recordProfileRecency(adapters, ADDR, 0, 1) + + assertRecency(adapters, { blockHeight: 0, seen: 1 }) + }) + + it('should treat a recency record missing seen as seen 0', async () => { + const adapters = await makeAdaptersWithProfile() + await adapters.profileRecencyDb.update(ADDR, { addr: ADDR, blockHeight: 600100 }) + + await recordProfileRecency(adapters, ADDR, 600100, 1) + + assertRecency(adapters, { blockHeight: 600100, seen: 1 }) + }) + + it('should return null when the profileRecency store is not configured', async () => { + const adapters = await makeAdaptersWithProfile({ profileRecencyDb: undefined }) + + const result = await recordProfileRecency(adapters, ADDR, 600100, 100) + + assert.equal(result, null) }) }) describe('#establishProfileRecency', () => { it('should pick the newest qualifying post and exclude replies and polls', async () => { const adapters = makeAdapters() - await adapters.addrPostHeightDb.update('bitcoincash:qaddr-a:000000600100:post-a1', { - txid: 'post-a1', addr: 'bitcoincash:qaddr-a', blockHeight: 600100 - }) - await adapters.addrPostHeightDb.update('bitcoincash:qaddr-a:000000600200:poll-a1', { - txid: 'poll-a1', addr: 'bitcoincash:qaddr-a', blockHeight: 600200 - }) - await adapters.addrPostHeightDb.update('bitcoincash:qaddr-a:000000600300:reply-a1', { - txid: 'reply-a1', addr: 'bitcoincash:qaddr-a', blockHeight: 600300 - }) - await adapters.postDb.update('post-a1', { addr: 'bitcoincash:qaddr-a', seen: 100, blockHeight: 600100 }) + await addAddrPost(adapters, 'post-a1', 600100) + await addAddrPost(adapters, 'poll-a1', 600200) + await addAddrPost(adapters, 'reply-a1', 600300) + await addPost(adapters, 'post-a1', { seen: 100, blockHeight: 600100 }) await adapters.postParentDb.update('reply-a1', { txid: 'reply-a1', parentTxid: 'post-a1' }) await adapters.pollDb.update('poll-a1', { txid: 'poll-a1' }) - await establishProfileRecency(adapters, 'bitcoincash:qaddr-a') + await establishProfileRecency(adapters, ADDR) - assert.deepEqual(adapters.profileRecencyDb.store.get('bitcoincash:qaddr-a'), { - addr: 'bitcoincash:qaddr-a', - blockHeight: 600100, - seen: 100 - }) + assertRecency(adapters, { blockHeight: 600100, seen: 100 }) }) it('should ignore an unconfirmed qualifying post', async () => { const adapters = makeAdapters({ statusDb: { getStatus: async () => ({ chainBlockHeight: 600450 }) } }) - await adapters.addrPostHeightDb.update('bitcoincash:qaddr-b:000000600500:post-b2', { - txid: 'post-b2', addr: 'bitcoincash:qaddr-b', blockHeight: 600500 - }) - await adapters.postDb.update('post-b2', { addr: 'bitcoincash:qaddr-b', seen: 250, blockHeight: 600500 }) + const addr = 'bitcoincash:qaddr-b' + await addAddrPost(adapters, 'post-b2', 600500, addr) + await addPost(adapters, 'post-b2', { seen: 250, blockHeight: 600500 }, addr) - await establishProfileRecency(adapters, 'bitcoincash:qaddr-b') + await establishProfileRecency(adapters, addr) assert.equal(adapters.profileRecencyDb.store.size, 0) }) @@ -132,4 +188,58 @@ describe('#establishProfileRecency', () => { assert.equal(adapters.profileRecencyDb.store.size, 0) }) + + it('should default a missing addrPostHeight block height to 0', async () => { + const adapters = makeAdapters() + await adapters.addrPostHeightDb.update(`${ADDR}:${pad(600100)}:post-a1`, { + txid: 'post-a1', addr: ADDR + }) + await addPost(adapters, 'post-a1', { seen: 42 }) + + await establishProfileRecency(adapters, ADDR) + + assertRecency(adapters, { blockHeight: 0, seen: 42 }) + }) + + it('should default a missing post seen to 0', async () => { + const adapters = makeAdapters() + await addAddrPost(adapters, 'post-a1', 600100) + await adapters.postDb.update('post-a1', { addr: ADDR }) + + await establishProfileRecency(adapters, ADDR) + + assertRecency(adapters, { blockHeight: 600100, seen: 0 }) + }) + + it('should pick the newest of several qualifying posts', async () => { + const adapters = makeAdapters() + await addAddrPost(adapters, 'post-a1', 600100) + await addAddrPost(adapters, 'post-a2', 600300) + await addPost(adapters, 'post-a1', { seen: 100 }) + await addPost(adapters, 'post-a2', { seen: 300 }) + + await establishProfileRecency(adapters, ADDR) + + assertRecency(adapters, { blockHeight: 600300, seen: 300 }) + }) + + it('should return null when the addrPostHeight store is not configured', async () => { + const adapters = makeAdapters({ addrPostHeightDb: undefined }) + + const result = await establishProfileRecency(adapters, ADDR) + + assert.equal(result, null) + }) + + it('should treat a status read error as no chain tip', async () => { + const adapters = makeAdapters({ + statusDb: { getStatus: async () => { throw new Error('status down') } } + }) + await addAddrPost(adapters, 'post-a1', 600100) + await addPost(adapters, 'post-a1', { seen: 100 }) + + await establishProfileRecency(adapters, ADDR) + + assertRecency(adapters, { blockHeight: 600100, seen: 100 }) + }) }) diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/set-profile.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/set-profile.unit.js index cf6722f..06a66ac 100644 --- a/psf-memo-indexer/test/unit/use-cases/action-types/set-profile.unit.js +++ b/psf-memo-indexer/test/unit/use-cases/action-types/set-profile.unit.js @@ -1,6 +1,7 @@ import { assert } from 'chai' import { makeMemoryDb } from '../../../support/memory-db.js' import { handleSetProfile } from '../../../../src/use-cases/action-types/set-profile.js' +import { MAX_POST_SIZE } from '../../../../src/lib/memo-codes.js' const PREFIX_SET_PROFILE = Buffer.from('6d05', 'hex') @@ -46,6 +47,7 @@ describe('#handleSetProfile', () => { blockHeight: 600100, seen: 100 }) + assert.equal(adapters.profileDb.store.get('bitcoincash:qaddr-a').text, 'my bio') }) it('should not create a recency record when the address has no qualifying post', async () => { @@ -66,4 +68,44 @@ describe('#handleSetProfile', () => { assert.equal(adapters.profileRecencyDb.store.size, 0) }) + + it('should accept a profile whose text is exactly the maximum size', async () => { + const adapters = makeAdapters() + const text = 'a'.repeat(MAX_POST_SIZE) + + await handleSetProfile({ + adapters, + txid: 'profile-a1', + signerAddr: 'bitcoincash:qaddr-a', + seen: 500, + blockHeight: 600400, + decoded: { + action: 'setProfile', + prefix: PREFIX_SET_PROFILE, + pushDatas: [PREFIX_SET_PROFILE, Buffer.from(text)] + } + }) + + assert.equal(adapters.profileDb.store.get('bitcoincash:qaddr-a').text.length, MAX_POST_SIZE) + }) + + it('should reject a profile larger than the maximum size', async () => { + const adapters = makeAdapters() + const text = 'a'.repeat(MAX_POST_SIZE + 1) + + await handleSetProfile({ + adapters, + txid: 'profile-a1', + signerAddr: 'bitcoincash:qaddr-a', + seen: 500, + blockHeight: 600400, + decoded: { + action: 'setProfile', + prefix: PREFIX_SET_PROFILE, + pushDatas: [PREFIX_SET_PROFILE, Buffer.from(text)] + } + }) + + assert.isFalse(adapters.profileDb.store.has('bitcoincash:qaddr-a')) + }) })