mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Merge commit 'd33fda88ba' into swarmforge-refactorer
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
Unit tests for the Memo reply behavior slice (src/services/memo-reply.js).
|
||||
|
||||
These tests express the observable behavior described by
|
||||
specs/reply-memo.feature:
|
||||
- a valid reply broadcasts an OP_RETURN transaction carrying the Memo reply
|
||||
prefix (0x6d03), the parent txid bytes, and the message text; the thread
|
||||
reflects the new reply.
|
||||
- an empty reply is rejected with a validation error and nothing is broadcast.
|
||||
- an over-long reply is rejected with a length error and nothing is broadcast.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoReply = require('../../src/services/memo-reply')
|
||||
|
||||
const PARENT_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
|
||||
// A fake wallet that records every broadcast attempt.
|
||||
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 thread that records replies added to the thread view.
|
||||
function fakeThread (rootTxid = PARENT_TXID) {
|
||||
const replies = []
|
||||
return {
|
||||
rootTxid,
|
||||
replies,
|
||||
addReply: (r) => replies.push(r)
|
||||
}
|
||||
}
|
||||
|
||||
// Decode the reply text from a raw Uint8Array payload (skipping the 32-byte parent txid).
|
||||
function decodeReplyText (raw) {
|
||||
const decoder = new TextDecoder()
|
||||
return decoder.decode(raw.slice(32))
|
||||
}
|
||||
|
||||
// Decode the parent txid from a raw Uint8Array payload.
|
||||
function decodeParentTxid (raw) {
|
||||
return Buffer.from(raw.slice(0, 32)).toString('hex')
|
||||
}
|
||||
|
||||
test('MEMO_REPLY_PREFIX is the Memo reply action 0x6d03', () => {
|
||||
assert.equal(MemoReply.MEMO_REPLY_PREFIX, '6d03')
|
||||
})
|
||||
|
||||
test('replying with a valid message broadcasts an OP_RETURN with the Memo reply prefix and payload', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const thread = fakeThread()
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
|
||||
const txid = await memoReply.reply('hello memo', PARENT_TXID)
|
||||
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
const b = wallet.broadcasts[0]
|
||||
assert.equal(b.prefix, '6d03')
|
||||
assert.ok(b.msg instanceof Uint8Array)
|
||||
assert.equal(decodeParentTxid(b.msg), PARENT_TXID)
|
||||
assert.equal(decodeReplyText(b.msg), 'hello memo')
|
||||
|
||||
// The thread reflects the new reply from this address with this text.
|
||||
assert.equal(thread.replies.length, 1)
|
||||
assert.equal(thread.replies[0].text, 'hello memo')
|
||||
assert.equal(thread.replies[0].address, wallet.walletInfo.cashAddress)
|
||||
assert.equal(thread.replies[0].parentTxid, PARENT_TXID)
|
||||
})
|
||||
|
||||
test('replying at the maximum byte length (184) is accepted', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoReply = new MemoReply({ wallet })
|
||||
|
||||
const msg = 'x'.repeat(184)
|
||||
const txid = await memoReply.reply(msg, PARENT_TXID)
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(decodeReplyText(wallet.broadcasts[0].msg), msg)
|
||||
})
|
||||
|
||||
test('replying with a multi-byte character at the byte limit is accepted', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoReply = new MemoReply({ wallet })
|
||||
|
||||
// 92 'é' characters encode to 184 UTF-8 bytes.
|
||||
const msg = 'é'.repeat(92)
|
||||
assert.equal(Buffer.byteLength(msg, 'utf8'), 184)
|
||||
const txid = await memoReply.reply(msg, PARENT_TXID)
|
||||
assert.equal(txid, 'fake-txid')
|
||||
})
|
||||
|
||||
test('replying with an empty message throws a validation error and broadcasts nothing', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const thread = fakeThread()
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
|
||||
await assert.rejects(
|
||||
memoReply.reply('', PARENT_TXID),
|
||||
(err) => err.code === 'reply_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(thread.replies.length, 0)
|
||||
})
|
||||
|
||||
test('replying with a whitespace-only or non-string message throws a validation error and broadcasts nothing', async () => {
|
||||
for (const invalid of [' ', 42]) {
|
||||
const wallet = fakeWallet()
|
||||
const memoReply = new MemoReply({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoReply.reply(invalid, PARENT_TXID),
|
||||
(err) => err.code === 'reply_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('replying with an over-long message (185 bytes) throws a length error and broadcasts nothing', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const thread = fakeThread()
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
|
||||
await assert.rejects(
|
||||
memoReply.reply('y'.repeat(185), PARENT_TXID),
|
||||
(err) => err.code === 'reply_length'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(thread.replies.length, 0)
|
||||
})
|
||||
|
||||
test('replying with a multi-byte character that exceeds the byte limit throws a length error', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoReply = new MemoReply({ wallet })
|
||||
|
||||
// 93 'é' characters encode to 186 UTF-8 bytes, exceeding the 184-byte limit.
|
||||
const msg = 'é'.repeat(93)
|
||||
assert.ok(Buffer.byteLength(msg, 'utf8') > 184)
|
||||
|
||||
await assert.rejects(
|
||||
memoReply.reply(msg, PARENT_TXID),
|
||||
(err) => err.code === 'reply_length'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('replying without a wallet reports a missing-wallet error', async () => {
|
||||
const memoReply = new MemoReply({})
|
||||
await assert.rejects(
|
||||
memoReply.reply('hello memo', PARENT_TXID),
|
||||
(err) => /wallet/i.test(err.message)
|
||||
)
|
||||
})
|
||||
|
||||
test('replying with an invalid parent txid reports a clear error', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoReply = new MemoReply({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoReply.reply('hello memo', 'not-a-txid'),
|
||||
(err) => /txid/i.test(err.message)
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
Unit tests for the Reply Thread Page behavior slice (src/services/reply-thread-page.js).
|
||||
|
||||
Expresses the observable behavior described by specs/reply-memo.feature:
|
||||
- replying with a valid message broadcasts an OP_RETURN with the Memo reply
|
||||
prefix and reflects the reply in the thread.
|
||||
- an empty reply is rejected with a validation error; nothing is broadcast.
|
||||
- an over-long reply is rejected with a length error; nothing is broadcast.
|
||||
- the byte counter counts down from the reply limit.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoReply = require('../../src/services/memo-reply')
|
||||
const ReplyThreadPage = require('../../src/services/reply-thread-page')
|
||||
|
||||
const MAX = MemoReply.MAX_REPLY_BYTES // 184
|
||||
const PARENT_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
|
||||
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 'reply-txid'
|
||||
}
|
||||
}
|
||||
wallet.broadcasts = broadcasts
|
||||
return wallet
|
||||
}
|
||||
|
||||
function fakeThread (rootTxid = PARENT_TXID) {
|
||||
const replies = []
|
||||
return { rootTxid, replies, addReply: (r) => replies.push(r) }
|
||||
}
|
||||
|
||||
function build (deps = {}) {
|
||||
const wallet = deps.wallet || fakeWallet()
|
||||
const thread = deps.thread || fakeThread()
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
const navigations = []
|
||||
const page = new ReplyThreadPage({
|
||||
memoReply,
|
||||
parentTxid: deps.parentTxid || PARENT_TXID,
|
||||
navigate: (path) => navigations.push(path)
|
||||
})
|
||||
return { wallet, thread, memoReply, page, navigations }
|
||||
}
|
||||
|
||||
test('REPLY_THREAD_PATH constant', () => {
|
||||
assert.equal(ReplyThreadPage.REPLY_THREAD_PATH, '/posts/thread')
|
||||
})
|
||||
|
||||
test('the byte counter counts down from the reply limit for an empty reply', () => {
|
||||
const { page } = build()
|
||||
page.setInput('')
|
||||
assert.equal(page.remainingCount(), MAX)
|
||||
})
|
||||
|
||||
test('the byte counter counts down from the reply limit for a short reply', () => {
|
||||
const { page } = build()
|
||||
page.setInput('hello')
|
||||
assert.equal(page.remainingCount(), MAX - 5)
|
||||
})
|
||||
|
||||
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 reply byte limit', () => {
|
||||
const { page } = build()
|
||||
page.setInput('x'.repeat(MAX))
|
||||
assert.equal(page.remainingCount(), 0)
|
||||
})
|
||||
|
||||
test('submitting a valid reply broadcasts the Memo reply prefix and reflects it in the thread', async () => {
|
||||
const { wallet, thread, page, navigations } = build()
|
||||
page.setInput('hello memo')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(page.replying, false)
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, '6d03')
|
||||
assert.deepEqual(navigations, [])
|
||||
assert.equal(thread.replies.length, 1)
|
||||
assert.equal(thread.replies[0].text, 'hello memo')
|
||||
assert.equal(thread.replies[0].parentTxid, PARENT_TXID)
|
||||
})
|
||||
|
||||
test('submitting an empty reply is rejected with a validation error and nothing is broadcast', async () => {
|
||||
const { wallet, thread, page, navigations } = build()
|
||||
page.setInput('')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'reply_validation')
|
||||
assert.equal(page.submitError, 'reply_validation')
|
||||
assert.equal(page.replying, false)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(thread.replies.length, 0)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('submitting an over-long reply is rejected with a length error and nothing is broadcast', async () => {
|
||||
const { wallet, thread, page, navigations } = build()
|
||||
page.setInput('y'.repeat(MAX + 1))
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'reply_length')
|
||||
assert.equal(page.submitError, 'reply_length')
|
||||
assert.equal(page.replying, false)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(thread.replies.length, 0)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('the reply page starts idle (not replying)', () => {
|
||||
const { page } = build()
|
||||
assert.equal(page.replying, false)
|
||||
})
|
||||
|
||||
test('replying is true while a submit is in flight and false once it settles', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const thread = fakeThread()
|
||||
|
||||
let resolveSend
|
||||
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
|
||||
const page = new ReplyThreadPage({
|
||||
memoReply: new MemoReply({ wallet, thread }),
|
||||
parentTxid: PARENT_TXID,
|
||||
navigate: () => {}
|
||||
})
|
||||
page.setInput('hello memo')
|
||||
|
||||
assert.equal(page.replying, false)
|
||||
const pending = page.submit()
|
||||
assert.equal(page.replying, true)
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
assert.equal(typeof resolveSend, 'function')
|
||||
resolveSend('in-flight-txid')
|
||||
await pending
|
||||
assert.equal(page.replying, false)
|
||||
})
|
||||
|
||||
test('submitting without a memo reply handler reports an error and does not navigate', async () => {
|
||||
const navigations = []
|
||||
const page = new ReplyThreadPage({ navigate: (p) => navigations.push(p) })
|
||||
page.setInput('hello')
|
||||
|
||||
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 thread = fakeThread()
|
||||
wallet.failWith = 'BCH UTXO list is empty'
|
||||
const navigations = []
|
||||
const page = new ReplyThreadPage({
|
||||
memoReply: new MemoReply({ wallet, thread }),
|
||||
parentTxid: PARENT_TXID,
|
||||
navigate: (p) => navigations.push(p)
|
||||
})
|
||||
page.setInput('hello memo')
|
||||
|
||||
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, '6d03')
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('replying to a nested reply uses the selected parent txid', async () => {
|
||||
const nestedTxid = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
||||
const { thread, page } = build()
|
||||
page.setParent(nestedTxid)
|
||||
page.setInput('hello nested')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(thread.replies[0].parentTxid, nestedTxid)
|
||||
assert.equal(thread.replies[0].text, 'hello nested')
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Unit tests for the UTF-8 byte-length helper (src/services/utf8.js).
|
||||
|
||||
The helper must report the same UTF-8 byte length as Node's Buffer without
|
||||
depending on the Node-only `Buffer` global, so the browser build (which has
|
||||
no Buffer) can count bytes for the Memo set-name counter and length check.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { byteLength } = require('../../src/services/utf8')
|
||||
|
||||
test('byteLength matches Buffer.byteLength for ASCII text', () => {
|
||||
for (const s of ['', 'trout', 'a longer name with spaces', 'x'.repeat(77)]) {
|
||||
assert.equal(byteLength(s), Buffer.byteLength(s, 'utf8'))
|
||||
}
|
||||
})
|
||||
|
||||
test('byteLength matches Buffer.byteLength for multi-byte characters', () => {
|
||||
for (const s of ['é', 'é'.repeat(38), '😀', '😀'.repeat(20), '日本語']) {
|
||||
assert.equal(byteLength(s), Buffer.byteLength(s, 'utf8'))
|
||||
}
|
||||
})
|
||||
|
||||
test('byteLength counts UTF-8 bytes, not characters', () => {
|
||||
// 'é' is 1 character but 2 UTF-8 bytes; an emoji is 1 character but 4 bytes.
|
||||
assert.equal(byteLength('é'), 2)
|
||||
assert.equal(byteLength('😀'), 4)
|
||||
assert.equal(byteLength('a'), 1)
|
||||
})
|
||||
|
||||
test('byteLength coerces non-string input to a string', () => {
|
||||
assert.equal(byteLength(42), 2)
|
||||
assert.equal(byteLength(null), 4) // String(null) === 'null'
|
||||
})
|
||||
Reference in New Issue
Block a user