Reduce CRAP in like/tip slice and add property/coverage tests

- Extract helpers in memo-like to bring all CRAP scores to 6 or below.
- Consolidate duplicated rejection assertions in memo-like and like-tip-page tests.
- Add hex unit tests to close a coverage gap.
- Add property tests for hex round-trip, like validation, and spendable sum.

By refactorer.
This commit is contained in:
Chris Troutner
2026-08-26 06:23:23 -07:00
parent dd0535d133
commit 6b7a7e2b86
5 changed files with 303 additions and 147 deletions
+44 -22
View File
@@ -62,6 +62,19 @@ class MemoLike extends MemoAction {
// Validate an optional tip amount against the dust limit, hard maximum, and // Validate an optional tip amount against the dust limit, hard maximum, and
// the wallet's spendable balance. // the wallet's spendable balance.
validateTip (tipSats, spendableSats) { validateTip (tipSats, spendableSats) {
this._validateTipAmount(tipSats)
if (tipSats > spendableSats) {
const err = new Error('Tip exceeds the spendable balance.')
err.code = 'like_balance'
throw err
}
return { ok: true }
}
// Validate the tip amount's integer-ness, dust floor, and hard maximum.
_validateTipAmount (tipSats) {
if (!Number.isInteger(tipSats) || tipSats < 0) { if (!Number.isInteger(tipSats) || tipSats < 0) {
const err = new Error('Tip must be a valid number of satoshis.') const err = new Error('Tip must be a valid number of satoshis.')
err.code = 'like_validation' err.code = 'like_validation'
@@ -79,14 +92,6 @@ class MemoLike extends MemoAction {
err.code = 'like_maximum' err.code = 'like_maximum'
throw err throw err
} }
if (tipSats > spendableSats) {
const err = new Error('Tip exceeds the spendable balance.')
err.code = 'like_balance'
throw err
}
return { ok: true }
} }
// Sum the wallet's spendable UTXOs. Tolerates the common value field names // Sum the wallet's spendable UTXOs. Tolerates the common value field names
@@ -121,17 +126,10 @@ class MemoLike extends MemoAction {
} }
this.validateTip(tipSats, spendable) this.validateTip(tipSats, spendable)
this._requireTipAddress(tipSats, authorAddress)
if (tipSats > 0 && (!authorAddress || typeof authorAddress !== 'string')) {
const err = new Error('Tip requires an author address.')
err.code = 'like_validation'
throw err
}
const raw = hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid') const raw = hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid')
const bchOutput = tipSats > 0 const bchOutput = this._buildTipOutput(tipSats, authorAddress)
? [{ address: authorAddress, amountSat: tipSats }]
: []
const txid = await this.wallet.sendOpReturn(raw, this.prefix, bchOutput) const txid = await this.wallet.sendOpReturn(raw, this.prefix, bchOutput)
@@ -142,6 +140,28 @@ class MemoLike extends MemoAction {
// Record the new like on the injected feed when one is present. // Record the new like on the injected feed when one is present.
reflect (txid, postTxid, tipSats) { reflect (txid, postTxid, tipSats) {
this._notifyFeed(txid, postTxid, tipSats)
this._incrementPostCount(postTxid)
}
// Require an author address whenever a tip is present.
_requireTipAddress (tipSats, authorAddress) {
if (tipSats <= 0) return
if (typeof authorAddress === 'string' && authorAddress.length > 0) return
const err = new Error('Tip requires an author address.')
err.code = 'like_validation'
throw err
}
// Build the optional BCH tip output for the transaction.
_buildTipOutput (tipSats, authorAddress) {
return tipSats > 0
? [{ address: authorAddress, amountSat: tipSats }]
: []
}
// Notify the feed store of the new like when it exposes addLike.
_notifyFeed (txid, postTxid, tipSats) {
if (this.feed && typeof this.feed.addLike === 'function') { if (this.feed && typeof this.feed.addLike === 'function') {
this.feed.addLike({ this.feed.addLike({
txid, txid,
@@ -150,12 +170,14 @@ class MemoLike extends MemoAction {
tipSats tipSats
}) })
} }
}
if (this.feed && Array.isArray(this.feed.posts)) { // Increment the liked post's counter on the feed store when it is present.
const post = this.feed.posts.find((p) => p.txid === postTxid) _incrementPostCount (postTxid) {
if (post) { if (!this.feed || !Array.isArray(this.feed.posts)) return
post.likeCount = (post.likeCount || 0) + 1 const post = this.feed.posts.find((p) => p.txid === postTxid)
} if (post) {
post.likeCount = (post.likeCount || 0) + 1
} }
} }
} }
+116
View File
@@ -0,0 +1,116 @@
/*
Property tests for the Memo like / tip behavior slices.
The like/tip slice centers on a post txid encoded as a 64-character hex
string. These tests assert useful invariants across a broad input range that
unit tests cover only at a few fixed points:
- hexToBytes round-trips any valid hex txid back to its canonical string.
- hexToBytes rejects any string that is not a valid 64-char hex txid.
- MemoLike.validate accepts any valid 64-char hex txid and rejects others.
- getSpendableSats conserves the sum of every spendable utxo value.
*/
'use strict'
const test = require('node:test')
const { forAll, seededRandom } = require('./harness')
const { fakeWallet } = require('../helpers/fake-wallet')
const MemoLike = require('../../src/services/memo-like')
const { hexToBytes } = require('../../src/services/hex')
const rng = seededRandom(20260720)
// Build a random lowercase-hex string of the given byte length.
function hexString (bytes) {
const out = []
for (let i = 0; i < bytes; i++) {
out.push(Math.floor(rng() * 256).toString(16).padStart(2, '0'))
}
return out.join('')
}
test('hexToBytes round-trips a valid hex txid back to its canonical string', async () => {
await forAll(
() => hexString(32),
(hex) => Buffer.from(hexToBytes(hex, 32)).toString('hex') === hex,
{ label: 'hexToBytes round-trip' }
)
})
test('hexToBytes rejects any string that is not a 64-character hex txid', async () => {
await forAll(
(i) => {
const len = 1 + Math.floor(rng() * 100)
if (len !== 64) return 'z'.repeat(len)
// Exactly 64 chars but containing a non-hex character.
return `z${'a'.repeat(63)}`
},
(input) => {
try {
hexToBytes(input, 32)
return false
} catch (err) {
return err instanceof Error
}
},
{ label: 'hexToBytes rejects invalid' }
)
})
test('MemoLike.validate accepts any valid 64-character hex post txid', async () => {
await forAll(
() => hexString(32),
(txid) => {
const result = new MemoLike({}).validate(txid)
return result.ok === true
},
{ label: 'valid txid accepted' }
)
})
test('MemoLike.validate rejects a non-hex post txid with like_validation', async () => {
await forAll(
() => {
// 64 chars drawn from g..z, guaranteed to be non-hex.
const chars = []
for (let i = 0; i < 64; i++) {
chars.push(String.fromCharCode(103 + Math.floor(rng() * 20)))
}
return chars.join('')
},
(txid) => {
try {
new MemoLike({}).validate(txid)
return false
} catch (err) {
return err.code === 'like_validation'
}
},
{ label: 'invalid txid rejected as like_validation' }
)
})
test('getSpendableSats conserves the sum of every spendable utxo value', async () => {
await forAll(
(i) => {
const fields = ['value', 'satoshis', 'amount']
const count = 1 + Math.floor(rng() * 8)
const utxos = []
for (let k = 0; k < count; k++) {
utxos.push({ [fields[k % 3]]: Math.floor(rng() * 1000000) })
}
return utxos
},
(utxos) => {
const wallet = fakeWallet({ utxos })
const expected = utxos.reduce(
(sum, u) => sum + (u.value ?? u.satoshis ?? u.amount ?? 0),
0
)
return new MemoLike({ wallet }).getSpendableSats() === expected
},
{ label: 'spendable sum conservation' }
)
})
+50
View File
@@ -0,0 +1,50 @@
/*
Unit tests for the hex conversion helper (src/services/hex.js).
Memo actions like replies and likes embed a post txid in the OP_RETURN
payload as raw bytes, so the helper must decode a canonical 64-character hex
string into exactly 32 bytes and reject anything else with a clear error.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { hexToBytes } = require('../../src/services/hex')
test('hexToBytes decodes a 64-char hex string into 32 bytes', () => {
const hex = 'a'.repeat(64)
const bytes = hexToBytes(hex, 32)
assert.ok(bytes instanceof Uint8Array)
assert.equal(bytes.length, 32)
assert.equal(Buffer.from(bytes).toString('hex'), hex)
})
test('hexToBytes rejects a string of the wrong length', () => {
assert.throws(
() => hexToBytes('a'.repeat(10), 32),
/64-character hex string/
)
})
test('hexToBytes rejects a 64-char string containing a non-hex character', () => {
assert.throws(
() => hexToBytes(`z${'a'.repeat(63)}`, 32),
/valid hex string/
)
})
test('hexToBytes rejects non-string input', () => {
assert.throws(
() => hexToBytes(null, 32),
/64-character hex string/
)
})
test('hexToBytes uses a custom label in error messages', () => {
assert.throws(
() => hexToBytes('nope', 32, 'Post txid'),
/Post txid/
)
})
+55 -46
View File
@@ -31,6 +31,23 @@ function build () {
return { wallet, feed, memoLike, page } 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', () => { test('opening the modal sets the target post and author', () => {
const { page } = build() const { page } = build()
@@ -118,30 +135,20 @@ test('submitting a like on a post authored by the wallet works without a tip', a
assert.equal(wallet.broadcasts[0].prefix, '6d04') assert.equal(wallet.broadcasts[0].prefix, '6d04')
}) })
test('submitting with a non-numeric tip is rejected', async () => { test('submitting with a non-numeric tip string is rejected', async () => {
const { wallet, page } = build() for (const tip of ['abc', '1.5']) {
page.open(POST_TXID, AUTHOR_ADDRESS) const { wallet, page } = build()
page.setTip('abc') page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip(tip)
const result = await page.submit() const result = await page.submit()
assert.equal(result.ok, false) assert.equal(result.ok, false)
assert.equal(result.error, 'like_validation') assert.equal(result.error, 'like_validation')
assert.equal(page.submitError, 'like_validation') assert.equal(page.submitError, 'like_validation')
assert.match(page.broadcastError, /valid number/i) assert.match(page.broadcastError, /valid number/i)
assert.equal(wallet.broadcasts.length, 0) assert.equal(wallet.broadcasts.length, 0)
}) }
test('submitting with a decimal tip string is rejected', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('1.5')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_validation')
assert.equal(wallet.broadcasts.length, 0)
}) })
test('submitting with a dust tip is rejected', async () => { test('submitting with a dust tip is rejected', async () => {
@@ -157,33 +164,21 @@ test('submitting with a dust tip is rejected', async () => {
}) })
test('submitting with a tip above the maximum is rejected', async () => { test('submitting with a tip above the maximum is rejected', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 150000000 }] }) await assertTipRejected(
const memoLike = new MemoLike({ wallet }) [{ txid: 'u1', value: 150000000 }],
const page = new LikeTipPage({ memoLike }) '100000001',
page.open(POST_TXID, AUTHOR_ADDRESS) 'like_maximum',
page.setTip('100000001') /maximum/i
)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_maximum')
assert.match(page.broadcastError, /maximum/i)
assert.equal(wallet.broadcasts.length, 0)
}) })
test('submitting with a tip above the spendable balance is rejected', async () => { test('submitting with a tip above the spendable balance is rejected', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 30000 }] }) await assertTipRejected(
const memoLike = new MemoLike({ wallet }) [{ txid: 'u1', value: 30000 }],
const page = new LikeTipPage({ memoLike }) '35000',
page.open(POST_TXID, AUTHOR_ADDRESS) 'like_balance',
page.setTip('35000') /spendable/i
)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_balance')
assert.match(page.broadcastError, /spendable/i)
assert.equal(wallet.broadcasts.length, 0)
}) })
test('tipping flag is true while a submit is in flight and false once it settles', async () => { test('tipping flag is true while a submit is in flight and false once it settles', async () => {
@@ -228,3 +223,17 @@ test('a failed broadcast surfaces the real error', async () => {
assert.equal(page.submitError, 'broadcast') assert.equal(page.submitError, 'broadcast')
assert.match(page.broadcastError, /Insufficient balance/) 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')
})
+38 -79
View File
@@ -35,11 +35,22 @@ function fakeFeed (posts = []) {
} }
} }
// Decode a like payload back into a 64-character hex txid. // Decode a like payload back into the canonical 64-character hex txid.
function decodeLikeTxid (raw) { function decodeLikeTxid (raw) {
return Buffer.from(raw).toString('hex') return Buffer.from(raw).toString('hex')
} }
// Assert that a like with the given post txid and tip rejects with a specific
// error code and performs no broadcast.
async function assertLikeRejected (wallet, tip, expectedCode, postTxid = POST_TXID) {
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(postTxid, tip, AUTHOR_ADDRESS),
(err) => err.code === expectedCode
)
assert.equal(wallet.broadcasts.length, 0)
}
test('MEMO_LIKE_PREFIX is the Memo like action 0x6d04', () => { test('MEMO_LIKE_PREFIX is the Memo like action 0x6d04', () => {
assert.equal(MemoLike.MEMO_LIKE_PREFIX, '6d04') assert.equal(MemoLike.MEMO_LIKE_PREFIX, '6d04')
}) })
@@ -105,71 +116,29 @@ test('liking without a wallet reports a missing-wallet error', async () => {
}) })
test('liking with an invalid post txid reports a clear validation error', async () => { test('liking with an invalid post txid reports a clear validation error', async () => {
const wallet = fakeWallet() await assertLikeRejected(fakeWallet(), 0, 'like_validation', 'not-a-txid')
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like('not-a-txid'),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
}) })
test('liking with a wrong-length but valid-hex post txid is rejected', async () => { test('liking with a wrong-length but valid-hex post txid is rejected', async () => {
const wallet = fakeWallet() await assertLikeRejected(fakeWallet(), 0, 'like_validation', 'a'.repeat(10))
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like('a'.repeat(10)),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
}) })
test('a non-integer tip like "1.5" is rejected with a validation error', async () => { test('a non-integer tip like "1.5" is rejected with a validation error', async () => {
const wallet = fakeWallet() await assertLikeRejected(fakeWallet(), 1.5, 'like_validation')
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, 1.5, AUTHOR_ADDRESS),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
}) })
test('a non-numeric tip like "abc" is rejected with a validation error', async () => { test('a non-numeric tip like "abc" is rejected with a validation error', async () => {
const wallet = fakeWallet() await assertLikeRejected(fakeWallet(), NaN, 'like_validation')
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, NaN, AUTHOR_ADDRESS),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
}) })
test('a negative tip is rejected with a validation error', async () => { test('a negative tip is rejected with a validation error', async () => {
const wallet = fakeWallet() await assertLikeRejected(fakeWallet(), -1, 'like_validation')
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, -1, AUTHOR_ADDRESS),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
}) })
test('a tip below the dust limit is rejected with a dust error', async () => { test('a tip below the dust limit is rejected with a dust error', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] }) const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] })
const memoLike = new MemoLike({ wallet }) await assertLikeRejected(wallet, 1, 'like_dust')
await assertLikeRejected(wallet, 2999, 'like_dust')
for (const tip of [1, 2999]) {
await assert.rejects(
memoLike.like(POST_TXID, tip, AUTHOR_ADDRESS),
(err) => err.code === 'like_dust'
)
}
assert.equal(wallet.broadcasts.length, 0)
}) })
test('a tip at the dust limit is accepted', async () => { test('a tip at the dust limit is accepted', async () => {
@@ -183,47 +152,31 @@ test('a tip at the dust limit is accepted', async () => {
}) })
test('a tip above the hard maximum is rejected with a maximum error', async () => { test('a tip above the hard maximum is rejected with a maximum error', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 150000000 }] }) await assertLikeRejected(
const memoLike = new MemoLike({ wallet }) fakeWallet({ utxos: [{ txid: 'u1', value: 150000000 }] }),
100000001,
await assert.rejects( 'like_maximum'
memoLike.like(POST_TXID, 100000001, AUTHOR_ADDRESS),
(err) => err.code === 'like_maximum'
) )
assert.equal(wallet.broadcasts.length, 0)
}) })
test('a tip above the spendable balance is rejected with a balance error', async () => { test('a tip above the spendable balance is rejected with a balance error', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 30000 }] }) await assertLikeRejected(
const memoLike = new MemoLike({ wallet }) fakeWallet({ utxos: [{ txid: 'u1', value: 30000 }] }),
35000,
await assert.rejects( 'like_balance'
memoLike.like(POST_TXID, 35000, AUTHOR_ADDRESS),
(err) => err.code === 'like_balance'
) )
assert.equal(wallet.broadcasts.length, 0)
}) })
test('a wallet with zero spendable balance cannot like', async () => { test('a wallet with zero spendable balance cannot like', async () => {
const wallet = fakeWallet({ utxos: [] }) await assertLikeRejected(fakeWallet({ utxos: [] }), 0, 'like_empty_balance')
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS),
(err) => err.code === 'like_empty_balance'
)
assert.equal(wallet.broadcasts.length, 0)
}) })
test('a wallet with balance below the dust limit cannot like', async () => { test('a wallet with balance below the dust limit cannot like', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 2999 }] }) await assertLikeRejected(
const memoLike = new MemoLike({ wallet }) fakeWallet({ utxos: [{ txid: 'u1', value: 2999 }] }),
0,
await assert.rejects( 'like_empty_balance'
memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS),
(err) => err.code === 'like_empty_balance'
) )
assert.equal(wallet.broadcasts.length, 0)
}) })
test('a pure like can be made on a post authored by the wallet address', async () => { test('a pure like can be made on a post authored by the wallet address', async () => {
@@ -259,3 +212,9 @@ test('getSpendableSats tolerates common utxo value field names', () => {
assert.equal(new MemoLike({ wallet: walletSatoshis }).getSpendableSats(), 2000) assert.equal(new MemoLike({ wallet: walletSatoshis }).getSpendableSats(), 2000)
assert.equal(new MemoLike({ wallet: walletAmount }).getSpendableSats(), 3000) assert.equal(new MemoLike({ wallet: walletAmount }).getSpendableSats(), 3000)
}) })
test('getSpendableSats returns 0 without a wallet or spendable values', () => {
assert.equal(new MemoLike({}).getSpendableSats(), 0)
assert.equal(new MemoLike({ wallet: { utxos: undefined } }).getSpendableSats(), 0)
assert.equal(new MemoLike({ wallet: { utxos: [{ txid: 'u1' }] } }).getSpendableSats(), 0)
})