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.
This commit is contained in:
Chris Troutner
2026-08-25 20:30:24 -07:00
parent 9caff9c4c3
commit 0159119921
13 changed files with 426 additions and 521 deletions
+1
View File
@@ -2,5 +2,6 @@ node_modules/
build/
docs/
tmp/
target/
.gitsigners
+13
View File
@@ -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 }
+26
View File
@@ -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 }
+117
View File
@@ -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 }
+20 -108
View File
@@ -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' }
)
})
+19 -120
View File
@@ -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
})
+61
View File
@@ -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 }
+14 -53
View File
@@ -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(
+14 -63
View File
@@ -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(
+25 -89
View File
@@ -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 () => {
+26
View File
@@ -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 }
+66
View File
@@ -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 }
+24 -88
View File
@@ -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'
})