mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
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:
@@ -2,5 +2,6 @@ node_modules/
|
|||||||
build/
|
build/
|
||||||
docs/
|
docs/
|
||||||
tmp/
|
tmp/
|
||||||
|
target/
|
||||||
|
|
||||||
.gitsigners
|
.gitsigners
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -2,21 +2,18 @@
|
|||||||
Property tests for the Memo post / New Post behavior slices.
|
Property tests for the Memo post / New Post behavior slices.
|
||||||
|
|
||||||
These assert useful invariants that unit tests cover only at a few fixed
|
These assert useful invariants that unit tests cover only at a few fixed
|
||||||
points:
|
points. The validation, counter-conservation, setInput round-trip, and
|
||||||
- Validation classification is stable across a broad input range: any
|
broadcast-failure invariants are shared with the Set Name slice and live in
|
||||||
non-blank string up to the length limit is accepted, any longer string is
|
behavior-helpers.js; this file supplies the Memo-post specifics and the
|
||||||
rejected with a length error, and blank/non-string input is a validation
|
menu-link idempotence property unique to the New Post page.
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
const test = require('node:test')
|
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 MemoPost = require('../../src/services/memo-post')
|
||||||
const NewPostPage = require('../../src/services/new-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 MAX = MemoPost.MAX_MEMO_CHARS // 217
|
||||||
|
|
||||||
const rng = seededRandom(20260826)
|
const rng = seededRandom(20260826)
|
||||||
const stringOf = makeStringGen(rng)
|
|
||||||
|
|
||||||
function buildPage () {
|
function buildPage () {
|
||||||
return new NewPostPage({
|
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 () {
|
function fakeFeed () {
|
||||||
const posts = []
|
const posts = []
|
||||||
return { posts, addPost: (p) => posts.push(p) }
|
return { posts, addPost: (p) => posts.push(p) }
|
||||||
}
|
}
|
||||||
|
|
||||||
test('memo validation: any non-blank string at or below the limit is valid', async () => {
|
function buildBroadcastPage ({ wallet, navigations }) {
|
||||||
await forAll(
|
return new NewPostPage({
|
||||||
(i) => {
|
memoPost: new MemoPost({ wallet, feed: fakeFeed() }),
|
||||||
const len = 1 + Math.floor(rng() * MAX) // 1..MAX
|
navigate: (p) => navigations.push(p)
|
||||||
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' }
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('memo validation: any string above the limit is a length error', async () => {
|
registerBehaviorProperties({
|
||||||
await forAll(
|
Module: MemoPost,
|
||||||
(i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX
|
MAX,
|
||||||
(msg) => {
|
label: 'memo',
|
||||||
const result = new MemoPost({}).validate(msg)
|
rng,
|
||||||
return result.ok === false && result.type === 'length'
|
measure: (input) => input.length,
|
||||||
},
|
buildPage,
|
||||||
{ label: 'over-long rejected as length' }
|
buildBroadcastPage
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
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' }
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('menu link registration is idempotent', async () => {
|
test('menu link registration is idempotent', async () => {
|
||||||
@@ -129,27 +65,3 @@ test('menu link registration is idempotent', async () => {
|
|||||||
{ label: 'menu link idempotence' }
|
{ 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' }
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -2,23 +2,16 @@
|
|||||||
Property tests for the Set Name behavior slice.
|
Property tests for the Set Name behavior slice.
|
||||||
|
|
||||||
These assert useful invariants that unit tests cover only at a few fixed
|
These assert useful invariants that unit tests cover only at a few fixed
|
||||||
points:
|
points. The validation, counter-conservation, setInput round-trip, and
|
||||||
- Set-name validation classification is stable across a broad input range:
|
broadcast-failure invariants are shared with the Memo post slice and live in
|
||||||
any non-blank string up to the byte limit is accepted, any longer string
|
behavior-helpers.js; this file supplies the Set Name specifics.
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
const test = require('node:test')
|
const { seededRandom } = require('./harness')
|
||||||
|
const { registerBehaviorProperties } = require('./behavior-helpers')
|
||||||
const { seededRandom, forAll, makeStringGen } = require('./harness')
|
const { fakeProfiles } = require('../helpers/fake-profiles')
|
||||||
|
|
||||||
const MemoSetName = require('../../src/services/memo-set-name')
|
const MemoSetName = require('../../src/services/memo-set-name')
|
||||||
const SetNamePage = require('../../src/services/set-name-page')
|
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 MAX = MemoSetName.MAX_NAME_BYTES // 77
|
||||||
|
|
||||||
const rng = seededRandom(20260827)
|
const rng = seededRandom(20260827)
|
||||||
const stringOf = makeStringGen(rng)
|
|
||||||
|
|
||||||
function buildPage () {
|
function buildPage () {
|
||||||
return new SetNamePage({
|
return new SetNamePage({
|
||||||
@@ -35,112 +27,19 @@ function buildPage () {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// A fake wallet recording broadcast attempts; fails when failWith is set.
|
function buildBroadcastPage ({ wallet, navigations }) {
|
||||||
function fakeWallet () {
|
return new SetNamePage({
|
||||||
const wallet = {
|
memoSetName: new MemoSetName({ wallet, profiles: fakeProfiles() }),
|
||||||
walletInfo: { cashAddress: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' },
|
navigate: (p) => navigations.push(p)
|
||||||
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 fakeProfiles () {
|
registerBehaviorProperties({
|
||||||
const names = new Map()
|
Module: MemoSetName,
|
||||||
return {
|
MAX,
|
||||||
names,
|
label: 'set-name',
|
||||||
setName: (addr, name) => names.set(addr, name),
|
rng,
|
||||||
getName: (addr) => names.get(addr) || null
|
measure: (input) => Buffer.byteLength(input, 'utf8'),
|
||||||
}
|
buildPage,
|
||||||
}
|
buildBroadcastPage
|
||||||
|
|
||||||
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' }
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -15,23 +15,8 @@ const test = require('node:test')
|
|||||||
const assert = require('node:assert/strict')
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
const MemoPost = require('../../src/services/memo-post')
|
const MemoPost = require('../../src/services/memo-post')
|
||||||
|
const { fakeWallet } = require('../helpers/fake-wallet')
|
||||||
// A fake wallet that records every broadcast attempt. It satisfies the small
|
const { registerMemoActionTests } = require('./memo-action-helpers')
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// A fake feed that records posts added to the recent posts feed.
|
// A fake feed that records posts added to the recent posts feed.
|
||||||
function fakeFeed () {
|
function fakeFeed () {
|
||||||
@@ -39,6 +24,18 @@ function fakeFeed () {
|
|||||||
return { posts, addPost: (p) => posts.push(p) }
|
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', () => {
|
test('MEMO_POST_PREFIX is the Memo post action 0x6d02', () => {
|
||||||
assert.equal(MemoPost.MEMO_POST_PREFIX, '6d02')
|
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)
|
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 () => {
|
test('posting an empty memo throws a validation error and broadcasts nothing', async () => {
|
||||||
const wallet = fakeWallet()
|
const wallet = fakeWallet()
|
||||||
const feed = fakeFeed()
|
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)
|
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 () => {
|
test('posting without a wallet reports a missing-wallet error', async () => {
|
||||||
const memoPost = new MemoPost({})
|
const memoPost = new MemoPost({})
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
|
|||||||
@@ -15,34 +15,21 @@ const test = require('node:test')
|
|||||||
const assert = require('node:assert/strict')
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
const MemoSetName = require('../../src/services/memo-set-name')
|
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
|
registerMemoActionTests({
|
||||||
// adapter surface the MemoSetName module needs: walletInfo, getUtxos(),
|
Action: MemoSetName,
|
||||||
// sendOpReturn().
|
method: 'setName',
|
||||||
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', utxos = [{ txid: 'utxo1' }]) {
|
MAX: 77,
|
||||||
const broadcasts = []
|
lengthCode: 'name_length',
|
||||||
const wallet = {
|
validationCode: 'name_validation',
|
||||||
walletInfo: { cashAddress },
|
label: 'setting a name',
|
||||||
getUtxos: async () => utxos,
|
storeKey: 'profiles',
|
||||||
sendOpReturn: async (msg, prefix) => {
|
storeFactory: fakeProfiles,
|
||||||
broadcasts.push({ msg, prefix })
|
assertStoreEmpty: (profiles, wallet) => assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test('MEMO_SET_NAME_PREFIX is the Memo set-name action 0x6d01', () => {
|
test('MEMO_SET_NAME_PREFIX is the Memo set-name action 0x6d01', () => {
|
||||||
assert.equal(MemoSetName.MEMO_SET_NAME_PREFIX, '6d01')
|
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')
|
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 () => {
|
test('setting a name at the maximum byte length with multi-byte characters is accepted', async () => {
|
||||||
const wallet = fakeWallet()
|
const wallet = fakeWallet()
|
||||||
const memoSetName = new MemoSetName({ wallet })
|
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)
|
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 () => {
|
test('setting a name without a wallet reports a missing-wallet error', async () => {
|
||||||
const memoSetName = new MemoSetName({})
|
const memoSetName = new MemoSetName({})
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
|
|||||||
+25
-89
@@ -17,41 +17,29 @@ const assert = require('node:assert/strict')
|
|||||||
|
|
||||||
const MemoPost = require('../../src/services/memo-post')
|
const MemoPost = require('../../src/services/memo-post')
|
||||||
const NewPostPage = require('../../src/services/new-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
|
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 () {
|
function fakeFeed () {
|
||||||
const posts = []
|
const posts = []
|
||||||
return { posts, addPost: (p) => posts.push(p) }
|
return { posts, addPost: (p) => posts.push(p) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function build () {
|
function build () {
|
||||||
const wallet = fakeWallet()
|
return buildPage({
|
||||||
const feed = fakeFeed()
|
Page: NewPostPage,
|
||||||
const memoPost = new MemoPost({ wallet, feed })
|
Action: MemoPost,
|
||||||
const navigations = []
|
actionKey: 'memoPost',
|
||||||
const page = new NewPostPage({
|
storeKey: 'feed',
|
||||||
memoPost,
|
storeFactory: fakeFeed
|
||||||
navigate: (path) => navigations.push(path)
|
|
||||||
})
|
})
|
||||||
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', () => {
|
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 () => {
|
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')
|
page.setInput('hello memo')
|
||||||
|
|
||||||
const result = await page.submit()
|
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.
|
// Navigated to the recent feed after posting.
|
||||||
assert.deepEqual(navigations, ['/posts/recent'])
|
assert.deepEqual(navigations, ['/posts/recent'])
|
||||||
// The feed reflects the new post from this address.
|
// The feed reflects the new post from this address.
|
||||||
assert.equal(feed.posts.length, 1)
|
assert.equal(store.posts.length, 1)
|
||||||
assert.equal(feed.posts[0].text, 'hello memo')
|
assert.equal(store.posts[0].text, 'hello memo')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('posting an empty memo is rejected with a validation error and nothing is broadcast', async () => {
|
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('')
|
page.setInput('')
|
||||||
|
|
||||||
const result = await page.submit()
|
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.submitError, 'memo_validation')
|
||||||
assert.equal(page.posting, false)
|
assert.equal(page.posting, false)
|
||||||
assert.equal(wallet.broadcasts.length, 0)
|
assert.equal(wallet.broadcasts.length, 0)
|
||||||
assert.equal(feed.posts.length, 0)
|
assert.equal(store.posts.length, 0)
|
||||||
assert.deepEqual(navigations, [])
|
assert.deepEqual(navigations, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('posting an over-long memo is rejected with a length error and nothing is broadcast', async () => {
|
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))
|
page.setInput('y'.repeat(MAX + 1))
|
||||||
|
|
||||||
const result = await page.submit()
|
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.submitError, 'memo_length')
|
||||||
assert.equal(page.posting, false)
|
assert.equal(page.posting, false)
|
||||||
assert.equal(wallet.broadcasts.length, 0)
|
assert.equal(wallet.broadcasts.length, 0)
|
||||||
assert.equal(feed.posts.length, 0)
|
assert.equal(store.posts.length, 0)
|
||||||
assert.deepEqual(navigations, [])
|
assert.deepEqual(navigations, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -137,63 +125,11 @@ test('the new post page starts idle (not posting)', () => {
|
|||||||
assert.equal(page.posting, false)
|
assert.equal(page.posting, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('posting is true while a submit is in flight and false once it settles', async () => {
|
registerPageControllerTests({
|
||||||
const wallet = fakeWallet()
|
buildPage: build,
|
||||||
const feed = fakeFeed()
|
buildBarePage,
|
||||||
|
busyFlag: 'posting',
|
||||||
// Defer the broadcast so we can observe the in-flight posting state.
|
prefix: '6d02'
|
||||||
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, [])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a failed broadcast surfaces a different real error message', async () => {
|
test('a failed broadcast surfaces a different real error message', async () => {
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -16,40 +16,24 @@ const assert = require('node:assert/strict')
|
|||||||
|
|
||||||
const MemoSetName = require('../../src/services/memo-set-name')
|
const MemoSetName = require('../../src/services/memo-set-name')
|
||||||
const SetNamePage = require('../../src/services/set-name-page')
|
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
|
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 () {
|
function build () {
|
||||||
const wallet = fakeWallet()
|
return buildPage({
|
||||||
const profiles = fakeProfiles()
|
Page: SetNamePage,
|
||||||
const memoSetName = new MemoSetName({ wallet, profiles })
|
Action: MemoSetName,
|
||||||
const navigations = []
|
actionKey: 'memoSetName',
|
||||||
const page = new SetNamePage({
|
storeKey: 'profiles',
|
||||||
memoSetName,
|
storeFactory: fakeProfiles
|
||||||
navigate: (path) => navigations.push(path)
|
|
||||||
})
|
})
|
||||||
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', () => {
|
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 () => {
|
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')
|
page.setInput('trout')
|
||||||
|
|
||||||
const result = await page.submit()
|
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].prefix, '6d01')
|
||||||
assert.equal(wallet.broadcasts[0].msg, 'trout')
|
assert.equal(wallet.broadcasts[0].msg, 'trout')
|
||||||
assert.deepEqual(navigations, ['/account'])
|
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 () => {
|
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('')
|
page.setInput('')
|
||||||
|
|
||||||
const result = await page.submit()
|
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.submitError, 'name_validation')
|
||||||
assert.equal(page.settingName, false)
|
assert.equal(page.settingName, false)
|
||||||
assert.equal(wallet.broadcasts.length, 0)
|
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, [])
|
assert.deepEqual(navigations, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('setting an over-long name is rejected with a length error and nothing is broadcast', async () => {
|
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))
|
page.setInput('y'.repeat(MAX + 1))
|
||||||
|
|
||||||
const result = await page.submit()
|
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.submitError, 'name_length')
|
||||||
assert.equal(page.settingName, false)
|
assert.equal(page.settingName, false)
|
||||||
assert.equal(wallet.broadcasts.length, 0)
|
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, [])
|
assert.deepEqual(navigations, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -137,57 +121,9 @@ test('the set name page starts idle (not setting name)', () => {
|
|||||||
assert.equal(page.settingName, false)
|
assert.equal(page.settingName, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('setting name is true while a submit is in flight and false once it settles', async () => {
|
registerPageControllerTests({
|
||||||
const wallet = fakeWallet()
|
buildPage: build,
|
||||||
const profiles = fakeProfiles()
|
buildBarePage,
|
||||||
|
busyFlag: 'settingName',
|
||||||
let resolveSend
|
prefix: '6d01'
|
||||||
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, [])
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user