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 (
+
+
+
+
+
+
+ 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/)
+})