diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 3e49045..4c5fa34 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -1507,19 +1507,7 @@ 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 page = world.likeTipPage - if (!page.showResultModal) { - throw new Error('Expected the like broadcast result to be shown.') - } - const message = page.getBroadcastMessage() - if (!message) { - throw new Error('Expected a like broadcast success message.') - } - const html = renderLikeResult({ - txid: page.lastResult.txid, - message, - explorerUrl: page.explorerUrl(page.lastResult.txid) - }) + const { message, html } = renderLikeBroadcastResult(world) if (!html.includes(message)) { throw new Error(`The rendered like result does not show the message "${message}".`) } @@ -1529,19 +1517,7 @@ const handlers = [ name: 'like/tip modal shows the like transaction id', pattern: /^the like\/tip modal shows the like transaction id$/, run (m, example, world) { - const page = world.likeTipPage - if (!page.showResultModal || !page.lastResult || !page.lastResult.ok) { - throw new Error('Expected a successful like broadcast result.') - } - const txid = page.lastResult.txid - if (!txid) { - throw new Error('Expected the like result to include a transaction id.') - } - const html = renderLikeResult({ - txid, - message: page.getBroadcastMessage(), - explorerUrl: page.explorerUrl(txid) - }) + const { txid, html } = renderLikeBroadcastResult(world) if (!html.includes(txid)) { throw new Error(`The rendered like result does not show the transaction id ${txid}.`) } @@ -1551,20 +1527,10 @@ const handlers = [ 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 page = world.likeTipPage - if (!page.showResultModal || !page.lastResult || !page.lastResult.ok) { - throw new Error('Expected a successful like broadcast result.') - } - const txid = page.lastResult.txid - const url = page.explorerUrl(txid) + 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}".`) } - const html = renderLikeResult({ - txid, - message: page.getBroadcastMessage(), - explorerUrl: url - }) if (!html.includes(`href="${url}"`)) { throw new Error(`The rendered like result does not link to ${url}.`) } @@ -3502,6 +3468,26 @@ 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.') + } + const txid = page.lastResult.txid + if (!txid) { + throw new Error('Expected the like result to include a transaction id.') + } + const message = page.getBroadcastMessage() + if (!message) { + throw new Error('Expected a like broadcast success message.') + } + const url = page.explorerUrl(txid) + const html = renderLikeResult({ txid, message, explorerUrl: url }) + return { txid, message, url, html } +} + // The posts currently rendered by the page the scenario has opened. function postsOnCurrentPage (world) { const path = world.currentPath || '' diff --git a/psf-memo-client/src/services/block-explorer.js b/psf-memo-client/src/services/block-explorer.js new file mode 100644 index 0000000..54ff984 --- /dev/null +++ b/psf-memo-client/src/services/block-explorer.js @@ -0,0 +1,20 @@ +/* + Block explorer link for a Bitcoin Cash transaction. + + Single source for the bch.loping.net transaction URL shared by the New Post + result modal, the post options menu, and the like/tip broadcast result, so + the base URL and link shape cannot drift between features. +*/ + +const BLOCK_EXPLORER_TX_BASE = 'https://bch.loping.net/tx' + +// Block explorer URL for a transaction, or '' without a txid. +function blockExplorerTxUrl (txid) { + if (!txid) return '' + return `${BLOCK_EXPLORER_TX_BASE}/${txid}` +} + +module.exports = { + BLOCK_EXPLORER_TX_BASE, + blockExplorerTxUrl +} diff --git a/psf-memo-client/src/services/like-tip-page.js b/psf-memo-client/src/services/like-tip-page.js index 1095360..8789f75 100644 --- a/psf-memo-client/src/services/like-tip-page.js +++ b/psf-memo-client/src/services/like-tip-page.js @@ -14,8 +14,8 @@ */ const PageController = require('./page-controller') +const { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } = require('./block-explorer') -const EXPLORER_TX_BASE = 'https://bch.loping.net/tx' const SUCCESS_MESSAGE = 'Your like was broadcast to the Bitcoin Cash network.' class LikeTipPage extends PageController { @@ -146,9 +146,6 @@ class LikeTipPage extends PageController { module.exports = LikeTipPage -LikeTipPage.EXPLORER_TX_BASE = EXPLORER_TX_BASE +LikeTipPage.EXPLORER_TX_BASE = BLOCK_EXPLORER_TX_BASE LikeTipPage.SUCCESS_MESSAGE = SUCCESS_MESSAGE -LikeTipPage.explorerUrl = function (txid) { - if (!txid) return '' - return `${EXPLORER_TX_BASE}/${txid}` -} +LikeTipPage.explorerUrl = blockExplorerTxUrl diff --git a/psf-memo-client/src/services/new-post.js b/psf-memo-client/src/services/new-post.js index 19c292e..42b0c46 100644 --- a/psf-memo-client/src/services/new-post.js +++ b/psf-memo-client/src/services/new-post.js @@ -15,10 +15,10 @@ const PageController = require('./page-controller') const MemoPost = require('./memo-post') +const { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } = require('./block-explorer') const NEW_POST_PATH = '/posts/new' const RECENT_FEED_PATH = '/posts/recent' -const EXPLORER_TX_BASE = 'https://bch.loping.net/tx' class NewPostPage extends PageController { constructor (deps = {}) { @@ -95,11 +95,8 @@ class NewPostPage extends PageController { NewPostPage.NEW_POST_PATH = NEW_POST_PATH NewPostPage.RECENT_FEED_PATH = RECENT_FEED_PATH -NewPostPage.EXPLORER_TX_BASE = EXPLORER_TX_BASE -NewPostPage.explorerUrl = function (txid) { - if (!txid) return '' - return `${EXPLORER_TX_BASE}/${txid}` -} +NewPostPage.EXPLORER_TX_BASE = BLOCK_EXPLORER_TX_BASE +NewPostPage.explorerUrl = blockExplorerTxUrl module.exports = NewPostPage diff --git a/psf-memo-client/src/services/post-options.js b/psf-memo-client/src/services/post-options.js index 7489ea0..4bef6cf 100644 --- a/psf-memo-client/src/services/post-options.js +++ b/psf-memo-client/src/services/post-options.js @@ -11,14 +11,9 @@ by the browser components, by unit tests, and by the acceptance handlers. */ -const BLOCK_EXPLORER_LABEL = 'See on block explorer' -const BLOCK_EXPLORER_TX_BASE = 'https://bch.loping.net/tx' +const { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } = require('./block-explorer') -// Block explorer URL for a post transaction, or '' without a txid. -function explorerTxUrl (txid) { - if (!txid) return '' - return `${BLOCK_EXPLORER_TX_BASE}/${txid}` -} +const BLOCK_EXPLORER_LABEL = 'See on block explorer' // The ordered menu items for a post. The block explorer link is first. function postOptionsItems (txid) { @@ -26,7 +21,7 @@ function postOptionsItems (txid) { { id: 'block-explorer', label: BLOCK_EXPLORER_LABEL, - href: explorerTxUrl(txid), + href: blockExplorerTxUrl(txid), target: '_blank', rel: 'noopener noreferrer' } @@ -99,7 +94,7 @@ function postOptionsKeyCommand (key, items = []) { module.exports = { BLOCK_EXPLORER_LABEL, BLOCK_EXPLORER_TX_BASE, - explorerTxUrl, + explorerTxUrl: blockExplorerTxUrl, postOptionsItems, initialPostOptionsState, openPostOptions, diff --git a/psf-memo-client/test/property/like-result.property.test.js b/psf-memo-client/test/property/like-result.property.test.js new file mode 100644 index 0000000..1d62b5d --- /dev/null +++ b/psf-memo-client/test/property/like-result.property.test.js @@ -0,0 +1,176 @@ +/* + Property tests for the like broadcast result. + + The unit tests probe the like 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 LikeResult component always shows the message, shows the txid and an + explorer link that opens in a new tab exactly when a txid is present, and + is deterministic. + - The result-modal state machine mirrors the last submit outcome: a + successful like opens the result with the success message and keeps the + modal open; a failed like shows no result. Dismissing always closes the + result and the modal, and reopening always clears the previous result. +*/ + +'use strict' + +const test = require('node:test') +const React = require('react') +const ReactDOMServer = require('react-dom/server') +const { seededRandom, forAll, intGen } = require('./harness') +const LikeTipPage = require('../../src/services/like-tip-page') +const MemoLike = require('../../src/services/memo-like') +const LikeResult = require('../../src/components/post-feed/like-result') + +const rng = seededRandom(20260916) + +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' +const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc' +const LIKE_TXID = 'ab'.repeat(32) + +const HEX = '0123456789abcdef' +const SAFE_WORDS = ['like', 'broadcast', 'success', 'memo', 'network', 'tip', 'post'] +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 makeWallet (failWith) { + return { + walletInfo: { cashAddress: MY_ADDRESS }, + utxos: [{ txid: 'utxo', value: 100000 }], + broadcasts: [], + async getUtxos () { + return this.utxos + }, + async sendOpReturn (msg, prefix, bchOutput = []) { + this.broadcasts.push({ msg, prefix, bchOutput }) + if (failWith) throw new Error(failWith) + return LIKE_TXID + } + } +} + +function makeSubmittedPage (fail) { + const memoLike = new MemoLike({ wallet: makeWallet(fail ? 'Insufficient balance' : null) }) + const page = new LikeTipPage({ memoLike }) + page.open(randomTxid(), AUTHOR_ADDRESS) + page.setTip('') + return page +} + +function render (props) { + return ReactDOMServer.renderToStaticMarkup( + React.createElement(LikeResult, props) + ) +} + +test('explorerUrl composes the shared base and the txid', async () => { + await forAll( + () => randomTxid(), + async (txid) => + LikeTipPage.explorerUrl(txid) === `${LikeTipPage.EXPLORER_TX_BASE}/${txid}`, + { label: 'like 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) => LikeTipPage.explorerUrl(value) === '', + { label: 'like explorer url falsy inputs', samples: 500 } + ) +}) + +test('LikeResult always shows the message and links only with a txid', async () => { + await forAll( + () => ({ txid: rng() < 0.2 ? '' : randomTxid(), message: randomMessage() }), + async ({ txid, message }) => { + const url = LikeTipPage.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() }), + async (props) => { + const first = render(props) + const second = render(props) + return first === second + }, + { label: 'like result render determinism', samples: 500 } + ) +}) + +test('the result modal mirrors the last submit outcome', async () => { + await forAll( + () => rng() < 0.4, + async (fail) => { + const page = makeSubmittedPage(fail) + const result = await page.submit() + if (result.ok) { + if (page.showResultModal !== true || page.modalOpen !== true) return false + if (page.getBroadcastMessage() !== LikeTipPage.SUCCESS_MESSAGE) return false + } else { + if (page.showResultModal !== false) return false + if (page.getBroadcastMessage() !== '') return false + } + return true + }, + { label: 'like result modal outcome', samples: 400 } + ) +}) + +test('dismissing the result always closes the result and the modal', async () => { + await forAll( + () => rng() < 0.5, + async (fail) => { + const page = makeSubmittedPage(fail) + await page.submit() + page.dismissResult() + return page.showResultModal === false && page.modalOpen === false + }, + { label: 'like result dismiss', samples: 400 } + ) +}) + +test('reopening the like/tip modal always clears the previous result', async () => { + await forAll( + () => rng() < 0.5, + async (fail) => { + const page = makeSubmittedPage(fail) + await page.submit() + page.open(randomTxid(), AUTHOR_ADDRESS) + return page.showResultModal === false && page.lastResult === null + }, + { label: 'like result reopen clears', samples: 400 } + ) +}) diff --git a/psf-memo-client/test/unit/block-explorer.test.js b/psf-memo-client/test/unit/block-explorer.test.js new file mode 100644 index 0000000..9eedfe2 --- /dev/null +++ b/psf-memo-client/test/unit/block-explorer.test.js @@ -0,0 +1,35 @@ +/* + Unit tests for the shared block explorer link. + + The New Post result modal, the post options menu, and the like/tip broadcast + result all use this module so the explorer base URL and link shape stay in + one place. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const { + BLOCK_EXPLORER_TX_BASE, + blockExplorerTxUrl +} = require('../../src/services/block-explorer') + +const SAMPLE_TXID = '1111111111111111111111111111111111111111111111111111111111111111' + +test('the block explorer base points at bch.loping.net', () => { + assert.equal(BLOCK_EXPLORER_TX_BASE, 'https://bch.loping.net/tx') +}) + +test('blockExplorerTxUrl builds a transaction link', () => { + assert.equal( + blockExplorerTxUrl(SAMPLE_TXID), + `${BLOCK_EXPLORER_TX_BASE}/${SAMPLE_TXID}` + ) +}) + +test('blockExplorerTxUrl returns an empty string without a txid', () => { + assert.equal(blockExplorerTxUrl(''), '') + assert.equal(blockExplorerTxUrl(null), '') + assert.equal(blockExplorerTxUrl(undefined), '') +}) diff --git a/psf-memo-client/test/unit/like-tip-page.test.js b/psf-memo-client/test/unit/like-tip-page.test.js index 62de2bd..4017e17 100644 --- a/psf-memo-client/test/unit/like-tip-page.test.js +++ b/psf-memo-client/test/unit/like-tip-page.test.js @@ -132,3 +132,55 @@ test('a broadcast failure stays on the form and opens no result', async () => { assert.equal(page.showResultModal, false) assert.equal(page.getBroadcastMessage(), '') }) + +test('open reports a validation error without a memo like handler', () => { + const page = new LikeTipPage({}) + + const result = page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_validation') + assert.match(result.message, /memo like handler/) + assert.equal(page.modalOpen, true) + assert.equal(page.showResultModal, false) +}) + +test('open reports an empty-balance error below the dust limit', () => { + const wallet = makeWallet() + wallet.utxos = [{ txid: 'utxo', value: 100 }] + const memoLike = new MemoLike({ wallet }) + const page = new LikeTipPage({ memoLike }) + + const result = page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_empty_balance') + assert.match(page.broadcastError, /add BCH/) + assert.equal(page.showResultModal, false) +}) + +test('submit fails without a memo like handler', async () => { + const page = new LikeTipPage({}) + page.setTip('') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'broadcast') + assert.match(page.broadcastError, /memo like handler/) + assert.equal(page.showResultModal, false) +}) + +test('a like with a positive tip sends the tip to the author', async () => { + const { wallet, page } = makePage() + page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + page.setTip('600') + + const result = await page.submit() + + assert.equal(result.ok, true) + assert.equal(wallet.broadcasts.length, 1) + assert.deepEqual(wallet.broadcasts[0].bchOutput, [ + { address: AUTHOR_ADDRESS, amountSat: 600 } + ]) +})