mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
New posts display TXID after broadcast
This commit is contained in:
@@ -1109,6 +1109,56 @@ const handlers = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'page shows success modal with txid',
|
||||||
|
pattern: /^the new post page shows a success modal with the post txid$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
const page = world.newPage
|
||||||
|
const txid = page.lastResult && page.lastResult.txid
|
||||||
|
if (!page.showResultModal) {
|
||||||
|
throw new Error('Expected the new post success modal to be visible.')
|
||||||
|
}
|
||||||
|
if (!page.lastResult || !page.lastResult.ok) {
|
||||||
|
throw new Error('Expected a successful post result in the modal.')
|
||||||
|
}
|
||||||
|
if (!txid) {
|
||||||
|
throw new Error('Expected the success modal to include a post txid.')
|
||||||
|
}
|
||||||
|
const expectedUrl = NewPostPage.explorerUrl(txid)
|
||||||
|
if (page.explorerUrl(txid) !== expectedUrl) {
|
||||||
|
throw new Error(`Expected explorer URL ${expectedUrl}, got ${page.explorerUrl(txid)}.`)
|
||||||
|
}
|
||||||
|
if (!expectedUrl.startsWith(NewPostPage.EXPLORER_TX_BASE + '/')) {
|
||||||
|
throw new Error(`Explorer URL ${expectedUrl} is not on bch.loping.net.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'page shows failure modal containing text',
|
||||||
|
pattern: /^the new post page shows a failure modal containing "<([A-Za-z0-9_]+)>"$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
const param = m[1]
|
||||||
|
const expected = example[param]
|
||||||
|
const page = world.newPage
|
||||||
|
const actual = (page.lastResult && page.lastResult.message) || page.broadcastError || ''
|
||||||
|
if (!page.showResultModal) {
|
||||||
|
throw new Error('Expected the new post failure modal to be visible.')
|
||||||
|
}
|
||||||
|
if (page.lastResult && page.lastResult.ok) {
|
||||||
|
throw new Error('Expected a failed post result in the modal.')
|
||||||
|
}
|
||||||
|
if (!actual.includes(expected)) {
|
||||||
|
throw new Error(`Expected a failure modal containing "${expected}", got "${actual}".`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dismiss result modal',
|
||||||
|
pattern: /^I dismiss the result modal$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
world.newPage.dismissResult()
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'page shows validation/length error',
|
name: 'page shows validation/length error',
|
||||||
pattern: /^the (?:app|new post page) shows a (validation|length) error$/,
|
pattern: /^the (?:app|new post page) shows a (validation|length) error$/,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ Feature: New Post Page
|
|||||||
When I type a memo with the text "<message>"
|
When I type a memo with the text "<message>"
|
||||||
When I click the post button
|
When I click the post button
|
||||||
Then the app broadcasts an OP_RETURN transaction with the Memo post prefix
|
Then the app broadcasts an OP_RETURN transaction with the Memo post prefix
|
||||||
|
Then the new post page shows a success modal with the post txid
|
||||||
|
Then I remain on the path /posts/new
|
||||||
|
When I dismiss the result modal
|
||||||
Then I navigate to the path /posts/recent
|
Then I navigate to the path /posts/recent
|
||||||
Then the feed shows a new post from my address with the text "<message>"
|
Then the feed shows a new post from my address with the text "<message>"
|
||||||
|
|
||||||
@@ -66,7 +69,9 @@ Feature: New Post Page
|
|||||||
When I type a memo with the text "<message>"
|
When I type a memo with the text "<message>"
|
||||||
When I click the post button
|
When I click the post button
|
||||||
Then the app attempts to broadcast an OP_RETURN transaction with the Memo post prefix
|
Then the app attempts to broadcast an OP_RETURN transaction with the Memo post prefix
|
||||||
Then the new post page shows an error containing "<broadcast_error>"
|
Then the new post page shows a failure modal containing "<broadcast_error>"
|
||||||
|
Then I remain on the path /posts/new
|
||||||
|
When I dismiss the result modal
|
||||||
Then I remain on the path /posts/new
|
Then I remain on the path /posts/new
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/*
|
/*
|
||||||
New Post view: compose and broadcast a Memo post, with a character counter
|
New Post view: compose and broadcast a Memo post, with a character counter
|
||||||
that counts down from the memo limit. On success the user is navigated to the
|
that counts down from the memo limit. After broadcast, a modal reports
|
||||||
recent feed.
|
success (txid + explorer link) or failure. Dismissing a successful result
|
||||||
|
navigates to the recent feed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Global npm libraries
|
// Global npm libraries
|
||||||
import React, { useState } from 'react'
|
import React, { useRef, useState } from 'react'
|
||||||
import { Container, Row, Col, Form, Button } from 'react-bootstrap'
|
import { Container, Row, Col, Form, Button, Modal } from 'react-bootstrap'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
// Local libraries
|
// Local libraries
|
||||||
@@ -21,8 +22,13 @@ function NewPost (props) {
|
|||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [posting, setPosting] = useState(false)
|
const [posting, setPosting] = useState(false)
|
||||||
|
const [showResultModal, setShowResultModal] = useState(false)
|
||||||
|
const [lastResult, setLastResult] = useState(null)
|
||||||
|
const pageRef = useRef(null)
|
||||||
|
|
||||||
const remaining = maxChars - input.length
|
const remaining = maxChars - input.length
|
||||||
|
const resultTxid = lastResult && lastResult.ok ? lastResult.txid : ''
|
||||||
|
const explorerUrl = NewPostPage.explorerUrl(resultTxid)
|
||||||
|
|
||||||
async function handleSubmit (event) {
|
async function handleSubmit (event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -32,28 +38,39 @@ function NewPost (props) {
|
|||||||
try {
|
try {
|
||||||
const memoPost = new MemoPost({ wallet: appData?.wallet })
|
const memoPost = new MemoPost({ wallet: appData?.wallet })
|
||||||
const page = new NewPostPage({ memoPost, navigate })
|
const page = new NewPostPage({ memoPost, navigate })
|
||||||
|
pageRef.current = page
|
||||||
page.setInput(input)
|
page.setInput(input)
|
||||||
|
|
||||||
const result = await page.submit()
|
const result = await page.submit()
|
||||||
|
setLastResult(result)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.error === 'memo_length') {
|
if (result.error === 'memo_length') {
|
||||||
setErr(`Memo is too long. Maximum is ${maxChars} characters.`)
|
setErr(`Memo is too long. Maximum is ${maxChars} characters.`)
|
||||||
} else if (result.error === 'memo_validation') {
|
} else if (result.error === 'memo_validation') {
|
||||||
setErr('Memo must not be empty.')
|
setErr('Memo must not be empty.')
|
||||||
} else if (result.message) {
|
|
||||||
setErr(`Failed to broadcast: ${result.message}`)
|
|
||||||
} else {
|
|
||||||
setErr('Failed to post memo.')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// On success page.submit() navigated to the recent feed.
|
if (page.showResultModal) {
|
||||||
|
setShowResultModal(true)
|
||||||
|
}
|
||||||
} catch (submitErr) {
|
} catch (submitErr) {
|
||||||
setErr(submitErr.message)
|
const result = { ok: false, error: 'broadcast', message: submitErr.message }
|
||||||
|
setLastResult(result)
|
||||||
|
setShowResultModal(true)
|
||||||
} finally {
|
} finally {
|
||||||
setPosting(false)
|
setPosting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDismissResult () {
|
||||||
|
if (pageRef.current) {
|
||||||
|
pageRef.current.dismissResult()
|
||||||
|
} else if (lastResult && lastResult.ok) {
|
||||||
|
navigate(NewPostPage.RECENT_FEED_PATH)
|
||||||
|
}
|
||||||
|
setShowResultModal(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Row className='justify-content-center'>
|
<Row className='justify-content-center'>
|
||||||
@@ -72,6 +89,7 @@ function NewPost (props) {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder='Write your Memo here...'
|
placeholder='Write your Memo here...'
|
||||||
|
disabled={posting}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
@@ -87,6 +105,43 @@ function NewPost (props) {
|
|||||||
</Form>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Modal show={showResultModal} onHide={handleDismissResult} centered>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>
|
||||||
|
{lastResult && lastResult.ok ? 'Post published' : 'Post failed'}
|
||||||
|
</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{lastResult && lastResult.ok
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<p>Your post was broadcast to the Bitcoin Cash network.</p>
|
||||||
|
<p className='new-post-txid mb-0'>
|
||||||
|
Transaction ID:{' '}
|
||||||
|
<a
|
||||||
|
href={explorerUrl}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
style={{ wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{resultTxid}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<p className='new-post-error mb-0'>
|
||||||
|
{(lastResult && lastResult.message) || 'Failed to post memo.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant='primary' onClick={handleDismissResult}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
This is the testable controller behind the React "New Post" page. It wraps
|
This is the testable controller behind the React "New Post" page. It wraps
|
||||||
the Memo post behavior (src/services/memo-post.js) and adds page-level
|
the Memo post behavior (src/services/memo-post.js) and adds page-level
|
||||||
concerns: holding the current input, computing the remaining character count,
|
concerns: holding the current input, computing the remaining character count,
|
||||||
surfacing validation/length errors, and navigating to the recent feed after a
|
surfacing validation/length errors, showing a success/failure result after
|
||||||
successful post.
|
broadcast, and navigating to the recent feed when that result is dismissed.
|
||||||
|
|
||||||
The memoPost and navigate concerns are injected so this module stays free of
|
The memoPost and navigate concerns are injected so this module stays free of
|
||||||
UI/network concerns; environmentally unsuitable I/O lives behind those small
|
UI/network concerns; environmentally unsuitable I/O lives behind those small
|
||||||
@@ -18,6 +18,7 @@ const MemoPost = require('./memo-post')
|
|||||||
|
|
||||||
const NEW_POST_PATH = '/posts/new'
|
const NEW_POST_PATH = '/posts/new'
|
||||||
const RECENT_FEED_PATH = '/posts/recent'
|
const RECENT_FEED_PATH = '/posts/recent'
|
||||||
|
const EXPLORER_TX_BASE = 'https://bch.loping.net/tx'
|
||||||
|
|
||||||
class NewPostPage extends PageController {
|
class NewPostPage extends PageController {
|
||||||
constructor (deps = {}) {
|
constructor (deps = {}) {
|
||||||
@@ -25,8 +26,11 @@ class NewPostPage extends PageController {
|
|||||||
this.memoPost = deps.memoPost || null
|
this.memoPost = deps.memoPost || null
|
||||||
this.menuLinks = deps.menuLinks || []
|
this.menuLinks = deps.menuLinks || []
|
||||||
this.posting = false
|
this.posting = false
|
||||||
this.successPath = RECENT_FEED_PATH
|
// Navigation is deferred until the result modal is dismissed.
|
||||||
|
this.successPath = null
|
||||||
this.validationCodes = ['memo_validation', 'memo_length']
|
this.validationCodes = ['memo_validation', 'memo_length']
|
||||||
|
this.showResultModal = false
|
||||||
|
this.lastResult = null
|
||||||
|
|
||||||
// The navigation menu links to the new post page.
|
// The navigation menu links to the new post page.
|
||||||
this.addMenuLink(NEW_POST_PATH)
|
this.addMenuLink(NEW_POST_PATH)
|
||||||
@@ -60,13 +64,45 @@ class NewPostPage extends PageController {
|
|||||||
}
|
}
|
||||||
return this.memoPost.post(input)
|
return this.memoPost.post(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block explorer URL for a broadcast transaction.
|
||||||
|
explorerUrl (txid) {
|
||||||
|
return NewPostPage.explorerUrl(txid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit the memo. On broadcast success or failure, open the result modal
|
||||||
|
// instead of navigating. Validation errors stay on the form.
|
||||||
|
async submit () {
|
||||||
|
this.showResultModal = false
|
||||||
|
this.lastResult = null
|
||||||
|
const result = await super.submit()
|
||||||
|
this.lastResult = result
|
||||||
|
if (result.ok || result.error === 'broadcast') {
|
||||||
|
this.showResultModal = true
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss the result modal. A successful post then navigates to the feed.
|
||||||
|
dismissResult () {
|
||||||
|
const shouldNavigate = Boolean(this.lastResult && this.lastResult.ok)
|
||||||
|
this.showResultModal = false
|
||||||
|
if (shouldNavigate) {
|
||||||
|
this.navigate(RECENT_FEED_PATH)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NewPostPage.NEW_POST_PATH = NEW_POST_PATH
|
NewPostPage.NEW_POST_PATH = NEW_POST_PATH
|
||||||
NewPostPage.RECENT_FEED_PATH = RECENT_FEED_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}`
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = NewPostPage
|
module.exports = NewPostPage
|
||||||
|
|
||||||
// mutate4javascript-manifest-begin
|
// mutate4javascript-manifest-begin
|
||||||
// {"version":1,"tested_at":"2026-08-26T12:18:47.139Z","module_hash":"45d772cfe40b8a34018092abbe924c01cbde741c5cbaa737a283ba35214a99ff","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":23,"end_line":33,"hash":"686653c181941b27c19cd8c1f9fa7b3fee50db6e19c1e39d0db79e6bfbe52a81"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":36,"end_line":39,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":42,"end_line":44,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":47,"end_line":49,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage._setBusy","name":"NewPostPage._setBusy","line":52,"end_line":54,"hash":"af4c51d75a9bbfc4d8c2566414ee950704d83831ad915ee04eea8bf2fab65a3e"},{"id":"func/NewPostPage._perform","name":"NewPostPage._perform","line":57,"end_line":62,"hash":"e66f893a4913b5d6f3cc4fcc1ad58e21557b4a316f0150050c961e3ece237795"}]}
|
// {"version":1,"tested_at":"2026-09-13T13:36:17.184Z","module_hash":"65edafa07e390d316197347119a4d2a50c3fe73980c8e526f38fa0f66ee69689","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":24,"end_line":37,"hash":"1a42004b33ac28f396df02f70b2cec6a91f890d3a065aefe890c23cb1456ace3"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":40,"end_line":43,"hash":"2bb99421f2cdbcf9a39f77244b250e4251b9bd3cc81a4f399589007f71486b79"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":46,"end_line":48,"hash":"f2e167f20bd3040ebbcfaf3a3e95254333b9739e0fa93d7801ead542be2a15e3"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":51,"end_line":53,"hash":"421d0af0e01278b479a5bb8d62d344498f875d188e8307eda420d637c23600d4"},{"id":"func/NewPostPage._setBusy","name":"NewPostPage._setBusy","line":56,"end_line":58,"hash":"1552af7690ee7a92b2a9ed84af1ba301c2468939b6994ca3942fe0b7004028fb"},{"id":"func/NewPostPage._perform","name":"NewPostPage._perform","line":61,"end_line":66,"hash":"c8b7394bcd5a2252278095e9af08cc5e76720b4fab08aa83218cb93fdb372407"},{"id":"func/NewPostPage.explorerUrl","name":"NewPostPage.explorerUrl","line":69,"end_line":71,"hash":"84a048fa78b38cef32573f1e31be11f870c765393213e59744d9799690da4d37"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":75,"end_line":84,"hash":"1f15969b62987f29fbc310845716fe65c9ace8bec2f7bb2d8bee62e59b7cee95"},{"id":"func/NewPostPage.dismissResult","name":"NewPostPage.dismissResult","line":87,"end_line":93,"hash":"c155b35aedd68c7bbde87329ec086079621cf362ecf01472b65c7e442c9646b7"}]}
|
||||||
// mutate4javascript-manifest-end
|
// mutate4javascript-manifest-end
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/*
|
||||||
|
Unit tests for the New Post page controller.
|
||||||
|
|
||||||
|
The page controller wraps the Memo post behavior, exposes a remaining
|
||||||
|
character count, shows a success/failure result after broadcast, and
|
||||||
|
navigates to the recent feed when that result is dismissed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const NewPostPage = require('../../src/services/new-post')
|
||||||
|
const MemoPost = require('../../src/services/memo-post')
|
||||||
|
|
||||||
|
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||||
|
const SAMPLE_TXID = '05280746adc9611baf0d6ae833c01343f19ef16a7c9434acc682dde129ae2622'
|
||||||
|
|
||||||
|
function makeWallet (address = MY_ADDRESS, txid = SAMPLE_TXID) {
|
||||||
|
return {
|
||||||
|
walletInfo: { cashAddress: address },
|
||||||
|
broadcasts: [],
|
||||||
|
async getUtxos () {
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
async sendOpReturn (msg, prefix) {
|
||||||
|
this.broadcasts.push({ msg, prefix })
|
||||||
|
if (this.failWith) throw new Error(this.failWith)
|
||||||
|
return txid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMemoPost (opts = {}) {
|
||||||
|
const wallet = makeWallet(opts.address, opts.txid)
|
||||||
|
if (opts.failWith) wallet.failWith = opts.failWith
|
||||||
|
const feed = { posts: [], addPost (post) { this.posts.push(post) } }
|
||||||
|
return { wallet, feed, memoPost: new MemoPost({ wallet, feed }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the in-flight flag starts false', () => {
|
||||||
|
const page = new NewPostPage({ navigate: () => {} })
|
||||||
|
|
||||||
|
assert.equal(page.posting, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('NEW_POST_PATH and RECENT_FEED_PATH constants', () => {
|
||||||
|
assert.equal(NewPostPage.NEW_POST_PATH, '/posts/new')
|
||||||
|
assert.equal(NewPostPage.RECENT_FEED_PATH, '/posts/recent')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explorerUrl builds a bch.loping.net transaction link', () => {
|
||||||
|
assert.equal(
|
||||||
|
NewPostPage.explorerUrl(SAMPLE_TXID),
|
||||||
|
`https://bch.loping.net/tx/${SAMPLE_TXID}`
|
||||||
|
)
|
||||||
|
assert.equal(NewPostPage.explorerUrl(''), '')
|
||||||
|
const page = new NewPostPage({ navigate: () => {} })
|
||||||
|
assert.equal(page.explorerUrl(SAMPLE_TXID), NewPostPage.explorerUrl(SAMPLE_TXID))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the new post page is linked from the navigation menu', () => {
|
||||||
|
const page = new NewPostPage({ navigate: () => {} })
|
||||||
|
|
||||||
|
assert.equal(page.hasMenuLink('/posts/new'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remainingCount returns the full budget for empty input', () => {
|
||||||
|
const { memoPost } = makeMemoPost()
|
||||||
|
const page = new NewPostPage({ memoPost, navigate: () => {} })
|
||||||
|
|
||||||
|
assert.equal(page.remainingCount(), MemoPost.MAX_MEMO_CHARS)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remainingCount subtracts the character length of the input', () => {
|
||||||
|
const { memoPost } = makeMemoPost()
|
||||||
|
const page = new NewPostPage({ memoPost, navigate: () => {} })
|
||||||
|
page.setInput('hello')
|
||||||
|
|
||||||
|
assert.equal(page.remainingCount(), MemoPost.MAX_MEMO_CHARS - 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submit does not navigate until the success modal is dismissed', async () => {
|
||||||
|
const navigated = []
|
||||||
|
const { memoPost } = makeMemoPost()
|
||||||
|
const page = new NewPostPage({
|
||||||
|
memoPost,
|
||||||
|
navigate: (path) => navigated.push(path)
|
||||||
|
})
|
||||||
|
page.setInput('hello memo')
|
||||||
|
|
||||||
|
const result = await page.submit()
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.txid, SAMPLE_TXID)
|
||||||
|
assert.equal(page.showResultModal, true)
|
||||||
|
assert.equal(page.lastResult.txid, SAMPLE_TXID)
|
||||||
|
assert.deepEqual(navigated, [])
|
||||||
|
|
||||||
|
page.dismissResult()
|
||||||
|
|
||||||
|
assert.equal(page.showResultModal, false)
|
||||||
|
assert.deepEqual(navigated, [NewPostPage.RECENT_FEED_PATH])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submit records a validation error for empty input and does not open the modal', async () => {
|
||||||
|
const navigated = []
|
||||||
|
const { memoPost } = makeMemoPost()
|
||||||
|
const page = new NewPostPage({
|
||||||
|
memoPost,
|
||||||
|
navigate: (path) => navigated.push(path)
|
||||||
|
})
|
||||||
|
page.setInput('')
|
||||||
|
|
||||||
|
const result = await page.submit()
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.error, 'memo_validation')
|
||||||
|
assert.equal(page.showResultModal, false)
|
||||||
|
assert.deepEqual(navigated, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submit records a length error for over-long input and does not open the modal', async () => {
|
||||||
|
const { memoPost } = makeMemoPost()
|
||||||
|
const page = new NewPostPage({ memoPost, navigate: () => {} })
|
||||||
|
page.setInput('a'.repeat(MemoPost.MAX_MEMO_CHARS + 1))
|
||||||
|
|
||||||
|
const result = await page.submit()
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.error, 'memo_length')
|
||||||
|
assert.equal(page.showResultModal, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failed broadcast opens the failure modal and stays on the page after dismiss', async () => {
|
||||||
|
const navigated = []
|
||||||
|
const { memoPost } = makeMemoPost({ failWith: 'Insufficient balance' })
|
||||||
|
const page = new NewPostPage({
|
||||||
|
memoPost,
|
||||||
|
navigate: (path) => navigated.push(path)
|
||||||
|
})
|
||||||
|
page.setInput('hello memo')
|
||||||
|
|
||||||
|
const result = await page.submit()
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.error, 'broadcast')
|
||||||
|
assert.match(result.message, /Insufficient balance/)
|
||||||
|
assert.equal(page.showResultModal, true)
|
||||||
|
assert.deepEqual(navigated, [])
|
||||||
|
|
||||||
|
page.dismissResult()
|
||||||
|
|
||||||
|
assert.equal(page.showResultModal, false)
|
||||||
|
assert.deepEqual(navigated, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submit records a broadcast error when no memo post handler is injected', async () => {
|
||||||
|
const page = new NewPostPage({ navigate: () => {} })
|
||||||
|
page.setInput('hello memo')
|
||||||
|
|
||||||
|
const result = await page.submit()
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.error, 'broadcast')
|
||||||
|
assert.match(result.message, /New post requires a memo post handler/)
|
||||||
|
assert.equal(page.showResultModal, true)
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
Expresses the observable behavior described by specs/memo-new.feature:
|
Expresses the observable behavior described by specs/memo-new.feature:
|
||||||
- posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and
|
- posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and
|
||||||
navigates the user to the recent feed.
|
navigates the user to the recent feed after the success modal is dismissed.
|
||||||
- an empty memo is rejected with a validation error; nothing is broadcast.
|
- an empty memo is rejected with a validation error; nothing is broadcast.
|
||||||
- an over-long memo is rejected with a length error; nothing is broadcast.
|
- an over-long memo is rejected with a length error; nothing is broadcast.
|
||||||
- the character counter counts down from the memo limit.
|
- the character counter counts down from the memo limit.
|
||||||
@@ -79,7 +79,7 @@ registerPageSubmitTests({
|
|||||||
validationCode: 'memo_validation',
|
validationCode: 'memo_validation',
|
||||||
lengthCode: 'memo_length',
|
lengthCode: 'memo_length',
|
||||||
MAX,
|
MAX,
|
||||||
successPath: '/posts/recent',
|
successPath: null,
|
||||||
assertBroadcastMsg: (broadcast) => assert.equal(broadcast.msg, 'hello memo'),
|
assertBroadcastMsg: (broadcast) => assert.equal(broadcast.msg, 'hello memo'),
|
||||||
assertStore: (store) => assert.equal(store.posts[0].text, 'hello memo'),
|
assertStore: (store) => assert.equal(store.posts[0].text, 'hello memo'),
|
||||||
assertStoreEmpty: (store) => assert.equal(store.posts.length, 0)
|
assertStoreEmpty: (store) => assert.equal(store.posts.length, 0)
|
||||||
@@ -110,6 +110,7 @@ test('a failed broadcast surfaces a different real error message', async () => {
|
|||||||
|
|
||||||
assert.equal(result.ok, false)
|
assert.equal(result.ok, false)
|
||||||
assert.match(page.broadcastError, /Insufficient balance/)
|
assert.match(page.broadcastError, /Insufficient balance/)
|
||||||
|
assert.equal(page.showResultModal, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a broadcast failure with an empty message falls back to a string form', async () => {
|
test('a broadcast failure with an empty message falls back to a string form', async () => {
|
||||||
@@ -126,4 +127,32 @@ test('a broadcast failure with an empty message falls back to a string form', as
|
|||||||
// The real (string) error is surfaced even though the message was empty.
|
// The real (string) error is surfaced even though the message was empty.
|
||||||
assert.equal(typeof page.broadcastError, 'string')
|
assert.equal(typeof page.broadcastError, 'string')
|
||||||
assert.ok(page.broadcastError.length > 0)
|
assert.ok(page.broadcastError.length > 0)
|
||||||
|
assert.equal(page.showResultModal, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dismissing a successful result navigates to the recent feed', async () => {
|
||||||
|
const { page, navigations } = build()
|
||||||
|
page.setInput('hello memo')
|
||||||
|
|
||||||
|
const result = await page.submit()
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(page.showResultModal, true)
|
||||||
|
assert.deepEqual(navigations, [])
|
||||||
|
|
||||||
|
page.dismissResult()
|
||||||
|
|
||||||
|
assert.equal(page.showResultModal, false)
|
||||||
|
assert.deepEqual(navigations, [NewPostPage.RECENT_FEED_PATH])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dismissing a failed result stays on the new post page', async () => {
|
||||||
|
const { wallet, page, navigations } = build()
|
||||||
|
wallet.failWith = 'BCH UTXO list is empty'
|
||||||
|
page.setInput('hello memo')
|
||||||
|
|
||||||
|
await page.submit()
|
||||||
|
page.dismissResult()
|
||||||
|
|
||||||
|
assert.deepEqual(navigations, [])
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user