mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Implement Set Name feature (0x6d01)
- Add MemoSetName service with 77-byte UTF-8 validation and broadcast. - Add SetNamePage controller with byte counter and navigation to /account. - Add AccountPage controller showing stored name and Set Name button. - Add Profiles session store to share the new name across pages. - Add React views /memo/set-name and /account, plus nav link. - Update acceptance handlers for set-name scenarios and fix fake wallet sendOpReturn signature to match public minimal-slp-wallet API. - Update existing post memo tests to match the corrected wallet API. By coder.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Unit tests for the Account Page behavior slice (src/services/account-page.js).
|
||||
|
||||
Expresses the observable behavior described by specs/set-name.feature:
|
||||
- the account page shows the authenticated user's display name.
|
||||
- the account page exposes a Set Name button.
|
||||
- clicking the Set Name button navigates to /memo/set-name.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const AccountPage = require('../../src/services/account-page')
|
||||
|
||||
const ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
|
||||
function fakeWallet (cashAddress = ADDRESS) {
|
||||
return { walletInfo: { cashAddress } }
|
||||
}
|
||||
|
||||
function fakeProfiles (initial = {}) {
|
||||
const names = { ...initial }
|
||||
return { names, setName: (addr, name) => { names[addr] = name }, getName: (addr) => names[addr] || null }
|
||||
}
|
||||
|
||||
function build (deps = {}) {
|
||||
const wallet = deps.wallet !== undefined ? deps.wallet : fakeWallet()
|
||||
const profiles = deps.profiles !== undefined ? deps.profiles : fakeProfiles()
|
||||
const navigations = []
|
||||
const page = new AccountPage({
|
||||
wallet,
|
||||
profiles,
|
||||
navigate: (path) => navigations.push(path)
|
||||
})
|
||||
return { page, navigations, profiles }
|
||||
}
|
||||
|
||||
test('SET_NAME_PATH and ACCOUNT_PATH constants', () => {
|
||||
assert.equal(AccountPage.SET_NAME_PATH, '/memo/set-name')
|
||||
assert.equal(AccountPage.ACCOUNT_PATH, '/account')
|
||||
})
|
||||
|
||||
test('the account page shows a Set Name button', () => {
|
||||
const { page } = build()
|
||||
assert.equal(page.hasSetNameButton(), true)
|
||||
})
|
||||
|
||||
test('clicking the Set Name button navigates to /memo/set-name', () => {
|
||||
const { page, navigations } = build()
|
||||
page.clickSetName()
|
||||
assert.deepEqual(navigations, ['/memo/set-name'])
|
||||
})
|
||||
|
||||
test('the account page shows the stored name for the authenticated address', () => {
|
||||
const profiles = fakeProfiles({ [ADDRESS]: 'trout' })
|
||||
const { page } = build({ profiles })
|
||||
assert.equal(page.getName(), 'trout')
|
||||
})
|
||||
|
||||
test('the account page returns null when no name is stored', () => {
|
||||
const { page } = build()
|
||||
assert.equal(page.getName(), null)
|
||||
})
|
||||
|
||||
test('the account page returns null when no wallet is present', () => {
|
||||
const { page } = build({ wallet: null })
|
||||
assert.equal(page.getName(), null)
|
||||
})
|
||||
|
||||
test('the account page returns null when no profile store is present', () => {
|
||||
const { page } = build({ profiles: null })
|
||||
assert.equal(page.getName(), null)
|
||||
})
|
||||
@@ -24,8 +24,8 @@ function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress },
|
||||
getUtxos: async () => utxos,
|
||||
sendOpReturn: async (walletInfo, bchUtxos, msg, prefix) => {
|
||||
broadcasts.push({ walletInfo, bchUtxos, msg, prefix })
|
||||
sendOpReturn: async (msg, prefix) => {
|
||||
broadcasts.push({ msg, prefix })
|
||||
return 'fake-txid'
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,6 @@ test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and
|
||||
const b = wallet.broadcasts[0]
|
||||
assert.equal(b.prefix, '6d02')
|
||||
assert.equal(b.msg, 'hello memo')
|
||||
// The broadcast uses the wallet's spendable UTXOs.
|
||||
assert.equal(b.bchUtxos.length, 1)
|
||||
// The wallet info passed to sendOpReturn is the authenticated wallet.
|
||||
assert.equal(b.walletInfo.cashAddress, wallet.walletInfo.cashAddress)
|
||||
|
||||
// The feed reflects the new post from this address with this text.
|
||||
assert.equal(feed.posts.length, 1)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
Unit tests for the Memo set-name behavior slice (src/services/memo-set-name.js).
|
||||
|
||||
These tests express the observable behavior described by specs/set-name.feature:
|
||||
- a valid name broadcasts an OP_RETURN transaction carrying the Memo set-name
|
||||
prefix (0x6d01) and the name text, and the profile store reflects the new
|
||||
name.
|
||||
- an empty name is rejected with a validation error and nothing is broadcast.
|
||||
- an over-long name is rejected with a length error and nothing is broadcast.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoSetName = require('../../src/services/memo-set-name')
|
||||
|
||||
// A fake wallet that records every broadcast attempt. It satisfies the small
|
||||
// adapter surface the MemoSetName module needs: walletInfo, getUtxos(),
|
||||
// sendOpReturn().
|
||||
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', utxos = [{ txid: 'utxo1' }]) {
|
||||
const broadcasts = []
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress },
|
||||
getUtxos: async () => utxos,
|
||||
sendOpReturn: async (msg, prefix) => {
|
||||
broadcasts.push({ msg, prefix })
|
||||
return 'fake-txid'
|
||||
}
|
||||
}
|
||||
wallet.broadcasts = broadcasts
|
||||
return wallet
|
||||
}
|
||||
|
||||
// A fake profile store that records names set for addresses.
|
||||
function fakeProfiles () {
|
||||
const names = {}
|
||||
return {
|
||||
names,
|
||||
setName: (addr, name) => { names[addr] = name },
|
||||
getName: (addr) => names[addr] || null
|
||||
}
|
||||
}
|
||||
|
||||
test('MEMO_SET_NAME_PREFIX is the Memo set-name action 0x6d01', () => {
|
||||
assert.equal(MemoSetName.MEMO_SET_NAME_PREFIX, '6d01')
|
||||
})
|
||||
|
||||
test('setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix and name', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const profiles = fakeProfiles()
|
||||
const memoSetName = new MemoSetName({ wallet, profiles })
|
||||
|
||||
const txid = await memoSetName.setName('trout')
|
||||
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
const b = wallet.broadcasts[0]
|
||||
assert.equal(b.prefix, '6d01')
|
||||
assert.equal(b.msg, 'trout')
|
||||
|
||||
// The profile store reflects the new name for this address.
|
||||
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout')
|
||||
})
|
||||
|
||||
test('setting a name at the maximum byte length (77) is accepted', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoSetName = new MemoSetName({ wallet })
|
||||
|
||||
const name = 'x'.repeat(77)
|
||||
const txid = await memoSetName.setName(name)
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts[0].msg, name)
|
||||
})
|
||||
|
||||
test('setting a name at the maximum byte length with multi-byte characters is accepted', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoSetName = new MemoSetName({ wallet })
|
||||
|
||||
// 38 'é' characters are 76 bytes in UTF-8.
|
||||
const name = 'é'.repeat(38)
|
||||
assert.equal(Buffer.byteLength(name, 'utf8'), 76)
|
||||
const txid = await memoSetName.setName(name)
|
||||
assert.equal(txid, 'fake-txid')
|
||||
})
|
||||
|
||||
test('setting an over-long name in bytes (78) throws a length error even when char count is lower', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoSetName = new MemoSetName({ wallet })
|
||||
|
||||
// 40 'é' characters are 80 bytes, exceeding the 77-byte limit.
|
||||
const name = 'é'.repeat(40)
|
||||
assert.ok(Buffer.byteLength(name, 'utf8') > 77)
|
||||
await assert.rejects(
|
||||
memoSetName.setName(name),
|
||||
(err) => err.code === 'name_length'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('setting an empty name throws a validation error and broadcasts nothing', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const profiles = fakeProfiles()
|
||||
const memoSetName = new MemoSetName({ wallet, profiles })
|
||||
|
||||
await assert.rejects(
|
||||
memoSetName.setName(''),
|
||||
(err) => err.code === 'name_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
|
||||
})
|
||||
|
||||
test('setting a whitespace-only or non-string name throws a validation error and broadcasts nothing', async () => {
|
||||
for (const invalid of [' ', 42]) {
|
||||
const wallet = fakeWallet()
|
||||
const memoSetName = new MemoSetName({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoSetName.setName(invalid),
|
||||
(err) => err.code === 'name_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('setting an over-long name (78) throws a length error and broadcasts nothing', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const profiles = fakeProfiles()
|
||||
const memoSetName = new MemoSetName({ wallet, profiles })
|
||||
|
||||
await assert.rejects(
|
||||
memoSetName.setName('y'.repeat(78)),
|
||||
(err) => err.code === 'name_length'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
|
||||
})
|
||||
|
||||
test('setting a name without a wallet reports a missing-wallet error', async () => {
|
||||
const memoSetName = new MemoSetName({})
|
||||
await assert.rejects(
|
||||
memoSetName.setName('trout'),
|
||||
(err) => /wallet/i.test(err.message)
|
||||
)
|
||||
})
|
||||
|
||||
test('a failed broadcast does not update the profile store', async () => {
|
||||
const wallet = fakeWallet()
|
||||
wallet.sendOpReturn = async () => { throw new Error('broadcast failure') }
|
||||
const profiles = fakeProfiles()
|
||||
const memoSetName = new MemoSetName({ wallet, profiles })
|
||||
|
||||
await assert.rejects(
|
||||
memoSetName.setName('trout'),
|
||||
(err) => /broadcast failure/i.test(err.message)
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
|
||||
})
|
||||
@@ -27,8 +27,8 @@ function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0
|
||||
walletInfo: { cashAddress },
|
||||
utxos: [{ txid: 'utxo-fee' }],
|
||||
getUtxos: async function () { return this.utxos },
|
||||
sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) {
|
||||
this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix })
|
||||
sendOpReturn: async function (msg, prefix) {
|
||||
this.broadcasts.push({ msg, prefix })
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return 'newpost-txid'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Unit tests for the session profile store (src/services/profiles.js).
|
||||
|
||||
The store indexes display names by BCH cash address so that pages can read
|
||||
a name immediately after it is broadcast.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const Profiles = require('../../src/services/profiles')
|
||||
|
||||
test('returns null when no name has been set for an address', () => {
|
||||
const profiles = new Profiles()
|
||||
assert.equal(profiles.getName('bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'), null)
|
||||
})
|
||||
|
||||
test('stores and retrieves a name by address', () => {
|
||||
const profiles = new Profiles()
|
||||
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
profiles.setName(addr, 'trout')
|
||||
assert.equal(profiles.getName(addr), 'trout')
|
||||
})
|
||||
|
||||
test('updating a name overwrites the previous value', () => {
|
||||
const profiles = new Profiles()
|
||||
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
profiles.setName(addr, 'trout')
|
||||
profiles.setName(addr, 'salmon')
|
||||
assert.equal(profiles.getName(addr), 'salmon')
|
||||
})
|
||||
|
||||
test('different addresses keep independent names', () => {
|
||||
const profiles = new Profiles()
|
||||
const addr1 = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const addr2 = 'bitcoincash:qq0ktdlgekdszmxhmg7y6a90t9dpj6p0pg3gctn9e'
|
||||
profiles.setName(addr1, 'trout')
|
||||
profiles.setName(addr2, 'salmon')
|
||||
assert.equal(profiles.getName(addr1), 'trout')
|
||||
assert.equal(profiles.getName(addr2), 'salmon')
|
||||
})
|
||||
|
||||
test('ignores setName for a missing address', () => {
|
||||
const profiles = new Profiles()
|
||||
profiles.setName(null, 'trout')
|
||||
assert.equal(profiles.getName(null), null)
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
Unit tests for the Set Name Page behavior slice (src/services/set-name-page.js).
|
||||
|
||||
Expresses the observable behavior described by specs/set-name.feature:
|
||||
- setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix
|
||||
and navigates the user to the account page.
|
||||
- an empty name is rejected with a validation error; nothing is broadcast.
|
||||
- an over-long name is rejected with a length error; nothing is broadcast.
|
||||
- the byte counter counts down from the name limit.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoSetName = require('../../src/services/memo-set-name')
|
||||
const SetNamePage = require('../../src/services/set-name-page')
|
||||
|
||||
const MAX = MemoSetName.MAX_NAME_BYTES // 77
|
||||
|
||||
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
|
||||
const broadcasts = []
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress },
|
||||
utxos: [{ txid: 'utxo-fee' }],
|
||||
getUtxos: async function () { return this.utxos },
|
||||
sendOpReturn: async function (msg, prefix) {
|
||||
this.broadcasts.push({ msg, prefix })
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return 'setname-txid'
|
||||
}
|
||||
}
|
||||
wallet.broadcasts = broadcasts
|
||||
return wallet
|
||||
}
|
||||
|
||||
function fakeProfiles () {
|
||||
const names = {}
|
||||
return { names, setName: (addr, name) => { names[addr] = name }, getName: (addr) => names[addr] || null }
|
||||
}
|
||||
|
||||
function build () {
|
||||
const wallet = fakeWallet()
|
||||
const profiles = fakeProfiles()
|
||||
const memoSetName = new MemoSetName({ wallet, profiles })
|
||||
const navigations = []
|
||||
const page = new SetNamePage({
|
||||
memoSetName,
|
||||
navigate: (path) => navigations.push(path)
|
||||
})
|
||||
return { wallet, profiles, memoSetName, page, navigations }
|
||||
}
|
||||
|
||||
test('SET_NAME_PATH and ACCOUNT_PATH constants', () => {
|
||||
assert.equal(SetNamePage.SET_NAME_PATH, '/memo/set-name')
|
||||
assert.equal(SetNamePage.ACCOUNT_PATH, '/account')
|
||||
})
|
||||
|
||||
test('the byte counter counts down from the name limit for an empty name', () => {
|
||||
const { page } = build()
|
||||
page.setInput('')
|
||||
assert.equal(page.remainingCount(), MAX)
|
||||
})
|
||||
|
||||
test('the byte counter counts multi-byte characters by bytes, not characters', () => {
|
||||
const { page } = build()
|
||||
page.setInput('é')
|
||||
assert.equal(page.remainingCount(), MAX - 2)
|
||||
})
|
||||
|
||||
test('the byte counter reaches zero at the byte limit with multi-byte characters', () => {
|
||||
const { page } = build()
|
||||
page.setInput('é'.repeat(38))
|
||||
assert.equal(page.remainingCount(), 1)
|
||||
})
|
||||
|
||||
test('the byte counter counts down from the name limit for a short name', () => {
|
||||
const { page } = build()
|
||||
page.setInput('trout')
|
||||
assert.equal(page.remainingCount(), MAX - 5)
|
||||
})
|
||||
|
||||
test('the byte counter reaches zero at the name limit', () => {
|
||||
const { page } = build()
|
||||
page.setInput('x'.repeat(MAX))
|
||||
assert.equal(page.remainingCount(), 0)
|
||||
})
|
||||
|
||||
test('setting a valid name broadcasts the Memo set-name prefix and navigates to the account page', async () => {
|
||||
const { wallet, profiles, page, navigations } = build()
|
||||
page.setInput('trout')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(page.settingName, false)
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, '6d01')
|
||||
assert.equal(wallet.broadcasts[0].msg, 'trout')
|
||||
assert.deepEqual(navigations, ['/account'])
|
||||
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout')
|
||||
})
|
||||
|
||||
test('setting an empty name is rejected with a validation error and nothing is broadcast', async () => {
|
||||
const { wallet, profiles, page, navigations } = build()
|
||||
page.setInput('')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'name_validation')
|
||||
assert.equal(page.submitError, 'name_validation')
|
||||
assert.equal(page.settingName, false)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('setting an over-long name is rejected with a length error and nothing is broadcast', async () => {
|
||||
const { wallet, profiles, page, navigations } = build()
|
||||
page.setInput('y'.repeat(MAX + 1))
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'name_length')
|
||||
assert.equal(page.submitError, 'name_length')
|
||||
assert.equal(page.settingName, false)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('the set name page starts idle (not setting name)', () => {
|
||||
const { page } = build()
|
||||
assert.equal(page.settingName, false)
|
||||
})
|
||||
|
||||
test('setting name is true while a submit is in flight and false once it settles', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const profiles = fakeProfiles()
|
||||
|
||||
let resolveSend
|
||||
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
|
||||
const page = new SetNamePage({
|
||||
memoSetName: new MemoSetName({ wallet, profiles }),
|
||||
navigate: () => {}
|
||||
})
|
||||
page.setInput('trout')
|
||||
|
||||
assert.equal(page.settingName, false)
|
||||
const pending = page.submit()
|
||||
assert.equal(page.settingName, true)
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
assert.equal(typeof resolveSend, 'function')
|
||||
resolveSend('in-flight-txid')
|
||||
await pending
|
||||
assert.equal(page.settingName, false)
|
||||
})
|
||||
|
||||
test('submitting without a memo set-name handler reports an error and does not navigate', async () => {
|
||||
const navigations = []
|
||||
const page = new SetNamePage({ navigate: (p) => navigations.push(p) })
|
||||
page.setInput('trout')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('a failed broadcast surfaces the real error and does not navigate', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const profiles = fakeProfiles()
|
||||
wallet.failWith = 'BCH UTXO list is empty'
|
||||
const navigations = []
|
||||
const page = new SetNamePage({
|
||||
memoSetName: new MemoSetName({ wallet, profiles }),
|
||||
navigate: (p) => navigations.push(p)
|
||||
})
|
||||
page.setInput('trout')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(page.submitError, 'broadcast')
|
||||
assert.match(page.broadcastError, /BCH UTXO list is empty/)
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, '6d01')
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
Reference in New Issue
Block a user