Demo images / Build & push demo images (push) Failing after 2m6s
fc98c1d8 replaced the five-minute timer with "stays until seen", but
kept "seen" in component state — so every page load forgot it and the
entire ecash history came back as new. That is worse than the timer it
replaced: the old behaviour at least let receipts go, this one resurrected
them on every refresh. Reported from the node, and correctly.
Acknowledgement now lives in localStorage, capped at 300 keys.
That opens the opposite trap: on a browser with nothing stored, treating
the whole history as unseen is the same wall of old receipts from the
other direction. So a first run seeds everything older than five minutes
as already seen — the window survives as a first-run heuristic, not as
an expiry. Unreadable storage takes the same path, because reading a
corrupt value as "nothing acknowledged" is the refresh bug wearing a hat.
Also guards the balance readout against NaN. `sats == null` does not
catch it, and arithmetic over a missing field produces it, so it would
have rendered as the literal text "NaN sats" — worse than the zero the
component exists to prevent, since a zero at least looks like a number.
Frontend: 1000 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
173 lines
6.9 KiB
TypeScript
173 lines
6.9 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import i18n from '@/i18n'
|
|
import HomeWalletCard from '@/views/home/HomeWalletCard.vue'
|
|
|
|
/**
|
|
* Instant rails (Lightning, Cashu, Fedimint, Ark) settle immediately, so
|
|
* there is no confirmation count to retire an incoming receipt from the
|
|
* badge. It used to leave on a five-minute wall clock, which meant a payment
|
|
* could arrive and vanish before anyone looked — and for ecash, which leaves
|
|
* no public ledger entry, this panel was the only place the receipt was ever
|
|
* shown.
|
|
*
|
|
* These cases pin the replacement: a receipt stays until it has been seen.
|
|
*/
|
|
|
|
const now = () => Math.floor(Date.now() / 1000)
|
|
|
|
function tx(over: Record<string, unknown> = {}) {
|
|
return {
|
|
tx_hash: '',
|
|
amount_sats: 1000,
|
|
direction: 'incoming' as const,
|
|
num_confirmations: 1,
|
|
time_stamp: now(),
|
|
total_fees: 0,
|
|
dest_addresses: [],
|
|
label: '',
|
|
block_height: 0,
|
|
kind: 'cashu' as const,
|
|
...over,
|
|
}
|
|
}
|
|
|
|
const base = {
|
|
animate: false,
|
|
walletConnected: true,
|
|
walletOnchain: 0,
|
|
walletLightning: 0,
|
|
walletEcash: 0,
|
|
walletFedimint: 0,
|
|
walletArk: 0,
|
|
isDev: false,
|
|
}
|
|
|
|
const mountCard = (transactions: ReturnType<typeof tx>[]) =>
|
|
mount(HomeWalletCard, {
|
|
props: { ...base, walletTransactions: transactions },
|
|
global: { plugins: [i18n] },
|
|
})
|
|
|
|
// Acknowledgement is stored per-browser, so each case starts from a clean
|
|
// slate — otherwise one test's "seen" set silently satisfies the next.
|
|
beforeEach(() => localStorage.clear())
|
|
|
|
describe('incoming payments', () => {
|
|
it('retires a receipt on acknowledgement, never on age alone', async () => {
|
|
// On a browser that has acknowledged before, an unacknowledged receipt
|
|
// stays put however old it is — age is not the signal, being seen is.
|
|
// (A brand-new browser is the separate first-run case below.)
|
|
localStorage.setItem('archy-seen-incoming-v1', JSON.stringify(['some-earlier-receipt']))
|
|
const w = mountCard([tx({ time_stamp: now() - 7200 })])
|
|
expect(w.text()).toContain('Incoming 1')
|
|
})
|
|
|
|
it('does not clear the receipt merely because the panel was opened', async () => {
|
|
// "Selecting incoming clears a pending token" — opening must show it,
|
|
// not consume it. Someone reading the row must be able to keep reading.
|
|
const w = mountCard([tx()])
|
|
await w.find('button').trigger('click')
|
|
expect(w.text()).toContain('Incoming Transactions')
|
|
expect(w.text()).toContain('+1,000 sats')
|
|
})
|
|
|
|
it('marks it seen once the panel is closed again', async () => {
|
|
localStorage.setItem('archy-seen-incoming-v1', JSON.stringify([]))
|
|
const w = mountCard([tx()])
|
|
const badge = w.find('button')
|
|
await badge.trigger('click') // open
|
|
await badge.trigger('click') // close — acknowledges
|
|
expect(w.text()).not.toContain('Incoming 1')
|
|
})
|
|
|
|
it('still surfaces a payment that arrives after an earlier one was seen', async () => {
|
|
const first = tx({ amount_sats: 1000, time_stamp: now() - 60 })
|
|
const w = mountCard([first])
|
|
const badge = w.find('button')
|
|
await badge.trigger('click')
|
|
await badge.trigger('click')
|
|
expect(w.text()).not.toContain('Incoming 1')
|
|
|
|
// A different payment must not inherit the first one's acknowledgement.
|
|
await w.setProps({ walletTransactions: [first, tx({ amount_sats: 2500, time_stamp: now() })] })
|
|
expect(w.text()).toContain('Incoming 1')
|
|
})
|
|
|
|
it('leaves on-chain transactions on their confirmation count', () => {
|
|
// On-chain has a real signal and is deliberately untouched: it drops out
|
|
// at three confirmations regardless of whether anyone looked.
|
|
const unconfirmed = mountCard([tx({ kind: 'onchain', num_confirmations: 0, tx_hash: 'abc' })])
|
|
expect(unconfirmed.text()).toContain('Incoming 1')
|
|
|
|
const settled = mountCard([tx({ kind: 'onchain', num_confirmations: 6, tx_hash: 'abc' })])
|
|
expect(settled.text()).not.toContain('Incoming 1')
|
|
})
|
|
|
|
it('lists several instant receipts separately even without txids', () => {
|
|
// Instant rails carry no txid, so keying the list on tx_hash gave every
|
|
// row the same empty key and Vue reused one node for all of them.
|
|
const w = mountCard([
|
|
tx({ amount_sats: 1000, time_stamp: now() - 10 }),
|
|
tx({ amount_sats: 2000, time_stamp: now() - 20, kind: 'lightning' }),
|
|
tx({ amount_sats: 3000, time_stamp: now() - 30, kind: 'fedimint' }),
|
|
])
|
|
expect(w.text()).toContain('Incoming 3')
|
|
})
|
|
|
|
it('does not show a receipt again after a refresh', async () => {
|
|
// The bug this whole model was supposed to prevent, and briefly caused:
|
|
// "seen" lived in component state, so every page load forgot it and the
|
|
// entire ecash history came back as new. Remounting is a refresh.
|
|
const received = tx({ amount_sats: 4200, time_stamp: now() - 30 })
|
|
const first = mountCard([received])
|
|
const badge = first.find('button')
|
|
await badge.trigger('click')
|
|
await badge.trigger('click')
|
|
expect(first.text()).not.toContain('Incoming 1')
|
|
|
|
const afterRefresh = mountCard([received])
|
|
expect(afterRefresh.text()).not.toContain('Incoming 1')
|
|
})
|
|
|
|
it('does not greet a brand-new browser with the whole history', () => {
|
|
// Nothing acknowledged yet and a long history: treating all of it as
|
|
// unseen would be the same wall of old receipts, just from the other
|
|
// direction. Only what is genuinely recent counts as news on a first run.
|
|
const old = [
|
|
tx({ amount_sats: 100, time_stamp: now() - 86400 }),
|
|
tx({ amount_sats: 200, time_stamp: now() - 3600 }),
|
|
tx({ amount_sats: 300, time_stamp: now() - 600 }),
|
|
]
|
|
expect(mountCard(old).text()).not.toContain('Incoming')
|
|
|
|
// …but a receipt from moments ago still is.
|
|
localStorage.clear()
|
|
expect(mountCard([...old, tx({ amount_sats: 400, time_stamp: now() - 5 })]).text())
|
|
.toContain('Incoming 1')
|
|
})
|
|
|
|
it('survives unreadable storage without resurrecting the history', () => {
|
|
// A corrupt value must not read as "nothing has been acknowledged" — that
|
|
// is precisely the refresh bug wearing a different hat.
|
|
localStorage.setItem('archy-seen-incoming-v1', '{not json')
|
|
const w = mountCard([tx({ amount_sats: 100, time_stamp: now() - 86400 })])
|
|
expect(w.text()).not.toContain('Incoming')
|
|
})
|
|
|
|
it('does not silently turn into a navigation button while the panel is open', async () => {
|
|
// The badge is two controls in one: with receipts it toggles the panel,
|
|
// without them it navigates to the full transactions view. If the list
|
|
// empties while the panel is open, the click under the user's cursor used
|
|
// to change meaning and take them to another screen.
|
|
const w = mountCard([tx()])
|
|
const badge = w.find('button')
|
|
await badge.trigger('click')
|
|
expect(w.text()).toContain('Incoming Transactions')
|
|
|
|
await w.setProps({ walletTransactions: [] })
|
|
await badge.trigger('click')
|
|
expect(w.emitted('showTransactions')).toBeUndefined()
|
|
})
|
|
})
|