mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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:
@@ -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
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in New Issue
Block a user