fix: harden node upgrades and prepare 1.9.0-alpha
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import BumpFeeModal from '../BumpFeeModal.vue'
|
||||
import TransactionsModal from '../TransactionsModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
const explorer = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/composables/useTxExplorer', () => ({ useTxExplorer: () => ({ openTx: explorer }) }))
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (s: string) => s }) }))
|
||||
const quote = { quote_id: 'q', txid: 'a'.repeat(64), expires_at: Math.floor(Date.now() / 1000) + 60, method: 'cpfp', recipient_sats: 161650, current_fee_sats: 144, additional_fee_sats: 618, total_fee_sats: 762, budget_sats: 618, rate_sat_vb: 3 }
|
||||
const stubs = { BaseModal: { template: '<div><slot /></div>' } }
|
||||
function open() {
|
||||
const wrapper = mount(BumpFeeModal, { props: { show: true, txid: quote.txid }, global: { stubs } })
|
||||
return { wrapper, vm: (wrapper.vm as any).$.setupState }
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'lnd.bump-status') return { status: 'none' } as never
|
||||
return { ...quote } as never
|
||||
})
|
||||
})
|
||||
describe('Bump review', () => {
|
||||
it('defaults to next block and clearly explains CPFP with additional/total preview', async () => {
|
||||
const { wrapper } = open(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('CPFP · Spend change')
|
||||
expect(wrapper.text()).toContain('RBF is unavailable')
|
||||
expect(wrapper.text()).toContain('618 sats')
|
||||
expect(wrapper.text()).toContain('762 sats')
|
||||
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit' }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows RBF only for a backend-supported sweep', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => (method === 'lnd.bump-status' ? { status: 'none' } : { ...quote, method: 'rbf' }) as never)
|
||||
const { wrapper } = open(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('RBF · Replace sweep')
|
||||
expect(wrapper.text()).not.toContain('RBF is unavailable')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('invalidates the old quote when custom settings change', async () => {
|
||||
const { wrapper, vm } = open(); await flushPromises()
|
||||
vm.mode = 'custom'; await flushPromises()
|
||||
expect(vm.quote).toBeNull()
|
||||
vm.customRate = 0.5; await flushPromises(); await vm.getQuote()
|
||||
expect(vm.error).toContain('whole-number')
|
||||
vm.customRate = 10; await flushPromises(); await vm.getQuote()
|
||||
expect(rpcClient.call).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'lnd.bump-quote', params: { txid: quote.txid, sat_per_vbyte: 10 } }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('blocks expired quotes and duplicate clicks; never retries a mutation', async () => {
|
||||
const { wrapper, vm } = open(); await flushPromises()
|
||||
vm.quote.expires_at = 1; await vm.submit()
|
||||
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit' }))
|
||||
vm.quote.expires_at = Math.floor(Date.now()/1000) + 60
|
||||
let finish!: (value: any) => void
|
||||
vi.mocked(rpcClient.call).mockImplementation(({ method }) => method === 'lnd.bump-submit' ? new Promise(resolve => { finish = resolve }) as never : Promise.resolve({ status: 'registered', message: 'Waiting for broadcast' }) as never)
|
||||
const pending = vm.submit(); await vm.submit()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'lnd.bump-submit')).toHaveLength(1)
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit', maxRetries: 1 }))
|
||||
finish({ status: 'registered', message: 'Waiting for broadcast' }); await pending
|
||||
expect(wrapper.text()).toContain('Waiting for broadcast')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps unknown submission outcome from becoming a second payment', async () => {
|
||||
const { wrapper, vm } = open(); await flushPromises()
|
||||
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Network timeout'))
|
||||
await vm.submit()
|
||||
expect(vm.operation.status).toBe('unknown')
|
||||
expect(wrapper.text()).not.toContain('Confirm bump')
|
||||
expect(wrapper.text()).toContain('Check status')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reconciles a previous submission when reopening', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ status: 'mempool', message: 'Accepted, awaiting confirmation', bump_txid: 'b'.repeat(64), actual_sweep_fee_sats: 618 } as never)
|
||||
const { wrapper } = open(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('BUMP BROADCAST')
|
||||
expect(wrapper.text()).toContain('Awaiting confirmation')
|
||||
expect(rpcClient.call).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).not.toContain('Confirm bump')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses the existing green success animation only after verified acceptance', async () => {
|
||||
const { wrapper, vm } = open(); await flushPromises()
|
||||
vm.operation = { status: 'registered', message: 'Waiting for broadcast' }; await flushPromises()
|
||||
expect(wrapper.find('.send-success-badge').exists()).toBe(false)
|
||||
vm.operation = { status: 'mempool', message: 'Accepted', bump_txid: 'b', quote: { method: 'rbf' } }; await flushPromises()
|
||||
expect(wrapper.find('.send-success-badge').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('BUMP BROADCAST')
|
||||
expect(wrapper.text()).toContain('Awaiting confirmation')
|
||||
vm.operation.status = 'confirmed'; await flushPromises()
|
||||
expect(wrapper.text()).toContain('CONFIRMED')
|
||||
expect(wrapper.text()).not.toContain('Awaiting confirmation')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('offers Bump only on pending on-chain rows and stops explorer navigation', async () => {
|
||||
const tx = { tx_hash: quote.txid, amount_sats: 161794, direction: 'outgoing', num_confirmations: 0, time_stamp: 1, total_fees: 144, dest_addresses: [], label: '', block_height: 0 }
|
||||
const wrapper = mount(TransactionsModal, { props: { show: true, transactions: [tx, { ...tx, tx_hash: 'b', num_confirmations: 1 }, { ...tx, tx_hash: 'c', kind: 'lightning' }] as any }, global: { stubs: { ...stubs, BumpFeeModal: true } } })
|
||||
const buttons = wrapper.findAll('button').filter(b => b.text() === 'Bump')
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0]!.trigger('click')
|
||||
expect(explorer).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'BumpFeeModal' }).props('txid')).toBe(quote.txid)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Grouped fee-bump history', () => {
|
||||
it('preserves recipient amount, counts only active extra fees, and opens the replacement bump', async () => {
|
||||
const parent = { tx_hash: 'a'.repeat(64), amount_sats: 100144, total_fees: 144, direction: 'outgoing' as const,
|
||||
num_confirmations: 0, time_stamp: 1, dest_addresses: [], label: '', block_height: 0,
|
||||
bump_fee_sats: 600, fee_bump_txid: 'c'.repeat(64), fee_bump_confirmations: 0,
|
||||
fee_bump_history: [{ tx_hash: 'b'.repeat(64), fee_sats: 300, status: 'replaced' },
|
||||
{ tx_hash: 'c'.repeat(64), fee_sats: 600, status: 'mempool' }] }
|
||||
const wrapper = mount(TransactionsModal, { props: { show: true, transactions: [parent] }, global: { stubs } })
|
||||
expect(wrapper.text()).toContain('-100,000 sats')
|
||||
expect(wrapper.text()).toContain('fee 744 sats')
|
||||
expect(wrapper.text()).toContain('Replaced bump')
|
||||
await wrapper.get(`button[aria-label="Bump transaction ${parent.tx_hash}"]`).trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(BumpFeeModal).props('txid')).toBe(parent.fee_bump_txid)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,20 @@ function open() {
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) })
|
||||
describe('channel closing fee choice', () => {
|
||||
it('defaults to next block, preserves explicit choices, and resets a new close to Fast', async () => {
|
||||
const { wrapper, vm } = open()
|
||||
expect(vm.closeForm.feePreset).toBe('fast')
|
||||
await vm.closeChannel()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 1 } }))
|
||||
vm.confirmClose(channel)
|
||||
vm.closeForm.feePreset = 'standard'
|
||||
await vm.closeChannel()
|
||||
expect(rpcClient.call).toHaveBeenLastCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 6 } }))
|
||||
vm.confirmClose(channel)
|
||||
expect(vm.closeForm.feePreset).toBe('fast')
|
||||
expect(vm.openForm.feePreset).toBe('fast')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([['standard', 6], ['medium', 3], ['fast', 1]])('forwards %s target and never automatically retries the mutation', async (preset, target) => {
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = preset
|
||||
await vm.closeChannel()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MeshDeviceSetupModal from '../mesh/MeshDeviceSetupModal.vue'
|
||||
|
||||
const state = reactive({
|
||||
flashFlowPath: null as string | null,
|
||||
undismissedDetectedDevices: [] as string[],
|
||||
status: {
|
||||
device_type: 'meshcore',
|
||||
detected_device_info: [{ path: '/dev/fixture', vid: '10c4', pid: 'ea60' }],
|
||||
},
|
||||
flashDevice: vi.fn(),
|
||||
flashStatus: vi.fn(),
|
||||
closeFlashFlow: vi.fn(),
|
||||
})
|
||||
vi.mock('@/stores/mesh', () => ({ useMeshStore: () => state }))
|
||||
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ serverName: 'Fixture' }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
async function open() {
|
||||
const wrapper = mount(MeshDeviceSetupModal, {
|
||||
global: { stubs: { BaseModal: { template: '<div><slot /></div>' } } },
|
||||
})
|
||||
state.flashFlowPath = '/dev/fixture'
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('LoRa firmware board selection', () => {
|
||||
beforeEach(() => {
|
||||
state.flashFlowPath = null
|
||||
state.flashDevice.mockReset()
|
||||
})
|
||||
|
||||
it('does not infer Heltec V3 from a generic CP2102 adapter', async () => {
|
||||
const wrapper = await open()
|
||||
const board = wrapper.findAll('select')[1]!
|
||||
expect((board.element as HTMLSelectElement).value).toBe('')
|
||||
await wrapper.find('input[type="checkbox"]').setValue(true)
|
||||
const flash = wrapper.findAll('button').find(button => button.text().includes('Erase & Flash Now'))!
|
||||
expect((flash.element as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(state.flashDevice).not.toHaveBeenCalled()
|
||||
await board.setValue('heltec-v3')
|
||||
await flash.trigger('click')
|
||||
expect(state.flashDevice).toHaveBeenCalledExactlyOnceWith('/dev/fixture', 'meshcore', 'heltec-v3')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows a failed tool preflight without entering flashing progress', async () => {
|
||||
state.flashDevice.mockRejectedValueOnce(new Error('Radio flashing tools are missing'))
|
||||
const wrapper = await open()
|
||||
await wrapper.findAll('select')[1]!.setValue('heltec-v4')
|
||||
await wrapper.find('input[type="checkbox"]').setValue(true)
|
||||
await wrapper.findAll('button').find(button => button.text().includes('Erase & Flash Now'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Radio flashing tools are missing')
|
||||
expect(wrapper.text()).toContain('Erase & Flash Now')
|
||||
expect(state.flashStatus).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SendBitcoinModal from '../SendBitcoinModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (s: string) => s }) }))
|
||||
vi.mock('@/composables/useLightningRequired', () => ({ useLightningRequired: () => ({}) }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'lnd.getinfo') return { balance_sats: 100000 } as never
|
||||
if (method === 'lnd.estimatefee') return { fee_sat: 500, sat_per_vbyte: 3 } as never
|
||||
return { txid: 'a'.repeat(64) } as never
|
||||
})
|
||||
})
|
||||
function open() {
|
||||
const wrapper = mount(SendBitcoinModal, { props: { show: true }, global: { stubs: { BaseModal: { template: '<div><slot /></div>' } } } })
|
||||
const vm = (wrapper.vm as any).$.setupState
|
||||
vm.sendMethod = 'onchain'; vm.dest = 'bc1qtestaddress'; vm.amount = 1000
|
||||
return { wrapper, vm }
|
||||
}
|
||||
describe('on-chain fee defaults', () => {
|
||||
it.each([NaN, Infinity, 0.5, -1, 5001])('rejects invalid custom rate %s before preview or submission', async rate => {
|
||||
const { wrapper, vm } = open()
|
||||
vm.feePreset = 'custom'; vm.customSatPerVbyte = rate
|
||||
await vm.review()
|
||||
expect(vm.confirming).toBe(false)
|
||||
expect(vm.error).toContain('whole number')
|
||||
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([['fast', 1], ['standard', 6], ['slow', 144]])('uses %s consistently in preview and submission', async (preset, target) => {
|
||||
const { wrapper, vm } = open()
|
||||
expect(vm.feePreset).toBe('fast')
|
||||
vm.feePreset = preset
|
||||
await vm.review(); await flushPromises()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.estimatefee', params: expect.objectContaining({ target_conf: target }) }))
|
||||
await vm.send()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.sendcoins', params: expect.objectContaining({ target_conf: target }) }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves an explicit custom rate and resets reopened/new sends to Fast', async () => {
|
||||
const { wrapper, vm } = open()
|
||||
vm.feePreset = 'custom'; vm.customSatPerVbyte = 17
|
||||
await vm.review(); await flushPromises(); await vm.send()
|
||||
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.sendcoins', params: expect.objectContaining({ sat_per_vbyte: 17 }) }))
|
||||
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.estimatefee' }))
|
||||
vm.sendAnother(); expect(vm.feePreset).toBe('fast'); expect(vm.customSatPerVbyte).toBeNull()
|
||||
vm.feePreset = 'slow'
|
||||
await wrapper.setProps({ show: false }); await wrapper.setProps({ show: true })
|
||||
expect(vm.feePreset).toBe('fast'); expect(vm.resolvedFeeParams).toEqual({})
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user