diff --git a/acceptance/lib/handlers.js b/acceptance/lib/handlers.js index 9fadb92..5ce441e 100644 --- a/acceptance/lib/handlers.js +++ b/acceptance/lib/handlers.js @@ -1,18 +1,23 @@ /* Project step handlers for the psf-memo-client acceptance pipeline. - These handlers connect Gherkin step text to the real project behavior in - src/services/memo-post.js, driving it through small injected adapters (a fake - wallet and a fake feed) so the acceptance run is deterministic and offline. + These handlers connect Gherkin step text to real project behavior + (src/services/memo-post.js and src/services/new-post.js), driving them + through small injected adapters (a fake wallet, a fake feed, and a fake + navigator) so the acceptance run is deterministic and offline. Regex matching with placeholder-name capture is the default style: a single handler pattern captures the placeholder name (e.g. ) and fetches the example value from the scenario example store. + + The handlers serve both specs/post-memo.feature and specs/memo-new.feature, + whose wording differs but which share the same underlying Memo post behavior. */ 'use strict' const MemoPost = require('../../src/services/memo-post') +const NewPostPage = require('../../src/services/new-post') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX @@ -47,17 +52,27 @@ function createWorld () { const wallet = makeWallet('') const feed = makeFeed() const memoPost = new MemoPost({ wallet, feed }) - return { + const world = { wallet, feed, memoPost, - message: null, - submitted: null + currentPath: null, + menuOpen: false } + + // The New Post Page controller wraps the memo post behavior. Its navigate + // adapter updates the world's current path so navigation can be asserted. + world.newPage = new NewPostPage({ + memoPost, + navigate: (path) => { world.currentPath = path }, + menuLinks: [] + }) + + return world } // Handler registry. Each entry: { pattern, run }. -// run receives (match, exampleStore, world). +// run receives (match, exampleStore, world, step). const handlers = [ { name: 'wallet authenticated for address', @@ -68,7 +83,7 @@ const handlers = [ }, { name: 'wallet has spendable output', - pattern: /^the wallet has a spendable output to pay the transaction fee$/, + pattern: /^the wallet has (?:a )?spendable output to pay the transaction fee$/, run (m, example, world) { world.wallet.utxos = [{ txid: 'utxo-for-fee', value: 100000 }] } @@ -76,34 +91,62 @@ const handlers = [ { name: 'viewing recent posts feed', pattern: /^I am viewing the recent posts feed$/, - run () {} + run (m, example, world) { + world.currentPath = NewPostPage.RECENT_FEED_PATH + } }, { - name: 'compose memo text', - pattern: /^I compose a memo with the text "<([A-Za-z0-9_]+)>"$/, + name: 'navigate to path', + pattern: /^I navigate to the path (.+)$/, + run (m, example, world, step) { + const target = m[1].trim() + if (step.keyword === 'Then') { + if (world.currentPath !== target) { + throw new Error(`Expected to be on path ${target}, but current path is ${world.currentPath}.`) + } + } else { + world.currentPath = target + } + } + }, + { + name: 'open navigation menu', + pattern: /^I open the navigation menu$/, + run (m, example, world) { + world.menuOpen = true + } + }, + { + name: 'menu shows link to path', + pattern: /^the menu shows a link to the path (.+)$/, + run (m, example, world) { + const target = m[1].trim() + if (!world.newPage.hasMenuLink(target)) { + throw new Error(`Navigation menu does not link to ${target}.`) + } + } + }, + { + name: 'compose/type memo text', + pattern: /^I (?:compose|type) a memo with the text "<([A-Za-z0-9_]+)>"$/, run (m, example, world) { const param = m[1] if (!(param in example)) { throw new Error(`Missing example value for "${param}"`) } - world.message = example[param] + world.newPage.setInput(example[param]) } }, { - name: 'submit memo', - pattern: /^I submit the memo$/, + name: 'submit/click post', + pattern: /^I (?:submit the memo|click the post button)$/, async run (m, example, world) { - world.submitted = { error: null, txid: null } - try { - world.submitted.txid = await world.memoPost.post(world.message) - } catch (err) { - world.submitted.error = err - } + await world.newPage.submit() } }, { name: 'broadcasts OP_RETURN with Memo post prefix', - pattern: /^the wallet broadcasts an OP_RETURN transaction with the Memo post prefix$/, + pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo post prefix$/, run (m, example, world) { const broadcasts = world.wallet.broadcasts if (!broadcasts.length) { @@ -113,7 +156,7 @@ const handlers = [ if (last.prefix !== MEMO_POST_PREFIX) { throw new Error(`Expected Memo post prefix ${MEMO_POST_PREFIX}, got "${last.prefix}".`) } - if (last.msg !== world.message) { + if (last.msg !== world.newPage.input) { throw new Error('Broadcast message text did not match the composed memo.') } } @@ -134,22 +177,34 @@ const handlers = [ } }, { - name: 'app shows validation/length error', - pattern: /^the app shows a (validation|length) error$/, + name: 'page shows validation/length error', + pattern: /^the (?:app|new post page) shows a (validation|length) error$/, run (m, example, world) { const kind = m[1] const expectedCode = kind === 'validation' ? 'memo_validation' : 'memo_length' - if (!world.submitted || !world.submitted.error) { - throw new Error(`Expected a ${kind} error but the submit succeeded.`) - } - if (world.submitted.error.code !== expectedCode) { - throw new Error(`Expected ${expectedCode}, got ${world.submitted.error.code}.`) + if (world.newPage.submitError !== expectedCode) { + throw new Error(`Expected ${expectedCode}, got ${world.newPage.submitError}.`) } } }, { - name: 'wallet does not broadcast any transaction', - pattern: /^the wallet does not broadcast any transaction$/, + name: 'remaining character count', + pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/, + run (m, example, world) { + const param = m[1] + const expected = parseInt(example[param], 10) + if (Number.isNaN(expected)) { + throw new Error(`Invalid expected count for "${param}".`) + } + const actual = world.newPage.remainingCount() + if (actual !== expected) { + throw new Error(`Expected ${expected} remaining characters, got ${actual}.`) + } + } + }, + { + name: 'app does not broadcast any transaction', + pattern: /^(?:the wallet|the app) does not broadcast any transaction$/, run (m, example, world) { if (world.wallet.broadcasts.length !== 0) { throw new Error('A transaction was broadcast when none was expected.') @@ -163,7 +218,7 @@ async function handleStep (step, example, world) { for (const handler of handlers) { const match = handler.pattern.exec(step.text) if (match) { - await handler.run(match, example, world) + await handler.run(match, example, world, step) return } } diff --git a/src/components/app-body/index.js b/src/components/app-body/index.js index dae45d3..e287851 100644 --- a/src/components/app-body/index.js +++ b/src/components/app-body/index.js @@ -25,6 +25,7 @@ import ServerSelectView from './configuration/select-server-view' import UserDataReview from './user-data-review' import RecentProfiles from './recent-profiles' import RecentPosts from './posts' +import NewPost from './new-post' import Profile from './profile' function AppBody (props) { @@ -42,6 +43,7 @@ function AppBody (props) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/app-body/new-post/index.js b/src/components/app-body/new-post/index.js new file mode 100644 index 0000000..647abfc --- /dev/null +++ b/src/components/app-body/new-post/index.js @@ -0,0 +1,90 @@ +/* + 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 + recent feed. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Container, Row, Col, Form, Button } from 'react-bootstrap' +import { useNavigate } from 'react-router-dom' + +// Local libraries +import MemoPost from '../../../services/memo-post' +import NewPostPage from '../../../services/new-post' + +function NewPost (props) { + const { appData } = props + const navigate = useNavigate() + + const maxChars = MemoPost.MAX_MEMO_CHARS + const [input, setInput] = useState('') + const [err, setErr] = useState('') + const [posting, setPosting] = useState(false) + + const remaining = maxChars - input.length + + async function handleSubmit (event) { + event.preventDefault() + setErr('') + setPosting(true) + + try { + const memoPost = new MemoPost({ wallet: appData?.wallet }) + const page = new NewPostPage({ memoPost, navigate }) + page.setInput(input) + + const result = await page.submit() + if (!result.ok) { + setErr( + result.error === 'memo_length' + ? `Memo is too long. Maximum is ${maxChars} characters.` + : 'Memo must not be empty.' + ) + } + // On success page.submit() navigated to the recent feed. + } catch (submitErr) { + setErr(submitErr.message) + } finally { + setPosting(false) + } + } + + return ( + + + +
+

New Post

+

Compose a Memo message and publish it to Bitcoin Cash.

+
+ +
+ + Message + setInput(e.target.value)} + placeholder='Write your Memo here...' + /> + + +

+ {remaining} characters remaining +

+ + {err &&

{err}

} + + +
+ +
+
+ ) +} + +export default NewPost diff --git a/src/components/nav-menu/index.js b/src/components/nav-menu/index.js index 9c1a7ae..e1acdc9 100644 --- a/src/components/nav-menu/index.js +++ b/src/components/nav-menu/index.js @@ -70,6 +70,14 @@ function NavMenu (props) { Posts + + New Post + + {}) + this.menuLinks = deps.menuLinks || [] + + this.input = '' + this.submitError = null + this.posting = false + + // The navigation menu links to the new post page. + this.addMenuLink(NEW_POST_PATH) + } + + // Record a navigation menu link offered by the app. + addMenuLink (path) { + if (!this.menuLinks.includes(path)) this.menuLinks.push(path) + return this + } + + // Whether the navigation menu exposes a link to the given path. + hasMenuLink (path) { + return this.menuLinks.includes(path) + } + + // Set the draft memo text and update the counter. + setInput (text) { + this.input = typeof text === 'string' ? text : '' + return this + } + + // Characters remaining before the memo limit is reached. + remainingCount () { + return MemoPost.MAX_MEMO_CHARS - this.input.length + } + + // Validate and post the current draft. On success, navigate to the recent + // feed. On failure, record the typed error. Resolves with a result object. + async submit () { + this.posting = true + this.submitError = null + + try { + if (!this.memoPost) { + throw new Error('New post requires a memo post handler.') + } + + const txid = await this.memoPost.post(this.input) + this.navigate(RECENT_FEED_PATH) + this.posting = false + return { ok: true, txid } + } catch (err) { + this.submitError = err.code || 'memo_validation' + this.posting = false + return { ok: false, error: this.submitError } + } + } +} + +NewPostPage.NEW_POST_PATH = NEW_POST_PATH +NewPostPage.RECENT_FEED_PATH = RECENT_FEED_PATH + +module.exports = NewPostPage diff --git a/test/unit/new-post.test.js b/test/unit/new-post.test.js new file mode 100644 index 0000000..61cba29 --- /dev/null +++ b/test/unit/new-post.test.js @@ -0,0 +1,139 @@ +/* + Unit tests for the New Post Page behavior slice (src/services/new-post.js). + + Expresses the observable behavior described by specs/memo-new.feature: + - posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and + navigates the user to the recent feed. + - 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. + - the character counter counts down from the memo limit. + - the navigation menu links to /posts/new. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const MemoPost = require('../../src/services/memo-post') +const NewPostPage = require('../../src/services/new-post') + +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 (walletInfo, bchUtxos, msg, prefix) { + this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) + return 'newpost-txid' + } + } + wallet.broadcasts = broadcasts + return wallet +} + +function fakeFeed () { + const posts = [] + return { posts, addPost: (p) => posts.push(p) } +} + +function build () { + const wallet = fakeWallet() + const feed = fakeFeed() + const memoPost = new MemoPost({ wallet, feed }) + const navigations = [] + const page = new NewPostPage({ + memoPost, + navigate: (path) => navigations.push(path) + }) + return { wallet, feed, memoPost, page, navigations } +} + +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('the new post page is linked from the navigation menu', () => { + const { page } = build() + assert.equal(page.hasMenuLink('/posts/new'), true) +}) + +test('the character counter counts down from the memo limit for an empty memo', () => { + const { page } = build() + page.setInput('') + assert.equal(page.remainingCount(), MAX) +}) + +test('the character counter counts down from the memo limit for a short memo', () => { + const { page } = build() + page.setInput('hello') + assert.equal(page.remainingCount(), MAX - 5) +}) + +test('the character counter reaches zero at the memo limit', () => { + const { page } = build() + page.setInput('x'.repeat(MAX)) + assert.equal(page.remainingCount(), 0) +}) + +test('posting a valid memo broadcasts the Memo post prefix and navigates to the feed', async () => { + const { wallet, feed, page, navigations } = build() + page.setInput('hello memo') + + const result = await page.submit() + + assert.equal(result.ok, true) + // Broadcast happened with the Memo post prefix and the exact message. + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, '6d02') + assert.equal(wallet.broadcasts[0].msg, 'hello memo') + // Navigated to the recent feed after posting. + assert.deepEqual(navigations, ['/posts/recent']) + // The feed reflects the new post from this address. + assert.equal(feed.posts.length, 1) + assert.equal(feed.posts[0].text, 'hello memo') +}) + +test('posting an empty memo is rejected with a validation error and nothing is broadcast', async () => { + const { wallet, feed, page, navigations } = build() + page.setInput('') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'memo_validation') + assert.equal(page.submitError, 'memo_validation') + assert.equal(wallet.broadcasts.length, 0) + assert.equal(feed.posts.length, 0) + assert.deepEqual(navigations, []) +}) + +test('posting an over-long memo is rejected with a length error and nothing is broadcast', async () => { + const { wallet, feed, page, navigations } = build() + page.setInput('y'.repeat(MAX + 1)) + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'memo_length') + assert.equal(page.submitError, 'memo_length') + assert.equal(wallet.broadcasts.length, 0) + assert.equal(feed.posts.length, 0) + assert.deepEqual(navigations, []) +}) + +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, []) +})