diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 57c4dce..6e60f3b 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -1686,36 +1686,21 @@ const handlers = [ name: 'like/tip modal shows a broadcast success message', pattern: /^the like\/tip modal shows a broadcast success message$/, run (m, example, world) { - const { message, html } = renderLikeBroadcastResult(world) - if (!html.includes(message)) { - throw new Error(`The rendered like result does not show the message "${message}".`) - } + assertBroadcastResultMessage(world, 'like') } }, { name: 'like/tip modal shows the like transaction id', pattern: /^the like\/tip modal shows the like transaction id$/, run (m, example, world) { - const { txid, html } = renderLikeBroadcastResult(world) - if (!html.includes(txid)) { - throw new Error(`The rendered like result does not show the transaction id ${txid}.`) - } + assertBroadcastResultTxid(world, 'like') } }, { name: 'like/tip modal shows a block explorer link', pattern: /^the like\/tip modal shows a link to the block explorer for the like transaction$/, run (m, example, world) { - const { url, html } = renderLikeBroadcastResult(world) - if (!url.startsWith('https://bch.loping.net/tx/')) { - throw new Error(`Expected a bch.loping.net explorer link, got "${url}".`) - } - if (!html.includes(`href="${url}"`)) { - throw new Error(`The rendered like result does not link to ${url}.`) - } - if (!html.includes('target="_blank"')) { - throw new Error('The rendered like explorer link does not open in a new tab.') - } + assertBroadcastResultExplorerLink(world, 'like') } }, { @@ -1977,38 +1962,14 @@ const handlers = [ name: 'broadcasts OP_RETURN with Memo follow prefix for address', pattern: /^the app broadcasts an OP_RETURN transaction with the Memo follow prefix for the address (.+)$/, run (m, example, world) { - const addr = resolveParam(m[1], example) - const hash160 = world.wallet.bchjs.Address.toHash160(addr) - const broadcasts = world.wallet.broadcasts - if (!broadcasts.length) { - throw new Error('No OP_RETURN transaction was broadcast.') - } - const last = broadcasts[broadcasts.length - 1] - if (last.prefix !== MEMO_FOLLOW_PREFIX) { - throw new Error(`Expected Memo follow prefix ${MEMO_FOLLOW_PREFIX}, got "${last.prefix}".`) - } - if (last.msg.toString('hex') !== hash160) { - throw new Error(`Broadcast follow hash160 did not match ${addr}.`) - } + assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_FOLLOW_PREFIX, 'follow') } }, { name: 'broadcasts OP_RETURN with Memo unfollow prefix for address', pattern: /^the app broadcasts an OP_RETURN transaction with the Memo unfollow prefix for the address (.+)$/, run (m, example, world) { - const addr = resolveParam(m[1], example) - const hash160 = world.wallet.bchjs.Address.toHash160(addr) - const broadcasts = world.wallet.broadcasts - if (!broadcasts.length) { - throw new Error('No OP_RETURN transaction was broadcast.') - } - const last = broadcasts[broadcasts.length - 1] - if (last.prefix !== MEMO_UNFOLLOW_PREFIX) { - throw new Error(`Expected Memo unfollow prefix ${MEMO_UNFOLLOW_PREFIX}, got "${last.prefix}".`) - } - if (last.msg.toString('hex') !== hash160) { - throw new Error(`Broadcast unfollow hash160 did not match ${addr}.`) - } + assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_UNFOLLOW_PREFIX, 'unfollow') } }, { @@ -2089,38 +2050,14 @@ const handlers = [ name: 'broadcasts or attempts Memo mute prefix for address', pattern: /^the app (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo mute prefix for the address (.+)$/, run (m, example, world) { - const addr = resolveParam(m[1], example) - const hash160 = world.wallet.bchjs.Address.toHash160(addr) - const broadcasts = world.wallet.broadcasts - if (!broadcasts.length) { - throw new Error('No OP_RETURN transaction was broadcast.') - } - const last = broadcasts[broadcasts.length - 1] - if (last.prefix !== MEMO_MUTE_PREFIX) { - throw new Error(`Expected Memo mute prefix ${MEMO_MUTE_PREFIX}, got "${last.prefix}".`) - } - if (last.msg.toString('hex') !== hash160) { - throw new Error(`Broadcast mute hash160 did not match ${addr}.`) - } + assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_MUTE_PREFIX, 'mute') } }, { name: 'broadcasts OP_RETURN with Memo unmute prefix for address', pattern: /^the app broadcasts an OP_RETURN transaction with the Memo unmute prefix for the address (.+)$/, run (m, example, world) { - const addr = resolveParam(m[1], example) - const hash160 = world.wallet.bchjs.Address.toHash160(addr) - const broadcasts = world.wallet.broadcasts - if (!broadcasts.length) { - throw new Error('No OP_RETURN transaction was broadcast.') - } - const last = broadcasts[broadcasts.length - 1] - if (last.prefix !== MEMO_UNMUTE_PREFIX) { - throw new Error(`Expected Memo unmute prefix ${MEMO_UNMUTE_PREFIX}, got "${last.prefix}".`) - } - if (last.msg.toString('hex') !== hash160) { - throw new Error(`Broadcast unmute hash160 did not match ${addr}.`) - } + assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_UNMUTE_PREFIX, 'unmute') } }, { @@ -2139,36 +2076,21 @@ const handlers = [ name: 'mute result modal shows a broadcast success message', pattern: /^the mute result modal shows a broadcast success message$/, run (m, example, world) { - const { message, html } = renderMuteBroadcastResult(world) - if (!html.includes(message)) { - throw new Error(`The rendered mute result does not show the message "${message}".`) - } + assertBroadcastResultMessage(world, 'mute') } }, { name: 'mute result modal shows the mute transaction id', pattern: /^the mute result modal shows the mute transaction id$/, run (m, example, world) { - const { txid, html } = renderMuteBroadcastResult(world) - if (!html.includes(txid)) { - throw new Error(`The rendered mute result does not show the transaction id ${txid}.`) - } + assertBroadcastResultTxid(world, 'mute') } }, { name: 'mute result modal shows a block explorer link', pattern: /^the mute result modal shows a link to the block explorer for the mute transaction$/, run (m, example, world) { - const { url, html } = renderMuteBroadcastResult(world) - if (!url.startsWith(`${ProfilePage.EXPLORER_TX_BASE}/`)) { - throw new Error(`Expected a bch.loping.net explorer link, got "${url}".`) - } - if (!html.includes(`href="${url}"`)) { - throw new Error(`The rendered mute result does not link to ${url}.`) - } - if (!html.includes('target="_blank"')) { - throw new Error('The rendered mute explorer link does not open in a new tab.') - } + assertBroadcastResultExplorerLink(world, 'mute') } }, { @@ -4027,44 +3949,93 @@ function togglePostOptionsMenu (world, txid) { world.activeMenuTxid = txid } -// Require a successful like broadcast result and render it to static HTML for -// the like/tip acceptance assertions. The caller inspects the returned fields. -function renderLikeBroadcastResult (world) { - const page = world.likeTipPage - if (!page.showResultModal || !page.lastResult || !page.lastResult.ok) { - throw new Error('Expected a successful like broadcast result.') +// Broadcast result plumbing shared by the like/tip and mute acceptance +// assertions. Each feature names the world field holding its page, the page +// fields/getters for the visible result, and the component renderer. +const BROADCAST_RESULTS = { + like: { + page: (world) => world.likeTipPage, + showField: 'showResultModal', + resultField: 'lastResult', + getMessage: (page) => page.getBroadcastMessage(), + render: renderLikeResult + }, + mute: { + page: (world) => world.profilePage, + showField: 'showMuteResultModal', + resultField: 'lastMuteResult', + getMessage: (page) => page.getMuteBroadcastMessage(), + render: renderMuteResult } - const txid = page.lastResult.txid +} + +// Require a successful broadcast result for `feature` and render it to static +// HTML for the acceptance assertions. The caller inspects the returned fields. +function renderBroadcastResult (world, feature) { + const spec = BROADCAST_RESULTS[feature] + const page = spec.page(world) + if (!page || !page[spec.showField] || !page[spec.resultField] || !page[spec.resultField].ok) { + throw new Error(`Expected a successful ${feature} broadcast result.`) + } + const txid = page[spec.resultField].txid if (!txid) { - throw new Error('Expected the like result to include a transaction id.') + throw new Error(`Expected the ${feature} result to include a transaction id.`) } - const message = page.getBroadcastMessage() + const message = spec.getMessage(page) if (!message) { - throw new Error('Expected a like broadcast success message.') + throw new Error(`Expected a ${feature} broadcast success message.`) } const url = page.explorerUrl(txid) - const html = renderLikeResult({ txid, message, explorerUrl: url }) + const html = spec.render({ txid, message, explorerUrl: url }) return { txid, message, url, html } } -// Require a successful mute broadcast result and render it to static HTML for -// the mute acceptance assertions. The caller inspects the returned fields. -function renderMuteBroadcastResult (world) { - const page = world.profilePage - if (!page || !page.showMuteResultModal || !page.lastMuteResult || !page.lastMuteResult.ok) { - throw new Error('Expected a successful mute broadcast result.') +// Assert the rendered broadcast result shows its success message. +function assertBroadcastResultMessage (world, feature) { + const { message, html } = renderBroadcastResult(world, feature) + if (!html.includes(message)) { + throw new Error(`The rendered ${feature} result does not show the message "${message}".`) } - const txid = page.lastMuteResult.txid - if (!txid) { - throw new Error('Expected the mute result to include a transaction id.') +} + +// Assert the rendered broadcast result shows its transaction id. +function assertBroadcastResultTxid (world, feature) { + const { txid, html } = renderBroadcastResult(world, feature) + if (!html.includes(txid)) { + throw new Error(`The rendered ${feature} result does not show the transaction id ${txid}.`) } - const message = page.getMuteBroadcastMessage() - if (!message) { - throw new Error('Expected a mute broadcast success message.') +} + +// Assert the rendered broadcast result links to the block explorer in a new +// tab. +function assertBroadcastResultExplorerLink (world, feature) { + const { url, html } = renderBroadcastResult(world, feature) + if (!url.startsWith(`${ProfilePage.EXPLORER_TX_BASE}/`)) { + throw new Error(`Expected a bch.loping.net explorer link, got "${url}".`) + } + if (!html.includes(`href="${url}"`)) { + throw new Error(`The rendered ${feature} result does not link to ${url}.`) + } + if (!html.includes('target="_blank"')) { + throw new Error(`The rendered ${feature} explorer link does not open in a new tab.`) + } +} + +// Assert that the wallet's most recent broadcast carries the Memo `label` +// prefix and the 20-byte hash160 payload for `addr`. +function assertMemoBroadcastPrefix (world, addr, prefix, label) { + const hash160 = world.wallet.bchjs.Address.toHash160(addr) + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No OP_RETURN transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (last.prefix !== prefix) { + throw new Error(`Expected Memo ${label} prefix ${prefix}, got "${last.prefix}".`) + } + if (last.msg.toString('hex') !== hash160) { + throw new Error(`Broadcast ${label} hash160 did not match ${addr}.`) } - const url = page.explorerUrl(txid) - const html = renderMuteResult({ txid, message, explorerUrl: url }) - return { txid, message, url, html } } // The posts currently rendered by the page the scenario has opened. diff --git a/psf-memo-client/src/components/app-body/profile/mute-result.js b/psf-memo-client/src/components/app-body/profile/mute-result.js index 51fbd19..f0fe1f9 100644 --- a/psf-memo-client/src/components/app-body/profile/mute-result.js +++ b/psf-memo-client/src/components/app-body/profile/mute-result.js @@ -12,6 +12,7 @@ */ const React = require('react') +const ExplorerTxLink = require('../../explorer-tx-link') function MuteResult ({ txid = '', message = '', error = '', explorerUrl = '' }) { return React.createElement( @@ -23,23 +24,11 @@ function MuteResult ({ txid = '', message = '', error = '', explorerUrl = '' }) React.Fragment, null, React.createElement('p', { className: 'mute-result-message' }, message), - txid - ? React.createElement( - 'p', - { className: 'mute-result-txid mb-0' }, - 'Transaction ID: ', - React.createElement( - 'a', - { - href: explorerUrl, - target: '_blank', - rel: 'noopener noreferrer', - style: { wordBreak: 'break-all' } - }, - txid - ) - ) - : null + React.createElement(ExplorerTxLink, { + txid, + explorerUrl, + className: 'mute-result-txid' + }) ) ) } diff --git a/psf-memo-client/src/components/explorer-tx-link.js b/psf-memo-client/src/components/explorer-tx-link.js new file mode 100644 index 0000000..b2f38b9 --- /dev/null +++ b/psf-memo-client/src/components/explorer-tx-link.js @@ -0,0 +1,34 @@ +/* + Explorer link for a broadcast transaction. + + Shown by the like/tip and mute broadcast result components: "Transaction ID:" + followed by the txid linked to its block explorer page, opening in a new tab. + The `className` is supplied by the caller so each result keeps its own + styling. + + Written in plain React.createElement style so the same module can be used by + the JSX components in the browser build and by the Node acceptance adapters. +*/ + +const React = require('react') + +function ExplorerTxLink ({ txid = '', explorerUrl = '', className = '' }) { + if (!txid) return null + return React.createElement( + 'p', + { className: `${className} mb-0`.trim() }, + 'Transaction ID: ', + React.createElement( + 'a', + { + href: explorerUrl, + target: '_blank', + rel: 'noopener noreferrer', + style: { wordBreak: 'break-all' } + }, + txid + ) + ) +} + +module.exports = ExplorerTxLink diff --git a/psf-memo-client/src/components/post-feed/like-result.js b/psf-memo-client/src/components/post-feed/like-result.js index 1f598fe..acb19f3 100644 --- a/psf-memo-client/src/components/post-feed/like-result.js +++ b/psf-memo-client/src/components/post-feed/like-result.js @@ -11,29 +11,18 @@ */ const React = require('react') +const ExplorerTxLink = require('../explorer-tx-link') function LikeResult ({ txid = '', message = '', explorerUrl = '' }) { return React.createElement( 'div', { className: 'like-result' }, React.createElement('p', { className: 'like-result-message' }, message), - txid - ? React.createElement( - 'p', - { className: 'like-result-txid mb-0' }, - 'Transaction ID: ', - React.createElement( - 'a', - { - href: explorerUrl, - target: '_blank', - rel: 'noopener noreferrer', - style: { wordBreak: 'break-all' } - }, - txid - ) - ) - : null + React.createElement(ExplorerTxLink, { + txid, + explorerUrl, + className: 'like-result-txid' + }) ) } diff --git a/psf-memo-client/test/property/mute-result.property.test.js b/psf-memo-client/test/property/mute-result.property.test.js new file mode 100644 index 0000000..632832f --- /dev/null +++ b/psf-memo-client/test/property/mute-result.property.test.js @@ -0,0 +1,241 @@ +/* + Property tests for the mute broadcast result. + + The unit tests probe the mute result at a few fixed fixtures. These + properties pin down the invariants over broad random inputs: + + - explorerUrl composes the shared block explorer base with the txid and + returns '' for every falsy input. + - The MuteResult component always shows the message, shows the txid and an + explorer link that opens in a new tab exactly when a txid is present, + shows only the error when an error is present, and is deterministic. + - The profile page mute result state machine mirrors the last broadcast: a + successful mute/unmute opens the modal with the matching success message, + a failure opens the failure modal with the error and leaves the button + state unchanged, a new broadcast replaces the previous result, and + dismissing closes the modal without changing the button. +*/ + +'use strict' + +const test = require('node:test') +const React = require('react') +const ReactDOMServer = require('react-dom/server') +const { seededRandom, forAll, intGen } = require('./harness') +const ProfilePage = require('../../src/services/profile-page') +const MuteResult = require('../../src/components/app-body/profile/mute-result') + +const rng = seededRandom(20260918) + +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' +const MUTE_ADDRESS = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy' +const MUTE_TXID = 'ab'.repeat(32) + +const HEX = '0123456789abcdef' +const SAFE_WORDS = ['mute', 'unmute', 'broadcast', 'success', 'memo', 'network'] +const FALSY = [undefined, null, '', 0, false] + +function randomTxid () { + const n = intGen(rng, 1, 64)() + let out = '' + for (let i = 0; i < n; i++) out += HEX[Math.floor(rng() * HEX.length)] + return out +} + +function randomMessage () { + const n = intGen(rng, 1, 6)() + let out = '' + for (let i = 0; i < n; i++) { + out += `${SAFE_WORDS[intGen(rng, 0, SAFE_WORDS.length - 1)()]} ` + } + return out.trim() +} + +function makeMemoDb () { + return { + async getPostsByAddr () { + return { posts: [], pagination: { total: 0 } } + }, + async getFollowState () { + return false + }, + async getMuteState () { + return false + } + } +} + +// A profile page whose memo mute handler either succeeds with MUTE_TXID or +// fails with the given message. +function makePage ({ fail = null, txid = MUTE_TXID } = {}) { + const page = new ProfilePage({ memoDb: makeMemoDb(), addr: MUTE_ADDRESS, myAddr: MY_ADDRESS }) + page.memoMute = { + async mute () { + if (fail) throw new Error(fail) + return txid + }, + async unmute () { + if (fail) throw new Error(fail) + return txid + } + } + return page +} + +function render (props) { + return ReactDOMServer.renderToStaticMarkup( + React.createElement(MuteResult, props) + ) +} + +test('explorerUrl composes the shared base and the txid', async () => { + await forAll( + () => randomTxid(), + async (txid) => + ProfilePage.explorerUrl(txid) === `${ProfilePage.EXPLORER_TX_BASE}/${txid}`, + { label: 'mute explorer url composition', samples: 2000 } + ) +}) + +test('explorerUrl returns an empty string for every falsy input', async () => { + await forAll( + () => FALSY[intGen(rng, 0, FALSY.length - 1)()], + async (value) => ProfilePage.explorerUrl(value) === '', + { label: 'mute explorer url falsy inputs', samples: 500 } + ) +}) + +test('MuteResult shows the message and links only with a txid', async () => { + await forAll( + () => ({ txid: rng() < 0.2 ? '' : randomTxid(), message: randomMessage() }), + async ({ txid, message }) => { + const url = ProfilePage.explorerUrl(txid) + const html = render({ txid, message, explorerUrl: url }) + if (!html.includes(message)) return false + if (txid) { + if (!html.includes(txid)) return false + if (!html.includes(`href="${url}"`)) return false + if (!html.includes('target="_blank"')) return false + } else if (html.includes(' { + await forAll( + () => ({ txid: randomTxid(), message: randomMessage(), error: randomMessage() }), + async ({ txid, message, error }) => { + const html = render({ txid, message, error, explorerUrl: ProfilePage.explorerUrl(txid) }) + return html.includes('mute-result-error') && + html.includes(error) && + !html.includes('mute-result-message') && + !html.includes(' { + await forAll( + () => ({ txid: randomTxid(), message: randomMessage() }), + async (props) => { + const first = render(props) + const second = render(props) + return first === second + }, + { label: 'mute result render determinism', samples: 500 } + ) +}) + +test('the mute result modal mirrors the last broadcast outcome', async () => { + await forAll( + () => ({ method: rng() < 0.5 ? 'mute' : 'unmute', fail: rng() < 0.4 }), + async ({ method, fail }) => { + const page = makePage({ fail: fail ? 'Insufficient balance' : null }) + const result = await page[method]() + + if (!page.showMuteResultModal) return false + if (result.ok) { + const expected = method === 'unmute' ? ProfilePage.UNMUTE_SUCCESS_MESSAGE : ProfilePage.MUTE_SUCCESS_MESSAGE + return page.getMuteBroadcastMessage() === expected && page.getMuteResultError() === '' + } + return page.getMuteBroadcastMessage() === '' && page.getMuteResultError() === 'Insufficient balance' + }, + { label: 'mute result modal outcome', samples: 800 } + ) +}) + +test('a failed mute broadcast leaves the button state unchanged', async () => { + await forAll( + () => ({ method: rng() < 0.5 ? 'mute' : 'unmute', initial: rng() < 0.5 }), + async ({ method, initial }) => { + const page = makePage({ fail: 'Insufficient balance' }) + page.muteState = initial + const result = await page[method]() + return result.ok === false && page.isMuting() === initial + }, + { label: 'mute failure leaves button state', samples: 500 } + ) +}) + +test('a successful mute then unmute round-trips the reflected mute state', async () => { + await forAll( + () => rng() < 0.5, + async (startMuted) => { + const page = makePage() + page.muteState = startMuted + const muteResult = await page.mute() + const afterMute = page.isMuting() + const unmuteResult = await page.unmute() + const afterUnmute = page.isMuting() + return muteResult.ok && afterMute === true && unmuteResult.ok && afterUnmute === false + }, + { label: 'mute reflected state round trip', samples: 400 } + ) +}) + +test('a new broadcast replaces the previous result', async () => { + await forAll( + () => rng() < 0.5, + async (firstFails) => { + const page = makePage({ fail: firstFails ? 'Insufficient balance' : null }) + await page.mute() + const before = { ok: page.lastMuteResult.ok, error: page.getMuteResultError() } + // Force the opposite outcome on the next broadcast. + page.memoMute = { + async mute () { + if (!firstFails) throw new Error('Insufficient balance') + return MUTE_TXID + }, + async unmute () { + return MUTE_TXID + } + } + await page.mute() + return before.ok === !firstFails && + page.lastMuteResult.ok === firstFails && + page.getMuteResultError() === (firstFails ? '' : 'Insufficient balance') + }, + { label: 'mute result replace', samples: 400 } + ) +}) + +test('dismissing the mute result closes the modal without changing the button', async () => { + await forAll( + () => ({ method: rng() < 0.5 ? 'mute' : 'unmute', fail: rng() < 0.5 }), + async ({ method, fail }) => { + const page = makePage({ fail: fail ? 'Insufficient balance' : null }) + const result = await page[method]() + const stateBeforeDismiss = page.muteState + page.dismissMuteResult() + return result.ok === !fail && + page.showMuteResultModal === false && + page.muteState === stateBeforeDismiss + }, + { label: 'mute result dismiss', samples: 500 } + ) +})