Harden recent profile identity with property tests

Add seeded property tests for the new identity join and table view model:
profile-path round trips, display-name fallback, account mapping, table
row shape, DB identity-field independence, missing-store null behavior,
and page-only join with conserved order and pagination.

Cover the account-cell click handler so RecentProfileAccount reaches 100%
unit coverage (CRAP <= 2).

Tests only; no production behavior changed.

By refactorer.
This commit is contained in:
Chris Troutner
2026-09-20 13:00:03 -07:00
parent 9fb092e845
commit 63107855a2
3 changed files with 324 additions and 0 deletions
@@ -0,0 +1,118 @@
/*
Property tests for the Recent Profiles table view model.
The unit tests probe buildRecentProfileAccount and buildRecentProfilesTable at
fixed fixtures. These properties pin the view-model contract over broad random
profiles:
- Link round trip: the profile path percent-encodes the address and decodes
back to it, for addresses mixing real cash-addr characters, routing
punctuation, reserved URL characters, and unicode.
- Display-name fallback: a non-empty name is shown verbatim; an absent name
falls back to the truncated address.
- Account mapping: the account preserves the address and maps the name,
avatar (null when absent), and profile path.
- Table shape: the headers stay fixed and there is exactly one account row
per input profile, in input order.
All generation is seeded, so runs are reproducible.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll, intGen } = require('./harness')
const {
PROFILE_PATH_PREFIX,
RECENT_PROFILES_TABLE_HEADERS,
profilePath,
accountDisplayName,
buildRecentProfileAccount,
buildRecentProfilesTable
} = require('../../src/services/recent-profiles-table')
const { truncateAddr } = require('../../src/util')
const rng = seededRandom(20260920)
const ADDR_CHARS = Array.from('abcdefghijklmnopqrstuvwxyz0123456789:?&=#%+/ são😀')
const NAMES = ['alice', 'bob', '名字', 'a/b', '', null, undefined]
function randomAddr () {
const length = intGen(rng, 1, 64)()
let addr = ''
for (let i = 0; i < length; i++) {
addr += ADDR_CHARS[Math.floor(rng() * ADDR_CHARS.length)]
}
return addr
}
function randomName () {
return NAMES[Math.floor(rng() * NAMES.length)]
}
function randomProfile () {
const profile = { addr: randomAddr(), name: randomName() }
const roll = rng()
if (roll < 0.6) {
profile.profilePicUrl = `https://example.com/${encodeURIComponent(randomAddr())}.png`
} else if (roll < 0.8) {
profile.profilePicUrl = null
} else {
profile.profilePicUrl = undefined
}
return profile
}
function profileListGen () {
return () => Array.from({ length: intGen(rng, 0, 12)() }, randomProfile)
}
test('profilePath percent-encodes the address and round-trips it', async () => {
await forAll(
(i) => randomAddr(),
(addr) => {
const path = profilePath(addr)
if (!path.startsWith(`${PROFILE_PATH_PREFIX}/`)) return false
return decodeURIComponent(path.slice(PROFILE_PATH_PREFIX.length + 1)) === addr
},
{ label: 'recent profile path round trip' }
)
})
test('accountDisplayName prefers a non-empty name and falls back to the truncated address', async () => {
await forAll(
(i) => ({ addr: randomAddr(), name: randomName() }),
({ addr, name }) => accountDisplayName(addr, name) === (name || truncateAddr(addr, 24)),
{ label: 'recent profile display name fallback' }
)
})
test('buildRecentProfileAccount preserves the address and maps name, avatar, and path', async () => {
await forAll(
(i) => randomProfile(),
(profile) => {
const account = buildRecentProfileAccount(profile)
return account.addr === profile.addr &&
account.displayName === accountDisplayName(profile.addr, profile.name) &&
account.avatarUrl === (profile.profilePicUrl || null) &&
account.profilePath === profilePath(profile.addr)
},
{ label: 'recent profile account mapping' }
)
})
test('buildRecentProfilesTable keeps one account row per profile in input order', async () => {
await forAll(
profileListGen(),
(profiles) => {
const table = buildRecentProfilesTable(profiles)
if (JSON.stringify(table.headers) !== JSON.stringify(RECENT_PROFILES_TABLE_HEADERS)) return false
if (table.rows.length !== profiles.length) return false
return table.rows.every((row, i) => {
if (row.addr !== profiles[i].addr) return false
return JSON.stringify(row.account) === JSON.stringify(buildRecentProfileAccount(profiles[i]))
})
},
{ label: 'recent profiles table rows' }
)
})
@@ -66,3 +66,32 @@ test('links the avatar and display name to the profile', () => {
assert.ok(html.includes('class="recent-profile-name-link"'))
assert.equal(html.split(`href="${PROFILE_PATH}"`).length - 1, 2)
})
// Invoke the component function directly so the anchor click handlers can be
// exercised without a DOM.
function tree (props) {
return RecentProfileAccount(props)
}
test('clicking either account link prevents the default and navigates to the profile', () => {
const clicked = []
const [avatarLink, nameLink] = tree({
account: makeAccount(),
onProfileClick: (path) => clicked.push(path)
}).props.children
let prevented = 0
const event = { preventDefault: () => { prevented += 1 } }
avatarLink.props.onClick(event)
nameLink.props.onClick(event)
assert.equal(prevented, 2)
assert.deepEqual(clicked, [PROFILE_PATH, PROFILE_PATH])
})
test('clicking an account link without a navigation handler is a no-op', () => {
const [avatarLink, nameLink] = tree({ account: makeAccount() }).props.children
assert.doesNotThrow(() => avatarLink.props.onClick({ preventDefault: () => {} }))
assert.doesNotThrow(() => nameLink.props.onClick({ preventDefault: () => {} }))
})