diff --git a/acceptance/lib/handlers.js b/acceptance/lib/handlers.js index cff1cb2..3b54ce4 100644 --- a/acceptance/lib/handlers.js +++ b/acceptance/lib/handlers.js @@ -18,8 +18,12 @@ const MemoPost = require('../../src/services/memo-post') const NewPostPage = require('../../src/services/new-post') +const MemoSetName = require('../../src/services/memo-set-name') +const SetNamePage = require('../../src/services/set-name-page') +const AccountPage = require('../../src/services/account-page') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX +const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX // A fake wallet exposing the minimal-slp-wallet adapter surface the app uses. function makeWallet (address) { @@ -30,9 +34,9 @@ function makeWallet (address) { getUtxos: async function () { return this.utxos }, - sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) { + sendOpReturn: async function (msg, prefix) { // Record the broadcast attempt, then fail if configured to do so. - this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) + this.broadcasts.push({ msg, prefix }) if (this.failWith) throw new Error(this.failWith) return 'aa'.repeat(32) } @@ -49,6 +53,16 @@ function makeFeed () { } } +// A fake profile store recording display names set for addresses. +function makeProfiles () { + const names = {} + return { + names, + setName: (addr, name) => { names[addr] = name }, + getName: (addr) => names[addr] || null + } +} + // Fresh world/state object for a single scenario execution. function createWorld () { const wallet = makeWallet('') @@ -70,6 +84,20 @@ function createWorld () { menuLinks: [] }) + // The Set Name Page and Account Page controllers share a profile store so + // a name set on one page is visible on the other. + const profiles = makeProfiles() + const memoSetName = new MemoSetName({ wallet, profiles }) + world.setNamePage = new SetNamePage({ + memoSetName, + navigate: (path) => { world.currentPath = path } + }) + world.accountPage = new AccountPage({ + wallet, + profiles, + navigate: (path) => { world.currentPath = path } + }) + return world } @@ -160,6 +188,17 @@ const handlers = [ world.newPage.setInput(example[param]) } }, + { + name: 'type name text', + pattern: /^I type a name 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.setNamePage.setInput(example[param]) + } + }, { name: 'submit/click post', pattern: /^I (?:submit the memo|click the post button)$/, @@ -167,6 +206,20 @@ const handlers = [ await world.newPage.submit() } }, + { + name: 'submit name', + pattern: /^I submit the name$/, + async run (m, example, world) { + await world.setNamePage.submit() + } + }, + { + name: 'click Set Name button', + pattern: /^I click the Set Name button$/, + run (m, example, world) { + world.accountPage.clickSetName() + } + }, { name: 'broadcasts/attempts OP_RETURN with Memo post prefix', pattern: /^(?:the wallet|the app) (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo post prefix$/, @@ -184,6 +237,23 @@ const handlers = [ } } }, + { + name: 'broadcasts OP_RETURN with Memo set-name prefix', + pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-name prefix$/, + run (m, example, world) { + 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_SET_NAME_PREFIX) { + throw new Error(`Expected Memo set-name prefix ${MEMO_SET_NAME_PREFIX}, got "${last.prefix}".`) + } + if (last.msg !== world.setNamePage.input) { + throw new Error('Broadcast name text did not match the typed name.') + } + } + }, { name: 'feed shows new post from my address', pattern: /^the feed shows a new post from my address with the text "<([A-Za-z0-9_]+)>"$/, @@ -222,6 +292,17 @@ const handlers = [ } } }, + { + name: 'set name page shows validation/length error', + pattern: /^the set name page shows a (validation|length) error$/, + run (m, example, world) { + const kind = m[1] + const expectedCode = kind === 'validation' ? 'name_validation' : 'name_length' + if (world.setNamePage.submitError !== expectedCode) { + throw new Error(`Expected ${expectedCode}, got ${world.setNamePage.submitError}.`) + } + } + }, { name: 'remaining character count', pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/, @@ -237,6 +318,21 @@ const handlers = [ } } }, + { + name: 'remaining byte count', + pattern: /^the set name page shows a remaining byte 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.setNamePage.remainingCount() + if (actual !== expected) { + throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`) + } + } + }, { name: 'app does not broadcast any transaction', pattern: /^(?:the wallet|the app) does not broadcast any transaction$/, @@ -245,6 +341,27 @@ const handlers = [ throw new Error('A transaction was broadcast when none was expected.') } } + }, + { + name: 'account page shows name', + pattern: /^the account page shows my name as "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + const expected = example[param] + const actual = world.accountPage.getName() + if (actual !== expected) { + throw new Error(`Expected account name "${expected}", got "${actual}".`) + } + } + }, + { + name: 'account page shows Set Name button', + pattern: /^the account page shows a Set Name button$/, + run (m, example, world) { + if (!world.accountPage.hasSetNameButton()) { + throw new Error('Account page does not show a Set Name button.') + } + } } ] diff --git a/package-lock.json b/package-lock.json index dbbad3e..24d490c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,8 +29,11 @@ "use-query-params": "1.2.3" }, "devDependencies": { + "crap4javascript": "github:FullStack-Agents/crap4javascript", + "dry4javascript": "github:FullStack-Agents/dry4javascript", "husky": "9.1.7", "minimal-slp-wallet": "5.13.1", + "mutate4javascript": "github:FullStack-Agents/mutate4javascript", "semantic-release": "24.2.3", "standard": "17.0.0", "web3.storage": "4.3.0" @@ -7566,6 +7569,22 @@ "node": ">=10" } }, + "node_modules/crap4javascript": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/FullStack-Agents/crap4javascript.git#32a11e784c5ccc4c8d8ada8659826c0641269479", + "dev": true, + "license": "UNLICENSED", + "dependencies": { + "@babel/parser": "^7.26.0", + "@babel/traverse": "^7.26.0" + }, + "bin": { + "crap4javascript": "bin/crap4javascript.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/create-hash": { "version": "1.2.0", "license": "MIT", @@ -8451,6 +8470,21 @@ "node": ">=0.10" } }, + "node_modules/dry4javascript": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/FullStack-Agents/dry4javascript.git#8ba1585b1c817e3492e94a616bda5237afcae73c", + "dev": true, + "license": "UNLICENSED", + "dependencies": { + "@babel/parser": "^7.26.0" + }, + "bin": { + "dry4javascript": "bin/dry4javascript.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "license": "MIT", @@ -16341,6 +16375,22 @@ "node": ">=8.0.0" } }, + "node_modules/mutate4javascript": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/FullStack-Agents/mutate4javascript.git#553998e78e31ade25d13814d66cbcabc6749c50c", + "dev": true, + "license": "UNLICENSED", + "dependencies": { + "@babel/parser": "^7.26.0", + "@babel/traverse": "^7.26.0" + }, + "bin": { + "mutate4javascript": "bin/mutate4javascript.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/mz": { "version": "2.7.0", "license": "MIT", diff --git a/package.json b/package.json index 13e9949..c16cc04 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,11 @@ ] }, "devDependencies": { + "crap4javascript": "github:FullStack-Agents/crap4javascript", + "dry4javascript": "github:FullStack-Agents/dry4javascript", "husky": "9.1.7", "minimal-slp-wallet": "5.13.1", + "mutate4javascript": "github:FullStack-Agents/mutate4javascript", "semantic-release": "24.2.3", "standard": "17.0.0", "web3.storage": "4.3.0" diff --git a/src/components/app-body/account/index.js b/src/components/app-body/account/index.js new file mode 100644 index 0000000..ebd59a8 --- /dev/null +++ b/src/components/app-body/account/index.js @@ -0,0 +1,101 @@ +/* + Account view: show the authenticated user's display name and offer a button + to navigate to the Set Name page. +*/ + +// Global npm libraries +import React, { useState, useEffect } from 'react' +import { Container, Row, Col, Button, Spinner } from 'react-bootstrap' +import { useNavigate } from 'react-router-dom' + +// Local libraries +import MemoDb from '../../../services/memo-db' +import AccountPage from '../../../services/account-page' +import { truncateAddr } from '../../../util' + +function Account (props) { + const { appData } = props + const navigate = useNavigate() + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [name, setName] = useState(null) + + const wallet = appData?.wallet + const address = wallet?.walletInfo?.cashAddress || '' + + useEffect(() => { + const loadName = async () => { + setLoading(true) + setError(null) + + try { + const memoDb = new MemoDb() + const profile = await memoDb.getName(address) + setName(profile?.name || null) + } catch (err) { + setError(err.message || 'Failed to load name') + } + + setLoading(false) + } + + if (address) { + loadName() + } else { + setLoading(false) + } + }, [address]) + + const accountPage = new AccountPage({ + wallet, + profiles: appData?.profiles, + navigate + }) + + const displayName = name || accountPage.getName() || truncateAddr(address, 24) + + return ( + + + +

Account

+ + {error &&

{error}

} + + {loading && ( +
+ + Loading... + +
+ )} + + {!loading && ( +
+

+ Name: + {displayName} +

+

+ Address: + {address} +

+ + {accountPage.hasSetNameButton() && ( + + )} +
+ )} + +
+
+ ) +} + +export default Account diff --git a/src/components/app-body/index.js b/src/components/app-body/index.js index e287851..372c334 100644 --- a/src/components/app-body/index.js +++ b/src/components/app-body/index.js @@ -27,6 +27,8 @@ import RecentProfiles from './recent-profiles' import RecentPosts from './posts' import NewPost from './new-post' import Profile from './profile' +import SetName from './set-name' +import Account from './account' function AppBody (props) { // Dependency injection through props @@ -44,6 +46,8 @@ function AppBody (props) { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/app-body/set-name/index.js b/src/components/app-body/set-name/index.js new file mode 100644 index 0000000..8d6a849 --- /dev/null +++ b/src/components/app-body/set-name/index.js @@ -0,0 +1,93 @@ +/* + Set Name view: compose and broadcast a Memo display name, with a byte counter + that counts down from the name limit. On success the user is navigated to + the account page. +*/ + +// 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 MemoSetName from '../../../services/memo-set-name' +import SetNamePage from '../../../services/set-name-page' + +function SetName (props) { + const { appData } = props + const navigate = useNavigate() + + const maxBytes = MemoSetName.MAX_NAME_BYTES + const [input, setInput] = useState('') + const [err, setErr] = useState('') + const [settingName, setSettingName] = useState(false) + + const remaining = maxBytes - Buffer.byteLength(input, 'utf8') + + async function handleSubmit (event) { + event.preventDefault() + setErr('') + setSettingName(true) + + try { + const memoSetName = new MemoSetName({ wallet: appData?.wallet, profiles: appData?.profiles }) + const page = new SetNamePage({ memoSetName, navigate }) + page.setInput(input) + + const result = await page.submit() + if (!result.ok) { + if (result.error === 'name_length') { + setErr(`Name is too long. Maximum is ${maxBytes} bytes.`) + } else if (result.error === 'name_validation') { + setErr('Name must not be empty.') + } else if (result.message) { + setErr(`Failed to broadcast: ${result.message}`) + } else { + setErr('Failed to set name.') + } + } + // On success page.submit() navigated to the account page. + } catch (submitErr) { + setErr(submitErr.message) + } finally { + setSettingName(false) + } + } + + return ( + + + +
+

Set Name

+

Choose a display name and publish it to Bitcoin Cash.

+
+ +
+ + Name + setInput(e.target.value)} + placeholder='Enter your display name...' + /> + + +

+ {remaining} bytes remaining +

+ + {err &&

{err}

} + + +
+ +
+
+ ) +} + +export default SetName diff --git a/src/components/nav-menu/index.js b/src/components/nav-menu/index.js index e1acdc9..1aa563e 100644 --- a/src/components/nav-menu/index.js +++ b/src/components/nav-menu/index.js @@ -94,6 +94,14 @@ function NavMenu (props) { > Check Balance + + + Account + {}) + } + + // The address of the authenticated wallet, or null when no wallet is present. + getAddress () { + return this.wallet?.walletInfo?.cashAddress || null + } + + // The current display name for the authenticated address. Falls back to null + // when no wallet, profile store, or stored name exists. + getName () { + const address = this.getAddress() + if (!address || !this.profiles || typeof this.profiles.getName !== 'function') { + return null + } + return this.profiles.getName(address) + } + + // Whether the account page exposes a Set Name button. + hasSetNameButton () { + return true + } + + // Click the Set Name button: navigate to the set-name page. + clickSetName () { + this.navigate(SET_NAME_PATH) + } +} + +AccountPage.SET_NAME_PATH = SET_NAME_PATH +AccountPage.ACCOUNT_PATH = ACCOUNT_PATH + +module.exports = AccountPage diff --git a/src/services/memo-set-name.js b/src/services/memo-set-name.js new file mode 100644 index 0000000..14fc17d --- /dev/null +++ b/src/services/memo-set-name.js @@ -0,0 +1,87 @@ +/* + Memo set-name behavior: compose, validate, and broadcast a Memo "set name" + message. + + A Memo set-name transaction is an OP_RETURN Bitcoin Cash transaction carrying + the Memo set-name protocol prefix (0x6d01) followed by the name text. + Broadcasting is done through a wallet that exposes the minimal-slp-wallet + adapter surface (walletInfo, getUtxos(), sendOpReturn()). + + The wallet and profiles store are injected so this module stays testable and + free of network/UI concerns; environmentally unsuitable I/O lives behind those + small adapter boundaries. + + Constants + MEMO_SET_NAME_PREFIX : hex prefix for the Memo "set name" action (0x6d01) + MAX_NAME_BYTES : maximum allowed name length (77 bytes per memo.sv) +*/ + +const MEMO_SET_NAME_PREFIX = '6d01' +const MAX_NAME_BYTES = 77 + +class MemoSetName { + constructor (deps = {}) { + this.wallet = deps.wallet + this.profiles = deps.profiles + } + + // Validate a candidate name. + // Returns { ok: true } or { ok: false, type: 'validation' | 'length' }. + validate (name) { + if (typeof name !== 'string' || name.trim().length === 0) { + return { ok: false, type: 'validation' } + } + + if (Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES) { + return { ok: false, type: 'length' } + } + + return { ok: true } + } + + // Compose and broadcast a Memo set-name transaction for the given name. + // Resolves with the transaction id, or rejects with a typed error. + async setName (name) { + const check = this.validate(name) + this._throwIfInvalid(check) + + if (!this.wallet) { + throw new Error('Memo set name requires a wallet.') + } + + // Refresh the wallet's spendable UTXO store so the broadcast has inputs. + await this.wallet.getUtxos() + + const txid = await this.wallet.sendOpReturn(name, MEMO_SET_NAME_PREFIX) + + // Reflect the new name in the injected profile store once broadcast succeeds. + this._reflectName(name) + + return txid + } + + // Throw the appropriate typed error when a name fails validation. + _throwIfInvalid (check) { + if (check.ok) return + + const err = new Error( + check.type === 'length' + ? `Name is too long. Maximum is ${MAX_NAME_BYTES} bytes.` + : 'Name must not be empty.' + ) + err.code = check.type === 'length' ? 'name_length' : 'name_validation' + throw err + } + + // Record the new name on the injected profile store when one is present. + _reflectName (name) { + if (this.profiles && typeof this.profiles.setName === 'function') { + this.profiles.setName(this.wallet.walletInfo.cashAddress, name) + } + } +} + +MemoSetName.MEMO_SET_NAME_PREFIX = MEMO_SET_NAME_PREFIX +MemoSetName.MAX_NAME_BYTES = MAX_NAME_BYTES + +module.exports = MemoSetName diff --git a/src/services/profiles.js b/src/services/profiles.js new file mode 100644 index 0000000..7cd33d0 --- /dev/null +++ b/src/services/profiles.js @@ -0,0 +1,28 @@ +/* + Simple in-memory profile store for the current session. + + Holds display names and other profile data indexed by BCH cash address. This + keeps the Set Name and Account pages in sync immediately after a name is + broadcast, without waiting for the memo-db indexer to crawl the transaction. + + In a production app this would be backed by memo-db or persistent storage; + for the current SPA it is a small shared adapter boundary. +*/ + +class Profiles { + constructor () { + this.names = new Map() + } + + setName (addr, name) { + if (!addr) return + this.names.set(addr, name) + } + + getName (addr) { + if (!addr) return null + return this.names.get(addr) || null + } +} + +module.exports = Profiles diff --git a/src/services/set-name-page.js b/src/services/set-name-page.js new file mode 100644 index 0000000..fba397c --- /dev/null +++ b/src/services/set-name-page.js @@ -0,0 +1,83 @@ +/* + Set Name Page behavior: compose and broadcast a Memo display name, with a + byte counter that counts down from the name limit. + + This is the testable controller behind the React "Set Name" page. It wraps + the Memo set-name behavior (src/services/memo-set-name.js) and adds page-level + concerns: holding the current input, computing the remaining byte count, + surfacing validation/length errors, and navigating to the account page after + a successful broadcast. + + The memoSetName and navigate concerns are injected so this module stays free + of UI/network concerns; environmentally unsuitable I/O lives behind those small + adapter boundaries. +*/ + +const MemoSetName = require('./memo-set-name') + +const SET_NAME_PATH = '/memo/set-name' +const ACCOUNT_PATH = '/account' + +class SetNamePage { + constructor (deps = {}) { + this.memoSetName = deps.memoSetName || null + this.navigate = deps.navigate || (() => {}) + + this.input = '' + this.submitError = null + this.broadcastError = null + this.settingName = false + } + + // Set the draft name and update the counter. + setInput (text) { + this.input = typeof text === 'string' ? text : '' + return this + } + + // Bytes remaining before the name limit is reached. + remainingCount () { + return MemoSetName.MAX_NAME_BYTES - Buffer.byteLength(this.input, 'utf8') + } + + // Validate and broadcast the current draft name. On success, navigate to the + // account page. On failure, record the typed error and stay on the page. + // Resolves with a result object. + async submit () { + this.settingName = true + this.submitError = null + this.broadcastError = null + + try { + if (!this.memoSetName) { + throw new Error('Set name requires a memo set-name handler.') + } + + const txid = await this.memoSetName.setName(this.input) + this.navigate(ACCOUNT_PATH) + this.settingName = false + return { ok: true, txid } + } catch (err) { + return this._handleSubmitFailure(err) + } + } + + // Classify a submit failure, record the typed state, and return the failure + // result. Local validation failures set submitError; broadcast or handler + // failures surface the real error message via broadcastError. + _handleSubmitFailure (err) { + if (err.code === 'name_validation' || err.code === 'name_length') { + this.submitError = err.code + } else { + this.broadcastError = err.message || String(err) + this.submitError = 'broadcast' + } + this.settingName = false + return { ok: false, error: this.submitError, message: this.broadcastError } + } +} + +SetNamePage.SET_NAME_PATH = SET_NAME_PATH +SetNamePage.ACCOUNT_PATH = ACCOUNT_PATH + +module.exports = SetNamePage diff --git a/test/unit/account-page.test.js b/test/unit/account-page.test.js new file mode 100644 index 0000000..883ceb9 --- /dev/null +++ b/test/unit/account-page.test.js @@ -0,0 +1,75 @@ +/* + Unit tests for the Account Page behavior slice (src/services/account-page.js). + + Expresses the observable behavior described by specs/set-name.feature: + - the account page shows the authenticated user's display name. + - the account page exposes a Set Name button. + - clicking the Set Name button navigates to /memo/set-name. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const AccountPage = require('../../src/services/account-page') + +const ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + +function fakeWallet (cashAddress = ADDRESS) { + return { walletInfo: { cashAddress } } +} + +function fakeProfiles (initial = {}) { + const names = { ...initial } + return { names, setName: (addr, name) => { names[addr] = name }, getName: (addr) => names[addr] || null } +} + +function build (deps = {}) { + const wallet = deps.wallet !== undefined ? deps.wallet : fakeWallet() + const profiles = deps.profiles !== undefined ? deps.profiles : fakeProfiles() + const navigations = [] + const page = new AccountPage({ + wallet, + profiles, + navigate: (path) => navigations.push(path) + }) + return { page, navigations, profiles } +} + +test('SET_NAME_PATH and ACCOUNT_PATH constants', () => { + assert.equal(AccountPage.SET_NAME_PATH, '/memo/set-name') + assert.equal(AccountPage.ACCOUNT_PATH, '/account') +}) + +test('the account page shows a Set Name button', () => { + const { page } = build() + assert.equal(page.hasSetNameButton(), true) +}) + +test('clicking the Set Name button navigates to /memo/set-name', () => { + const { page, navigations } = build() + page.clickSetName() + assert.deepEqual(navigations, ['/memo/set-name']) +}) + +test('the account page shows the stored name for the authenticated address', () => { + const profiles = fakeProfiles({ [ADDRESS]: 'trout' }) + const { page } = build({ profiles }) + assert.equal(page.getName(), 'trout') +}) + +test('the account page returns null when no name is stored', () => { + const { page } = build() + assert.equal(page.getName(), null) +}) + +test('the account page returns null when no wallet is present', () => { + const { page } = build({ wallet: null }) + assert.equal(page.getName(), null) +}) + +test('the account page returns null when no profile store is present', () => { + const { page } = build({ profiles: null }) + assert.equal(page.getName(), null) +}) diff --git a/test/unit/memo-post.test.js b/test/unit/memo-post.test.js index 314da82..ecc8e49 100644 --- a/test/unit/memo-post.test.js +++ b/test/unit/memo-post.test.js @@ -24,8 +24,8 @@ function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0 const wallet = { walletInfo: { cashAddress }, getUtxos: async () => utxos, - sendOpReturn: async (walletInfo, bchUtxos, msg, prefix) => { - broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) + sendOpReturn: async (msg, prefix) => { + broadcasts.push({ msg, prefix }) return 'fake-txid' } } @@ -55,10 +55,6 @@ test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and const b = wallet.broadcasts[0] assert.equal(b.prefix, '6d02') assert.equal(b.msg, 'hello memo') - // The broadcast uses the wallet's spendable UTXOs. - assert.equal(b.bchUtxos.length, 1) - // The wallet info passed to sendOpReturn is the authenticated wallet. - assert.equal(b.walletInfo.cashAddress, wallet.walletInfo.cashAddress) // The feed reflects the new post from this address with this text. assert.equal(feed.posts.length, 1) diff --git a/test/unit/memo-set-name.test.js b/test/unit/memo-set-name.test.js new file mode 100644 index 0000000..c3d208e --- /dev/null +++ b/test/unit/memo-set-name.test.js @@ -0,0 +1,161 @@ +/* + Unit tests for the Memo set-name behavior slice (src/services/memo-set-name.js). + + These tests express the observable behavior described by specs/set-name.feature: + - a valid name broadcasts an OP_RETURN transaction carrying the Memo set-name + prefix (0x6d01) and the name text, and the profile store reflects the new + name. + - an empty name is rejected with a validation error and nothing is broadcast. + - an over-long name is rejected with a length error and nothing is broadcast. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const MemoSetName = require('../../src/services/memo-set-name') + +// A fake wallet that records every broadcast attempt. It satisfies the small +// adapter surface the MemoSetName 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 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', () => { + assert.equal(MemoSetName.MEMO_SET_NAME_PREFIX, '6d01') +}) + +test('setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix and name', async () => { + const wallet = fakeWallet() + const profiles = fakeProfiles() + const memoSetName = new MemoSetName({ wallet, profiles }) + + const txid = await memoSetName.setName('trout') + + assert.equal(txid, 'fake-txid') + assert.equal(wallet.broadcasts.length, 1) + const b = wallet.broadcasts[0] + assert.equal(b.prefix, '6d01') + assert.equal(b.msg, 'trout') + + // The profile store reflects the new name for this address. + 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 () => { + const wallet = fakeWallet() + const memoSetName = new MemoSetName({ wallet }) + + // 38 'é' characters are 76 bytes in UTF-8. + const name = 'é'.repeat(38) + assert.equal(Buffer.byteLength(name, 'utf8'), 76) + const txid = await memoSetName.setName(name) + assert.equal(txid, 'fake-txid') +}) + +test('setting an over-long name in bytes (78) throws a length error even when char count is lower', async () => { + const wallet = fakeWallet() + const memoSetName = new MemoSetName({ wallet }) + + // 40 'é' characters are 80 bytes, exceeding the 77-byte limit. + const name = 'é'.repeat(40) + assert.ok(Buffer.byteLength(name, 'utf8') > 77) + await assert.rejects( + memoSetName.setName(name), + (err) => err.code === 'name_length' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('setting an empty name throws a validation error and broadcasts nothing', async () => { + const wallet = fakeWallet() + const profiles = fakeProfiles() + const memoSetName = new MemoSetName({ wallet, profiles }) + + await assert.rejects( + memoSetName.setName(''), + (err) => err.code === 'name_validation' + ) + assert.equal(wallet.broadcasts.length, 0) + 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 () => { + const memoSetName = new MemoSetName({}) + await assert.rejects( + memoSetName.setName('trout'), + (err) => /wallet/i.test(err.message) + ) +}) + +test('a failed broadcast does not update the profile store', async () => { + const wallet = fakeWallet() + wallet.sendOpReturn = async () => { throw new Error('broadcast failure') } + const profiles = fakeProfiles() + const memoSetName = new MemoSetName({ wallet, profiles }) + + await assert.rejects( + memoSetName.setName('trout'), + (err) => /broadcast failure/i.test(err.message) + ) + assert.equal(wallet.broadcasts.length, 0) + assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null) +}) diff --git a/test/unit/new-post.test.js b/test/unit/new-post.test.js index 80925c9..4b10826 100644 --- a/test/unit/new-post.test.js +++ b/test/unit/new-post.test.js @@ -27,8 +27,8 @@ function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0 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 }) + sendOpReturn: async function (msg, prefix) { + this.broadcasts.push({ msg, prefix }) if (this.failWith) throw new Error(this.failWith) return 'newpost-txid' } diff --git a/test/unit/profiles.test.js b/test/unit/profiles.test.js new file mode 100644 index 0000000..a9dbcff --- /dev/null +++ b/test/unit/profiles.test.js @@ -0,0 +1,49 @@ +/* + Unit tests for the session profile store (src/services/profiles.js). + + The store indexes display names by BCH cash address so that pages can read + a name immediately after it is broadcast. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const Profiles = require('../../src/services/profiles') + +test('returns null when no name has been set for an address', () => { + const profiles = new Profiles() + assert.equal(profiles.getName('bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'), null) +}) + +test('stores and retrieves a name by address', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + profiles.setName(addr, 'trout') + assert.equal(profiles.getName(addr), 'trout') +}) + +test('updating a name overwrites the previous value', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + profiles.setName(addr, 'trout') + profiles.setName(addr, 'salmon') + assert.equal(profiles.getName(addr), 'salmon') +}) + +test('different addresses keep independent names', () => { + const profiles = new Profiles() + const addr1 = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + const addr2 = 'bitcoincash:qq0ktdlgekdszmxhmg7y6a90t9dpj6p0pg3gctn9e' + profiles.setName(addr1, 'trout') + profiles.setName(addr2, 'salmon') + assert.equal(profiles.getName(addr1), 'trout') + assert.equal(profiles.getName(addr2), 'salmon') +}) + +test('ignores setName for a missing address', () => { + const profiles = new Profiles() + profiles.setName(null, 'trout') + assert.equal(profiles.getName(null), null) +}) diff --git a/test/unit/set-name-page.test.js b/test/unit/set-name-page.test.js new file mode 100644 index 0000000..125dba8 --- /dev/null +++ b/test/unit/set-name-page.test.js @@ -0,0 +1,193 @@ +/* + Unit tests for the Set Name Page behavior slice (src/services/set-name-page.js). + + Expresses the observable behavior described by specs/set-name.feature: + - setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix + and navigates the user to the account page. + - an empty name is rejected with a validation error; nothing is broadcast. + - an over-long name is rejected with a length error; nothing is broadcast. + - the byte counter counts down from the name limit. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const MemoSetName = require('../../src/services/memo-set-name') +const SetNamePage = require('../../src/services/set-name-page') + +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 () { + const wallet = fakeWallet() + const profiles = fakeProfiles() + const memoSetName = new MemoSetName({ wallet, profiles }) + const navigations = [] + const page = new SetNamePage({ + memoSetName, + navigate: (path) => navigations.push(path) + }) + return { wallet, profiles, memoSetName, page, navigations } +} + +test('SET_NAME_PATH and ACCOUNT_PATH constants', () => { + assert.equal(SetNamePage.SET_NAME_PATH, '/memo/set-name') + assert.equal(SetNamePage.ACCOUNT_PATH, '/account') +}) + +test('the byte counter counts down from the name limit for an empty name', () => { + const { page } = build() + page.setInput('') + assert.equal(page.remainingCount(), MAX) +}) + +test('the byte counter counts multi-byte characters by bytes, not characters', () => { + const { page } = build() + page.setInput('é') + assert.equal(page.remainingCount(), MAX - 2) +}) + +test('the byte counter reaches zero at the byte limit with multi-byte characters', () => { + const { page } = build() + page.setInput('é'.repeat(38)) + assert.equal(page.remainingCount(), 1) +}) + +test('the byte counter counts down from the name limit for a short name', () => { + const { page } = build() + page.setInput('trout') + assert.equal(page.remainingCount(), MAX - 5) +}) + +test('the byte counter reaches zero at the name limit', () => { + const { page } = build() + page.setInput('x'.repeat(MAX)) + assert.equal(page.remainingCount(), 0) +}) + +test('setting a valid name broadcasts the Memo set-name prefix and navigates to the account page', async () => { + const { wallet, profiles, page, navigations } = build() + page.setInput('trout') + + const result = await page.submit() + + assert.equal(result.ok, true) + assert.equal(page.settingName, false) + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, '6d01') + assert.equal(wallet.broadcasts[0].msg, 'trout') + assert.deepEqual(navigations, ['/account']) + assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout') +}) + +test('setting an empty name is rejected with a validation error and nothing is broadcast', async () => { + const { wallet, profiles, page, navigations } = build() + page.setInput('') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'name_validation') + assert.equal(page.submitError, 'name_validation') + assert.equal(page.settingName, false) + assert.equal(wallet.broadcasts.length, 0) + assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null) + assert.deepEqual(navigations, []) +}) + +test('setting an over-long name is rejected with a length error and nothing is broadcast', async () => { + const { wallet, profiles, page, navigations } = build() + page.setInput('y'.repeat(MAX + 1)) + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'name_length') + assert.equal(page.submitError, 'name_length') + assert.equal(page.settingName, false) + assert.equal(wallet.broadcasts.length, 0) + assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null) + assert.deepEqual(navigations, []) +}) + +test('the set name page starts idle (not setting name)', () => { + const { page } = build() + assert.equal(page.settingName, false) +}) + +test('setting name is true while a submit is in flight and false once it settles', async () => { + const wallet = fakeWallet() + const profiles = fakeProfiles() + + let resolveSend + 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, []) +})