From 0159119921bc21bcfb42610f62c0e005368dd1cf Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Tue, 25 Aug 2026 20:30:24 -0700 Subject: [PATCH] Reduce DRY duplication in unit and property tests Extract shared test helpers for the Memo post and Set Name behavior slices: fake wallet/profiles, MemoAction tests, page-controller tests, page build wiring, and shared property-test invariants. Behavior is preserved; all unit, property, and acceptance tests pass. By refactorer. --- .gitignore | 1 + test/helpers/fake-profiles.js | 13 +++ test/helpers/fake-wallet.js | 26 +++++ test/property/behavior-helpers.js | 117 +++++++++++++++++++ test/property/memo-post.property.test.js | 128 ++++----------------- test/property/set-name.property.test.js | 139 ++++------------------- test/unit/memo-action-helpers.js | 61 ++++++++++ test/unit/memo-post.test.js | 67 +++-------- test/unit/memo-set-name.test.js | 77 +++---------- test/unit/new-post.test.js | 114 ++++--------------- test/unit/page-build-helpers.js | 26 +++++ test/unit/page-controller-helpers.js | 66 +++++++++++ test/unit/set-name-page.test.js | 112 ++++-------------- 13 files changed, 426 insertions(+), 521 deletions(-) create mode 100644 test/helpers/fake-profiles.js create mode 100644 test/helpers/fake-wallet.js create mode 100644 test/property/behavior-helpers.js create mode 100644 test/unit/memo-action-helpers.js create mode 100644 test/unit/page-build-helpers.js create mode 100644 test/unit/page-controller-helpers.js diff --git a/.gitignore b/.gitignore index 3b921bf..861bf9c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ node_modules/ build/ docs/ tmp/ +target/ .gitsigners diff --git a/test/helpers/fake-profiles.js b/test/helpers/fake-profiles.js new file mode 100644 index 0000000..54c1205 --- /dev/null +++ b/test/helpers/fake-profiles.js @@ -0,0 +1,13 @@ +'use strict' + +// A fake profile store that records names set for addresses. +function fakeProfiles () { + const names = new Map() + return { + names, + setName: (addr, name) => names.set(addr, name), + getName: (addr) => names.get(addr) || null + } +} + +module.exports = { fakeProfiles } diff --git a/test/helpers/fake-wallet.js b/test/helpers/fake-wallet.js new file mode 100644 index 0000000..2d2feb0 --- /dev/null +++ b/test/helpers/fake-wallet.js @@ -0,0 +1,26 @@ +'use strict' + +// A fake wallet that records every broadcast attempt and can be made to fail. +// It satisfies the small adapter surface the Memo action modules need: +// walletInfo, getUtxos(), sendOpReturn(). Set `wallet.failWith` to make +// sendOpReturn throw. +function fakeWallet ({ + cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', + utxos = [{ txid: 'utxo1' }], + txid = 'fake-txid' +} = {}) { + const broadcasts = [] + const wallet = { + walletInfo: { cashAddress }, + getUtxos: async () => utxos, + sendOpReturn: async function (msg, prefix) { + broadcasts.push({ msg, prefix }) + if (this.failWith) throw new Error(this.failWith) + return txid + } + } + wallet.broadcasts = broadcasts + return wallet +} + +module.exports = { fakeWallet } diff --git a/test/property/behavior-helpers.js b/test/property/behavior-helpers.js new file mode 100644 index 0000000..c5e8a3d --- /dev/null +++ b/test/property/behavior-helpers.js @@ -0,0 +1,117 @@ +/* + Shared property tests for the Memo post and Set Name behavior slices. + + Both slices validate a value against a length limit, expose a character/byte + counter that conserves its relationship to input length, round-trip the draft + text through setInput, and surface broadcast failures without navigating. + These tests assert those invariants across a broad input range; the slice + specifics are supplied through `cfg` so the two behavior slices share one + implementation instead of duplicating it. +*/ + +'use strict' + +const test = require('node:test') + +const { forAll, makeStringGen } = require('./harness') +const { fakeWallet } = require('../helpers/fake-wallet') + +// Register the property tests shared by a Memo behavior slice. `cfg` supplies +// the slice-specific pieces: +// Module - the action class under test (MemoPost or MemoSetName) +// MAX - the length limit (characters or bytes) +// label - a short label used in test names +// rng - the seeded random generator +// measure - (input) => length in the slice's unit (chars or bytes) +// buildPage - () => a fresh page for counter and round-trip tests +// buildBroadcastPage - ({ wallet, navigations }) => a page wired to broadcast +function registerBehaviorProperties (cfg) { + const { Module, MAX, label, rng, measure, buildPage, buildBroadcastPage } = cfg + const stringOf = makeStringGen(rng) + + test(`${label} validation: any non-blank string at or below the limit is valid`, async () => { + await forAll( + (i) => { + const len = 1 + Math.floor(rng() * MAX) // 1..MAX + return stringOf(len) + }, + (input) => { + // A random ASCII string may occasionally be all whitespace; whitespace-only + // input is a validation error, so only assert for non-blank strings. + if (input.trim().length === 0) return true + const result = new Module({}).validate(input) + return result.ok === true + }, + { label: 'valid length' } + ) + }) + + test(`${label} validation: any string above the limit is a length error`, async () => { + await forAll( + (i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX + (input) => { + const result = new Module({}).validate(input) + return result.ok === false && result.type === 'length' + }, + { label: 'over-long rejected as length' } + ) + }) + + test(`${label} validation: blank and non-string input are validation errors`, async () => { + await forAll( + (i) => (i % 2 === 0 ? ' ' : null), + (input) => { + const result = new Module({}).validate(input) + return result.ok === false && result.type === 'validation' + }, + { label: 'blank/non-string rejected as validation' } + ) + }) + + test(`${label} counter conserves length: remaining === MAX - measure(input)`, async () => { + await forAll( + (i) => stringOf(Math.floor(rng() * (MAX + 8))), + (input) => { + const page = buildPage() + page.setInput(input) + return page.remainingCount() === MAX - measure(input) + }, + { label: 'counter conservation' } + ) + }) + + test('setInput round-trips the draft text exactly', async () => { + await forAll( + (i) => stringOf(Math.floor(rng() * 50)), + (input) => { + const page = buildPage() + page.setInput(input) + return page.input === input + }, + { label: 'setInput round-trip' } + ) + }) + + test('a broadcast failure surfaces the error and never navigates', async () => { + await forAll( + (i) => ({ text: stringOf(1 + Math.floor(rng() * 40)), failWith: `boom-${i % 97}` }), + ({ text, failWith }) => { + const wallet = fakeWallet() + wallet.failWith = failWith + const navigations = [] + const page = buildBroadcastPage({ wallet, navigations }) + page.setInput(text) + + return page.submit().then((result) => { + if (result.ok) return false + if (page.submitError !== 'broadcast') return false + if (!page.broadcastError || !page.broadcastError.includes('boom')) return false + return navigations.length === 0 + }) + }, + { label: 'broadcast failure does not navigate' } + ) + }) +} + +module.exports = { registerBehaviorProperties } diff --git a/test/property/memo-post.property.test.js b/test/property/memo-post.property.test.js index 66829b2..3812ae3 100644 --- a/test/property/memo-post.property.test.js +++ b/test/property/memo-post.property.test.js @@ -2,21 +2,18 @@ Property tests for the Memo post / New Post behavior slices. These assert useful invariants that unit tests cover only at a few fixed - points: - - Validation classification is stable across a broad input range: any - non-blank string up to the length limit is accepted, any longer string is - rejected with a length error, and blank/non-string input is a validation - error. - - The New Post character counter conserves its relationship to input - length: input.length + remainingCount() === MAX_MEMO_CHARS for any string. - - setInput round-trips the exact draft text. + points. The validation, counter-conservation, setInput round-trip, and + broadcast-failure invariants are shared with the Set Name slice and live in + behavior-helpers.js; this file supplies the Memo-post specifics and the + menu-link idempotence property unique to the New Post page. */ 'use strict' const test = require('node:test') -const { seededRandom, forAll, makeStringGen } = require('./harness') +const { seededRandom, forAll } = require('./harness') +const { registerBehaviorProperties } = require('./behavior-helpers') const MemoPost = require('../../src/services/memo-post') const NewPostPage = require('../../src/services/new-post') @@ -24,7 +21,6 @@ const NewPostPage = require('../../src/services/new-post') const MAX = MemoPost.MAX_MEMO_CHARS // 217 const rng = seededRandom(20260826) -const stringOf = makeStringGen(rng) function buildPage () { return new NewPostPage({ @@ -34,86 +30,26 @@ function buildPage () { }) } -// A fake wallet recording broadcast attempts; fails when failWith is set. -function fakeWallet () { - const wallet = { - walletInfo: { cashAddress: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' }, - utxos: [{ txid: 'utxo-fee' }], - getUtxos: async function () { return this.utxos }, - sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) { - if (this.failWith) throw new Error(this.failWith) - return 'prop-txid' - } - } - return wallet -} - function fakeFeed () { const posts = [] return { posts, addPost: (p) => posts.push(p) } } -test('memo validation: any non-blank string at or below the limit is valid', async () => { - await forAll( - (i) => { - const len = 1 + Math.floor(rng() * MAX) // 1..MAX - return stringOf(len) - }, - (msg) => { - // A random ASCII string may occasionally be all whitespace; whitespace-only - // input is a validation error, so only assert for non-blank strings. - if (msg.trim().length === 0) return true - const result = new MemoPost({}).validate(msg) - return result.ok === true - }, - { label: 'valid length' } - ) -}) +function buildBroadcastPage ({ wallet, navigations }) { + return new NewPostPage({ + memoPost: new MemoPost({ wallet, feed: fakeFeed() }), + navigate: (p) => navigations.push(p) + }) +} -test('memo validation: any string above the limit is a length error', async () => { - await forAll( - (i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX - (msg) => { - const result = new MemoPost({}).validate(msg) - return result.ok === false && result.type === 'length' - }, - { label: 'over-long rejected as length' } - ) -}) - -test('memo validation: blank and non-string input are validation errors', async () => { - await forAll( - (i) => (i % 2 === 0 ? ' ' : null), - (msg) => { - const result = new MemoPost({}).validate(msg) - return result.ok === false && result.type === 'validation' - }, - { label: 'blank/non-string rejected as validation' } - ) -}) - -test('character counter conserves length: remaining === MAX - input.length', async () => { - await forAll( - (i) => stringOf(Math.floor(rng() * (MAX + 8))), - (msg) => { - const page = buildPage() - page.setInput(msg) - return page.remainingCount() === MAX - msg.length - }, - { label: 'counter conservation' } - ) -}) - -test('setInput round-trips the draft text exactly', async () => { - await forAll( - (i) => stringOf(Math.floor(rng() * 50)), - (msg) => { - const page = buildPage() - page.setInput(msg) - return page.input === msg - }, - { label: 'setInput round-trip' } - ) +registerBehaviorProperties({ + Module: MemoPost, + MAX, + label: 'memo', + rng, + measure: (input) => input.length, + buildPage, + buildBroadcastPage }) test('menu link registration is idempotent', async () => { @@ -129,27 +65,3 @@ test('menu link registration is idempotent', async () => { { label: 'menu link idempotence' } ) }) - -test('a broadcast failure surfaces the error and never navigates', async () => { - await forAll( - (i) => ({ message: stringOf(1 + Math.floor(rng() * 40)), failWith: `boom-${i % 97}` }), - ({ message, failWith }) => { - const wallet = fakeWallet() - wallet.failWith = failWith - const navigations = [] - const page = new NewPostPage({ - memoPost: new MemoPost({ wallet, feed: fakeFeed() }), - navigate: (p) => navigations.push(p) - }) - page.setInput(message) - - return page.submit().then((result) => { - if (result.ok) return false - if (page.submitError !== 'broadcast') return false - if (!page.broadcastError || !page.broadcastError.includes('boom')) return false - return navigations.length === 0 - }) - }, - { label: 'broadcast failure does not navigate' } - ) -}) diff --git a/test/property/set-name.property.test.js b/test/property/set-name.property.test.js index fe2210a..33f8a72 100644 --- a/test/property/set-name.property.test.js +++ b/test/property/set-name.property.test.js @@ -2,23 +2,16 @@ Property tests for the Set Name behavior slice. These assert useful invariants that unit tests cover only at a few fixed - points: - - Set-name validation classification is stable across a broad input range: - any non-blank string up to the byte limit is accepted, any longer string - is rejected with a length error, and blank/non-string input is a - validation error. - - The Set Name byte counter conserves its relationship to input byte - length: Buffer.byteLength(input) + remainingCount() === MAX_NAME_BYTES - for any string. - - setInput round-trips the exact draft text. - - A broadcast failure surfaces the error and never navigates. + points. The validation, counter-conservation, setInput round-trip, and + broadcast-failure invariants are shared with the Memo post slice and live in + behavior-helpers.js; this file supplies the Set Name specifics. */ 'use strict' -const test = require('node:test') - -const { seededRandom, forAll, makeStringGen } = require('./harness') +const { seededRandom } = require('./harness') +const { registerBehaviorProperties } = require('./behavior-helpers') +const { fakeProfiles } = require('../helpers/fake-profiles') const MemoSetName = require('../../src/services/memo-set-name') const SetNamePage = require('../../src/services/set-name-page') @@ -26,7 +19,6 @@ const SetNamePage = require('../../src/services/set-name-page') const MAX = MemoSetName.MAX_NAME_BYTES // 77 const rng = seededRandom(20260827) -const stringOf = makeStringGen(rng) function buildPage () { return new SetNamePage({ @@ -35,112 +27,19 @@ function buildPage () { }) } -// A fake wallet recording broadcast attempts; fails when failWith is set. -function fakeWallet () { - const wallet = { - walletInfo: { cashAddress: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' }, - utxos: [{ txid: 'utxo-fee' }], - getUtxos: async function () { return this.utxos }, - sendOpReturn: async function (msg, prefix) { - if (this.failWith) throw new Error(this.failWith) - return 'prop-txid' - } - } - return wallet +function buildBroadcastPage ({ wallet, navigations }) { + return new SetNamePage({ + memoSetName: new MemoSetName({ wallet, profiles: fakeProfiles() }), + navigate: (p) => navigations.push(p) + }) } -function fakeProfiles () { - const names = new Map() - return { - names, - setName: (addr, name) => names.set(addr, name), - getName: (addr) => names.get(addr) || null - } -} - -test('set-name validation: any non-blank string at or below the byte limit is valid', async () => { - await forAll( - (i) => { - const len = 1 + Math.floor(rng() * MAX) // 1..MAX - return stringOf(len) - }, - (name) => { - // A random ASCII string may occasionally be all whitespace; whitespace-only - // input is a validation error, so only assert for non-blank strings. - if (name.trim().length === 0) return true - const result = new MemoSetName({}).validate(name) - return result.ok === true - }, - { label: 'valid byte length' } - ) -}) - -test('set-name validation: any string above the byte limit is a length error', async () => { - await forAll( - (i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX - (name) => { - const result = new MemoSetName({}).validate(name) - return result.ok === false && result.type === 'length' - }, - { label: 'over-long rejected as length' } - ) -}) - -test('set-name validation: blank and non-string input are validation errors', async () => { - await forAll( - (i) => (i % 2 === 0 ? ' ' : null), - (name) => { - const result = new MemoSetName({}).validate(name) - return result.ok === false && result.type === 'validation' - }, - { label: 'blank/non-string rejected as validation' } - ) -}) - -test('byte counter conserves byte length: remaining === MAX - byteLength(input)', async () => { - await forAll( - (i) => stringOf(Math.floor(rng() * (MAX + 8))), - (name) => { - const page = buildPage() - page.setInput(name) - return page.remainingCount() === MAX - Buffer.byteLength(name, 'utf8') - }, - { label: 'byte counter conservation' } - ) -}) - -test('setInput round-trips the draft text exactly', async () => { - await forAll( - (i) => stringOf(Math.floor(rng() * 50)), - (name) => { - const page = buildPage() - page.setInput(name) - return page.input === name - }, - { label: 'setInput round-trip' } - ) -}) - -test('a broadcast failure surfaces the error and never navigates', async () => { - await forAll( - (i) => ({ name: stringOf(1 + Math.floor(rng() * 40)), failWith: `boom-${i % 97}` }), - ({ name, failWith }) => { - const wallet = fakeWallet() - wallet.failWith = failWith - const navigations = [] - const page = new SetNamePage({ - memoSetName: new MemoSetName({ wallet, profiles: fakeProfiles() }), - navigate: (p) => navigations.push(p) - }) - page.setInput(name) - - return page.submit().then((result) => { - if (result.ok) return false - if (page.submitError !== 'broadcast') return false - if (!page.broadcastError || !page.broadcastError.includes('boom')) return false - return navigations.length === 0 - }) - }, - { label: 'broadcast failure does not navigate' } - ) +registerBehaviorProperties({ + Module: MemoSetName, + MAX, + label: 'set-name', + rng, + measure: (input) => Buffer.byteLength(input, 'utf8'), + buildPage, + buildBroadcastPage }) diff --git a/test/unit/memo-action-helpers.js b/test/unit/memo-action-helpers.js new file mode 100644 index 0000000..0db0983 --- /dev/null +++ b/test/unit/memo-action-helpers.js @@ -0,0 +1,61 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { fakeWallet } = require('../helpers/fake-wallet') + +// Register the MemoAction tests shared by the Memo post and Set Name slices. +// Both slices broadcast a value through a wallet and reflect the result on an +// injected store, so the maximum-length and over-length behaviors are +// identical; only the slice-specific pieces differ. `cfg` supplies: +// Action - the action class (MemoPost or MemoSetName) +// method - the broadcast method name ('post' or 'setName') +// MAX - the length limit +// lengthCode - the length error code +// validationCode - the validation error code +// label - a short label for test names +// storeKey - the action's store dependency key ('feed' or 'profiles') +// storeFactory - () => a fresh store +// assertStoreEmpty - (store, wallet) => asserts the store was not updated +function registerMemoActionTests (cfg) { + const { Action, method, MAX, lengthCode, validationCode, label, storeKey, storeFactory, assertStoreEmpty } = cfg + + test(`${label} at the maximum length (${MAX}) is accepted`, async () => { + const wallet = fakeWallet() + const action = new Action({ wallet }) + + const value = 'x'.repeat(MAX) + const txid = await action[method](value) + assert.equal(txid, 'fake-txid') + assert.equal(wallet.broadcasts[0].msg, value) + }) + + test(`${label} over the limit (${MAX + 1}) throws a length error and broadcasts nothing`, async () => { + const wallet = fakeWallet() + const store = storeFactory() + const action = new Action({ wallet, [storeKey]: store }) + + await assert.rejects( + action[method]('y'.repeat(MAX + 1)), + (err) => err.code === lengthCode + ) + assert.equal(wallet.broadcasts.length, 0) + assertStoreEmpty(store, wallet) + }) + + test(`${label} that is whitespace-only or non-string throws a validation error and broadcasts nothing`, async () => { + for (const invalid of [' ', 42]) { + const wallet = fakeWallet() + const action = new Action({ wallet }) + + await assert.rejects( + action[method](invalid), + (err) => err.code === validationCode + ) + assert.equal(wallet.broadcasts.length, 0) + } + }) +} + +module.exports = { registerMemoActionTests } diff --git a/test/unit/memo-post.test.js b/test/unit/memo-post.test.js index ecc8e49..b04f92d 100644 --- a/test/unit/memo-post.test.js +++ b/test/unit/memo-post.test.js @@ -15,23 +15,8 @@ const test = require('node:test') const assert = require('node:assert/strict') const MemoPost = require('../../src/services/memo-post') - -// A fake wallet that records every broadcast attempt. It satisfies the small -// adapter surface the MemoPost module needs: walletInfo, getUtxos(), -// sendOpReturn(). -function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', utxos = [{ txid: 'utxo1' }]) { - const broadcasts = [] - const wallet = { - walletInfo: { cashAddress }, - getUtxos: async () => utxos, - sendOpReturn: async (msg, prefix) => { - broadcasts.push({ msg, prefix }) - return 'fake-txid' - } - } - wallet.broadcasts = broadcasts - return wallet -} +const { fakeWallet } = require('../helpers/fake-wallet') +const { registerMemoActionTests } = require('./memo-action-helpers') // A fake feed that records posts added to the recent posts feed. function fakeFeed () { @@ -39,6 +24,18 @@ function fakeFeed () { return { posts, addPost: (p) => posts.push(p) } } +registerMemoActionTests({ + Action: MemoPost, + method: 'post', + MAX: 217, + lengthCode: 'memo_length', + validationCode: 'memo_validation', + label: 'posting a memo', + storeKey: 'feed', + storeFactory: fakeFeed, + assertStoreEmpty: (feed) => assert.equal(feed.posts.length, 0) +}) + test('MEMO_POST_PREFIX is the Memo post action 0x6d02', () => { assert.equal(MemoPost.MEMO_POST_PREFIX, '6d02') }) @@ -62,16 +59,6 @@ test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and assert.equal(feed.posts[0].address, wallet.walletInfo.cashAddress) }) -test('posting a memo at the maximum length (217) is accepted', async () => { - const wallet = fakeWallet() - const memoPost = new MemoPost({ wallet }) - - const msg = 'x'.repeat(217) - const txid = await memoPost.post(msg) - assert.equal(txid, 'fake-txid') - assert.equal(wallet.broadcasts[0].msg, msg) -}) - test('posting an empty memo throws a validation error and broadcasts nothing', async () => { const wallet = fakeWallet() const feed = fakeFeed() @@ -85,32 +72,6 @@ test('posting an empty memo throws a validation error and broadcasts nothing', a assert.equal(feed.posts.length, 0) }) -test('posting a whitespace-only or non-string memo throws a validation error and broadcasts nothing', async () => { - for (const invalid of [' ', 42]) { - const wallet = fakeWallet() - const memoPost = new MemoPost({ wallet }) - - await assert.rejects( - memoPost.post(invalid), - (err) => err.code === 'memo_validation' - ) - assert.equal(wallet.broadcasts.length, 0) - } -}) - -test('posting an over-long memo (218) throws a length error and broadcasts nothing', async () => { - const wallet = fakeWallet() - const feed = fakeFeed() - const memoPost = new MemoPost({ wallet, feed }) - - await assert.rejects( - memoPost.post('y'.repeat(218)), - (err) => err.code === 'memo_length' - ) - assert.equal(wallet.broadcasts.length, 0) - assert.equal(feed.posts.length, 0) -}) - test('posting without a wallet reports a missing-wallet error', async () => { const memoPost = new MemoPost({}) await assert.rejects( diff --git a/test/unit/memo-set-name.test.js b/test/unit/memo-set-name.test.js index c3d208e..623ff17 100644 --- a/test/unit/memo-set-name.test.js +++ b/test/unit/memo-set-name.test.js @@ -15,34 +15,21 @@ const test = require('node:test') const assert = require('node:assert/strict') const MemoSetName = require('../../src/services/memo-set-name') +const { fakeWallet } = require('../helpers/fake-wallet') +const { fakeProfiles } = require('../helpers/fake-profiles') +const { registerMemoActionTests } = require('./memo-action-helpers') -// A fake wallet that records every broadcast attempt. It satisfies the small -// adapter surface the MemoSetName module needs: walletInfo, getUtxos(), -// sendOpReturn(). -function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', utxos = [{ txid: 'utxo1' }]) { - const broadcasts = [] - const wallet = { - walletInfo: { cashAddress }, - getUtxos: async () => utxos, - sendOpReturn: async (msg, prefix) => { - broadcasts.push({ msg, prefix }) - return 'fake-txid' - } - } - wallet.broadcasts = broadcasts - return wallet -} - -// A fake profile store that records names set for addresses. -function fakeProfiles () { - const names = {} - return { - names, - setName: (addr, name) => { names[addr] = name }, - getName: (addr) => names[addr] || null - } -} - +registerMemoActionTests({ + Action: MemoSetName, + method: 'setName', + MAX: 77, + lengthCode: 'name_length', + validationCode: 'name_validation', + label: 'setting a name', + storeKey: 'profiles', + storeFactory: fakeProfiles, + assertStoreEmpty: (profiles, wallet) => assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null) +}) test('MEMO_SET_NAME_PREFIX is the Memo set-name action 0x6d01', () => { assert.equal(MemoSetName.MEMO_SET_NAME_PREFIX, '6d01') }) @@ -64,16 +51,6 @@ test('setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout') }) -test('setting a name at the maximum byte length (77) is accepted', async () => { - const wallet = fakeWallet() - const memoSetName = new MemoSetName({ wallet }) - - const name = 'x'.repeat(77) - const txid = await memoSetName.setName(name) - assert.equal(txid, 'fake-txid') - assert.equal(wallet.broadcasts[0].msg, name) -}) - test('setting a name at the maximum byte length with multi-byte characters is accepted', async () => { const wallet = fakeWallet() const memoSetName = new MemoSetName({ wallet }) @@ -112,32 +89,6 @@ test('setting an empty name throws a validation error and broadcasts nothing', a assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null) }) -test('setting a whitespace-only or non-string name throws a validation error and broadcasts nothing', async () => { - for (const invalid of [' ', 42]) { - const wallet = fakeWallet() - const memoSetName = new MemoSetName({ wallet }) - - await assert.rejects( - memoSetName.setName(invalid), - (err) => err.code === 'name_validation' - ) - assert.equal(wallet.broadcasts.length, 0) - } -}) - -test('setting an over-long name (78) throws a length error and broadcasts nothing', async () => { - const wallet = fakeWallet() - const profiles = fakeProfiles() - const memoSetName = new MemoSetName({ wallet, profiles }) - - await assert.rejects( - memoSetName.setName('y'.repeat(78)), - (err) => err.code === 'name_length' - ) - assert.equal(wallet.broadcasts.length, 0) - assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null) -}) - test('setting a name without a wallet reports a missing-wallet error', async () => { const memoSetName = new MemoSetName({}) await assert.rejects( diff --git a/test/unit/new-post.test.js b/test/unit/new-post.test.js index 4b10826..f1944ab 100644 --- a/test/unit/new-post.test.js +++ b/test/unit/new-post.test.js @@ -17,41 +17,29 @@ const assert = require('node:assert/strict') const MemoPost = require('../../src/services/memo-post') const NewPostPage = require('../../src/services/new-post') +const { fakeWallet } = require('../helpers/fake-wallet') +const { registerPageControllerTests } = require('./page-controller-helpers') +const { buildPage } = require('./page-build-helpers') const MAX = MemoPost.MAX_MEMO_CHARS // 217 -// A fake wallet recording broadcast attempts. -function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') { - const broadcasts = [] - const wallet = { - walletInfo: { cashAddress }, - utxos: [{ txid: 'utxo-fee' }], - getUtxos: async function () { return this.utxos }, - sendOpReturn: async function (msg, prefix) { - this.broadcasts.push({ msg, prefix }) - if (this.failWith) throw new Error(this.failWith) - return 'newpost-txid' - } - } - wallet.broadcasts = broadcasts - return wallet -} - function fakeFeed () { const posts = [] return { posts, addPost: (p) => posts.push(p) } } function build () { - const wallet = fakeWallet() - const feed = fakeFeed() - const memoPost = new MemoPost({ wallet, feed }) - const navigations = [] - const page = new NewPostPage({ - memoPost, - navigate: (path) => navigations.push(path) + return buildPage({ + Page: NewPostPage, + Action: MemoPost, + actionKey: 'memoPost', + storeKey: 'feed', + storeFactory: fakeFeed }) - return { wallet, feed, memoPost, page, navigations } +} + +function buildBarePage (navigations) { + return new NewPostPage({ navigate: (p) => navigations.push(p) }) } test('NEW_POST_PATH and RECENT_FEED_PATH constants', () => { @@ -83,7 +71,7 @@ test('the character counter reaches zero at the memo limit', () => { }) test('posting a valid memo broadcasts the Memo post prefix and navigates to the feed', async () => { - const { wallet, feed, page, navigations } = build() + const { wallet, store, page, navigations } = build() page.setInput('hello memo') const result = await page.submit() @@ -98,12 +86,12 @@ test('posting a valid memo broadcasts the Memo post prefix and navigates to the // Navigated to the recent feed after posting. assert.deepEqual(navigations, ['/posts/recent']) // The feed reflects the new post from this address. - assert.equal(feed.posts.length, 1) - assert.equal(feed.posts[0].text, 'hello memo') + assert.equal(store.posts.length, 1) + assert.equal(store.posts[0].text, 'hello memo') }) test('posting an empty memo is rejected with a validation error and nothing is broadcast', async () => { - const { wallet, feed, page, navigations } = build() + const { wallet, store, page, navigations } = build() page.setInput('') const result = await page.submit() @@ -113,12 +101,12 @@ test('posting an empty memo is rejected with a validation error and nothing is b assert.equal(page.submitError, 'memo_validation') assert.equal(page.posting, false) assert.equal(wallet.broadcasts.length, 0) - assert.equal(feed.posts.length, 0) + assert.equal(store.posts.length, 0) assert.deepEqual(navigations, []) }) test('posting an over-long memo is rejected with a length error and nothing is broadcast', async () => { - const { wallet, feed, page, navigations } = build() + const { wallet, store, page, navigations } = build() page.setInput('y'.repeat(MAX + 1)) const result = await page.submit() @@ -128,7 +116,7 @@ test('posting an over-long memo is rejected with a length error and nothing is b assert.equal(page.submitError, 'memo_length') assert.equal(page.posting, false) assert.equal(wallet.broadcasts.length, 0) - assert.equal(feed.posts.length, 0) + assert.equal(store.posts.length, 0) assert.deepEqual(navigations, []) }) @@ -137,63 +125,11 @@ test('the new post page starts idle (not posting)', () => { assert.equal(page.posting, false) }) -test('posting is true while a submit is in flight and false once it settles', async () => { - const wallet = fakeWallet() - const feed = fakeFeed() - - // Defer the broadcast so we can observe the in-flight posting state. - let resolveSend - wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve }) - const page = new NewPostPage({ - memoPost: new MemoPost({ wallet, feed }), - navigate: () => {} - }) - page.setInput('hello memo') - - assert.equal(page.posting, false) - const pending = page.submit() - assert.equal(page.posting, true) - - // Yield until the async chain reaches the deferred sendOpReturn call. - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(typeof resolveSend, 'function') - resolveSend('in-flight-txid') - await pending - assert.equal(page.posting, false) -}) - -test('submitting without a memo post handler reports an error and does not navigate', async () => { - const navigations = [] - const page = new NewPostPage({ navigate: (p) => navigations.push(p) }) - page.setInput('hello') - - const result = await page.submit() - - assert.equal(result.ok, false) - assert.deepEqual(navigations, []) -}) - -test('a failed broadcast surfaces the real error and does not navigate', async () => { - const wallet = fakeWallet() - const feed = fakeFeed() - wallet.failWith = 'BCH UTXO list is empty' - const navigations = [] - const page = new NewPostPage({ - memoPost: new MemoPost({ wallet, feed }), - navigate: (p) => navigations.push(p) - }) - page.setInput('hello memo') - - const result = await page.submit() - - assert.equal(result.ok, false) - assert.equal(page.submitError, 'broadcast') - assert.match(page.broadcastError, /BCH UTXO list is empty/) - // The broadcast was attempted (recorded) before it failed. - assert.equal(wallet.broadcasts.length, 1) - assert.equal(wallet.broadcasts[0].prefix, '6d02') - // The user stays on the page. - assert.deepEqual(navigations, []) +registerPageControllerTests({ + buildPage: build, + buildBarePage, + busyFlag: 'posting', + prefix: '6d02' }) test('a failed broadcast surfaces a different real error message', async () => { diff --git a/test/unit/page-build-helpers.js b/test/unit/page-build-helpers.js new file mode 100644 index 0000000..402b571 --- /dev/null +++ b/test/unit/page-build-helpers.js @@ -0,0 +1,26 @@ +'use strict' + +const { fakeWallet } = require('../helpers/fake-wallet') + +// Build a page controller wired to a working action and a navigation recorder. +// Both the New Post and Set Name pages extend PageController and take a single +// action dependency plus a navigate callback, so the wiring is identical; only +// the page-specific pieces differ. `cfg` supplies: +// Page - the page controller class (NewPostPage or SetNamePage) +// Action - the action class (MemoPost or MemoSetName) +// actionKey - the page's action dependency key ('memoPost' or 'memoSetName') +// storeKey - the action's store dependency key ('feed' or 'profiles') +// storeFactory - () => a fresh store +function buildPage ({ Page, Action, actionKey, storeKey, storeFactory }) { + const wallet = fakeWallet() + const store = storeFactory() + const action = new Action({ wallet, [storeKey]: store }) + const navigations = [] + const page = new Page({ + [actionKey]: action, + navigate: (path) => navigations.push(path) + }) + return { wallet, store, action, page, navigations } +} + +module.exports = { buildPage } diff --git a/test/unit/page-controller-helpers.js b/test/unit/page-controller-helpers.js new file mode 100644 index 0000000..795b16b --- /dev/null +++ b/test/unit/page-controller-helpers.js @@ -0,0 +1,66 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +// Register the page-controller tests shared by the New Post and Set Name pages. +// Both pages extend PageController, so the in-flight flag, missing-handler, and +// broadcast-failure behaviors are identical; only the page-specific pieces +// differ. `cfg` supplies: +// buildPage - () => ({ wallet, page, navigations }) with a working handler +// buildBarePage - (navigations) => a page with no action handler +// busyFlag - the page's in-flight flag name ('posting' or 'settingName') +// prefix - the broadcast prefix ('6d02' or '6d01') +function registerPageControllerTests (cfg) { + const { buildPage, buildBarePage, busyFlag, prefix } = cfg + + test(`${busyFlag} is true while a submit is in flight and false once it settles`, async () => { + const { wallet, page } = buildPage() + + // Defer the broadcast so we can observe the in-flight state. + let resolveSend + wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve }) + page.setInput('hello') + + assert.equal(page[busyFlag], false) + const pending = page.submit() + assert.equal(page[busyFlag], true) + + // Yield until the async chain reaches the deferred sendOpReturn call. + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(typeof resolveSend, 'function') + resolveSend('in-flight-txid') + await pending + assert.equal(page[busyFlag], false) + }) + + test('submitting without a handler reports an error and does not navigate', async () => { + const navigations = [] + const page = buildBarePage(navigations) + page.setInput('hello') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.deepEqual(navigations, []) + }) + + test('a failed broadcast surfaces the real error and does not navigate', async () => { + const { wallet, page, navigations } = buildPage() + wallet.failWith = 'BCH UTXO list is empty' + page.setInput('hello') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(page.submitError, 'broadcast') + assert.match(page.broadcastError, /BCH UTXO list is empty/) + // The broadcast was attempted (recorded) before it failed. + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, prefix) + // The user stays on the page. + assert.deepEqual(navigations, []) + }) +} + +module.exports = { registerPageControllerTests } diff --git a/test/unit/set-name-page.test.js b/test/unit/set-name-page.test.js index 125dba8..9b6905b 100644 --- a/test/unit/set-name-page.test.js +++ b/test/unit/set-name-page.test.js @@ -16,40 +16,24 @@ const assert = require('node:assert/strict') const MemoSetName = require('../../src/services/memo-set-name') const SetNamePage = require('../../src/services/set-name-page') +const { fakeProfiles } = require('../helpers/fake-profiles') +const { registerPageControllerTests } = require('./page-controller-helpers') +const { buildPage } = require('./page-build-helpers') const MAX = MemoSetName.MAX_NAME_BYTES // 77 -function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') { - const broadcasts = [] - const wallet = { - walletInfo: { cashAddress }, - utxos: [{ txid: 'utxo-fee' }], - getUtxos: async function () { return this.utxos }, - sendOpReturn: async function (msg, prefix) { - this.broadcasts.push({ msg, prefix }) - if (this.failWith) throw new Error(this.failWith) - return 'setname-txid' - } - } - wallet.broadcasts = broadcasts - return wallet -} - -function fakeProfiles () { - const names = {} - return { names, setName: (addr, name) => { names[addr] = name }, getName: (addr) => names[addr] || null } -} - function build () { - const wallet = fakeWallet() - const profiles = fakeProfiles() - const memoSetName = new MemoSetName({ wallet, profiles }) - const navigations = [] - const page = new SetNamePage({ - memoSetName, - navigate: (path) => navigations.push(path) + return buildPage({ + Page: SetNamePage, + Action: MemoSetName, + actionKey: 'memoSetName', + storeKey: 'profiles', + storeFactory: fakeProfiles }) - return { wallet, profiles, memoSetName, page, navigations } +} + +function buildBarePage (navigations) { + return new SetNamePage({ navigate: (p) => navigations.push(p) }) } test('SET_NAME_PATH and ACCOUNT_PATH constants', () => { @@ -88,7 +72,7 @@ test('the byte counter reaches zero at the name limit', () => { }) test('setting a valid name broadcasts the Memo set-name prefix and navigates to the account page', async () => { - const { wallet, profiles, page, navigations } = build() + const { wallet, store, page, navigations } = build() page.setInput('trout') const result = await page.submit() @@ -99,11 +83,11 @@ test('setting a valid name broadcasts the Memo set-name prefix and navigates to assert.equal(wallet.broadcasts[0].prefix, '6d01') assert.equal(wallet.broadcasts[0].msg, 'trout') assert.deepEqual(navigations, ['/account']) - assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout') + assert.equal(store.getName(wallet.walletInfo.cashAddress), 'trout') }) test('setting an empty name is rejected with a validation error and nothing is broadcast', async () => { - const { wallet, profiles, page, navigations } = build() + const { wallet, store, page, navigations } = build() page.setInput('') const result = await page.submit() @@ -113,12 +97,12 @@ test('setting an empty name is rejected with a validation error and nothing is b assert.equal(page.submitError, 'name_validation') assert.equal(page.settingName, false) assert.equal(wallet.broadcasts.length, 0) - assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null) + assert.equal(store.getName(wallet.walletInfo.cashAddress), null) assert.deepEqual(navigations, []) }) test('setting an over-long name is rejected with a length error and nothing is broadcast', async () => { - const { wallet, profiles, page, navigations } = build() + const { wallet, store, page, navigations } = build() page.setInput('y'.repeat(MAX + 1)) const result = await page.submit() @@ -128,7 +112,7 @@ test('setting an over-long name is rejected with a length error and nothing is b assert.equal(page.submitError, 'name_length') assert.equal(page.settingName, false) assert.equal(wallet.broadcasts.length, 0) - assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null) + assert.equal(store.getName(wallet.walletInfo.cashAddress), null) assert.deepEqual(navigations, []) }) @@ -137,57 +121,9 @@ test('the set name page starts idle (not setting name)', () => { assert.equal(page.settingName, false) }) -test('setting name is true while a submit is in flight and false once it settles', async () => { - const wallet = fakeWallet() - const profiles = fakeProfiles() - - let resolveSend - wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve }) - const page = new SetNamePage({ - memoSetName: new MemoSetName({ wallet, profiles }), - navigate: () => {} - }) - page.setInput('trout') - - assert.equal(page.settingName, false) - const pending = page.submit() - assert.equal(page.settingName, true) - - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(typeof resolveSend, 'function') - resolveSend('in-flight-txid') - await pending - assert.equal(page.settingName, false) -}) - -test('submitting without a memo set-name handler reports an error and does not navigate', async () => { - const navigations = [] - const page = new SetNamePage({ navigate: (p) => navigations.push(p) }) - page.setInput('trout') - - const result = await page.submit() - - assert.equal(result.ok, false) - assert.deepEqual(navigations, []) -}) - -test('a failed broadcast surfaces the real error and does not navigate', async () => { - const wallet = fakeWallet() - const profiles = fakeProfiles() - wallet.failWith = 'BCH UTXO list is empty' - const navigations = [] - const page = new SetNamePage({ - memoSetName: new MemoSetName({ wallet, profiles }), - navigate: (p) => navigations.push(p) - }) - page.setInput('trout') - - const result = await page.submit() - - assert.equal(result.ok, false) - assert.equal(page.submitError, 'broadcast') - assert.match(page.broadcastError, /BCH UTXO list is empty/) - assert.equal(wallet.broadcasts.length, 1) - assert.equal(wallet.broadcasts[0].prefix, '6d01') - assert.deepEqual(navigations, []) +registerPageControllerTests({ + buildPage: build, + buildBarePage, + busyFlag: 'settingName', + prefix: '6d01' })