Files
psf-memo-client/test/unit/like-tip-page.test.js
Chris Troutner 962b2eb627 Lower tip dust limit to 600 sats and keep the like/tip modal editable after errors
Allow tips as low as 600 sats (tip output dust) while keeping the wallet
balance gate at 3000 sats to cover transaction fees. Keep the tip input and
Like button enabled after a validation error so the user can edit and retry.

By specifier.
2026-08-26 07:10:06 -07:00

240 lines
7.6 KiB
JavaScript

/*
Unit tests for the Like / Tip page behavior slice
(src/services/like-tip-page.js).
These tests express the page-level behavior described by
specs/like-tip-memo.feature:
- opening the modal for a post checks the wallet balance.
- submitting a like without a tip broadcasts the Memo like action.
- submitting a like with a tip broadcasts the action and the tip.
- invalid, dust, maximum, and balance errors are surfaced.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const LikeTipPage = require('../../src/services/like-tip-page')
const MemoLike = require('../../src/services/memo-like')
const { fakeWallet } = require('../helpers/fake-wallet')
const POST_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
function build () {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 100000 }] })
const feed = { posts: [], likes: [], addLike: (l) => feed.likes.push(l) }
const memoLike = new MemoLike({ wallet, feed })
const page = new LikeTipPage({ memoLike })
return { wallet, feed, memoLike, page }
}
// Build a page with the given wallet balance, submit a tip string, and assert
// that the submit is rejected with the expected error code and message.
async function assertTipRejected (utxos, tip, expectedCode, messageRe) {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos })
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip(tip)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, expectedCode)
assert.match(page.broadcastError, messageRe)
assert.equal(wallet.broadcasts.length, 0)
}
test('opening the modal sets the target post and author', () => {
const { page } = build()
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
assert.equal(result.ok, true)
assert.equal(page.modalOpen, true)
assert.equal(page.postTxid, POST_TXID)
assert.equal(page.authorAddress, AUTHOR_ADDRESS)
})
test('opening the modal with insufficient balance surfaces an add-BCH error', () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [] })
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
assert.equal(result.ok, false)
assert.equal(result.error, 'like_empty_balance')
assert.equal(page.submitError, 'like_empty_balance')
assert.match(page.broadcastError, /add BCH/i)
assert.equal(page.modalOpen, true)
})
test('opening the modal without a memo-like handler surfaces an error', () => {
const page = new LikeTipPage({})
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
assert.equal(result.ok, false)
assert.equal(result.error, 'like_validation')
assert.match(page.broadcastError, /memo like/i)
})
test('closing the modal resets input and errors', () => {
const { page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('5000')
page.submitError = 'like_dust'
page.broadcastError = 'some error'
page.close()
assert.equal(page.modalOpen, false)
assert.equal(page.input, '')
assert.equal(page.submitError, null)
assert.equal(page.broadcastError, null)
})
test('submitting a pure like broadcasts the Memo like prefix', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(page.tipping, false)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d04')
assert.deepEqual(wallet.broadcasts[0].bchOutput, [])
})
test('submitting a like with a tip broadcasts the prefix and the tip output', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('3000')
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d04')
assert.deepEqual(wallet.broadcasts[0].bchOutput, [{ address: AUTHOR_ADDRESS, amountSat: 3000 }])
})
test('submitting a like on a post authored by the wallet works without a tip', async () => {
const { wallet, page } = build()
page.open(POST_TXID, MY_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d04')
})
test('submitting with a non-numeric tip string is rejected', async () => {
for (const tip of ['abc', '1.5']) {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip(tip)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_validation')
assert.equal(page.submitError, 'like_validation')
assert.match(page.broadcastError, /valid number/i)
assert.equal(wallet.broadcasts.length, 0)
}
})
test('submitting with a dust tip is rejected', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('599')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_dust')
assert.equal(wallet.broadcasts.length, 0)
})
test('submitting with a tip above the maximum is rejected', async () => {
await assertTipRejected(
[{ txid: 'u1', value: 150000000 }],
'100000001',
'like_maximum',
/maximum/i
)
})
test('submitting with a tip above the spendable balance is rejected', async () => {
await assertTipRejected(
[{ txid: 'u1', value: 30000 }],
'35000',
'like_balance',
/spendable/i
)
})
test('tipping flag is true while a submit is in flight and false once it settles', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
let resolveSend
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
assert.equal(page.tipping, false)
const pending = page.submit()
assert.equal(page.tipping, true)
await new Promise((resolve) => setImmediate(resolve))
assert.equal(typeof resolveSend, 'function')
resolveSend('in-flight-txid')
await pending
assert.equal(page.tipping, false)
})
test('submitting without a memo-like handler reports an error', async () => {
const page = new LikeTipPage({})
page.open(POST_TXID, AUTHOR_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
assert.match(page.broadcastError, /memo like/i)
})
test('a failed broadcast surfaces the real error', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
wallet.failWith = 'Insufficient balance'
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
page.open(POST_TXID, AUTHOR_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
assert.match(page.broadcastError, /Insufficient balance/)
})
test('a submit failure with no error message surfaces the error name', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
const memoLike = new MemoLike({ wallet })
memoLike.like = async () => { throw new Error('') }
const page = new LikeTipPage({ memoLike })
page.open(POST_TXID, AUTHOR_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
assert.equal(page.broadcastError, 'Error')
})