Implement Set Bio (0x6d05) client write path

Add Memo set-bio action, Set Bio page controller, Set Bio React view,
account bio display, and acceptance step handlers. Includes unit tests.

By coder.
This commit is contained in:
Chris Troutner
2026-08-27 07:50:33 -07:00
parent fcab46d706
commit 01a16769bd
12 changed files with 789 additions and 8 deletions
+106 -2
View File
@@ -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$/,
@@ -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 (
<Container className='account-page mt-4'>
@@ -77,6 +83,10 @@ function Account (props) {
<strong>Name: </strong>
{displayName}
</p>
<p className='account-bio'>
<strong>Bio: </strong>
{displayBio || <span className='text-muted'>No bio set</span>}
</p>
<p className='account-address'>
<strong>Address: </strong>
{address}
@@ -85,11 +95,21 @@ function Account (props) {
{accountPage.hasSetNameButton() && (
<Button
variant='primary'
className='me-2'
onClick={() => accountPage.clickSetName()}
>
Set Name
</Button>
)}
{accountPage.hasSetBioButton() && (
<Button
variant='primary'
onClick={() => accountPage.clickSetBio()}
>
Set Bio
</Button>
)}
</div>
)}
</Col>
@@ -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) {
<Route path='/posts/recent' element={<RecentPosts appData={appData} />} />
<Route path='/posts/new' element={<NewPost appData={appData} />} />
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
<Route path='/memo/set-bio' element={<SetBio appData={appData} />} />
<Route path='/account' element={<Account appData={appData} />} />
<Route path='/placeholder2' element={<Placeholder2 />} />
<Route path='/placeholder3' element={<Placeholder3 />} />
@@ -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 (
<Container>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='set-bio-heading'>
<h1>Set Bio</h1>
<p>Set your profile bio and publish it to Bitcoin Cash.</p>
</header>
<Form onSubmit={handleSubmit}>
<Form.Group controlId='set-bio-input' className='mb-3'>
<Form.Label><b>Bio</b></Form.Label>
<Form.Control
as='textarea'
rows={4}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder='Write a short bio...'
/>
</Form.Group>
<p className='set-bio-counter'>
{remaining} bytes remaining
</p>
{err && <p className='set-bio-error'>{err}</p>}
<Button type='submit' variant='primary' disabled={settingBio}>
{settingBio ? 'Setting Bio...' : 'Set Bio'}
</Button>
</Form>
</Col>
</Row>
</Container>
)
}
export default SetBio
@@ -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
@@ -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
+11
View File
@@ -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
@@ -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
@@ -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])
})
@@ -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/
)
})
@@ -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')
})
@@ -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/)
})