From dea4dd0c56019ab93ef681863f33f1f0ef9f1a03 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 22:23:49 +0530 Subject: [PATCH 01/28] chore: ignore .worktrees directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ec94a961b..cf5c50fea 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ npm-debug.log* .idea/ +.worktrees From 38747846931ccc04ad825dbb9a85708fd20b9e7c Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 22:46:13 +0530 Subject: [PATCH 02/28] test: unit tests for Send/helpers isValidAddress, isValidAmount, isValidNumCollaborators --- src/components/Send/helpers.test.ts | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/components/Send/helpers.test.ts diff --git a/src/components/Send/helpers.test.ts b/src/components/Send/helpers.test.ts new file mode 100644 index 000000000..36ad57cd4 --- /dev/null +++ b/src/components/Send/helpers.test.ts @@ -0,0 +1,67 @@ +import { isValidAddress, isValidAmount, isValidNumCollaborators, MAX_NUM_COLLABORATORS } from './helpers' + +describe('isValidAddress', () => { + it('returns true for a non-empty string', () => { + expect(isValidAddress('bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toBe(true) + }) + + it('returns false for empty string', () => { + expect(isValidAddress('')).toBe(false) + }) + + it('returns false for null', () => { + expect(isValidAddress(null)).toBe(false) + }) +}) + +describe('isValidAmount', () => { + it('returns true for positive amount when not sweep', () => { + expect(isValidAmount(100_000, false)).toBe(true) + }) + + it('returns true for zero in sweep mode', () => { + expect(isValidAmount(0, true)).toBe(true) + }) + + it('returns false for zero when not sweep', () => { + expect(isValidAmount(0, false)).toBe(false) + }) + + it('returns false for negative amount', () => { + expect(isValidAmount(-1, false)).toBe(false) + }) + + it('returns false for null', () => { + expect(isValidAmount(null, false)).toBe(false) + }) + + it('returns false for NaN', () => { + expect(isValidAmount(NaN, false)).toBe(false) + }) +}) + +describe('isValidNumCollaborators', () => { + it('returns true at lower boundary (minValue)', () => { + expect(isValidNumCollaborators(3, 3)).toBe(true) + }) + + it('returns true at upper boundary (MAX_NUM_COLLABORATORS = 99)', () => { + expect(isValidNumCollaborators(MAX_NUM_COLLABORATORS, 1)).toBe(true) + }) + + it('returns false one below minValue', () => { + expect(isValidNumCollaborators(2, 3)).toBe(false) + }) + + it('returns false one above MAX_NUM_COLLABORATORS', () => { + expect(isValidNumCollaborators(100, 1)).toBe(false) + }) + + it('returns false for null', () => { + expect(isValidNumCollaborators(null, 1)).toBe(false) + }) + + it('returns false for NaN', () => { + expect(isValidNumCollaborators(NaN, 1)).toBe(false) + }) +}) From 838a66e92cf410e35cec116368a4297078085102 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 22:49:28 +0530 Subject: [PATCH 03/28] test: add sweep-mode positive and NaN cases to isValidAmount tests --- src/components/Send/helpers.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/Send/helpers.test.ts b/src/components/Send/helpers.test.ts index 36ad57cd4..5dd94be93 100644 --- a/src/components/Send/helpers.test.ts +++ b/src/components/Send/helpers.test.ts @@ -38,6 +38,14 @@ describe('isValidAmount', () => { it('returns false for NaN', () => { expect(isValidAmount(NaN, false)).toBe(false) }) + + it('returns false for positive amount in sweep mode', () => { + expect(isValidAmount(100_000, true)).toBe(false) + }) + + it('returns false for NaN in sweep mode', () => { + expect(isValidAmount(NaN, true)).toBe(false) + }) }) describe('isValidNumCollaborators', () => { @@ -45,7 +53,7 @@ describe('isValidNumCollaborators', () => { expect(isValidNumCollaborators(3, 3)).toBe(true) }) - it('returns true at upper boundary (MAX_NUM_COLLABORATORS = 99)', () => { + it('returns true at upper boundary (MAX_NUM_COLLABORATORS)', () => { expect(isValidNumCollaborators(MAX_NUM_COLLABORATORS, 1)).toBe(true) }) From 21d32487d0acf520818ea3b0e3a75147f3cec36d Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 22:51:26 +0530 Subject: [PATCH 04/28] test: unit tests for isFeatureEnabled version boundary logic --- src/constants/features.test.ts | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/constants/features.test.ts diff --git a/src/constants/features.test.ts b/src/constants/features.test.ts new file mode 100644 index 000000000..f533c5e35 --- /dev/null +++ b/src/constants/features.test.ts @@ -0,0 +1,49 @@ +import { isFeatureEnabled } from './features' +import type { ServiceInfo } from '../context/ServiceInfoContext' + +const makeServiceInfo = (version: SemVer): ServiceInfo => + ({ + sessionActive: false, + makerRunning: false, + coinjoinInProgress: false, + rescanning: false, + walletFileName: null, + schedule: null, + offers: null, + nickname: null, + server: { version }, + }) as ServiceInfo + +describe('isFeatureEnabled — txFeeOnSend (requires >= 0.9.11)', () => { + it('returns false for version 0.9.10', () => { + expect(isFeatureEnabled('txFeeOnSend', makeServiceInfo({ major: 0, minor: 9, patch: 10 }))).toBe(false) + }) + + it('returns true for version 0.9.11 (exact minimum)', () => { + expect(isFeatureEnabled('txFeeOnSend', makeServiceInfo({ major: 0, minor: 9, patch: 11 }))).toBe(true) + }) + + it('returns true for version 0.9.12', () => { + expect(isFeatureEnabled('txFeeOnSend', makeServiceInfo({ major: 0, minor: 9, patch: 12 }))).toBe(true) + }) + + it('returns true for version 1.0.0', () => { + expect(isFeatureEnabled('txFeeOnSend', makeServiceInfo({ major: 1, minor: 0, patch: 0 }))).toBe(true) + }) + + it('returns false when server is undefined', () => { + const info: ServiceInfo = makeServiceInfo({ major: 1, minor: 0, patch: 0 }) + delete (info as any).server + expect(isFeatureEnabled('txFeeOnSend', info)).toBe(false) + }) +}) + +describe('isFeatureEnabled — importWallet (requires >= 0.9.10)', () => { + it('returns false for version 0.9.9', () => { + expect(isFeatureEnabled('importWallet', makeServiceInfo({ major: 0, minor: 9, patch: 9 }))).toBe(false) + }) + + it('returns true for version 0.9.10', () => { + expect(isFeatureEnabled('importWallet', makeServiceInfo({ major: 0, minor: 9, patch: 10 }))).toBe(true) + }) +}) From 2f2068b9f5afd30cd9ec578ffc661d79cf3ddc9e Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 22:58:52 +0530 Subject: [PATCH 05/28] test: unit tests for utxoTags status, label, fidelity bond, and locktime logic --- src/components/utxo/utils.test.ts | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/components/utxo/utils.test.ts diff --git a/src/components/utxo/utils.test.ts b/src/components/utxo/utils.test.ts new file mode 100644 index 000000000..70095563c --- /dev/null +++ b/src/components/utxo/utils.test.ts @@ -0,0 +1,66 @@ +import type { TFunction } from 'i18next' +import { utxoTags } from './utils' +import type { Utxo, WalletInfo } from '../../context/WalletContext' + +const t: TFunction = ((key: string) => key) as TFunction + +const baseUtxo: Utxo = { + address: 'bc1qtest', + path: "m/84'/0'/0'/0/0", + label: '', + value: 100_000, + tries: 0, + tries_remaining: 0, + external: false, + mixdepth: 0, + confirmations: 6, + frozen: false, + utxo: 'abc123:0', + locktime: undefined, +} + +const makeWalletInfo = (status: string): WalletInfo => + ({ + addressSummary: { bc1qtest: { status } }, + fidelityBondSummary: { fbOutputs: [] }, + utxosByJar: {}, + balanceSummary: {} as any, + data: {} as any, + }) as unknown as WalletInfo + +describe('utxoTags', () => { + it('returns cj-out tag for cj-out status', () => { + const tags = utxoTags(baseUtxo, makeWalletInfo('cj-out'), t) + expect(tags.some((tag) => tag.value === 'cj-out')).toBe(true) + expect(tags.find((tag) => tag.value === 'cj-out')?.color).toBe('success') + }) + + it('returns danger color for reused status', () => { + const tags = utxoTags(baseUtxo, makeWalletInfo('reused'), t) + expect(tags.find((tag) => tag.value === 'reused')?.color).toBe('danger') + }) + + it('returns label tag when utxo has a label', () => { + const utxoWithLabel = { ...baseUtxo, label: 'savings' } + const tags = utxoTags(utxoWithLabel, makeWalletInfo('new'), t) + expect(tags.some((tag) => tag.value === 'savings')).toBe(true) + }) + + it('returns fidelity bond tag for fb utxo', () => { + const fbUtxo = { ...baseUtxo, locktime: '2099-12-01 00:00:00' } + const tags = utxoTags(fbUtxo, makeWalletInfo('new'), t) + expect(tags.some((tag) => tag.value === 'bond')).toBe(true) + }) + + it('strips bracket suffixes from status (e.g. "reused [FROZEN]")', () => { + const tags = utxoTags(baseUtxo, makeWalletInfo('reused [FROZEN]'), t) + expect(tags.some((tag) => tag.value === 'reused')).toBe(true) + expect(tags.every((tag) => !tag.value.includes('['))).toBe(true) + }) + + it('returns no status tag when utxo has locktime', () => { + const lockedUtxo = { ...baseUtxo, locktime: '2099-12-01 00:00:00' } + const tags = utxoTags(lockedUtxo, makeWalletInfo('cj-out'), t) + expect(tags.every((tag) => tag.value !== 'cj-out')).toBe(true) + }) +}) From 788ac913c1a344c333282855167f4387b10abaca Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 23:04:02 +0530 Subject: [PATCH 06/28] test: component tests for SendForm field rendering --- src/components/Send/SendForm.test.tsx | 86 +++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/components/Send/SendForm.test.tsx diff --git a/src/components/Send/SendForm.test.tsx b/src/components/Send/SendForm.test.tsx new file mode 100644 index 000000000..4a5f0a17c --- /dev/null +++ b/src/components/Send/SendForm.test.tsx @@ -0,0 +1,86 @@ +import { BrowserRouter } from 'react-router-dom' +import { render, screen, fireEvent } from '../../testUtils' +import { SendForm } from './SendForm' +import type { WalletInfo } from '../../context/WalletContext' +import type { SendFormValues } from './SendForm' + +jest.mock('../../libs/JmWalletApi', () => ({ + ...jest.requireActual('../../libs/JmWalletApi'), + getAddressNew: jest.fn(), +})) + +const mockWalletInfo: Partial = { + balanceSummary: { + calculatedTotalBalanceInSats: 0, + calculatedAvailableBalanceInSats: 0, + calculatedFrozenOrLockedBalanceInSats: 0, + accountBalances: {}, + }, + addressSummary: {}, + fidelityBondSummary: { fbOutputs: [] }, + utxosByJar: { 0: [] }, + data: { + utxos: { utxos: [] }, + display: { walletinfo: { accounts: [], total_balance: '0.00000000' } }, + } as any, +} + +const initialValues: SendFormValues = { + sourceJarIndex: 0, + destination: { value: null, fromJar: null }, + amount: { value: null, isSweep: false }, + isCoinJoin: true, + numCollaborators: 3, + txFee: undefined, +} + +const defaultProps = { + wallet: { name: 'test.jmdat', token: 'mock-token' }, + walletInfo: mockWalletInfo as WalletInfo, + initialValues, + onSubmit: jest.fn().mockResolvedValue(undefined), + isLoading: false, + minNumCollaborators: 3, + loadNewWalletAddress: jest.fn().mockResolvedValue('bc1qtest'), + reloadFeeConfigValues: jest.fn(), +} + +const setup = (overrides = {}) => + render( + + + , + ) + +describe('', () => { + it('renders destination address field', () => { + setup() + expect(screen.getByRole('textbox', { name: /recipient/i })).toBeInTheDocument() + }) + + it('renders amount field', () => { + setup() + // BitcoinAmountInput initially renders as type="text" (switches to number on focus) + expect(screen.getByRole('textbox', { name: /amount/i })).toBeInTheDocument() + }) + + it('renders coinjoin toggle', () => { + setup() + const buttons = screen.getAllByRole('button') + const sendingOptionsBtn = buttons.find((b) => /sending/i.test(b.textContent || '')) + expect(sendingOptionsBtn).toBeTruthy() + fireEvent.click(sendingOptionsBtn!) + expect(screen.getByRole('checkbox')).toBeInTheDocument() + }) + + it('renders collaborators field when coinjoin is on', () => { + setup() + // Open the accordion by clicking the sending options button + const buttons = screen.getAllByRole('button') + const sendingOptionsBtn = buttons.find((b) => /sending/i.test(b.textContent || '')) + expect(sendingOptionsBtn).toBeTruthy() + fireEvent.click(sendingOptionsBtn!) + // CollaboratorsSelector renders a custom number input; i18n in tests returns keys directly + expect(screen.getByPlaceholderText(/num_collaborators_placeholder/i)).toBeInTheDocument() + }) +}) From 9a5a181cea4aaa7ea19430e394ec63f506d058f5 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 23:20:18 +0530 Subject: [PATCH 07/28] test: integration tests for Send page rendering and loading state --- src/components/Send/index.test.tsx | 53 ++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/components/Send/index.test.tsx diff --git a/src/components/Send/index.test.tsx b/src/components/Send/index.test.tsx new file mode 100644 index 000000000..572cd877c --- /dev/null +++ b/src/components/Send/index.test.tsx @@ -0,0 +1,53 @@ +import { BrowserRouter } from 'react-router-dom' +import { act, render, screen, waitFor } from '../../testUtils' +import Send from './index' +import * as apiMock from '../../libs/JmWalletApi' + +jest.mock('../../libs/JmWalletApi', () => ({ + ...jest.requireActual('../../libs/JmWalletApi'), + getWalletDisplay: jest.fn(), + getWalletUtxos: jest.fn(), + postCoinjoin: jest.fn(), + postDirectSend: jest.fn(), + postConfigGet: jest.fn(), + getSession: jest.fn(), + getGetinfo: jest.fn(), +})) + +const neverResolves = new Promise(() => {}) + +const mockWallet = { name: 'test.jmdat', token: 'mock-token' } + +const setup = () => + render( + + + , + ) + +describe('', () => { + beforeEach(() => { + ;(apiMock.getWalletDisplay as jest.Mock).mockReturnValue(neverResolves) + ;(apiMock.getWalletUtxos as jest.Mock).mockReturnValue(neverResolves) + ;(apiMock.postConfigGet as jest.Mock).mockReturnValue(neverResolves) + ;(apiMock.getSession as jest.Mock).mockReturnValue(neverResolves) + ;(apiMock.getGetinfo as jest.Mock).mockReturnValue(neverResolves) + }) + + it('renders without crashing', async () => { + await act(async () => { + setup() + }) + // PageTitle renders a div with the i18n key as text (empty translations in test env) + expect(screen.getByText('send.title')).toBeInTheDocument() + }) + + it('shows loading state while fetching wallet info', async () => { + await act(async () => { + setup() + }) + // SendForm destination input not shown while wallet info is loading + // (DestinationInputField uses aria-label={label} where label = t('send.label_recipient')) + expect(screen.queryByRole('textbox', { name: /send\.label_recipient/i })).not.toBeInTheDocument() + }) +}) From c6bb4716012b60099c5e6af08f68573f71d1d425 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 23:24:38 +0530 Subject: [PATCH 08/28] test: component tests for Earn page rendering and initial state --- src/components/Earn.test.tsx | 51 ++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/components/Earn.test.tsx diff --git a/src/components/Earn.test.tsx b/src/components/Earn.test.tsx new file mode 100644 index 000000000..15259d026 --- /dev/null +++ b/src/components/Earn.test.tsx @@ -0,0 +1,51 @@ +import { BrowserRouter } from 'react-router-dom' +import { act, render, screen } from '../testUtils' +import Earn from './Earn' +import * as apiMock from '../libs/JmWalletApi' + +jest.mock('../libs/JmWalletApi', () => ({ + ...jest.requireActual('../libs/JmWalletApi'), + getGetinfo: jest.fn(), + getSession: jest.fn(), + getWalletDisplay: jest.fn(), + getWalletUtxos: jest.fn(), + postMakerStart: jest.fn(), + getMakerStop: jest.fn(), + postConfigGet: jest.fn(), +})) + +const neverResolves = new Promise(() => {}) + +const mockWallet = { name: 'test.jmdat', token: 'mock-token' } + +const setup = () => + render( + + + , + ) + +describe('', () => { + beforeEach(() => { + ;(apiMock.getGetinfo as jest.Mock).mockReturnValue(neverResolves) + ;(apiMock.getSession as jest.Mock).mockReturnValue(neverResolves) + ;(apiMock.getWalletDisplay as jest.Mock).mockReturnValue(neverResolves) + ;(apiMock.getWalletUtxos as jest.Mock).mockReturnValue(neverResolves) + ;(apiMock.postConfigGet as jest.Mock).mockReturnValue(neverResolves) + }) + + it('renders without crashing', async () => { + await act(async () => { + setup() + }) + expect(screen.getByText('earn.title')).toBeInTheDocument() + }) + + it('shows start button when maker is not running', async () => { + await act(async () => { + setup() + }) + // When context reports makerRunning=false (default), start button visible + expect(screen.getByRole('button', { name: /start/i })).toBeInTheDocument() + }) +}) From 98a9819cf9288fc187c5f2699470193c7faf2cdd Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 23:26:58 +0530 Subject: [PATCH 09/28] test: component tests for FeeConfigModal visibility and section rendering --- .../settings/FeeConfigModal.test.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/components/settings/FeeConfigModal.test.tsx diff --git a/src/components/settings/FeeConfigModal.test.tsx b/src/components/settings/FeeConfigModal.test.tsx new file mode 100644 index 000000000..fe3bf677d --- /dev/null +++ b/src/components/settings/FeeConfigModal.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, act } from '../../testUtils' +import FeeConfigModal from './FeeConfigModal' +import * as apiMock from '../../libs/JmWalletApi' +import * as FeesHook from '../../hooks/Fees' + +jest.mock('../../libs/JmWalletApi', () => ({ + ...jest.requireActual('../../libs/JmWalletApi'), + getGetinfo: jest.fn(), + getSession: jest.fn(), + postConfigGet: jest.fn(), + postConfigSet: jest.fn(), +})) + +const defaultProps = { + show: true, + onHide: jest.fn(), + onSuccess: jest.fn(), + onCancel: jest.fn(), +} + +const neverResolves = new Promise(() => {}) + +describe('', () => { + beforeEach(() => { + ;(apiMock.getGetinfo as jest.Mock).mockReturnValue(neverResolves) + ;(apiMock.getSession as jest.Mock).mockReturnValue(neverResolves) + ;(apiMock.postConfigGet as jest.Mock).mockReturnValue(neverResolves) + }) + + it('renders when show=true', async () => { + await act(async () => { + render() + }) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('does not render when show=false', async () => { + await act(async () => { + render() + }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('shows tx fee section by default', async () => { + const loadFeeConfigValues = jest.fn().mockResolvedValue({ + tx_fees: { value: 10000, unit: 'sats/kilo-vbyte' }, + tx_fees_factor: 0.2, + max_cj_fee_abs: 1000, + max_cj_fee_rel: 0.001, + max_sweep_fee_change: 0.8, + }) + jest.spyOn(FeesHook, 'useLoadFeeConfigValues').mockReturnValue(loadFeeConfigValues) + + await act(async () => { + render() + }) + expect(screen.getByText('settings.fees.title_general_fee_settings')).toBeInTheDocument() + }) +}) From a45903dfe4cb7079e8b666c2353c71e5908aee39 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 23:29:04 +0530 Subject: [PATCH 10/28] test: add afterEach cleanup for FeeConfigModal spy isolation --- src/components/settings/FeeConfigModal.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/settings/FeeConfigModal.test.tsx b/src/components/settings/FeeConfigModal.test.tsx index fe3bf677d..f65c8362a 100644 --- a/src/components/settings/FeeConfigModal.test.tsx +++ b/src/components/settings/FeeConfigModal.test.tsx @@ -27,6 +27,10 @@ describe('', () => { ;(apiMock.postConfigGet as jest.Mock).mockReturnValue(neverResolves) }) + afterEach(() => { + jest.restoreAllMocks() + }) + it('renders when show=true', async () => { await act(async () => { render() From 754ee9825817aaa10df8dc3eba84dfaca1b6d4b9 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 23:30:35 +0530 Subject: [PATCH 11/28] test: add jest coverage thresholds above measured baseline --- package.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e9f75c7f5..37199f662 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,14 @@ "clearMocks": true, "transformIgnorePatterns": [ "node_modules/(?!@table-library)" - ] + ], + "coverageThreshold": { + "global": { + "statements": 38, + "branches": 28, + "functions": 30, + "lines": 39 + } + } } } From 3a3830ce7332430799aea5fa76d9f8dd05bf6816 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 23:31:38 +0530 Subject: [PATCH 12/28] ci: enforce coverage thresholds in build pipeline --- .github/workflows/build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ced3292b..74b57d64b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,7 +36,9 @@ jobs: run: npm run lint # Test - name: Test - run: npm test + run: npm test -- --coverage --watchAll=false + env: + CI: true # Build - name: Build run: npm run build From 66caf838876bbf78b29ea19b579285361b57e662 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sat, 18 Apr 2026 23:33:25 +0530 Subject: [PATCH 13/28] docs: add TESTING.md with provider wrapping, API mocking, and WebSocket patterns --- TESTING.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 TESTING.md diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 000000000..0df61d06d --- /dev/null +++ b/TESTING.md @@ -0,0 +1,80 @@ +# Testing Guide + +## Provider Wrapping + +All component tests must render inside the full provider tree. Import `render` from `src/testUtils.tsx` instead of `@testing-library/react` — it wraps components with `I18nextProvider`, `SettingsProvider`, `WalletProvider`, `ServiceConfigProvider`, `WebsocketProvider`, and `ServiceInfoProvider`. + +```tsx +// correct +import { render, screen } from '../../testUtils' + +// incorrect — missing context +import { render, screen } from '@testing-library/react' +``` + +Components that use `react-router-dom` hooks (`useNavigate`, `Link`) also need a `BrowserRouter` wrapper: + +```tsx +render( + + + +) +``` + +## API Mocking + +Mock `src/libs/JmWalletApi` at the top of each test file. Spread `jest.requireActual` to keep type definitions and only override the functions you need: + +```tsx +jest.mock('../../libs/JmWalletApi', () => ({ + ...jest.requireActual('../../libs/JmWalletApi'), + postCoinjoin: jest.fn(), + postDirectSend: jest.fn(), +})) +``` + +Use `neverResolves` for loading states, `Promise.resolve(mockResponse)` for success, `Promise.reject(new Error('...'))` for error states: + +```tsx +const neverResolves = new Promise(() => {}) +const successResponse = { ok: true, status: 200, json: async () => ({ result: 'ok' }) } + +beforeEach(() => { + ;(apiMock.postCoinjoin as jest.Mock).mockReturnValue(neverResolves) +}) +``` + +## WebSocket Mocking + +`jest-websocket-mock` is available as a devDependency. Create a mock server before each test and clean up after: + +```tsx +import WS from 'jest-websocket-mock' + +let wsServer: WS + +beforeEach(() => { + wsServer = new WS('wss://localhost/jmws') +}) + +afterEach(() => { + WS.clean() +}) +``` + +Simulate incoming messages to trigger state updates in `WebsocketProvider`: + +```tsx +wsServer.send(JSON.stringify({ type: 'walletupdate', utxos: [] })) +``` + +## Coverage Enforcement + +Coverage thresholds live in the `jest.coverageThreshold` block in `package.json`. CI runs `npm test -- --coverage --watchAll=false` which fails if any threshold is not met. + +To raise thresholds after adding new tests: +1. Run `CI=true npm test -- --coverage --watchAll=false` +2. Check the `All files` row in the output +3. Update thresholds to `floor(new value)` in `package.json` +4. Commit and push From 391e29eceda9850fb478506eac65da3e5e8d941f Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sun, 19 Apr 2026 00:22:07 +0530 Subject: [PATCH 14/28] fix: correct test fixtures and boundary values from review - SendForm: walletFileName instead of name in wallet fixture - helpers: use MAX_NUM_COLLABORATORS + 1 instead of hardcoded 100 - Earn: exact i18n key instead of broad regex for start button --- src/components/Earn.test.tsx | 2 +- src/components/Send/SendForm.test.tsx | 2 +- src/components/Send/helpers.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Earn.test.tsx b/src/components/Earn.test.tsx index 15259d026..2178a2d03 100644 --- a/src/components/Earn.test.tsx +++ b/src/components/Earn.test.tsx @@ -46,6 +46,6 @@ describe('', () => { setup() }) // When context reports makerRunning=false (default), start button visible - expect(screen.getByRole('button', { name: /start/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'earn.button_start' })).toBeInTheDocument() }) }) diff --git a/src/components/Send/SendForm.test.tsx b/src/components/Send/SendForm.test.tsx index 4a5f0a17c..8b3fca8a1 100644 --- a/src/components/Send/SendForm.test.tsx +++ b/src/components/Send/SendForm.test.tsx @@ -35,7 +35,7 @@ const initialValues: SendFormValues = { } const defaultProps = { - wallet: { name: 'test.jmdat', token: 'mock-token' }, + wallet: { walletFileName: 'test.jmdat' as any, token: 'mock-token' }, walletInfo: mockWalletInfo as WalletInfo, initialValues, onSubmit: jest.fn().mockResolvedValue(undefined), diff --git a/src/components/Send/helpers.test.ts b/src/components/Send/helpers.test.ts index 5dd94be93..4666f071a 100644 --- a/src/components/Send/helpers.test.ts +++ b/src/components/Send/helpers.test.ts @@ -62,7 +62,7 @@ describe('isValidNumCollaborators', () => { }) it('returns false one above MAX_NUM_COLLABORATORS', () => { - expect(isValidNumCollaborators(100, 1)).toBe(false) + expect(isValidNumCollaborators(MAX_NUM_COLLABORATORS + 1, 1)).toBe(false) }) it('returns false for null', () => { From f384affb8047e40c6db4b155838c8f05d451b4e5 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Sun, 19 Apr 2026 00:43:41 +0530 Subject: [PATCH 15/28] fix: address critical review issues before PR merge - Use walletFileName instead of name in Earn and Send/index wallet fixtures - Commit TESTING.md ws:// URL fix and threshold note (were in working tree only) - Add isValidAmount(null, true) edge case to complete sweep-mode coverage --- TESTING.md | 4 +++- src/components/Earn.test.tsx | 2 +- src/components/Send/helpers.test.ts | 4 ++++ src/components/Send/index.test.tsx | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/TESTING.md b/TESTING.md index 0df61d06d..73b57e727 100644 --- a/TESTING.md +++ b/TESTING.md @@ -55,7 +55,7 @@ import WS from 'jest-websocket-mock' let wsServer: WS beforeEach(() => { - wsServer = new WS('wss://localhost/jmws') + wsServer = new WS('ws://localhost/jmws') }) afterEach(() => { @@ -78,3 +78,5 @@ To raise thresholds after adding new tests: 2. Check the `All files` row in the output 3. Update thresholds to `floor(new value)` in `package.json` 4. Commit and push + +Also update thresholds when adding new source files without tests — new untested code lowers coverage percentages and will cause CI to fail if thresholds aren't adjusted. diff --git a/src/components/Earn.test.tsx b/src/components/Earn.test.tsx index 2178a2d03..d942aff7a 100644 --- a/src/components/Earn.test.tsx +++ b/src/components/Earn.test.tsx @@ -16,7 +16,7 @@ jest.mock('../libs/JmWalletApi', () => ({ const neverResolves = new Promise(() => {}) -const mockWallet = { name: 'test.jmdat', token: 'mock-token' } +const mockWallet = { walletFileName: 'test.jmdat' as any, token: 'mock-token' } const setup = () => render( diff --git a/src/components/Send/helpers.test.ts b/src/components/Send/helpers.test.ts index 4666f071a..0be6e05bb 100644 --- a/src/components/Send/helpers.test.ts +++ b/src/components/Send/helpers.test.ts @@ -46,6 +46,10 @@ describe('isValidAmount', () => { it('returns false for NaN in sweep mode', () => { expect(isValidAmount(NaN, true)).toBe(false) }) + + it('returns false for null in sweep mode', () => { + expect(isValidAmount(null, true)).toBe(false) + }) }) describe('isValidNumCollaborators', () => { diff --git a/src/components/Send/index.test.tsx b/src/components/Send/index.test.tsx index 572cd877c..bd5b72ab6 100644 --- a/src/components/Send/index.test.tsx +++ b/src/components/Send/index.test.tsx @@ -16,7 +16,7 @@ jest.mock('../../libs/JmWalletApi', () => ({ const neverResolves = new Promise(() => {}) -const mockWallet = { name: 'test.jmdat', token: 'mock-token' } +const mockWallet = { walletFileName: 'test.jmdat' as any, token: 'mock-token' } const setup = () => render( From c615090b5e038987be3b9b173fcaeada91364e2e Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 09:29:55 +0530 Subject: [PATCH 16/28] test: add unit tests for toStartMakerRequest maker fee logic --- src/components/Earn.test.tsx | 57 +++++++++++++++++++++++++++++++++++- src/components/Earn.tsx | 2 +- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/components/Earn.test.tsx b/src/components/Earn.test.tsx index d942aff7a..ee90ee8ae 100644 --- a/src/components/Earn.test.tsx +++ b/src/components/Earn.test.tsx @@ -1,7 +1,8 @@ import { BrowserRouter } from 'react-router-dom' import { act, render, screen } from '../testUtils' -import Earn from './Earn' +import Earn, { toStartMakerRequest, EarnFormValues } from './Earn' import * as apiMock from '../libs/JmWalletApi' +import type { AmountValue } from './BitcoinAmountInput' jest.mock('../libs/JmWalletApi', () => ({ ...jest.requireActual('../libs/JmWalletApi'), @@ -49,3 +50,57 @@ describe('', () => { expect(screen.getByRole('button', { name: 'earn.button_start' })).toBeInTheDocument() }) }) + +const makeAmountValue = (value: number): AmountValue => ({ value, isSweep: false }) + +const relValues: EarnFormValues = { + offertype: 'sw0reloffer', + feeRel: 0.0003, + feeAbs: makeAmountValue(250), + minsize: makeAmountValue(100_000), +} + +const absValues: EarnFormValues = { + offertype: 'sw0absoffer', + feeRel: 0.0003, + feeAbs: makeAmountValue(250), + minsize: makeAmountValue(100_000), +} + +describe('toStartMakerRequest', () => { + it('relative offer: sets cjfee_r from feeRel, zeroes cjfee_a', () => { + const req = toStartMakerRequest(relValues) + expect(req.cjfee_r).toBe(0.0003) + expect(req.cjfee_a).toBe(0) + expect(req.ordertype).toBe('sw0reloffer') + expect(req.minsize).toBe(100_000) + }) + + it('absolute offer: sets cjfee_a from feeAbs.value, zeroes cjfee_r', () => { + const req = toStartMakerRequest(absValues) + expect(req.cjfee_a).toBe(250) + expect(req.cjfee_r).toBe(0) + expect(req.ordertype).toBe('sw0absoffer') + expect(req.minsize).toBe(100_000) + }) + + it('both fee properties always present in the returned request', () => { + const relReq = toStartMakerRequest(relValues) + expect(relReq.cjfee_a).toBeDefined() + expect(relReq.cjfee_r).toBeDefined() + + const absReq = toStartMakerRequest(absValues) + expect(absReq.cjfee_a).toBeDefined() + expect(absReq.cjfee_r).toBeDefined() + }) + + it('ordertype and minsize pass through unchanged for both offer types', () => { + const relReq = toStartMakerRequest(relValues) + expect(relReq.ordertype).toBe(relValues.offertype) + expect(relReq.minsize).toBe(relValues.minsize!.value) + + const absReq = toStartMakerRequest(absValues) + expect(absReq.ordertype).toBe(absValues.offertype) + expect(absReq.minsize).toBe(absValues.minsize!.value) + }) +}) diff --git a/src/components/Earn.tsx b/src/components/Earn.tsx index 1c0dab1fb..8ef69f19a 100644 --- a/src/components/Earn.tsx +++ b/src/components/Earn.tsx @@ -409,7 +409,7 @@ const EarnForm = ({ ) } -const toStartMakerRequest = (values: EarnFormValues): Api.StartMakerRequest => { +export const toStartMakerRequest = (values: EarnFormValues): Api.StartMakerRequest => { // both fee properties need to be provided. // prevent providing an invalid value by setting the ignored prop to zero const cjfee_a = isAbsoluteOffer(values.offertype) ? values.feeAbs!.value! : 0 From 018af99f75bac7d5f0be8d7f9c4ad285f57271b3 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 09:33:42 +0530 Subject: [PATCH 17/28] test: add per-folder coverage threshold for Send/ directory --- package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/package.json b/package.json index 37199f662..1b3bd4b30 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,12 @@ "branches": 28, "functions": 30, "lines": 39 + }, + "./src/components/Send/": { + "statements": 42, + "branches": 38, + "functions": 31, + "lines": 43 } } } From d21b2c155f38eaae97ca9e4545b46740db820bb5 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 09:43:23 +0530 Subject: [PATCH 18/28] docs: add per-file baseline coverage notes with risk annotations --- docs/baseline-coverage-notes.md | 89 +++++++++++++++++++++++++++++++++ package.json | 4 +- 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 docs/baseline-coverage-notes.md diff --git a/docs/baseline-coverage-notes.md b/docs/baseline-coverage-notes.md new file mode 100644 index 000000000..ff6edd1c7 --- /dev/null +++ b/docs/baseline-coverage-notes.md @@ -0,0 +1,89 @@ +# Baseline Coverage Notes + +## Test environment + +- Framework: Jest via `react-scripts` (CRA) +- Test runner: `CI=true npm test -- --coverage --watchAll=false` +- Coverage reporter: Istanbul (built into react-scripts) + +## Pre-PR baseline (upstream master at v0.4.1, commit dcfab49) + +Running the test suite with coverage restricted to the test files that existed +at dcfab49 (i.e., excluding the seven test files added in this PR): + +| Metric | Value | +|------------|--------| +| Statements | 27.58% | +| Branches | 17.26% | +| Functions | 21.34% | +| Lines | 28.04% | + +Test suites: 14 | Tests: 119 + +## Target files — absent from pre-PR coverage output + +These files were not imported (directly or transitively) by the pre-existing test +files, so they did not appear in the coverage report at all: + +| File | Risk | Reason | +|---|---|---| +| `src/components/Send/helpers.ts` | High | Pure validation logic for all send amounts and addresses | +| `src/constants/features.ts` | High | Version-gated feature flags control UI availability | +| `src/components/utxo/utils.ts` | Medium | UTXO tag classification drives UI display logic | +| `src/components/Send/SendForm.tsx` | High | Central form controlling send transaction construction | +| `src/components/Send/index.tsx` | High | Orchestrates direct send and coinjoin flows | +| `src/components/Earn.tsx` | High | Maker start/stop lifecycle and fee configuration | +| `src/components/settings/FeeConfigModal.tsx` | Medium | Fee configuration that affects transaction economics | + +## Post-PR coverage (feat/test-coverage) + +After adding 169 tests across 22 suites: + +| Metric | Before | After | +|------------|--------|--------| +| Statements | 27.58% | 38.79% | +| Branches | 17.26% | 28.70% | +| Functions | 21.34% | 31.08% | +| Lines | 28.04% | 39.53% | + +Per-file coverage for the seven target files: + +| File | Statements | Branches | Functions | Lines | +|---|---|---|---|---| +| `Send/helpers.ts` | 86.66% | 80.00% | 83.33% | 86.66% | +| `constants/features.ts` | 100% | 100% | 100% | 100% | +| `utxo/utils.ts` | 100% | 92.85% | 100% | 100% | +| `Send/SendForm.tsx` | 59.21% | 49.52% | 52.38% | 57.74% | +| `Send/index.tsx` | 40.11% | 26.85% | 36.95% | 39.02% | +| `Earn.tsx` | 42.85% | 35.53% | 29.09% | 44.44% | +| `settings/FeeConfigModal.tsx` | 47.05% | 38.88% | 38.09% | 46.34% | + +## Notable uncovered files (high risk, no tests added) + +These files remain uncovered because they require full integration setup +(wallet unlock, WebSocket session, fidelity bond state) that is out of scope +for this PR: + +| File | Risk | +|---|---| +| `src/components/fb/CreateFidelityBond.tsx` | High | +| `src/components/fb/SpendFidelityBondModal.tsx` | High | +| `src/components/jar_details/JarDetailsOverlay.tsx` | High | +| `src/hooks/WaitForUtxosToBeSpent.ts` | Medium | + +## Threshold justification + +Global thresholds are set at `floor(measured_after)` — the minimum the test suite +reliably clears. This prevents false passing while not inflating the bar above +what the tests actually deliver. + +The `Send/` folder threshold is set tighter (statements 42, branches 38, +functions 31, lines 43) because Send is a wallet-critical path and its coverage +is meaningfully higher than the global average. Regressions here should fail CI +independently of the global gate. + +Note: Jest's internal branch and line computations differ slightly from the +rounded table values. Global thresholds for branches (27) and lines (38) are +set at `floor(jest_internal_value)` — the table shows 28.70% branches and +39.53% lines, but Jest computes 27.2% and 38.98% respectively when checking +thresholds. diff --git a/package.json b/package.json index 1b3bd4b30..b3d0ae6b0 100644 --- a/package.json +++ b/package.json @@ -110,9 +110,9 @@ "coverageThreshold": { "global": { "statements": 38, - "branches": 28, + "branches": 27, "functions": 30, - "lines": 39 + "lines": 38 }, "./src/components/Send/": { "statements": 42, From f3f3c0e5678a50ab10f6b433f079af5ba5a52a0f Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 10:05:09 +0530 Subject: [PATCH 19/28] fix: add missing displayName to SendForm.test.tsx wallet fixture CurrentWallet requires displayName; the test fixture was missing it, causing a TypeScript build failure in CI. --- src/components/Send/SendForm.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Send/SendForm.test.tsx b/src/components/Send/SendForm.test.tsx index 8b3fca8a1..70948097e 100644 --- a/src/components/Send/SendForm.test.tsx +++ b/src/components/Send/SendForm.test.tsx @@ -35,7 +35,7 @@ const initialValues: SendFormValues = { } const defaultProps = { - wallet: { walletFileName: 'test.jmdat' as any, token: 'mock-token' }, + wallet: { walletFileName: 'test.jmdat' as any, token: 'mock-token', displayName: 'test' }, walletInfo: mockWalletInfo as WalletInfo, initialValues, onSubmit: jest.fn().mockResolvedValue(undefined), From 9c946f282de23cad135480a9419538594c4f7334 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 10:26:50 +0530 Subject: [PATCH 20/28] test: add SendForm behavioral tests for valid submission and validation blocking --- src/components/Send/SendForm.test.tsx | 56 ++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/src/components/Send/SendForm.test.tsx b/src/components/Send/SendForm.test.tsx index 70948097e..16c5acf7d 100644 --- a/src/components/Send/SendForm.test.tsx +++ b/src/components/Send/SendForm.test.tsx @@ -1,8 +1,10 @@ import { BrowserRouter } from 'react-router-dom' -import { render, screen, fireEvent } from '../../testUtils' +import { render, screen, fireEvent, waitFor } from '../../testUtils' import { SendForm } from './SendForm' import type { WalletInfo } from '../../context/WalletContext' import type { SendFormValues } from './SendForm' +import type { CurrentWallet } from '../../context/WalletContext' +import * as Api from '../../libs/JmWalletApi' jest.mock('../../libs/JmWalletApi', () => ({ ...jest.requireActual('../../libs/JmWalletApi'), @@ -34,8 +36,14 @@ const initialValues: SendFormValues = { txFee: undefined, } +const makeWallet = (): CurrentWallet => ({ + walletFileName: 'test.jmdat' as Api.WalletFileName, + token: 'mock-token' as Api.ApiToken, + displayName: 'test', +}) + const defaultProps = { - wallet: { walletFileName: 'test.jmdat' as any, token: 'mock-token', displayName: 'test' }, + wallet: makeWallet(), walletInfo: mockWalletInfo as WalletInfo, initialValues, onSubmit: jest.fn().mockResolvedValue(undefined), @@ -83,4 +91,48 @@ describe('', () => { // CollaboratorsSelector renders a custom number input; i18n in tests returns keys directly expect(screen.getByPlaceholderText(/num_collaborators_placeholder/i)).toBeInTheDocument() }) + + it('calls onSubmit when form has valid direct-send values', async () => { + const onSubmit = jest.fn().mockResolvedValue(undefined) + setup({ + onSubmit, + initialValues: { + sourceJarIndex: 0, + destination: { value: 'bc1qtest', fromJar: null }, + amount: { value: 100_000, isSweep: false }, + isCoinJoin: false, + numCollaborators: 3, + txFee: { value: 1, unit: 'blocks' }, + }, + }) + fireEvent.click(screen.getByRole('button', { name: /send.button_send/i })) + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + isCoinJoin: false, + destination: expect.objectContaining({ value: 'bc1qtest' }), + }), + expect.anything(), + ) + }) + }) + + it('does not call onSubmit when collaborator count is below minimum', async () => { + const onSubmit = jest.fn().mockResolvedValue(undefined) + setup({ + onSubmit, + initialValues: { + sourceJarIndex: 0, + destination: { value: 'bc1qtest', fromJar: null }, + amount: { value: 100_000, isSweep: false }, + isCoinJoin: true, + numCollaborators: 1, + txFee: undefined, + }, + }) + fireEvent.click(screen.getByRole('button', { name: /send.button_send/i })) + await waitFor(() => { + expect(onSubmit).not.toHaveBeenCalled() + }) + }) }) From 19d0917fdb82039e31f89fa34ad274b30104beed Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 10:28:40 +0530 Subject: [PATCH 21/28] test: add FeeConfigModal behavioral tests for load failure, save success, and save error --- .../settings/FeeConfigModal.test.tsx | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/components/settings/FeeConfigModal.test.tsx b/src/components/settings/FeeConfigModal.test.tsx index f65c8362a..b66c4ad64 100644 --- a/src/components/settings/FeeConfigModal.test.tsx +++ b/src/components/settings/FeeConfigModal.test.tsx @@ -1,7 +1,8 @@ -import { render, screen, act } from '../../testUtils' +import { render, screen, act, waitFor, fireEvent } from '../../testUtils' import FeeConfigModal from './FeeConfigModal' import * as apiMock from '../../libs/JmWalletApi' import * as FeesHook from '../../hooks/Fees' +import * as ServiceConfigCtx from '../../context/ServiceConfigContext' jest.mock('../../libs/JmWalletApi', () => ({ ...jest.requireActual('../../libs/JmWalletApi'), @@ -20,6 +21,14 @@ const defaultProps = { const neverResolves = new Promise(() => {}) +const mockFeeFormData = { + tx_fees: { value: 10_000, unit: 'sats/kilo-vbyte' as const }, + tx_fees_factor: 0.2, + max_cj_fee_abs: 1_000, + max_cj_fee_rel: 0.001, + max_sweep_fee_change: 0.8, +} + describe('', () => { beforeEach(() => { ;(apiMock.getGetinfo as jest.Mock).mockReturnValue(neverResolves) @@ -60,4 +69,49 @@ describe('', () => { }) expect(screen.getByText('settings.fees.title_general_fee_settings')).toBeInTheDocument() }) + + it('shows error when fee config fails to load', async () => { + jest + .spyOn(FeesHook, 'useLoadFeeConfigValues') + .mockReturnValue(jest.fn().mockRejectedValue(new Error('network error'))) + await act(async () => { + render() + }) + await waitFor(() => { + expect(screen.getByText('settings.fees.error_loading_fee_config_failed')).toBeInTheDocument() + }) + }) + + it('calls onSuccess after successful save', async () => { + const onSuccess = jest.fn() + jest.spyOn(FeesHook, 'useLoadFeeConfigValues').mockReturnValue(jest.fn().mockResolvedValue(mockFeeFormData)) + jest.spyOn(ServiceConfigCtx, 'useUpdateConfigValues').mockReturnValue(jest.fn().mockResolvedValue({})) + await act(async () => { + render() + }) + await waitFor(() => { + expect(screen.getByRole('button', { name: /settings.fees.text_button_submit/i })).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: /settings.fees.text_button_submit/i })) + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled() + }) + }) + + it('shows error message when save fails', async () => { + jest.spyOn(FeesHook, 'useLoadFeeConfigValues').mockReturnValue(jest.fn().mockResolvedValue(mockFeeFormData)) + jest + .spyOn(ServiceConfigCtx, 'useUpdateConfigValues') + .mockReturnValue(jest.fn().mockRejectedValue(new Error('save failed'))) + await act(async () => { + render() + }) + await waitFor(() => { + expect(screen.getByRole('button', { name: /settings.fees.text_button_submit/i })).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: /settings.fees.text_button_submit/i })) + await waitFor(() => { + expect(screen.getByText(/settings.fees.error_saving_fee_config_failed/i)).toBeInTheDocument() + }) + }) }) From a71167cebeed24e9a51bc2b8e1eab512b767c85a Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 10:38:48 +0530 Subject: [PATCH 22/28] test: add Send/index loading and direct-send API branching tests --- src/components/Send/index.test.tsx | 163 ++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/src/components/Send/index.test.tsx b/src/components/Send/index.test.tsx index bd5b72ab6..f0e485d14 100644 --- a/src/components/Send/index.test.tsx +++ b/src/components/Send/index.test.tsx @@ -1,7 +1,8 @@ import { BrowserRouter } from 'react-router-dom' -import { act, render, screen, waitFor } from '../../testUtils' +import { act, fireEvent, render, screen, waitFor } from '../../testUtils' import Send from './index' import * as apiMock from '../../libs/JmWalletApi' +import { setSession } from '../../session' jest.mock('../../libs/JmWalletApi', () => ({ ...jest.requireActual('../../libs/JmWalletApi'), @@ -16,6 +17,76 @@ jest.mock('../../libs/JmWalletApi', () => ({ const neverResolves = new Promise(() => {}) +const makeOkResponse = (data: T): Response => { + const body = JSON.stringify(data) + return { + ok: true, + status: 200, + json: () => Promise.resolve(JSON.parse(body)), + } as unknown as Response +} + +const mockGetinfoData = { version: '0.9.10' } +const mockSessionData = { + session: true, + maker_running: false, + coinjoin_in_process: false, + wallet_name: 'test.jmdat', + schedule: null, + offer_list: null, + nickname: null, + rescanning: false, +} +const mockWalletDisplayData = { + walletinfo: { + wallet_name: 'test.jmdat', + total_balance: '1.00000000', + available_balance: '1.00000000', + accounts: [ + { + account: '0', + account_balance: '1.00000000', + available_balance: '1.00000000', + branches: [ + { + branch: 'external addresses', + balance: '1.00000000', + available_balance: '1.00000000', + entries: [ + { + hd_path: 'm/0/0/0', + address: 'bc1qtest000000000000000000000000000000aaaa', + amount: '1.00000000', + available_balance: '1.00000000', + status: 'deposit', + label: '', + extradata: '', + }, + ], + }, + ], + }, + ], + }, +} +const mockUtxosData = { + utxos: [ + { + address: 'bc1qtest000000000000000000000000000000aaaa', + path: 'm/0/0/0', + label: '', + value: 100000000, + tries: 0, + tries_remaining: 3, + external: true, + mixdepth: 0, + confirmations: 6, + frozen: false, + utxo: 'aaaa:0', + }, + ], +} + const mockWallet = { walletFileName: 'test.jmdat' as any, token: 'mock-token' } const setup = () => @@ -25,8 +96,16 @@ const setup = () => , ) +const setupSession = () => { + setSession({ + walletFileName: 'test.jmdat' as any, + auth: { token: 'mock-token' as any, refresh_token: undefined }, + } as any) +} + describe('', () => { beforeEach(() => { + sessionStorage.clear() ;(apiMock.getWalletDisplay as jest.Mock).mockReturnValue(neverResolves) ;(apiMock.getWalletUtxos as jest.Mock).mockReturnValue(neverResolves) ;(apiMock.postConfigGet as jest.Mock).mockReturnValue(neverResolves) @@ -34,6 +113,10 @@ describe('', () => { ;(apiMock.getGetinfo as jest.Mock).mockReturnValue(neverResolves) }) + afterEach(() => { + sessionStorage.clear() + }) + it('renders without crashing', async () => { await act(async () => { setup() @@ -50,4 +133,82 @@ describe('', () => { // (DestinationInputField uses aria-label={label} where label = t('send.label_recipient')) expect(screen.queryByRole('textbox', { name: /send\.label_recipient/i })).not.toBeInTheDocument() }) + + it('renders the send form once all loading APIs resolve', async () => { + setupSession() + ;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(makeOkResponse(mockGetinfoData)) + ;(apiMock.getSession as jest.Mock).mockResolvedValue(makeOkResponse(mockSessionData)) + ;(apiMock.getWalletDisplay as jest.Mock).mockResolvedValue(makeOkResponse(mockWalletDisplayData)) + ;(apiMock.getWalletUtxos as jest.Mock).mockResolvedValue(makeOkResponse(mockUtxosData)) + ;(apiMock.postConfigGet as jest.Mock).mockResolvedValue(makeOkResponse({ configvalue: '3' })) + + await act(async () => { + setup() + }) + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /send.label_recipient/i })).toBeInTheDocument() + }) + }) + + it('calls postDirectSend when direct-send form is confirmed', async () => { + setupSession() + ;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(makeOkResponse(mockGetinfoData)) + ;(apiMock.getSession as jest.Mock).mockResolvedValue(makeOkResponse(mockSessionData)) + ;(apiMock.getWalletDisplay as jest.Mock).mockResolvedValue(makeOkResponse(mockWalletDisplayData)) + ;(apiMock.getWalletUtxos as jest.Mock).mockResolvedValue(makeOkResponse(mockUtxosData)) + ;(apiMock.postConfigGet as jest.Mock).mockResolvedValue(makeOkResponse({ configvalue: '3' })) + ;(apiMock.postDirectSend as jest.Mock).mockReturnValue(new Promise(() => {})) + + await act(async () => { + setup() + }) + + // Wait for form to load + const recipient = await screen.findByRole('textbox', { name: /send.label_recipient/i }) + + // Select source jar 0 (click its radio button) + const jarRadios = screen.getAllByRole('radio') + await act(async () => { + fireEvent.click(jarRadios[0]) + }) + + // Fill recipient + await act(async () => { + fireEvent.change(recipient, { target: { value: 'bc1qrecipient000000000000000000000000test' } }) + }) + + // Fill amount (find the amount textbox by aria-label) + const amountInput = screen.getByRole('textbox', { name: /send.label_amount_input/i }) + await act(async () => { + fireEvent.change(amountInput, { target: { value: '10000' } }) + }) + + // Open sending options accordion to toggle off coinjoin + const sendingOptionsBtn = screen.getAllByRole('button').find((b) => /sending/i.test(b.textContent || '')) + if (sendingOptionsBtn) { + await act(async () => { + fireEvent.click(sendingOptionsBtn) + }) + } + + // Disable coinjoin (first checkbox is the coinjoin toggle) + const checkboxes = screen.queryAllByRole('checkbox') + if (checkboxes.length > 0) { + await act(async () => { + fireEvent.click(checkboxes[0]) + }) + } + + // Click send + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /send.button_send/i })) + }) + + await waitFor(() => screen.getByRole('button', { name: /modal.confirm_button_accept/i }), { timeout: 3000 }) + fireEvent.click(screen.getByRole('button', { name: /modal.confirm_button_accept/i })) + + await waitFor(() => { + expect(apiMock.postDirectSend).toHaveBeenCalled() + }) + }) }) From 8ecdcabda5bae7f7b1d1b7a4e2b3149ff8de0c0d Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 10:48:06 +0530 Subject: [PATCH 23/28] test: add Earn behavioral tests for maker start and disabled state --- src/components/Earn.test.tsx | 136 ++++++++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/src/components/Earn.test.tsx b/src/components/Earn.test.tsx index ee90ee8ae..d60041070 100644 --- a/src/components/Earn.test.tsx +++ b/src/components/Earn.test.tsx @@ -1,7 +1,10 @@ +import { useEffect, useState } from 'react' import { BrowserRouter } from 'react-router-dom' -import { act, render, screen } from '../testUtils' +import { act, fireEvent, render, screen, waitFor } from '../testUtils' import Earn, { toStartMakerRequest, EarnFormValues } from './Earn' import * as apiMock from '../libs/JmWalletApi' +import { setSession } from '../session' +import { useReloadCurrentWalletInfo } from '../context/WalletContext' import type { AmountValue } from './BitcoinAmountInput' jest.mock('../libs/JmWalletApi', () => ({ @@ -17,6 +20,45 @@ jest.mock('../libs/JmWalletApi', () => ({ const neverResolves = new Promise(() => {}) +const makeOkResponse = (data: T) => ({ ok: true, json: () => Promise.resolve(data) }) + +const mockGetinfoData = { version: '0.9.10' } +const mockSessionData = { + session: true, + maker_running: false, + coinjoin_in_process: false, + wallet_name: 'test.jmdat', + schedule: null, + offer_list: null, + nickname: null, + rescanning: false, +} +const mockWalletDisplayData = { + walletinfo: { + wallet_name: 'test.jmdat', + total_balance: '0.00100000', + available_balance: '0.00100000', + accounts: [{ account: '0', account_balance: '0.00100000', available_balance: '0.00100000', branches: [] }], + }, +} +const mockUtxosData = { + utxos: [ + { + utxo: 'abc0000000000000000000000000000000000000000000000000000000000000001:0', + address: 'bc1qtest', + path: "m/49'/1'/0'/0/0", + label: '', + value: 10_000_000, + tries: 0, + tries_remaining: 0, + external: false, + mixdepth: 0, + confirmations: 6, + frozen: false, + }, + ], +} + const mockWallet = { walletFileName: 'test.jmdat' as any, token: 'mock-token' } const setup = () => @@ -26,8 +68,43 @@ const setup = () => , ) +// Pre-loads both utxos and display data so that currentWalletInfo is populated +// in WalletContext before Earn mounts, enabling the start button. +const WalletPreloader = ({ children }: { children: React.ReactNode }) => { + const reloadCurrentWalletInfo = useReloadCurrentWalletInfo() + const [ready, setReady] = useState(false) + + useEffect(() => { + const ctrl = new AbortController() + reloadCurrentWalletInfo + .reloadAllForce({ signal: ctrl.signal }) + .then(() => setReady(true)) + .catch(() => setReady(true)) + return () => ctrl.abort() + }, [reloadCurrentWalletInfo]) + + return ready ? <>{children} : null +} + +const setupFull = () => + render( + + + + + , + ) + +const setupSession = () => { + setSession({ + walletFileName: 'test.jmdat' as any, + auth: { token: 'mock-token' as any, refresh_token: undefined }, + } as any) +} + describe('', () => { beforeEach(() => { + sessionStorage.clear() ;(apiMock.getGetinfo as jest.Mock).mockReturnValue(neverResolves) ;(apiMock.getSession as jest.Mock).mockReturnValue(neverResolves) ;(apiMock.getWalletDisplay as jest.Mock).mockReturnValue(neverResolves) @@ -35,6 +112,10 @@ describe('', () => { ;(apiMock.postConfigGet as jest.Mock).mockReturnValue(neverResolves) }) + afterEach(() => { + sessionStorage.clear() + }) + it('renders without crashing', async () => { await act(async () => { setup() @@ -49,6 +130,59 @@ describe('', () => { // When context reports makerRunning=false (default), start button visible expect(screen.getByRole('button', { name: 'earn.button_start' })).toBeInTheDocument() }) + + it('start button is disabled while maker is waiting to start', async () => { + setupSession() + ;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(makeOkResponse(mockGetinfoData)) + ;(apiMock.getSession as jest.Mock).mockResolvedValue(makeOkResponse(mockSessionData)) + ;(apiMock.getWalletDisplay as jest.Mock).mockResolvedValue(makeOkResponse(mockWalletDisplayData)) + ;(apiMock.getWalletUtxos as jest.Mock).mockResolvedValue(makeOkResponse(mockUtxosData)) + ;(apiMock.postMakerStart as jest.Mock).mockReturnValue(new Promise(() => {})) + + await act(async () => { + setupFull() + }) + + const startBtn = await screen.findByRole('button', { name: /earn.button_start/i }) + + await waitFor(() => { + expect(startBtn).not.toBeDisabled() + }) + + fireEvent.click(startBtn) + + await waitFor(() => { + expect(startBtn).toBeDisabled() + }) + }) + + it('calls postMakerStart when start is clicked', async () => { + setupSession() + ;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(makeOkResponse(mockGetinfoData)) + ;(apiMock.getSession as jest.Mock).mockResolvedValue(makeOkResponse(mockSessionData)) + ;(apiMock.getWalletDisplay as jest.Mock).mockResolvedValue(makeOkResponse(mockWalletDisplayData)) + ;(apiMock.getWalletUtxos as jest.Mock).mockResolvedValue(makeOkResponse(mockUtxosData)) + ;(apiMock.postMakerStart as jest.Mock).mockReturnValue(new Promise(() => {})) + + await act(async () => { + setupFull() + }) + + const startBtn = await screen.findByRole('button', { name: /earn.button_start/i }) + + await waitFor(() => { + expect(startBtn).not.toBeDisabled() + }) + + fireEvent.click(startBtn) + + await waitFor(() => { + expect(apiMock.postMakerStart).toHaveBeenCalledWith( + expect.objectContaining({ walletFileName: 'test.jmdat' }), + expect.objectContaining({ ordertype: expect.any(String) }), + ) + }) + }) }) const makeAmountValue = (value: number): AmountValue => ({ value, isSweep: false }) From 2dd308d58a1387c5876bffd3fcc6241ee84964bf Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 10:50:05 +0530 Subject: [PATCH 24/28] test: add coverage thresholds for Earn and settings/ critical paths --- package.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/package.json b/package.json index b3d0ae6b0..3d8265734 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,18 @@ "branches": 38, "functions": 31, "lines": 43 + }, + "./src/components/settings/": { + "statements": 60, + "branches": 60, + "functions": 53, + "lines": 59 + }, + "./src/components/Earn.tsx": { + "statements": 57, + "branches": 53, + "functions": 37, + "lines": 59 } } } From 52a57c877a4325a8c24413bffe8c3bc694c63671 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 11:48:53 +0530 Subject: [PATCH 25/28] test: add Send/index negative-path tests and fix coinjoin toggle selector --- src/components/Send/index.test.tsx | 111 +++++++++++++++++++++++++++-- 1 file changed, 105 insertions(+), 6 deletions(-) diff --git a/src/components/Send/index.test.tsx b/src/components/Send/index.test.tsx index f0e485d14..29fda9eb5 100644 --- a/src/components/Send/index.test.tsx +++ b/src/components/Send/index.test.tsx @@ -3,6 +3,8 @@ import { act, fireEvent, render, screen, waitFor } from '../../testUtils' import Send from './index' import * as apiMock from '../../libs/JmWalletApi' import { setSession } from '../../session' +import type { CurrentWallet } from '../../context/WalletContext' +import * as Api from '../../libs/JmWalletApi' jest.mock('../../libs/JmWalletApi', () => ({ ...jest.requireActual('../../libs/JmWalletApi'), @@ -87,12 +89,16 @@ const mockUtxosData = { ], } -const mockWallet = { walletFileName: 'test.jmdat' as any, token: 'mock-token' } +const mockWallet: CurrentWallet = { + walletFileName: 'test.jmdat' as Api.WalletFileName, + token: 'mock-token' as Api.ApiToken, + displayName: 'test', +} const setup = () => render( - + , ) @@ -191,11 +197,11 @@ describe('', () => { }) } - // Disable coinjoin (first checkbox is the coinjoin toggle) - const checkboxes = screen.queryAllByRole('checkbox') - if (checkboxes.length > 0) { + // Disable coinjoin + const coinjoinToggle = screen.queryByRole('checkbox', { name: /send.toggle_coinjoin/i }) + if (coinjoinToggle) { await act(async () => { - fireEvent.click(checkboxes[0]) + fireEvent.click(coinjoinToggle) }) } @@ -211,4 +217,97 @@ describe('', () => { expect(apiMock.postDirectSend).toHaveBeenCalled() }) }) + + it('shows error alert and re-enables send button when postDirectSend rejects', async () => { + setupSession() + ;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(makeOkResponse(mockGetinfoData)) + ;(apiMock.getSession as jest.Mock).mockResolvedValue(makeOkResponse(mockSessionData)) + ;(apiMock.getWalletDisplay as jest.Mock).mockResolvedValue(makeOkResponse(mockWalletDisplayData)) + ;(apiMock.getWalletUtxos as jest.Mock).mockResolvedValue(makeOkResponse(mockUtxosData)) + ;(apiMock.postConfigGet as jest.Mock).mockResolvedValue(makeOkResponse({ configvalue: '3' })) + ;(apiMock.postDirectSend as jest.Mock).mockRejectedValue(new Error('direct send failed')) + + await act(async () => { + setup() + }) + + const recipient = await screen.findByRole('textbox', { name: /send.label_recipient/i }) + const jarRadios = screen.getAllByRole('radio') + await act(async () => { + fireEvent.click(jarRadios[0]) + }) + await act(async () => { + fireEvent.change(recipient, { target: { value: 'bc1qrecipient000000000000000000000000test' } }) + }) + const amountInput = screen.getByRole('textbox', { name: /send.label_amount_input/i }) + await act(async () => { + fireEvent.change(amountInput, { target: { value: '10000' } }) + }) + + const sendingOptionsBtn = screen.getAllByRole('button').find((b) => /sending/i.test(b.textContent || '')) + if (sendingOptionsBtn) { + await act(async () => { + fireEvent.click(sendingOptionsBtn) + }) + } + const coinjoinToggle = screen.queryByRole('checkbox', { name: /send.toggle_coinjoin/i }) + if (coinjoinToggle) { + await act(async () => { + fireEvent.click(coinjoinToggle) + }) + } + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /send.button_send/i })) + }) + await waitFor(() => screen.getByRole('button', { name: /modal.confirm_button_accept/i }), { timeout: 3000 }) + fireEvent.click(screen.getByRole('button', { name: /modal.confirm_button_accept/i })) + + await waitFor(() => { + expect(screen.getByText('direct send failed')).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: /send.button_send/i })).not.toBeDisabled() + }) + + it('calls postCoinjoin when coinjoin form is confirmed', async () => { + setupSession() + ;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(makeOkResponse(mockGetinfoData)) + ;(apiMock.getSession as jest.Mock).mockResolvedValue(makeOkResponse(mockSessionData)) + ;(apiMock.getWalletDisplay as jest.Mock).mockResolvedValue(makeOkResponse(mockWalletDisplayData)) + ;(apiMock.getWalletUtxos as jest.Mock).mockResolvedValue(makeOkResponse(mockUtxosData)) + ;(apiMock.postConfigGet as jest.Mock).mockResolvedValue(makeOkResponse({ configvalue: '3' })) + ;(apiMock.postCoinjoin as jest.Mock).mockReturnValue(new Promise(() => {})) + + await act(async () => { + setup() + }) + + const recipient = await screen.findByRole('textbox', { name: /send.label_recipient/i }) + const jarRadios = screen.getAllByRole('radio') + await act(async () => { + fireEvent.click(jarRadios[0]) + }) + await act(async () => { + fireEvent.change(recipient, { target: { value: 'bc1qrecipient000000000000000000000000test' } }) + }) + const amountInput = screen.getByRole('textbox', { name: /send.label_amount_input/i }) + await act(async () => { + fireEvent.change(amountInput, { target: { value: '10000' } }) + }) + + // Leave coinjoin ON (default) — do not open accordion or toggle + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /send.button_send/i })) + }) + await waitFor(() => screen.getByRole('button', { name: /modal.confirm_button_accept/i }), { timeout: 3000 }) + fireEvent.click(screen.getByRole('button', { name: /modal.confirm_button_accept/i })) + + await waitFor(() => { + expect(apiMock.postCoinjoin).toHaveBeenCalledWith( + expect.objectContaining({ walletFileName: 'test.jmdat' }), + expect.objectContaining({ counterparties: expect.any(Number) }), + ) + }) + }) }) From b203e5e38ff8ca44d34e6953c3372e4b4db819b5 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 11:58:57 +0530 Subject: [PATCH 26/28] test: fix double import and strengthen selectors in Send/index.test --- src/components/Send/index.test.tsx | 31 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/components/Send/index.test.tsx b/src/components/Send/index.test.tsx index 29fda9eb5..289c9249c 100644 --- a/src/components/Send/index.test.tsx +++ b/src/components/Send/index.test.tsx @@ -2,9 +2,9 @@ import { BrowserRouter } from 'react-router-dom' import { act, fireEvent, render, screen, waitFor } from '../../testUtils' import Send from './index' import * as apiMock from '../../libs/JmWalletApi' +import type { WalletFileName, ApiToken } from '../../libs/JmWalletApi' import { setSession } from '../../session' import type { CurrentWallet } from '../../context/WalletContext' -import * as Api from '../../libs/JmWalletApi' jest.mock('../../libs/JmWalletApi', () => ({ ...jest.requireActual('../../libs/JmWalletApi'), @@ -90,8 +90,8 @@ const mockUtxosData = { } const mockWallet: CurrentWallet = { - walletFileName: 'test.jmdat' as Api.WalletFileName, - token: 'mock-token' as Api.ApiToken, + walletFileName: 'test.jmdat' as WalletFileName, + token: 'mock-token' as ApiToken, displayName: 'test', } @@ -104,9 +104,9 @@ const setup = () => const setupSession = () => { setSession({ - walletFileName: 'test.jmdat' as any, - auth: { token: 'mock-token' as any, refresh_token: undefined }, - } as any) + walletFileName: 'test.jmdat' as WalletFileName, + auth: { token: 'mock-token' as ApiToken, refresh_token: undefined }, + }) } describe('', () => { @@ -245,17 +245,14 @@ describe('', () => { }) const sendingOptionsBtn = screen.getAllByRole('button').find((b) => /sending/i.test(b.textContent || '')) - if (sendingOptionsBtn) { - await act(async () => { - fireEvent.click(sendingOptionsBtn) - }) - } - const coinjoinToggle = screen.queryByRole('checkbox', { name: /send.toggle_coinjoin/i }) - if (coinjoinToggle) { - await act(async () => { - fireEvent.click(coinjoinToggle) - }) - } + if (!sendingOptionsBtn) throw new Error('sending options button not found') + await act(async () => { + fireEvent.click(sendingOptionsBtn) + }) + const coinjoinToggle = screen.getByRole('checkbox', { name: /send.toggle_coinjoin/i }) + await act(async () => { + fireEvent.click(coinjoinToggle) + }) await act(async () => { fireEvent.click(screen.getByRole('button', { name: /send.button_send/i })) From 84460bc85079c744e903b079e74a58101c1aa433 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 12:02:05 +0530 Subject: [PATCH 27/28] test: add Earn maker-start failure and stop-maker lifecycle tests --- src/components/Earn.test.tsx | 66 ++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/src/components/Earn.test.tsx b/src/components/Earn.test.tsx index d60041070..2cf02a92d 100644 --- a/src/components/Earn.test.tsx +++ b/src/components/Earn.test.tsx @@ -6,6 +6,8 @@ import * as apiMock from '../libs/JmWalletApi' import { setSession } from '../session' import { useReloadCurrentWalletInfo } from '../context/WalletContext' import type { AmountValue } from './BitcoinAmountInput' +import type { CurrentWallet } from '../context/WalletContext' +import type { WalletFileName, ApiToken } from '../libs/JmWalletApi' jest.mock('../libs/JmWalletApi', () => ({ ...jest.requireActual('../libs/JmWalletApi'), @@ -59,12 +61,16 @@ const mockUtxosData = { ], } -const mockWallet = { walletFileName: 'test.jmdat' as any, token: 'mock-token' } +const mockWallet: CurrentWallet = { + walletFileName: 'test.jmdat' as WalletFileName, + token: 'mock-token' as ApiToken, + displayName: 'test', +} const setup = () => render( - + , ) @@ -90,7 +96,7 @@ const setupFull = () => render( - + , ) @@ -183,6 +189,60 @@ describe('', () => { ) }) }) + + it('shows error alert and re-enables start button when postMakerStart rejects', async () => { + setupSession() + ;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(makeOkResponse(mockGetinfoData)) + ;(apiMock.getSession as jest.Mock).mockResolvedValue(makeOkResponse(mockSessionData)) + ;(apiMock.getWalletDisplay as jest.Mock).mockResolvedValue(makeOkResponse(mockWalletDisplayData)) + ;(apiMock.getWalletUtxos as jest.Mock).mockResolvedValue(makeOkResponse(mockUtxosData)) + ;(apiMock.postMakerStart as jest.Mock).mockRejectedValue(new Error('maker start failed')) + + await act(async () => { + setupFull() + }) + + const startBtn = await screen.findByRole('button', { name: /earn.button_start/i }) + await waitFor(() => { + expect(startBtn).not.toBeDisabled() + }) + + await act(async () => { + fireEvent.click(startBtn) + }) + + await waitFor(() => { + expect(screen.getByText('maker start failed')).toBeInTheDocument() + }) + await waitFor(() => { + expect(startBtn).not.toBeDisabled() + }) + }) + + it('calls getMakerStop when stop is clicked', async () => { + setupSession() + ;(apiMock.getGetinfo as jest.Mock).mockResolvedValue(makeOkResponse(mockGetinfoData)) + ;(apiMock.getSession as jest.Mock).mockResolvedValue(makeOkResponse({ ...mockSessionData, maker_running: true })) + ;(apiMock.getWalletDisplay as jest.Mock).mockResolvedValue(makeOkResponse(mockWalletDisplayData)) + ;(apiMock.getWalletUtxos as jest.Mock).mockResolvedValue(makeOkResponse(mockUtxosData)) + ;(apiMock.getMakerStop as jest.Mock).mockReturnValue(new Promise(() => {})) + + await act(async () => { + setupFull() + }) + + // serviceInfo.makerRunning=true causes stop form to render instead of start form + const stopBtn = await screen.findByRole('button', { name: /earn.button_stop/i }) + await waitFor(() => { + expect(stopBtn).not.toBeDisabled() + }) + + fireEvent.click(stopBtn) + + await waitFor(() => { + expect(apiMock.getMakerStop).toHaveBeenCalledWith(expect.objectContaining({ walletFileName: 'test.jmdat' })) + }) + }) }) const makeAmountValue = (value: number): AmountValue => ({ value, isSweep: false }) From 92ffe309b15e10a828091a4e39a959493b16a789 Mon Sep 17 00:00:00 2001 From: Ayash-Bera Date: Mon, 20 Apr 2026 12:36:24 +0530 Subject: [PATCH 28/28] test: type setupSession and makeOkResponse in Earn.test --- src/components/Earn.test.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/Earn.test.tsx b/src/components/Earn.test.tsx index 2cf02a92d..a3803c569 100644 --- a/src/components/Earn.test.tsx +++ b/src/components/Earn.test.tsx @@ -22,7 +22,8 @@ jest.mock('../libs/JmWalletApi', () => ({ const neverResolves = new Promise(() => {}) -const makeOkResponse = (data: T) => ({ ok: true, json: () => Promise.resolve(data) }) +const makeOkResponse = (data: T): Response => + ({ ok: true, status: 200, json: () => Promise.resolve(data) }) as unknown as Response const mockGetinfoData = { version: '0.9.10' } const mockSessionData = { @@ -103,9 +104,9 @@ const setupFull = () => const setupSession = () => { setSession({ - walletFileName: 'test.jmdat' as any, - auth: { token: 'mock-token' as any, refresh_token: undefined }, - } as any) + walletFileName: 'test.jmdat' as WalletFileName, + auth: { token: 'mock-token' as ApiToken, refresh_token: undefined }, + }) } describe('', () => {