diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 99765af..5a7c040 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -25,6 +25,8 @@ const MemoReply = require('../../src/services/memo-reply') const ReplyThreadPage = require('../../src/services/reply-thread-page') const MemoSetName = require('../../src/services/memo-set-name') const SetNamePage = require('../../src/services/set-name-page') +const MemoSetBio = require('../../src/services/memo-set-bio') +const SetBioPage = require('../../src/services/set-bio-page') const AccountPage = require('../../src/services/account-page') const MemoLike = require('../../src/services/memo-like') const LikeTipPage = require('../../src/services/like-tip-page') @@ -35,6 +37,7 @@ const ThreadPage = require('../../src/services/thread-page') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX +const MEMO_SET_BIO_PREFIX = MemoSetBio.MEMO_SET_BIO_PREFIX const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX // Default author address used by Gherkin steps that refer to "the author address". @@ -73,13 +76,17 @@ function makeFeed () { } } -// A fake profile store recording display names set for addresses. +// A fake profile store recording display names and bios set for addresses. function makeProfiles () { const names = {} + const bios = {} return { names, + bios, setName: (addr, name) => { names[addr] = name }, - getName: (addr) => names[addr] || null + getName: (addr) => names[addr] || null, + setBio: (addr, bio) => { bios[addr] = bio }, + getBio: (addr) => bios[addr] || null } } @@ -177,6 +184,14 @@ function createWorld () { memoSetName, navigate: (path) => { world.currentPath = path } }) + + // The Set Bio Page controller shares the same profile store. + const memoSetBio = new MemoSetBio({ wallet, profiles }) + world.setBioPage = new SetBioPage({ + memoSetBio, + navigate: (path) => { world.currentPath = path } + }) + world.accountPage = new AccountPage({ wallet, profiles, @@ -313,6 +328,17 @@ const handlers = [ world.newPage.setInput(example[param]) } }, + { + name: 'type bio text', + pattern: /^I type a bio 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.setBioPage.setInput(example[param]) + } + }, { name: 'type name text', pattern: /^I type a name with the text "<([A-Za-z0-9_]+)>"$/, @@ -331,6 +357,13 @@ const handlers = [ await world.newPage.submit() } }, + { + name: 'submit bio', + pattern: /^I submit the bio$/, + async run (m, example, world) { + await world.setBioPage.submit() + } + }, { name: 'submit name', pattern: /^I submit the name$/, @@ -442,6 +475,13 @@ const handlers = [ }) } }, + { + name: 'click Set Bio button', + pattern: /^I click the Set Bio button$/, + run (m, example, world) { + world.accountPage.clickSetBio() + } + }, { name: 'click Set Name button', pattern: /^I click the Set Name button$/, @@ -483,6 +523,23 @@ const handlers = [ } } }, + { + name: 'broadcasts OP_RETURN with Memo set-profile prefix', + pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-profile 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_BIO_PREFIX) { + throw new Error(`Expected Memo set-profile prefix ${MEMO_SET_BIO_PREFIX}, got "${last.prefix}".`) + } + if (last.msg !== world.setBioPage.input) { + throw new Error('Broadcast bio text did not match the typed bio.') + } + } + }, { name: 'broadcasts OP_RETURN with Memo reply prefix', pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo reply prefix$/, @@ -594,6 +651,17 @@ const handlers = [ } } }, + { + name: 'set bio page shows validation/length error', + pattern: /^the set bio page shows a (validation|length) error$/, + run (m, example, world) { + const kind = m[1] + const expectedCode = kind === 'validation' ? 'bio_validation' : 'bio_length' + if (world.setBioPage.submitError !== expectedCode) { + throw new Error(`Expected ${expectedCode}, got ${world.setBioPage.submitError}.`) + } + } + }, { name: 'remaining character count', pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/, @@ -624,6 +692,21 @@ const handlers = [ } } }, + { + name: 'set bio remaining byte count', + pattern: /^the set bio 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.setBioPage.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$/, @@ -645,6 +728,18 @@ const handlers = [ } } }, + { + name: 'account page shows bio', + pattern: /^the account page shows my bio as "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + const expected = example[param] + const actual = world.accountPage.getBio() + if (actual !== expected) { + throw new Error(`Expected account bio "${expected}", got "${actual}".`) + } + } + }, { name: 'account page shows Set Name button', pattern: /^the account page shows a Set Name button$/, @@ -654,6 +749,15 @@ const handlers = [ } } }, + { + name: 'account page shows Set Bio button', + pattern: /^the account page shows a Set Bio button$/, + run (m, example, world) { + if (!world.accountPage.hasSetBioButton()) { + throw new Error('Account page does not show a Set Bio button.') + } + } + }, { name: 'wallet has spendable balance', pattern: /^the wallet has a spendable balance of (.+) sats$/, diff --git a/psf-memo-client/src/components/app-body/account/index.js b/psf-memo-client/src/components/app-body/account/index.js index ebd59a8..bc1b20b 100644 --- a/psf-memo-client/src/components/app-body/account/index.js +++ b/psf-memo-client/src/components/app-body/account/index.js @@ -20,28 +20,33 @@ function Account (props) { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [name, setName] = useState(null) + const [bio, setBio] = useState(null) const wallet = appData?.wallet const address = wallet?.walletInfo?.cashAddress || '' useEffect(() => { - const loadName = async () => { + const loadAccount = async () => { setLoading(true) setError(null) try { const memoDb = new MemoDb() - const profile = await memoDb.getName(address) - setName(profile?.name || null) + const [profile, nameDoc] = await Promise.all([ + memoDb.getProfile(address), + memoDb.getName(address) + ]) + setBio(profile?.text || null) + setName(nameDoc?.name || null) } catch (err) { - setError(err.message || 'Failed to load name') + setError(err.message || 'Failed to load account') } setLoading(false) } if (address) { - loadName() + loadAccount() } else { setLoading(false) } @@ -53,7 +58,8 @@ function Account (props) { navigate }) - const displayName = name || accountPage.getName() || truncateAddr(address, 24) + const displayName = accountPage.getName() || name || truncateAddr(address, 24) + const displayBio = accountPage.getBio() || bio || '' return ( @@ -77,6 +83,10 @@ function Account (props) { Name: {displayName}

+

+ Bio: + {displayBio || No bio set} +

Address: {address} @@ -85,11 +95,21 @@ function Account (props) { {accountPage.hasSetNameButton() && ( )} + + {accountPage.hasSetBioButton() && ( + + )} )} diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js index 78241d3..87a2d43 100644 --- a/psf-memo-client/src/components/app-body/index.js +++ b/psf-memo-client/src/components/app-body/index.js @@ -28,6 +28,7 @@ import RecentPosts from './posts' import NewPost from './new-post' import Profile from './profile' import SetName from './set-name' +import SetBio from './set-bio' import Account from './account' function AppBody (props) { @@ -47,6 +48,7 @@ function AppBody (props) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/psf-memo-client/src/components/app-body/set-bio/index.js b/psf-memo-client/src/components/app-body/set-bio/index.js new file mode 100644 index 0000000..2d2f245 --- /dev/null +++ b/psf-memo-client/src/components/app-body/set-bio/index.js @@ -0,0 +1,95 @@ +/* + Set Bio view: compose and broadcast a Memo profile text, with a byte counter + that counts down from the bio 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 MemoSetBio from '../../../services/memo-set-bio' +import SetBioPage from '../../../services/set-bio-page' +import { byteLength } from '../../../services/utf8' + +function SetBio (props) { + const { appData } = props + const navigate = useNavigate() + + const maxBytes = MemoSetBio.MAX_BIO_BYTES + const [input, setInput] = useState('') + const [err, setErr] = useState('') + const [settingBio, setSettingBio] = useState(false) + + const remaining = maxBytes - byteLength(input) + + async function handleSubmit (event) { + event.preventDefault() + setErr('') + setSettingBio(true) + + try { + const memoSetBio = new MemoSetBio({ wallet: appData?.wallet, profiles: appData?.profiles }) + const page = new SetBioPage({ memoSetBio, navigate }) + page.setInput(input) + + const result = await page.submit() + if (!result.ok) { + if (result.error === 'bio_length') { + setErr(`Bio is too long. Maximum is ${maxBytes} bytes.`) + } else if (result.error === 'bio_validation') { + setErr('Bio must not be empty.') + } else if (result.message) { + setErr(`Failed to broadcast: ${result.message}`) + } else { + setErr('Failed to set bio.') + } + } + // On success page.submit() navigated to the account page. + } catch (submitErr) { + setErr(submitErr.message) + } finally { + setSettingBio(false) + } + } + + return ( + + + +

+

Set Bio

+

Set your profile bio and publish it to Bitcoin Cash.

+
+ +
+ + Bio + setInput(e.target.value)} + placeholder='Write a short bio...' + /> + + +

+ {remaining} bytes remaining +

+ + {err &&

{err}

} + + +
+ + +
+ ) +} + +export default SetBio diff --git a/psf-memo-client/src/services/account-page.js b/psf-memo-client/src/services/account-page.js index e8182a1..172ef56 100644 --- a/psf-memo-client/src/services/account-page.js +++ b/psf-memo-client/src/services/account-page.js @@ -12,6 +12,7 @@ */ const SET_NAME_PATH = '/memo/set-name' +const SET_BIO_PATH = '/memo/set-bio' const ACCOUNT_PATH = '/account' class AccountPage { @@ -36,18 +37,39 @@ class AccountPage { return this.profiles.getName(address) } + // The current bio for the authenticated address. Falls back to null when no + // wallet, profile store, or stored bio exists. + getBio () { + const address = this.getAddress() + if (!address || !this.profiles || typeof this.profiles.getBio !== 'function') { + return null + } + return this.profiles.getBio(address) + } + // Whether the account page exposes a Set Name button. hasSetNameButton () { return true } + // Whether the account page exposes a Set Bio button. + hasSetBioButton () { + return true + } + // Click the Set Name button: navigate to the set-name page. clickSetName () { this.navigate(SET_NAME_PATH) } + + // Click the Set Bio button: navigate to the set-bio page. + clickSetBio () { + this.navigate(SET_BIO_PATH) + } } AccountPage.SET_NAME_PATH = SET_NAME_PATH +AccountPage.SET_BIO_PATH = SET_BIO_PATH AccountPage.ACCOUNT_PATH = ACCOUNT_PATH module.exports = AccountPage diff --git a/psf-memo-client/src/services/memo-set-bio.js b/psf-memo-client/src/services/memo-set-bio.js new file mode 100644 index 0000000..b9a8bde --- /dev/null +++ b/psf-memo-client/src/services/memo-set-bio.js @@ -0,0 +1,62 @@ +/* + Memo set-bio behavior: compose, validate, and broadcast a Memo "set profile + text" message. + + A Memo set-bio transaction is an OP_RETURN Bitcoin Cash transaction carrying + the Memo set-profile protocol prefix (0x6d05) followed by the bio 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_BIO_PREFIX : hex prefix for the Memo "set profile text" action (0x6d05) + MAX_BIO_BYTES : maximum allowed bio length (217 bytes per memo.sv) +*/ + +const MemoAction = require('./memo-action') +const { byteLength } = require('./utf8') + +const MEMO_SET_BIO_PREFIX = '6d05' +const MAX_BIO_BYTES = 217 + +class MemoSetBio extends MemoAction { + static config = { + prefix: MEMO_SET_BIO_PREFIX, + walletRequiredMsg: 'Memo set bio requires a wallet.', + lengthMessage: `Bio is too long. Maximum is ${MAX_BIO_BYTES} bytes.`, + emptyMessage: 'Bio must not be empty.', + lengthCode: 'bio_length', + validationCode: 'bio_validation' + } + + constructor (deps = {}) { + super(deps) + this.profiles = deps.profiles + } + + // A bio is over-length when it exceeds the byte limit. + isTooLong (bio) { + return byteLength(bio) > MAX_BIO_BYTES + } + + // Compose and broadcast a Memo set-bio transaction for the given bio. + // Resolves with the transaction id, or rejects with a typed error. + async setBio (bio) { + return this.broadcast(bio) + } + + // Record the new bio on the injected profile store when one is present. + reflect (txid, bio) { + if (this.profiles && typeof this.profiles.setBio === 'function') { + this.profiles.setBio(this.wallet.walletInfo.cashAddress, bio) + } + } +} + +MemoSetBio.MEMO_SET_BIO_PREFIX = MEMO_SET_BIO_PREFIX +MemoSetBio.MAX_BIO_BYTES = MAX_BIO_BYTES + +module.exports = MemoSetBio diff --git a/psf-memo-client/src/services/profiles.js b/psf-memo-client/src/services/profiles.js index 7334edc..540bad9 100644 --- a/psf-memo-client/src/services/profiles.js +++ b/psf-memo-client/src/services/profiles.js @@ -12,6 +12,7 @@ class Profiles { constructor () { this.names = new Map() + this.bios = new Map() } setName (addr, name) { @@ -23,6 +24,16 @@ class Profiles { if (!addr) return null return this.names.get(addr) || null } + + setBio (addr, bio) { + if (!addr) return + this.bios.set(addr, bio) + } + + getBio (addr) { + if (!addr) return null + return this.bios.get(addr) || null + } } module.exports = Profiles diff --git a/psf-memo-client/src/services/set-bio-page.js b/psf-memo-client/src/services/set-bio-page.js new file mode 100644 index 0000000..aa07c5c --- /dev/null +++ b/psf-memo-client/src/services/set-bio-page.js @@ -0,0 +1,54 @@ +/* + Set Bio Page behavior: compose and broadcast a Memo profile text, with a + byte counter that counts down from the bio limit. + + This is the testable controller behind the React "Set Bio" page. It wraps + the Memo set-bio behavior (src/services/memo-set-bio.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 memoSetBio 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 PageController = require('./page-controller') +const MemoSetBio = require('./memo-set-bio') +const { byteLength } = require('./utf8') + +const SET_BIO_PATH = '/memo/set-bio' +const ACCOUNT_PATH = '/account' + +class SetBioPage extends PageController { + constructor (deps = {}) { + super(deps) + this.memoSetBio = deps.memoSetBio || null + this.settingBio = false + this.successPath = ACCOUNT_PATH + this.validationCodes = ['bio_validation', 'bio_length'] + } + + // Bytes remaining before the bio limit is reached. + remainingCount () { + return MemoSetBio.MAX_BIO_BYTES - byteLength(this.input) + } + + // Set the in-flight setting-bio flag. + _setBusy (value) { + this.settingBio = value + } + + // Run the memo set-bio action for the current input. + async _perform (input) { + if (!this.memoSetBio) { + throw new Error('Set bio requires a memo set-bio handler.') + } + return this.memoSetBio.setBio(input) + } +} + +SetBioPage.SET_BIO_PATH = SET_BIO_PATH +SetBioPage.ACCOUNT_PATH = ACCOUNT_PATH + +module.exports = SetBioPage diff --git a/psf-memo-client/test/unit/account-page.test.js b/psf-memo-client/test/unit/account-page.test.js new file mode 100644 index 0000000..78cb1ac --- /dev/null +++ b/psf-memo-client/test/unit/account-page.test.js @@ -0,0 +1,105 @@ +/* + Unit tests for the account page behavior. + + The account page exposes the authenticated wallet's display name and bio, + along with buttons that navigate to the set-name and set-bio pages. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const AccountPage = require('../../src/services/account-page') + +function makeProfiles () { + const names = {} + const bios = {} + return { + setName: (addr, name) => { names[addr] = name }, + getName: (addr) => names[addr] || null, + setBio: (addr, bio) => { bios[addr] = bio }, + getBio: (addr) => bios[addr] || null + } +} + +function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') { + return { walletInfo: { cashAddress: address } } +} + +test('getName returns the stored display name', () => { + const profiles = makeProfiles() + const wallet = makeWallet() + const page = new AccountPage({ wallet, profiles }) + + profiles.setName(wallet.walletInfo.cashAddress, 'trout') + + assert.equal(page.getName(), 'trout') +}) + +test('getName returns null without a wallet', () => { + const profiles = makeProfiles() + const page = new AccountPage({ profiles }) + + assert.equal(page.getName(), null) +}) + +test('getName returns null without a profile store', () => { + const wallet = makeWallet() + const page = new AccountPage({ wallet }) + + assert.equal(page.getName(), null) +}) + +test('getBio returns the stored bio', () => { + const profiles = makeProfiles() + const wallet = makeWallet() + const page = new AccountPage({ wallet, profiles }) + + profiles.setBio(wallet.walletInfo.cashAddress, 'Building on BCH') + + assert.equal(page.getBio(), 'Building on BCH') +}) + +test('getBio returns null without a wallet', () => { + const profiles = makeProfiles() + const page = new AccountPage({ profiles }) + + assert.equal(page.getBio(), null) +}) + +test('getBio returns null without a profile store', () => { + const wallet = makeWallet() + const page = new AccountPage({ wallet }) + + assert.equal(page.getBio(), null) +}) + +test('hasSetNameButton is true', () => { + const page = new AccountPage({}) + + assert.equal(page.hasSetNameButton(), true) +}) + +test('hasSetBioButton is true', () => { + const page = new AccountPage({}) + + assert.equal(page.hasSetBioButton(), true) +}) + +test('clickSetName navigates to the set-name page', () => { + const navigated = [] + const page = new AccountPage({ navigate: (path) => navigated.push(path) }) + + page.clickSetName() + + assert.deepEqual(navigated, [AccountPage.SET_NAME_PATH]) +}) + +test('clickSetBio navigates to the set-bio page', () => { + const navigated = [] + const page = new AccountPage({ navigate: (path) => navigated.push(path) }) + + page.clickSetBio() + + assert.deepEqual(navigated, [AccountPage.SET_BIO_PATH]) +}) diff --git a/psf-memo-client/test/unit/memo-set-bio.test.js b/psf-memo-client/test/unit/memo-set-bio.test.js new file mode 100644 index 0000000..2608261 --- /dev/null +++ b/psf-memo-client/test/unit/memo-set-bio.test.js @@ -0,0 +1,125 @@ +/* + Unit tests for the Memo set-bio behavior. + + The set-bio action validates that the bio is non-empty and within the + 217-byte Memo protocol limit, then broadcasts it with the 0x6d05 prefix. + A successful broadcast is reflected on the injected profile store. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const MemoSetBio = require('../../src/services/memo-set-bio') + +function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') { + return { + walletInfo: { cashAddress: address }, + broadcasts: [], + async getUtxos () { + return [] + }, + async sendOpReturn (msg, prefix) { + this.broadcasts.push({ msg, prefix }) + return 'aa'.repeat(32) + } + } +} + +function makeProfiles () { + const bios = {} + return { + bios, + setBio: (addr, bio) => { bios[addr] = bio }, + getBio: (addr) => bios[addr] || null + } +} + +test('setBio broadcasts with the Memo set-profile prefix', async () => { + const wallet = makeWallet() + const memoSetBio = new MemoSetBio({ wallet }) + + await memoSetBio.setBio('Building on BCH') + + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, MemoSetBio.MEMO_SET_BIO_PREFIX) + assert.equal(wallet.broadcasts[0].msg, 'Building on BCH') +}) + +test('setBio reflects the bio on the profile store', async () => { + const wallet = makeWallet() + const profiles = makeProfiles() + const memoSetBio = new MemoSetBio({ wallet, profiles }) + + await memoSetBio.setBio('Building on BCH') + + assert.equal(profiles.getBio(wallet.walletInfo.cashAddress), 'Building on BCH') +}) + +test('setBio rejects an empty bio', async () => { + const wallet = makeWallet() + const memoSetBio = new MemoSetBio({ wallet }) + + await assert.rejects( + () => memoSetBio.setBio(''), + { code: 'bio_validation', message: /Bio must not be empty/ } + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('setBio rejects a bio that exceeds the byte limit', async () => { + const wallet = makeWallet() + const memoSetBio = new MemoSetBio({ wallet }) + + // 218 ASCII bytes is one byte over the 217 limit. + const tooLong = 'a'.repeat(MemoSetBio.MAX_BIO_BYTES + 1) + + await assert.rejects( + () => memoSetBio.setBio(tooLong), + { code: 'bio_length', message: /Bio is too long/ } + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('setBio accepts a bio exactly at the byte limit', async () => { + const wallet = makeWallet() + const memoSetBio = new MemoSetBio({ wallet }) + + const exactly = 'a'.repeat(MemoSetBio.MAX_BIO_BYTES) + + await memoSetBio.setBio(exactly) + + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].msg, exactly) +}) + +test('setBio counts bytes, not characters', async () => { + const wallet = makeWallet() + const memoSetBio = new MemoSetBio({ wallet }) + + // A single multi-byte character should be counted by byte length. + await assert.rejects( + () => memoSetBio.setBio('😀'.repeat(Math.ceil(MemoSetBio.MAX_BIO_BYTES / 4) + 1)), + { code: 'bio_length' } + ) +}) + +test('setBio requires a wallet', async () => { + const memoSetBio = new MemoSetBio({}) + + await assert.rejects( + () => memoSetBio.setBio('Building on BCH'), + /Memo set bio requires a wallet/ + ) +}) + +test('setBio surfaces a broadcast failure', async () => { + const wallet = makeWallet() + wallet.sendOpReturn = async () => { throw new Error('broadcast failed') } + const memoSetBio = new MemoSetBio({ wallet }) + + await assert.rejects( + () => memoSetBio.setBio('Building on BCH'), + /broadcast failed/ + ) +}) diff --git a/psf-memo-client/test/unit/profiles.test.js b/psf-memo-client/test/unit/profiles.test.js new file mode 100644 index 0000000..9b207ec --- /dev/null +++ b/psf-memo-client/test/unit/profiles.test.js @@ -0,0 +1,71 @@ +/* + Unit tests for the in-memory profile store. + + The profile store keeps display names and bios indexed by BCH cash address + so that pages stay in sync immediately after a broadcast. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const Profiles = require('../../src/services/profiles') + +test('setName stores and getName retrieves a display name', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + profiles.setName(addr, 'trout') + + assert.equal(profiles.getName(addr), 'trout') +}) + +test('getName returns null for an unknown address', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + assert.equal(profiles.getName(addr), null) +}) + +test('setName with no address does nothing', () => { + const profiles = new Profiles() + + profiles.setName('', 'trout') + + assert.equal(profiles.getName(''), null) +}) + +test('setBio stores and getBio retrieves a bio', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + profiles.setBio(addr, 'Building on BCH') + + assert.equal(profiles.getBio(addr), 'Building on BCH') +}) + +test('getBio returns null for an unknown address', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + assert.equal(profiles.getBio(addr), null) +}) + +test('setBio with no address does nothing', () => { + const profiles = new Profiles() + + profiles.setBio('', 'Building on BCH') + + assert.equal(profiles.getBio(''), null) +}) + +test('name and bio storage are independent', () => { + const profiles = new Profiles() + const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + + profiles.setName(addr, 'trout') + profiles.setBio(addr, 'Building on BCH') + + assert.equal(profiles.getName(addr), 'trout') + assert.equal(profiles.getBio(addr), 'Building on BCH') +}) diff --git a/psf-memo-client/test/unit/set-bio-page.test.js b/psf-memo-client/test/unit/set-bio-page.test.js new file mode 100644 index 0000000..fbff4ee --- /dev/null +++ b/psf-memo-client/test/unit/set-bio-page.test.js @@ -0,0 +1,110 @@ +/* + Unit tests for the Set Bio page controller. + + The page controller wraps the Memo set-bio behavior, exposes a remaining + byte count, and navigates to the account page on a successful broadcast. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const SetBioPage = require('../../src/services/set-bio-page') +const MemoSetBio = require('../../src/services/memo-set-bio') + +function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') { + return { + walletInfo: { cashAddress: address }, + broadcasts: [], + async getUtxos () { + return [] + }, + async sendOpReturn (msg, prefix) { + this.broadcasts.push({ msg, prefix }) + return 'aa'.repeat(32) + } + } +} + +function makeMemoSetBio () { + const wallet = makeWallet() + return new MemoSetBio({ wallet }) +} + +test('remainingCount returns full byte budget for empty input', () => { + const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} }) + + assert.equal(page.remainingCount(), MemoSetBio.MAX_BIO_BYTES) +}) + +test('remainingCount subtracts the byte length of the input', () => { + const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} }) + page.setInput('hello') + + assert.equal(page.remainingCount(), MemoSetBio.MAX_BIO_BYTES - 5) +}) + +test('remainingCount counts multi-byte characters correctly', () => { + const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} }) + page.setInput('é') + + assert.equal(page.remainingCount(), MemoSetBio.MAX_BIO_BYTES - 2) +}) + +test('submit navigates to the account page on success', async () => { + const navigated = [] + const page = new SetBioPage({ + memoSetBio: makeMemoSetBio(), + navigate: (path) => navigated.push(path) + }) + page.setInput('Building on BCH') + + const result = await page.submit() + + assert.equal(result.ok, true) + assert.deepEqual(navigated, [SetBioPage.ACCOUNT_PATH]) +}) + +test('submit records a validation error for empty input', async () => { + const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} }) + page.setInput('') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'bio_validation') +}) + +test('submit records a length error for over-long input', async () => { + const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} }) + page.setInput('a'.repeat(MemoSetBio.MAX_BIO_BYTES + 1)) + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'bio_length') +}) + +test('submit surfaces a broadcast failure', async () => { + const memoSetBio = makeMemoSetBio() + memoSetBio.wallet.sendOpReturn = async () => { throw new Error('network down') } + const page = new SetBioPage({ memoSetBio, navigate: () => {} }) + page.setInput('Building on BCH') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'broadcast') + assert.match(result.message, /network down/) +}) + +test('submit records a broadcast error when no memo set-bio handler is injected', async () => { + const page = new SetBioPage({ navigate: () => {} }) + page.setInput('Building on BCH') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'broadcast') + assert.match(result.message, /Set bio requires a memo set-bio handler/) +})