test: expand full-project coverage (#1276)

This commit is contained in:
Parth 2026-06-22 04:32:45 +05:30 committed by GitHub
parent 945ece0df8
commit 6e3c905ddc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 12727 additions and 10 deletions

View file

@ -29,9 +29,9 @@
"storybook:build": "storybook build --disable-telemetry",
"storybook:build-gh-pages": "STORYBOOK_MSW_SERVICE_WORKER_URL=./mockServiceWorker.js npm run storybook:build",
"storybook:serve-static": "cd storybook-static/ && python3 -m http.server",
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:storybook": "vitest run --project storybook",
"test": "NODE_OPTIONS=--no-webstorage vitest run",
"test:unit": "NODE_OPTIONS=--no-webstorage vitest run --project unit",
"test:storybook": "NODE_OPTIONS=--no-webstorage vitest run --project storybook",
"check": "npm run format && npm run test && npm run build",
"regtest:build": "npm run regtest:clear && npm run regtest:pull && docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml build --pull",
"regtest:pull": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml pull",

246
src/App.test.tsx Normal file
View file

@ -0,0 +1,246 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import App from './App'
type Holders = {
walletFileName?: string
token?: string
refreshToken?: string
developerMode: boolean
jmSession: Record<string, unknown>
rescanning: boolean
blockHeight?: number
takerRunning: boolean
}
const {
holders,
clearAuth,
updateAuth,
queryClientClear,
navigateStub,
refetchWalletBalance,
mutateAsync,
fetchMissing,
} = vi.hoisted(() => {
const holders: Holders = {
walletFileName: 'wallet.jmdat',
token: 'tok',
refreshToken: 'refresh',
developerMode: false,
jmSession: { maker_running: true, coinjoin_in_process: false, schedule: [] },
rescanning: false,
blockHeight: 100,
takerRunning: false,
}
return {
holders,
clearAuth: vi.fn(),
updateAuth: vi.fn(),
queryClientClear: vi.fn(),
navigateStub: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
refetchWalletBalance: vi.fn<() => Promise<unknown>>().mockResolvedValue(undefined),
mutateAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue(undefined),
fetchMissing: vi.fn<() => Promise<unknown>>().mockResolvedValue([]),
}
})
vi.mock('zustand', () => ({
useStore: (store: { getState: () => unknown }, selector: (s: unknown) => unknown) => selector(store.getState()),
}))
vi.mock('@/store/authStore', () => ({
authStore: {
getState: () => ({
state: {
walletFileName: holders.walletFileName,
auth: holders.token ? { token: holders.token, refresh_token: holders.refreshToken } : undefined,
},
clear: clearAuth,
update: updateAuth,
}),
},
}))
vi.mock('./store/jmSessionStore', () => ({
jmSessionStore: { getState: () => ({ state: holders.jmSession }) },
}))
vi.mock('@/store/jamSettingsStore', () => ({
jamSettingsStore: { getState: () => ({ state: { developerMode: holders.developerMode } }) },
useDeveloperMode: () => ({ enabled: holders.developerMode }),
}))
vi.mock('@/constants/debugFeatures', () => ({
isDebugFeatureEnabled: () => true,
}))
vi.mock('@tanstack/react-query', () => ({
QueryClientProvider: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
useQuery: () => ({ refetch: vi.fn().mockResolvedValue({}) }),
useMutation: () => ({ mutateAsync }),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
lockwalletOptions: () => ({ queryKey: ['lock'] }),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
token: vi.fn().mockResolvedValue({ data: { token: 't', refresh_token: 'r' } }),
}))
vi.mock('next-themes', () => ({
ThemeProvider: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
vi.mock('@/lib/queryClient', () => ({
queryClient: { clear: queryClientClear },
withMutationDelay: (function_: unknown) => function_,
}))
vi.mock('@/lib/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/utils')>()),
setIntervalDebounced: vi.fn(),
walletDisplayName: (s: string) => s,
}))
vi.mock('./lib/errorReason', () => ({
getErrorReason: () => 'reason',
}))
vi.mock('@/hooks/useApiClient', () => ({ useApiClient: () => ({}) }))
vi.mock('@/hooks/useFeeConfigValidation', () => ({ useFeeConfigValidation: () => ({ fetchMissing }) }))
vi.mock('@/hooks/useRefreshSession', () => ({ useRefreshSession: () => undefined }))
vi.mock('./context/JamSessionInfoContext', () => ({
useJamSessionInfoContext: () => ({
blockHeight: holders.blockHeight,
takerInfo: { running: holders.takerRunning },
rescanInfo: { rescanning: holders.rescanning },
}),
}))
vi.mock('./context/JamWalletInfoContext', () => ({
useJamWalletInfoContext: () => ({ refetch: refetchWalletBalance, utxosHashHex: 'hash' }),
}))
function passthrough(name: string) {
return ({ children }: { children?: ReactNode }) => <div data-testid={name}>{children}</div>
}
vi.mock('@/context/JamDisplayContextProvider', () => ({ JamDisplayContextProvider: passthrough('display') }))
vi.mock('@/context/JamWalletInfoContextProvider', () => ({
JamWalletInfoContextProvider: passthrough('wallet-provider'),
}))
vi.mock('./context/JamSessionInfoContextProvider', () => ({
JamSessionInfoContextProvider: passthrough('session-provider'),
}))
vi.mock('./context/JmWebsocketContextProvider', () => ({ JmWebsocketContextProvider: passthrough('ws') }))
vi.mock('@/components/layout/Layout', () => ({
Layout: ({
children,
onLogout,
onLockWallet,
}: {
children?: ReactNode
onLogout: (n: () => Promise<void>) => void
onLockWallet: (n: () => Promise<void>, t: (k: string) => string) => void
}) => (
<div>
<button onClick={() => onLockWallet(navigateStub, (k: string) => k)}>lock</button>
<button onClick={() => void onLogout(navigateStub)}>logout</button>
{children}
</div>
),
}))
function stub(name: string) {
return () => <div>{name}</div>
}
vi.mock('@/components/LogsPage', () => ({ LogsPage: stub('logs-page') }))
vi.mock('@/components/MainWalletPage', () => ({ default: stub('main-wallet-page') }))
vi.mock('@/components/create/CreateWalletPage', () => ({ default: stub('create-page') }))
vi.mock('@/components/earn/EarnPage', () => ({ EarnPage: stub('earn-page') }))
vi.mock('@/components/error/ErrorPage', () => ({ default: stub('error-page') }))
vi.mock('@/components/import/ImportWalletPage', () => ({ default: stub('import-page') }))
vi.mock('@/components/login/LoginPage', () => ({ default: stub('login-page') }))
vi.mock('@/components/orderbook/OrderbookPage', () => ({ OrderbookPage: stub('orderbook-page') }))
vi.mock('@/components/receive/ReceivePage', () => ({ ReceivePage: stub('receive-page') }))
vi.mock('@/components/send/SendPage', () => ({ SendPage: stub('send-page') }))
vi.mock('@/components/settings/RescanChainPage', () => ({ RescanChainPage: stub('rescan-page') }))
vi.mock('@/components/settings/SettingsPage', () => ({ SettingsPage: stub('settings-page') }))
vi.mock('@/components/sweep/SweepPage', () => ({ SweepPage: stub('sweep-page') }))
vi.mock('./components/earn/report/EarnReportPage', () => ({ EarnReportPage: stub('earn-report-page') }))
vi.mock('./components/wallet/WalletJarsDetailsPage', () => ({ WalletJarsDetailsPage: stub('jars-page') }))
vi.mock('@/components/ui/sonner', () => ({ Toaster: () => <div data-testid="toaster" /> }))
vi.mock('./components/ui/spinner', () => ({ Spinner: () => <div data-testid="spinner" /> }))
vi.mock('./components/ui/jam/LockWalletConfirmDialog', () => ({
LockWalletConfirmDialog: ({ onConfirm }: { onConfirm: () => void }) => (
<button data-testid="confirm-lock" onClick={onConfirm}>
confirm
</button>
),
}))
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
window.history.pushState({}, '', '/')
holders.walletFileName = 'wallet.jmdat'
holders.token = 'tok'
holders.refreshToken = 'refresh'
holders.developerMode = false
holders.jmSession = { maker_running: true, coinjoin_in_process: false, schedule: [] }
holders.rescanning = false
holders.blockHeight = 100
holders.takerRunning = false
})
it('renders the home page when authenticated', async () => {
render(<App />)
await waitFor(() => expect(screen.getByText('main-wallet-page')).toBeInTheDocument())
expect(screen.getByTestId('toaster')).toBeInTheDocument()
})
it('redirects to login when not authenticated', async () => {
holders.token = undefined
render(<App />)
await waitFor(() => expect(screen.getByText('login-page')).toBeInTheDocument())
})
it('opens the lock wallet dialog when maker is running and confirms locking', async () => {
render(<App />)
await waitFor(() => expect(screen.getByText('lock')).toBeInTheDocument())
fireEvent.click(screen.getByText('lock'))
await waitFor(() => expect(screen.getByTestId('confirm-lock')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('confirm-lock'))
await waitFor(() => expect(mutateAsync).toHaveBeenCalled())
expect(clearAuth).toHaveBeenCalled()
})
it('logs out directly via the layout', async () => {
render(<App />)
await waitFor(() => expect(screen.getByText('logout')).toBeInTheDocument())
fireEvent.click(screen.getByText('logout'))
await waitFor(() => expect(clearAuth).toHaveBeenCalled())
})
it('includes developer routes when developer mode is enabled', async () => {
holders.developerMode = true
render(<App />)
await waitFor(() => expect(screen.getByText('main-wallet-page')).toBeInTheDocument())
})
})

View file

@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LogsContent } from './LogsContent'
const mocks = vi.hoisted(() => ({
logState: {
alert: undefined as { variant: 'destructive' | 'warning'; message: string } | undefined,
fileName: 'jmwalletd_stdout.log',
isInitialized: true,
logFileContent: undefined as string | undefined,
refresh: vi.fn(),
},
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/components/logging/useJmwalletdStdoutLog', () => ({
useJmwalletdStdoutLog: vi.fn(() => mocks.logState),
}))
vi.mock('@/components/logging/LogViewer', () => ({
LogViewer: ({ fileName, value }: { fileName: string; value: string }) => (
<div>
viewer:{fileName}:{value}
</div>
),
}))
describe('LogsContent', () => {
beforeEach(() => {
mocks.logState.alert = undefined
mocks.logState.fileName = 'jmwalletd_stdout.log'
mocks.logState.isInitialized = true
mocks.logState.logFileContent = 'log body'
mocks.logState.refresh.mockReset()
})
it('shows a loading state before logs initialize', () => {
mocks.logState.isInitialized = false
render(<LogsContent enabled={true} />)
expect(screen.getByText('global.loading')).toBeInTheDocument()
})
it('renders alerts and log content after initialization', () => {
mocks.logState.alert = {
variant: 'warning',
message: 'log loading failed',
}
render(<LogsContent enabled={true} />)
expect(screen.getByText('log loading failed')).toBeInTheDocument()
expect(screen.getByText('viewer:jmwalletd_stdout.log:log body')).toBeInTheDocument()
})
it('does not render the viewer without content', () => {
mocks.logState.logFileContent = undefined
render(<LogsContent enabled={true} />)
expect(screen.queryByText(/viewer:/u)).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,66 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { LogsOverlay } from './LogsOverlay'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/components/LogsContent', () => ({
LogsContent: ({ enabled, className }: { enabled: boolean; className?: string }) => (
<div data-testid="logs-content" data-enabled={enabled} className={className}>
logs-content
</div>
),
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <h1 data-testid="page-title">{title}</h1>,
}))
// Mock Dialog to avoid dealing with portals and radix UI internals in this simple test
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
describe('LogsOverlay', () => {
it('renders dialog content when open', () => {
render(<LogsOverlay open={true} onOpenChange={vi.fn()} />)
expect(screen.getByTestId('page-title')).toHaveTextContent('logs.title')
const content = screen.getByTestId('logs-content')
expect(content).toBeInTheDocument()
expect(content).toHaveAttribute('data-enabled', 'true')
})
it('calls onOpenChange when closed', () => {
const onOpenChange = vi.fn()
render(<LogsOverlay open={true} onOpenChange={onOpenChange} />)
const closeButton = screen.getByText('Close')
fireEvent.click(closeButton)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('does not render when closed', () => {
render(<LogsOverlay open={false} onOpenChange={vi.fn()} />)
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { LogsPage } from './LogsPage'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/components/LogsContent', () => ({
LogsContent: ({ enabled, className }: { enabled: boolean; className?: string }) => (
<div data-testid="logs-content" data-enabled={enabled} className={className}>
logs-content
</div>
),
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <h1 data-testid="page-title">{title}</h1>,
}))
describe('LogsPage', () => {
it('renders title and content', () => {
render(<LogsPage />)
expect(screen.getByTestId('page-title')).toHaveTextContent('logs.title')
const content = screen.getByTestId('logs-content')
expect(content).toBeInTheDocument()
expect(content).toHaveAttribute('data-enabled', 'true')
})
})

View file

@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { WalletFileName } from '@/lib/utils'
import MainWalletPage from './MainWalletPage'
const navigateMock = vi.fn()
const refetch = vi.fn()
const toggleDisplayMode = vi.fn()
let walletInfo: { isLoading: boolean; isFetching: boolean; error: { message: string } | null; refetch: () => void }
let jars: Jar[]
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
}))
vi.mock('@/context/JamDisplayContext', () => ({
useJamDisplayContext: () => ({ toggleDisplayMode }),
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useJamWalletInfoContext: () => walletInfo,
useWalletBalanceSummary: () => ({ walletBalanceSummary: { calculatedTotalBalanceInSats: 5000 } }),
useJars: () => ({ jars }),
}))
vi.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
shortenStringMiddle: (s: string) => s,
walletDisplayName: (s: string) => s,
}))
vi.mock('@/components/ui/jam/ClickableJar', () => ({
ClickableJar: ({ name, onClick }: { name: string; onClick: () => void }) => <button onClick={onClick}>{name}</button>,
}))
vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
WalletJarsDetailsOverlay: ({ open }: { open: boolean }) => (
<div data-testid="overlay">{open ? 'open' : 'closed'}</div>
),
}))
vi.mock('./ui/jam/Balance', () => ({
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
<span onClick={onClick}>{valueString}</span>
),
}))
vi.mock('./ui/spinner', () => ({
Spinner: () => <div data-testid="spinner" />,
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/alert', () => ({
Alert: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
<button onClick={onClick}>{children}</button>
),
}))
const walletFileName = 'wallet.jmdat' as WalletFileName
const makeJar = (jarIndex: number, name: string): Jar =>
({
jarIndex,
name,
color: '#000',
balanceSummary: {
calculatedTotalBalanceInSats: 100,
calculatedAvailableBalanceInSats: 100,
calculatedFrozenOrLockedBalanceInSats: 0,
},
}) as unknown as Jar
describe('MainWalletPage', () => {
beforeEach(() => {
vi.clearAllMocks()
walletInfo = { isLoading: false, isFetching: false, error: null, refetch }
jars = [makeJar(0, 'Jar 0'), makeJar(1, 'Jar 1')]
})
it('shows a loading spinner while loading', () => {
walletInfo = { ...walletInfo, isLoading: true }
render(<MainWalletPage walletFileName={walletFileName} />)
expect(screen.getAllByTestId('spinner').length).toBeGreaterThan(0)
})
it('renders balance and jars, and opens the jar overlay on click', () => {
render(<MainWalletPage walletFileName={walletFileName} />)
expect(screen.getByText('5000')).toBeInTheDocument()
expect(screen.getByTestId('overlay')).toHaveTextContent('closed')
fireEvent.click(screen.getByText('Jar 0'))
expect(screen.getByTestId('overlay')).toHaveTextContent('open')
})
it('navigates to deposit and withdraw routes', () => {
render(<MainWalletPage walletFileName={walletFileName} />)
fireEvent.click(screen.getByText('current_wallet.button_deposit'))
fireEvent.click(screen.getByText('current_wallet.button_withdraw'))
expect(navigateMock).toHaveBeenCalledTimes(2)
})
it('shows an error alert with a retry button', () => {
walletInfo = { ...walletInfo, error: { message: 'load failed' } }
render(<MainWalletPage walletFileName={walletFileName} />)
expect(screen.getByText(/error_loading_wallet_failed/)).toBeInTheDocument()
fireEvent.click(screen.getByText('global.retry'))
expect(refetch).toHaveBeenCalled()
})
it('shows a jars spinner while fetching and refreshes on click', () => {
walletInfo = { ...walletInfo, isFetching: true }
render(<MainWalletPage walletFileName={walletFileName} />)
expect(screen.getByTestId('spinner')).toBeInTheDocument()
fireEvent.click(screen.getByText('global.refresh'))
expect(refetch).toHaveBeenCalled()
})
it('toggles display mode when clicking the balance', () => {
render(<MainWalletPage walletFileName={walletFileName} />)
fireEvent.click(screen.getByText('5000'))
expect(toggleDisplayMode).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,63 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import '@/i18n/config'
import type { MnemonicPhrase } from '@/types/global'
import { CreateStepVerifyMnemonic } from './CreateStepVerifyMnemonic'
const mnemonicPhrase: MnemonicPhrase = ['alpha', 'bravo', 'charlie']
const renderVerifyMnemonic = ({
onVerified = vi.fn().mockResolvedValue(undefined),
onBack = vi.fn(),
}: {
onVerified?: () => Promise<void>
onBack?: () => void
} = {}) => {
const queryClient = new QueryClient({
defaultOptions: {
mutations: { retry: false },
queries: { retry: false },
},
})
render(
<QueryClientProvider client={queryClient}>
<CreateStepVerifyMnemonic mnemonicPhrase={mnemonicPhrase} onVerified={onVerified} onBack={onBack} />
</QueryClientProvider>,
)
return { onVerified, onBack }
}
describe('<CreateStepVerifyMnemonic />', () => {
it('verifies the seed phrase after words are selected in order', async () => {
const { onVerified } = renderVerifyMnemonic()
expect(screen.getByText('1.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Fund Wallet' })).toBeDisabled()
for (const word of mnemonicPhrase) {
await userEvent.click(screen.getByRole('button', { name: word }))
}
expect(screen.getByText('Mnemonic phrase confirmed.')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Fund Wallet' }))
await waitFor(() => expect(onVerified).toHaveBeenCalledTimes(1))
})
it('keeps the user on the step after a wrong word and supports going back', async () => {
const { onBack } = renderVerifyMnemonic()
await userEvent.click(screen.getByRole('button', { name: 'bravo' }))
expect(screen.getByRole('button', { name: 'Fund Wallet' })).toBeDisabled()
await userEvent.click(screen.getByRole('button', { name: /Back/ }))
expect(onBack).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,85 @@
import type { ComponentProps } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import '@/i18n/config'
import { CreateWalletForm } from './CreateWalletForm'
type CreateWalletFormProps = ComponentProps<typeof CreateWalletForm>
type RenderCreateWalletFormOptions = {
wallets?: string[]
onSubmit?: ReturnType<typeof vi.fn>
disabled?: boolean
}
const renderCreateWalletForm = ({
wallets = ['existing.jmdat'],
onSubmit = vi.fn(),
disabled = false,
}: RenderCreateWalletFormOptions = {}) => {
render(
<CreateWalletForm
wallets={wallets as CreateWalletFormProps['wallets']}
onSubmit={onSubmit as unknown as CreateWalletFormProps['onSubmit']}
disabled={disabled}
submitButtonText={({ isSubmitting }) => (isSubmitting ? 'Creating' : 'Create')}
/>,
)
return { onSubmit }
}
describe('<CreateWalletForm />', () => {
it('submits valid wallet details', async () => {
const { onSubmit } = renderCreateWalletForm()
await userEvent.type(screen.getByLabelText('Wallet name'), 'new-wallet')
await userEvent.type(screen.getByLabelText('Password to unlock the wallet'), 'secret')
await userEvent.type(screen.getByLabelText('Confirm password'), 'secret')
await userEvent.click(screen.getByRole('button', { name: 'Create' }))
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
{
walletName: 'new-wallet',
password: 'secret',
confirmPassword: 'secret',
},
expect.anything(),
),
)
})
it('shows validation errors for invalid values', async () => {
const { onSubmit } = renderCreateWalletForm()
await userEvent.type(screen.getByLabelText('Wallet name'), 'existing')
await userEvent.type(screen.getByLabelText('Password to unlock the wallet'), 'secret')
await userEvent.type(screen.getByLabelText('Confirm password'), 'different')
await userEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(
await screen.findByText('Please choose another wallet name. This one is already in use.'),
).toBeInTheDocument()
expect(screen.getByText('Given passwords do not match.')).toBeInTheDocument()
expect(onSubmit).not.toHaveBeenCalled()
})
it('toggles password visibility and supports disabled state', async () => {
renderCreateWalletForm({ disabled: true })
const passwordInput = screen.getByLabelText('Password to unlock the wallet')
const confirmPasswordInput = screen.getByLabelText('Confirm password')
expect(passwordInput).toBeDisabled()
expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled()
const toggleButtons = screen.getAllByRole('button', { name: '' })
await userEvent.click(toggleButtons[0])
await userEvent.click(toggleButtons[1])
expect(passwordInput).toHaveAttribute('type', 'text')
expect(confirmPasswordInput).toHaveAttribute('type', 'text')
})
})

View file

@ -0,0 +1,178 @@
import type { PropsWithChildren } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { routes } from '@/constants/routes'
import { authStore } from '@/store/authStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import CreateWalletPage from './CreateWalletPage'
const mocks = vi.hoisted(() => ({
createWallet: vi.fn(),
unlockWallet: vi.fn(),
lockWallet: vi.fn(),
hashPassword: vi.fn(),
navigate: vi.fn(),
sessionRefetch: vi.fn(),
toastDismiss: vi.fn(),
toastLoading: vi.fn(() => 'toast-id'),
}))
type QueryOptions = { queryKey?: readonly unknown[] }
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
createwalletMutation: vi.fn(() => ({ mutationFn: mocks.createWallet })),
listwalletsOptions: vi.fn(() => ({ queryKey: ['wallets'], queryFn: vi.fn() })),
sessionOptions: vi.fn(() => ({ queryKey: ['session'], queryFn: vi.fn() })),
unlockwalletMutation: vi.fn(() => ({ mutationFn: mocks.unlockWallet })),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
lockwallet: mocks.lockWallet,
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn((options: QueryOptions) =>
options.queryKey?.[0] === 'session'
? {
data: undefined,
refetch: mocks.sessionRefetch,
}
: {
data: { wallets: ['existing.jmdat'] },
},
),
useMutation: vi.fn((options: MutationOptions) => ({
isPending: false,
mutateAsync: async (input: unknown) => await options.mutationFn(input),
})),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('react-router-dom', () => ({
useNavigate: () => mocks.navigate,
}))
vi.mock('sonner', () => ({
toast: {
dismiss: mocks.toastDismiss,
error: vi.fn(),
loading: mocks.toastLoading,
},
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/lib/config', () => ({
buildAuthHeaderMap: (token: string) => ({ 'x-jm-authorization': `Bearer ${token}` }),
}))
vi.mock('@/lib/hash', () => ({
hashPassword: mocks.hashPassword,
}))
vi.mock('@/lib/utils', () => ({
cn: (...classes: Array<string | undefined | false>) => classes.filter(Boolean).join(' '),
delayedPromise: vi.fn(() => Promise.resolve()),
parseSemanticVersion: (raw?: string) => {
const match = /^v?(\d+)\.(\d+)\.(\d+).*$/u.exec(raw ?? '')
return match
? { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), raw }
: { major: 0, minor: 0, patch: 0, raw: 'unknown' }
},
percentageToFactor: (value: number) => value / 100,
walletDisplayName: (walletFileName: string) => walletFileName.replace(/\.jmdat$/, ''),
walletDisplayNameToFileName: (walletName: string) => `${walletName}.jmdat`,
}))
vi.mock('../layout/AuthPageShell', () => ({
AuthPageShell: ({ children }: PropsWithChildren) => <div>{children}</div>,
}))
vi.mock('../utils/PreventLeavingPageByMistake', () => ({
default: () => <div>prevent-leaving</div>,
}))
vi.mock('./CreateStepWalletDetails', () => ({
CreateStepWalletDetails: ({
onSubmit,
}: {
onSubmit: (values: { walletName: string; password: string; confirmPassword: string }) => Promise<void>
}) => (
<button onClick={() => void onSubmit({ walletName: 'fresh', password: 'secret', confirmPassword: 'secret' })}>
create wallet
</button>
),
}))
vi.mock('./CreateStepConfirm', () => ({
CreateStepConfirm: ({ walletFileName, onConfirm }: { walletFileName: string; onConfirm: () => Promise<void> }) => (
<button onClick={() => void onConfirm()}>confirm {walletFileName}</button>
),
}))
vi.mock('./CreateStepVerifyMnemonic', () => ({
CreateStepVerifyMnemonic: ({ onVerified }: { onVerified: () => Promise<void> }) => (
<button onClick={() => void onVerified()}>verify mnemonic</button>
),
}))
describe('CreateWalletPage', () => {
beforeEach(() => {
mocks.createWallet.mockReset()
mocks.unlockWallet.mockReset()
mocks.lockWallet.mockReset()
mocks.hashPassword.mockReset()
mocks.navigate.mockReset()
mocks.sessionRefetch.mockResolvedValue({ data: { session: false } })
mocks.toastDismiss.mockReset()
mocks.toastLoading.mockClear()
authStore.getState().clear()
jmSessionStore.setState({ state: undefined })
mocks.createWallet.mockResolvedValue({
walletname: 'fresh.jmdat',
token: 'create-token',
seedphrase: 'abandon ability able about above absent absorb abstract absurd abuse access accident',
})
mocks.lockWallet.mockResolvedValue({ data: {} })
mocks.unlockWallet.mockResolvedValue({
walletname: 'fresh.jmdat',
token: 'unlock-token',
refresh_token: 'refresh-token',
})
mocks.hashPassword.mockResolvedValue('hashed-secret')
})
it('creates, confirms, and unlocks a new wallet', async () => {
const user = userEvent.setup()
render(<CreateWalletPage />)
await user.click(screen.getByRole('button', { name: 'create wallet' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'confirm fresh.jmdat' })).toBeInTheDocument())
expect(mocks.lockWallet).toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'confirm fresh.jmdat' }))
await user.click(screen.getByRole('button', { name: 'verify mnemonic' }))
await waitFor(() =>
expect(authStore.getState().state).toEqual({
walletFileName: 'fresh.jmdat',
auth: { token: 'unlock-token', refresh_token: 'refresh-token' },
hashed_password: 'hashed-secret',
}),
)
expect(mocks.navigate).toHaveBeenCalledWith(routes.home)
expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-id')
})
})

View file

@ -0,0 +1,10 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { DevBadge } from './DevBadge'
describe('DevBadge', () => {
it('renders correctly', () => {
render(<DevBadge />)
expect(screen.getByText('dev')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,16 @@
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DevErrorThrowingComponent from './DevErrorThrowingComponent'
describe('DevErrorThrowingComponent', () => {
it('throws an error on mount', () => {
// Prevent React from logging the error to console during the test
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
render(<DevErrorThrowingComponent />)
}).toThrow('This error is thrown on purpose. Only to be used for testing.')
consoleSpy.mockRestore()
})
})

View file

@ -0,0 +1,60 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DevPage from './DevPage'
vi.mock('react-router-dom', () => ({
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
}))
vi.mock('zustand', async (importOriginal) => {
const actual = await importOriginal<typeof import('zustand')>()
return {
...actual,
useStore: () => ({ some: 'state' }),
}
})
vi.mock('@/hooks/useFeeConfigValidation', () => ({
useFeeConfigValidation: () => ({
feeConfigValues: {},
isLoading: false,
maxFeesConfigMissing: false,
refetchAll: vi.fn(),
}),
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useJars: () => [],
useWalletBalanceSummary: () => ({}),
}))
vi.mock('@/components/settings/fees/FeeConfigDialog', () => ({
FeeConfigDialog: () => <div data-testid="fee-config-dialog" />,
}))
vi.mock('./FeeConfigTestComponent', () => ({
FeeConfigTestComponent: () => <div data-testid="fee-config-test" />,
}))
describe('DevPage', () => {
it('renders correctly', () => {
render(<DevPage walletFileName="test.jmdat" />)
expect(screen.getByText('Development specific information')).toBeInTheDocument()
expect(screen.getAllByText('Config')[0]).toBeInTheDocument()
expect(screen.getAllByText('Wallet')[0]).toBeInTheDocument()
expect(screen.getAllByText('Links')[0]).toBeInTheDocument()
})
it('renders tabs', () => {
render(<DevPage walletFileName="test.jmdat" />)
// Check Config tab
expect(screen.getByText(/import\.meta\.env/)).toBeInTheDocument()
// Check FeeConfig validations
expect(screen.getByTestId('fee-config-dialog')).toBeInTheDocument()
expect(screen.getByTestId('fee-config-test')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DevSetupPage from './DevSetupPage'
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title, subtitle }: { title: React.ReactNode; subtitle: string }) => (
<div data-testid="page-title">
<div>{title}</div>
<div>{subtitle}</div>
</div>
),
}))
vi.mock('./DevBadge', () => ({
DevBadge: () => <span data-testid="dev-badge">dev-badge</span>,
}))
describe('DevSetupPage', () => {
it('renders development setup information', () => {
render(<DevSetupPage />)
expect(screen.getByTestId('page-title')).toBeInTheDocument()
expect(screen.getByTestId('dev-badge')).toBeInTheDocument()
// Check some specific content
expect(screen.getByText('Test Wallet')).toBeInTheDocument()
expect(screen.getByText('Satoshi')).toBeInTheDocument()
expect(screen.getByText('Jam Instances')).toBeInTheDocument()
expect(screen.getByText(/jm_regtest_joinmarket2/i)).toBeInTheDocument()
expect(screen.getByText(/jm_regtest_joinmarket3/i)).toBeInTheDocument()
expect(screen.getByText('Block Explorer')).toBeInTheDocument()
expect(screen.getByText(/jm_regtest_explorer/i)).toBeInTheDocument()
expect(screen.getByText(/Regtest RPC Terminal/i)).toBeInTheDocument()
})
})

View file

@ -0,0 +1,63 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FeeConfigTestComponent } from './FeeConfigTestComponent'
vi.mock('@/hooks/useFeeConfigValidation', () => ({
useFeeConfigValidation: () => ({}),
}))
vi.mock('@/components/settings/fees/FeeConfigDialog', () => ({
FeeConfigDialog: ({ open }: { open?: boolean }) => <div data-testid="fee-config-dialog" data-open={open} />,
}))
vi.mock('@/components/ui/jam/FeeConfigErrorAlert', () => ({
FeeConfigErrorAlert: ({ onOpenFeeConfig }: { onOpenFeeConfig: () => void }) => (
<div data-testid="fee-config-error-alert">
<button onClick={onOpenFeeConfig}>Open Error Alert Dialog</button>
</div>
),
}))
describe('FeeConfigTestComponent', () => {
it('renders correctly', () => {
render(<FeeConfigTestComponent walletFileName="test.jmdat" />)
expect(screen.getByText('🧪 Fee Config Error Test Component')).toBeInTheDocument()
expect(screen.getByText('Show Error')).toBeInTheDocument()
expect(screen.getByText('Open Fee Config Dialog')).toBeInTheDocument()
expect(screen.queryByTestId('fee-config-error-alert')).not.toBeInTheDocument()
})
it('toggles error alert', () => {
render(<FeeConfigTestComponent walletFileName="test.jmdat" />)
const toggleButton = screen.getByText('Show Error')
fireEvent.click(toggleButton)
expect(screen.getByText('Hide Error')).toBeInTheDocument()
expect(screen.getByTestId('fee-config-error-alert')).toBeInTheDocument()
fireEvent.click(screen.getByText('Hide Error'))
expect(screen.queryByTestId('fee-config-error-alert')).not.toBeInTheDocument()
})
it('opens dialog via button', () => {
render(<FeeConfigTestComponent walletFileName="test.jmdat" />)
const openDialogButton = screen.getByText('Open Fee Config Dialog')
fireEvent.click(openDialogButton)
expect(screen.getByTestId('fee-config-dialog')).toHaveAttribute('data-open', 'true')
})
it('opens dialog via error alert', () => {
render(<FeeConfigTestComponent walletFileName="test.jmdat" />)
fireEvent.click(screen.getByText('Show Error'))
const alertButton = screen.getByText('Open Error Alert Dialog')
fireEvent.click(alertButton)
expect(screen.getByTestId('fee-config-dialog')).toHaveAttribute('data-open', 'true')
})
})

View file

@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { CreateFidelityBondDialog } from './CreateFidelityBondDialog'
const mockWizard = {
step: 'select_date',
error: null,
frozenUtxos: [],
utxosToFreeze: [] as unknown[],
freezeUtxo: { isPending: false },
unfreezeUtxo: { isPending: false },
directSend: { isPending: false },
handleOpenChange: vi.fn(),
handleBack: vi.fn(),
handleNext: vi.fn(),
handleUnfreezeUtxos: vi.fn(),
canProceed: () => true,
getStepNumber: () => 0,
t: (key: string) => key,
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('./useCreateFidelityBondWizard', () => ({
useCreateFidelityBondWizard: () => mockWizard,
}))
vi.mock('./CreateFidelityBondDialogSteps', () => ({
CreateFidelityBondDialogSteps: () => <div data-testid="steps-component">Steps Component</div>,
}))
vi.mock('./StepProgress', () => ({
StepProgress: ({ currentStep }: { currentStep: number }) => <div data-testid="step-progress">{currentStep}</div>,
}))
describe('CreateFidelityBondDialog', () => {
it('renders correctly on first step', () => {
render(<CreateFidelityBondDialog open={true} onOpenChange={vi.fn()} walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.fidelity_bond.create_fidelity_bond.title')).toBeInTheDocument()
expect(screen.getByTestId('steps-component')).toBeInTheDocument()
expect(screen.getByTestId('step-progress')).toHaveTextContent('0')
// Check buttons
expect(screen.queryByText('global.back')).not.toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.select_date.text_secondary_button')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.select_date.text_primary_button')).toBeInTheDocument()
})
it('renders correctly on review step', () => {
mockWizard.step = 'review'
mockWizard.getStepNumber = () => 3
render(<CreateFidelityBondDialog open={true} onOpenChange={vi.fn()} walletFileName="wallet.jmdat" />)
expect(screen.getByText('global.back')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.review_inputs.text_primary_button')).toBeInTheDocument()
})
it('renders correctly on success step', () => {
mockWizard.step = 'success'
render(<CreateFidelityBondDialog open={true} onOpenChange={vi.fn()} walletFileName="wallet.jmdat" />)
expect(screen.queryByTestId('step-progress')).not.toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.create_fidelity_bond.text_primary_button')).toBeInTheDocument()
})
it('renders correctly on freeze_utxos step', () => {
mockWizard.step = 'freeze_utxos'
render(<CreateFidelityBondDialog open={true} onOpenChange={vi.fn()} walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.fidelity_bond.freeze_utxos.text_primary_button_all_frozen')).toBeInTheDocument()
mockWizard.utxosToFreeze = [{}]
render(<CreateFidelityBondDialog open={true} onOpenChange={vi.fn()} walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.fidelity_bond.freeze_utxos.text_primary_button')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,218 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { CreateFidelityBondDialogSteps } from './CreateFidelityBondDialogSteps'
import type { useCreateFidelityBondWizard } from './useCreateFidelityBondWizard'
type Wizard = ReturnType<typeof useCreateFidelityBondWizard>
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey, children }: { i18nKey?: string; children?: ReactNode }) => (
<div data-testid={`trans-${i18nKey}`}>{children}</div>
),
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/components/ui/jam/BitcoinQrCode', () => ({
BitcoinAddressQrCode: () => <div data-testid="qr-code">QR</div>,
}))
vi.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
formatSats: (sats: number) => `${sats} sats`,
clamp: (val: number, min: number, max: number) => Math.min(Math.max(val, min), max),
}))
vi.mock('@/lib/fidelityBondUtils', () => ({
utxo: {
isFidelityBond: vi.fn(),
},
}))
const getBaseWizard = (): Wizard =>
({
step: 'select_date',
setSelectedLockdate: vi.fn(),
selectedLockdate: '2025-01',
selectedYear: '2025',
selectedMonth: '01',
minYear: 2024,
minMonth: 1,
yearOptions: [{ value: '2025', label: '2025' }],
monthOptions: [{ value: '01', label: 'Jan' }],
clampLockdate: vi.fn((x: unknown) => x),
hasDuplicateLockdate: false,
selectedDateLabel: 'Jan 2025',
selectedJarIndex: 0,
setSelectedJarIndex: vi.fn(),
jarsWithUtxos: [],
selectedUtxos: [],
availableUtxos: [],
utxoPage: 0,
setUtxoPage: vi.fn(),
toggleUtxoSelection: vi.fn(),
selectAllUtxos: vi.fn(),
deselectAllUtxos: vi.fn(),
totalAmount: 1000,
isUsingAllFunds: false,
utxosToFreeze: [],
confirmationChecked: false,
setConfirmationChecked: vi.fn(),
address: 'bc1...',
timelockAddressQuery: { isLoading: false },
txResult: null,
frozenUtxos: [],
t: (key: string) => key,
}) as unknown as Wizard
describe('CreateFidelityBondDialogSteps', () => {
it('renders select_date step', () => {
const wizard = getBaseWizard()
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.select_date.description')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.select_date.form_label_month')).toBeInTheDocument()
expect(screen.getByText('Jan 2025')).toBeInTheDocument()
})
it('renders select_jar step with jars', () => {
const wizard = getBaseWizard()
wizard.step = 'select_jar'
wizard.jarsWithUtxos = [
{
jarIndex: 0,
name: 'Jar 0',
color: '#000',
balanceSummary: { calculatedAvailableBalanceInSats: 5000 },
utxos: [],
},
] as unknown as Wizard['jarsWithUtxos']
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.select_jar.title')).toBeInTheDocument()
expect(screen.getByText('Jar 0')).toBeInTheDocument()
expect(screen.getByText('5000 sats')).toBeInTheDocument()
})
it('renders select_utxos step', () => {
const wizard = getBaseWizard()
wizard.step = 'select_utxos'
wizard.availableUtxos = [
{ utxo: 'tx1:0', value: 1000, confirmations: 10 },
{ utxo: 'tx2:0', value: 2000, confirmations: 20 },
] as unknown as Wizard['availableUtxos']
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.select_utxos.title')).toBeInTheDocument()
expect(screen.getByText('tx1:0')).toBeInTheDocument()
expect(screen.getByText('tx2:0')).toBeInTheDocument()
})
it('renders freeze_utxos step', () => {
const wizard = getBaseWizard()
wizard.step = 'freeze_utxos'
wizard.selectedUtxos = [{ utxo: 'tx1:0', value: 1000 }] as unknown as Wizard['selectedUtxos']
wizard.utxosToFreeze = [{ utxo: 'tx1:0', value: 1000 }] as unknown as Wizard['utxosToFreeze']
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.freeze_utxos.title')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.freeze_utxos.label_selected_utxos')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.freeze_utxos.label_utxos_to_freeze')).toBeInTheDocument()
})
it('renders review step', () => {
const wizard = getBaseWizard()
wizard.step = 'review'
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
expect(screen.getByText('bc1...')).toBeInTheDocument()
expect(screen.getByTestId('qr-code')).toBeInTheDocument()
})
it('renders creating step', () => {
const wizard = getBaseWizard()
wizard.step = 'creating'
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.text_creating')).toBeInTheDocument()
})
it('renders success step', () => {
const wizard = getBaseWizard()
wizard.step = 'success'
wizard.txResult = { txinfo: { txid: '1234abcd' } }
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.create_fidelity_bond.success_text')).toBeInTheDocument()
expect(screen.getByText('1234abcd')).toBeInTheDocument()
})
it('warns about a duplicate lock date on the select_date step', () => {
const wizard = getBaseWizard()
wizard.hasDuplicateLockdate = true
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByTestId('trans-earn.fidelity_bond.select_date.warning_fb_with_same_expiry')).toBeInTheDocument()
})
it('highlights the selected jar and shows the no-jars alert state', () => {
const wizard = getBaseWizard()
wizard.step = 'select_jar'
wizard.selectedJarIndex = 1
wizard.jarsWithUtxos = [
{
jarIndex: 0,
name: 'Jar 0',
color: '#000',
balanceSummary: { calculatedAvailableBalanceInSats: 5000 },
utxos: [],
},
{
jarIndex: 1,
name: 'Jar 1',
color: '#111',
balanceSummary: { calculatedAvailableBalanceInSats: 9000 },
utxos: [],
},
] as unknown as Wizard['jarsWithUtxos']
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('Jar 0')).toBeInTheDocument()
expect(screen.getByText('Jar 1')).toBeInTheDocument()
})
it('renders select_utxos with a selected utxo, pagination, and the all-funds warning', () => {
const wizard = getBaseWizard()
wizard.step = 'select_utxos'
const utxos = Array.from({ length: 7 }, (_, index) => ({
utxo: `tx${index}:0`,
value: 1000 + index,
confirmations: index,
}))
wizard.availableUtxos = utxos as unknown as Wizard['availableUtxos']
wizard.selectedUtxos = [utxos[0]] as unknown as Wizard['selectedUtxos']
wizard.isUsingAllFunds = true
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.select_utxos.label_total_selected')).toBeInTheDocument()
expect(screen.getByTestId('trans-earn.fidelity_bond.alert_all_funds_in_use')).toBeInTheDocument()
// 7 utxos / 5 per page -> pagination shows page indicator
expect(screen.getByText('1 / 2')).toBeInTheDocument()
})
it('shows the loading state while the timelock address is fetched on review', () => {
const wizard = getBaseWizard()
wizard.step = 'review'
wizard.timelockAddressQuery = { isLoading: true } as unknown as Wizard['timelockAddressQuery']
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.text_loading')).toBeInTheDocument()
})
it('shows the unfreeze hint on the success step when there are frozen utxos', () => {
const wizard = getBaseWizard()
wizard.step = 'success'
wizard.txResult = undefined
wizard.frozenUtxos = [{ utxo: 'tx1:0', value: 1000 }] as unknown as Wizard['frozenUtxos']
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.create_fidelity_bond.label_utxos_to_unfreeze')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,30 @@
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { StepProgress } from './StepProgress'
vi.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))
describe('StepProgress', () => {
it('renders correct number of steps', () => {
const { container } = render(<StepProgress currentStep={0} totalSteps={3} />)
const steps = container.firstChild?.childNodes
expect(steps).toHaveLength(3)
})
it('applies correct classes based on currentStep', () => {
const { container } = render(<StepProgress currentStep={1} totalSteps={3} />)
const steps = container.firstChild?.childNodes as NodeListOf<HTMLElement>
// Step 0: completed (i < currentStep)
expect(steps[0].className).toContain('bg-primary w-8')
// Step 1: current (i === currentStep)
expect(steps[1].className).toContain('bg-primary w-12')
// Step 2: pending (i > currentStep)
expect(steps[2].className).toContain('bg-muted w-8')
})
})

View file

@ -0,0 +1,526 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { Utxo } from '@/hooks/useQueryUtxos'
import { useCreateFidelityBondWizard } from './useCreateFidelityBondWizard'
const mocks = vi.hoisted(() => ({
directSendMutateAsync: vi.fn(),
freezeMutateAsync: vi.fn(),
walletInfoRefetch: vi.fn(),
toastSuccess: vi.fn(),
queryAddress: undefined as string | undefined,
walletInfo: {
jars: [] as Jar[],
fidelityBondSummary: { fbOutputs: [] as Utxo[] },
refetch: vi.fn(),
},
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({ mutationKey: ['directsend'] })),
freezeMutation: vi.fn(() => ({ mutationKey: ['freeze'] })),
gettimelockaddressOptions: vi.fn(() => ({ queryKey: ['timelock-address'] })),
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options: { mutationKey?: string[] }) => ({
mutateAsync: options.mutationKey?.[0] === 'directsend' ? mocks.directSendMutateAsync : mocks.freezeMutateAsync,
})),
useQuery: vi.fn(() => ({
data: mocks.queryAddress === undefined ? undefined : { address: mocks.queryAddress },
error: null,
isFetching: false,
isLoading: false,
})),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('sonner', () => ({
toast: {
success: mocks.toastSuccess,
},
}))
vi.mock('@/context/JamWalletInfoContext', async (importOriginal) => ({
...(await importOriginal<typeof import('@/context/JamWalletInfoContext')>()),
useJamWalletInfoContext: () => mocks.walletInfo,
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/store/jamSettingsStore', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/jamSettingsStore')>()),
useDeveloperMode: () => ({ enabled: false }),
}))
const utxo = (overrides: Partial<Utxo>): Utxo => ({
utxo: 'tx:0',
address: 'bcrt1qsource',
path: "m/84'/1'/0'/0/0",
label: '',
value: 100_000,
tries: 0,
tries_remaining: 3,
external: false,
mixdepth: 0,
confirmations: 6,
frozen: false,
locktime: undefined,
...overrides,
})
const jar = (jarIndex: number, utxos: Utxo[]): Jar =>
({
jarIndex,
name: `Jar ${jarIndex}`,
color: '#808080',
balanceSummary: {},
utxos,
}) as unknown as Jar
const lockdateFromWizard = (wizard: ReturnType<typeof useCreateFidelityBondWizard>) => {
const lockdate = wizard.clampLockdate('1900-01')
if (!lockdate) throw new Error('Expected a generated lockdate option')
return lockdate
}
describe('useCreateFidelityBondWizard', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.directSendMutateAsync.mockResolvedValue({ txid: 'created-tx' })
mocks.freezeMutateAsync.mockResolvedValue(undefined)
mocks.walletInfoRefetch.mockResolvedValue(undefined)
mocks.walletInfo.refetch = mocks.walletInfoRefetch
mocks.queryAddress = 'bcrt1qtimelockdestination'
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [] }
mocks.walletInfo.jars = [
jar(0, [
utxo({ utxo: 'small:0', value: 10_000 }),
utxo({ utxo: 'large:0', value: 50_000 }),
utxo({ utxo: 'frozen:0', value: 75_000, frozen: true }),
utxo({
utxo: 'bond:0',
value: 100_000,
locktime: '2999-01-01 00:00:00',
path: "m/84'/1'/0'/0/3:32503680000",
}),
]),
jar(1, [utxo({ utxo: 'other:0', mixdepth: 1, value: 20_000 })]),
]
})
it('derives selectable utxos and selection totals from wallet state', async () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
expect(result.current.availableUtxos.map((entry) => entry.utxo)).toEqual(['large:0', 'small:0'])
act(() => result.current.selectAllUtxos())
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['large:0', 'small:0'])
expect(result.current.totalAmount).toBe(60_000)
expect(result.current.utxosToFreeze.map((entry) => entry.utxo)).toEqual([])
expect(result.current.isUsingAllFunds).toBe(false)
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['small:0'])
expect(result.current.utxosToFreeze.map((entry) => entry.utxo)).toEqual(['large:0'])
act(() => result.current.deselectAllUtxos())
await waitFor(() => expect(result.current.selectedUtxos).toHaveLength(0))
})
it('skips jar selection when only one jar has eligible utxos', async () => {
mocks.walletInfo.jars = [jar(0, [utxo({ utxo: 'only:0', value: 25_000 })])]
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = lockdateFromWizard(result.current)
act(() => result.current.setSelectedLockdate(lockdate))
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('select_utxos')
expect(result.current.selectedJarIndex).toBe(0)
act(() => result.current.selectAllUtxos())
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('review')
act(() => result.current.setConfirmationChecked(true))
await act(async () => result.current.handleNext())
expect(mocks.directSendMutateAsync).toHaveBeenCalledWith({
path: { walletname: 'wallet.jmdat' },
body: {
mixdepth: 0,
amount_sats: 0,
destination: 'bcrt1qtimelockdestination',
},
})
expect(result.current.step).toBe('success')
expect(result.current.txResult).toEqual({ txid: 'created-tx' })
expect(mocks.walletInfoRefetch).toHaveBeenCalled()
expect(mocks.toastSuccess).toHaveBeenCalledWith('earn.fidelity_bond.create_fidelity_bond.success_text')
})
it('freezes unselected utxos before review and supports back navigation', async () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = lockdateFromWizard(result.current)
act(() => result.current.setSelectedLockdate(lockdate))
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('select_jar')
act(() => result.current.setSelectedJarIndex(0))
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('select_utxos')
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('freeze_utxos')
await act(async () => result.current.handleNext())
expect(mocks.freezeMutateAsync).toHaveBeenCalledWith({
path: { walletname: 'wallet.jmdat' },
body: { 'utxo-string': 'small:0', freeze: true },
})
expect(result.current.step).toBe('review')
expect(result.current.frozenUtxos.map((entry) => entry.utxo)).toEqual(['small:0'])
act(() => result.current.handleBack())
expect(result.current.step).toBe('freeze_utxos')
})
it('resets transient state when the dialog closes', () => {
const onOpenChange = vi.fn()
const { result } = renderHook(() => useCreateFidelityBondWizard(true, onOpenChange, 'wallet.jmdat'))
act(() => {
result.current.setStep('success')
result.current.setSelectedJarIndex(0)
result.current.selectAllUtxos()
result.current.handleOpenChange(false)
})
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(result.current.step).toBe('select_date')
expect(result.current.selectedJarIndex).toBeUndefined()
expect(result.current.selectedUtxos).toEqual([])
expect(result.current.txResult).toBeUndefined()
})
it('does not reset when the dialog opens', () => {
const onOpenChange = vi.fn()
const { result } = renderHook(() => useCreateFidelityBondWizard(true, onOpenChange, 'wallet.jmdat'))
act(() => {
result.current.setStep('review')
result.current.handleOpenChange(true)
})
expect(onOpenChange).toHaveBeenCalledWith(true)
expect(result.current.step).toBe('review')
})
it('exposes lockdate boundaries and clamps out-of-range values', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const { minYear, minMonth, yearOptions, monthOptions } = result.current
expect(minYear).toBeGreaterThan(0)
expect(minMonth).toBeGreaterThanOrEqual(1)
expect(yearOptions.length).toBeGreaterThan(0)
expect(monthOptions.length).toBe(12)
const min = result.current.clampLockdate('1900-01')
const max = result.current.clampLockdate('9999-12')
expect(min).not.toBe('')
expect(max).not.toBe('')
expect(min < max).toBe(true)
// empty input clamps to min
expect(result.current.clampLockdate('')).toBe(min)
// a value already inside the range is returned untouched
expect(result.current.clampLockdate(min)).toBe(min)
// derived year/month default to empty before a selection
expect(result.current.selectedYear).toBe('')
expect(result.current.selectedMonth).toBe('')
act(() => result.current.setSelectedLockdate(min))
expect(result.current.selectedYear).toBe(min.slice(0, 4))
expect(result.current.selectedMonth).toBe(min.slice(5, 7))
expect(result.current.selectedDateLabel).not.toBeNull()
})
it('flags duplicate lockdates against existing fidelity bonds', () => {
const { result: probe } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = lockdateFromWizard(probe.current)
const timestampSeconds = Math.floor(new Date(`${lockdate}-01T00:00:00Z`).getTime() / 1000)
mocks.walletInfo.fidelityBondSummary = {
fbOutputs: [
utxo({
utxo: 'existing-bond:0',
value: 100_000,
locktime: '2999-01-01 00:00:00',
path: `m/84'/1'/0'/0/0:${timestampSeconds}`,
}),
],
}
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
expect(result.current.hasDuplicateLockdate).toBeFalsy()
act(() => result.current.setSelectedLockdate(lockdate))
expect(result.current.hasDuplicateLockdate).toBe(true)
})
it('ignores existing fidelity bonds whose locktime cannot be derived', () => {
mocks.walletInfo.fidelityBondSummary = {
fbOutputs: [
// locked (far-future locktime) but the path carries no parseable timestamp
utxo({
utxo: 'malformed-bond:0',
value: 100_000,
locktime: '2999-01-01 00:00:00',
path: "m/84'/1'/0'/0/0",
}),
],
}
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = lockdateFromWizard(result.current)
act(() => result.current.setSelectedLockdate(lockdate))
expect(result.current.hasDuplicateLockdate).toBeFalsy()
})
it('drops selected utxo ids that are no longer available', async () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.selectAllUtxos())
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['large:0', 'small:0'])
// switch to a jar that does not contain the previously selected ids
act(() => result.current.setSelectedJarIndex(1))
await waitFor(() => expect(result.current.selectedUtxos).toEqual([]))
})
it('reflects canProceed gating for each step', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = lockdateFromWizard(result.current)
// select_date
expect(result.current.canProceed()).toBe(false)
act(() => result.current.setSelectedLockdate(lockdate))
expect(result.current.canProceed()).toBe(true)
// select_jar
act(() => result.current.setStep('select_jar'))
expect(result.current.canProceed()).toBe(false)
act(() => result.current.setSelectedJarIndex(0))
expect(result.current.canProceed()).toBe(true)
// select_utxos
act(() => result.current.setStep('select_utxos'))
expect(result.current.canProceed()).toBe(false)
act(() => result.current.selectAllUtxos())
expect(result.current.canProceed()).toBe(true)
// freeze_utxos always proceeds
act(() => result.current.setStep('freeze_utxos'))
expect(result.current.canProceed()).toBe(true)
// review needs confirmation and an address
act(() => result.current.setStep('review'))
expect(result.current.canProceed()).toBe(false)
act(() => result.current.setConfirmationChecked(true))
expect(result.current.canProceed()).toBe(true)
// unknown step falls through to default
act(() => result.current.setStep('creating'))
expect(result.current.canProceed()).toBe(false)
})
it('blocks review when no timelock address is available', () => {
mocks.queryAddress = undefined
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => {
result.current.setStep('review')
result.current.setConfirmationChecked(true)
})
expect(result.current.address).toBeUndefined()
expect(result.current.canProceed()).toBe(false)
})
it('does not submit a fidelity bond without an address or selected jar', async () => {
mocks.queryAddress = undefined
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
await act(async () => result.current.handleCreateFidelityBond())
expect(mocks.directSendMutateAsync).not.toHaveBeenCalled()
expect(result.current.step).toBe('select_date')
})
it('reports step numbers in wizard order', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
expect(result.current.getStepNumber()).toBe(0)
act(() => result.current.setStep('select_jar'))
expect(result.current.getStepNumber()).toBe(1)
act(() => result.current.setStep('review'))
expect(result.current.getStepNumber()).toBe(4)
})
describe('handleNext validation guards', () => {
it('stays on select_date when the lockdate is invalid', async () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('select_date')
})
it('stays on select_jar when no jar is chosen', async () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setStep('select_jar'))
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('select_jar')
})
it('stays on select_utxos when nothing is selected', async () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.setStep('select_utxos'))
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('select_utxos')
})
it('skips freezing and moves straight to review when all utxos are selected', async () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = lockdateFromWizard(result.current)
act(() => result.current.setSelectedLockdate(lockdate))
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('select_jar')
act(() => result.current.setSelectedJarIndex(0))
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('select_utxos')
act(() => result.current.selectAllUtxos())
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('review')
expect(result.current.utxosToFreeze).toEqual([])
})
it('blocks review submission when the form is invalid', async () => {
mocks.walletInfo.jars = [jar(0, [utxo({ utxo: 'only:0', value: 25_000 })])]
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.selectAllUtxos())
act(() => result.current.setStep('review'))
// confirmation not accepted -> trigger() fails the whole form
await act(async () => result.current.handleNext())
expect(mocks.directSendMutateAsync).not.toHaveBeenCalled()
expect(result.current.step).toBe('review')
})
})
describe('handleBack navigation', () => {
it('returns from select_jar to select_date', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setStep('select_jar'))
act(() => result.current.handleBack())
expect(result.current.step).toBe('select_date')
})
it('returns from select_utxos to select_jar when several jars are eligible', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.selectAllUtxos())
act(() => result.current.setStep('select_utxos'))
act(() => result.current.handleBack())
expect(result.current.step).toBe('select_jar')
expect(result.current.selectedJarIndex).toBe(0)
expect(result.current.selectedUtxos).toEqual([])
})
it('returns from select_utxos to select_date when only one jar is eligible', () => {
mocks.walletInfo.jars = [jar(0, [utxo({ utxo: 'only:0', value: 25_000 })])]
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.selectAllUtxos())
act(() => result.current.setStep('select_utxos'))
act(() => result.current.handleBack())
expect(result.current.step).toBe('select_date')
expect(result.current.selectedJarIndex).toBeUndefined()
expect(result.current.selectedUtxos).toEqual([])
})
it('returns from freeze_utxos to select_utxos', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setStep('freeze_utxos'))
act(() => result.current.handleBack())
expect(result.current.step).toBe('select_utxos')
})
it('returns from review to select_utxos when nothing was frozen', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.selectAllUtxos())
act(() => result.current.setStep('review'))
expect(result.current.utxosToFreeze).toEqual([])
act(() => result.current.handleBack())
expect(result.current.step).toBe('select_utxos')
})
it('returns from review to freeze_utxos when utxos remain to be frozen', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
act(() => result.current.setStep('review'))
expect(result.current.utxosToFreeze.length).toBeGreaterThan(0)
act(() => result.current.handleBack())
expect(result.current.step).toBe('freeze_utxos')
})
})
})

View file

@ -0,0 +1,106 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EarnForm } from './EarnForm'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/components/ui/jam/CurrencySymbol', () => ({
SatSymbol: (props: Record<string, unknown>) => <span {...props}>sat-symbol</span>,
}))
vi.mock('../dev/DevBadge', () => ({
DevBadge: ({ className }: { className?: string }) => <span className={className}>dev-badge</span>,
}))
describe('EarnForm', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal(
'ResizeObserver',
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
it('submits the default absolute-fee offer values', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<EarnForm isWaitingMakerStart={false} offerMinsizeMax={100_000_000} onSubmit={onSubmit} />)
expect(screen.getByLabelText('earn.label_abs_fee')).toBeInTheDocument()
expect(screen.getByLabelText('earn.label_min_amount_input')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'earn.button_start' }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
offerAbsoluteFee: expect.any(Number) as number,
offerMinAmount: expect.any(Number) as number,
offerType: 'sw0absoffer',
}),
expect.anything(),
)
})
})
it('switches to relative fees and submits the edited values', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<EarnForm isWaitingMakerStart={false} offerMinsizeMax={100_000_000} onSubmit={onSubmit} />)
await user.click(screen.getByLabelText('earn.radio_rel_offer_label'))
await user.clear(screen.getByLabelText('earn.label_rel_fee'))
await user.type(screen.getByLabelText('earn.label_rel_fee'), '0.5')
await user.clear(screen.getByLabelText('earn.label_min_amount_input'))
await user.type(screen.getByLabelText('earn.label_min_amount_input'), '50000')
await user.click(screen.getByRole('button', { name: 'earn.button_start' }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
offerMinAmount: 50_000,
offerRelativeFeeInPercent: 0.5,
offerType: 'sw0reloffer',
}),
expect.anything(),
)
})
})
it('shows validation feedback when the minimum amount exceeds available funds', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<EarnForm isWaitingMakerStart={false} offerMinsizeMax={1} onSubmit={onSubmit} />)
await user.click(screen.getByRole('button', { name: 'earn.button_start' }))
expect(await screen.findByText('earn.feedback_invalid_min_amount_insufficient_funds')).toBeInTheDocument()
expect(onSubmit).not.toHaveBeenCalled()
})
it('renders waiting and debug state', async () => {
// wrap in async act so react-hook-form's deferred mount validation settles
// inside act (otherwise it updates formState after the test → act warning)
await act(async () => {
render(<EarnForm debug disabled isWaitingMakerStart={true} offerMinsizeMax={100_000_000} onSubmit={vi.fn()} />)
await Promise.resolve()
})
expect(screen.getByRole('button', { name: /earn.text_starting/ })).toBeDisabled()
expect(screen.getByText('dev-badge')).toBeInTheDocument()
expect(screen.getByText('isValid:')).toBeInTheDocument()
expect(screen.getByText('values:')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,414 @@
import type React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { jmSessionStore } from '@/store/jmSessionStore'
import type { EarnFormValues } from './EarnForm'
import { EarnPage } from './EarnPage'
const mocks = vi.hoisted(() => ({
developerMode: false,
feeConfigMissing: false,
scrollToTop: vi.fn(),
startMaker: vi.fn(),
startMutationState: {
isPending: false,
isSuccess: false,
},
stopMakerRefetch: vi.fn(),
stopMutationState: {
isPending: false,
isSuccess: false,
},
toastDismiss: vi.fn(),
toastError: vi.fn(),
toastInfo: vi.fn(),
toastSuccess: vi.fn(),
walletInfo: {
fidelityBondSummary: { fbOutputs: [] as FidelityBondUtxo[] },
isFetching: false,
isLoading: false,
jars: [] as Array<{
balanceSummary: {
calculatedAvailableBalanceInSats: number
calculatedConfirmedAvailableBalanceInSats: number
calculatedFrozenOrLockedBalanceInSats: number
calculatedTotalBalanceInSats: number
}
}>,
maxJarAvailableBalance: 100_000_000,
},
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
startmakerMutation: vi.fn(() => ({ mutationFn: mocks.startMaker })),
stopmakerOptions: vi.fn(() => ({ queryKey: ['stopmaker'], queryFn: vi.fn() })),
}))
type MutationOptions = {
mutationFn: (input?: unknown) => Promise<unknown>
onError?: (error: unknown) => void
onMutate?: () => void
onSuccess?: (result: unknown, input?: unknown) => void
}
vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options: MutationOptions) => {
const isStartMaker = options.mutationFn === mocks.startMaker
const state = isStartMaker ? mocks.startMutationState : mocks.stopMutationState
return {
...state,
mutateAsync: async (input?: unknown) => {
options.onMutate?.()
try {
const result = await options.mutationFn(input)
options.onSuccess?.(result, input)
return result
} catch (error) {
options.onError?.(error)
// do not rethrow: the component fires these mutations without catching,
// and the UI is driven entirely by the onError handler above
return undefined
}
},
reset: vi.fn(),
}
}),
useQuery: vi.fn(() => ({
refetch: mocks.stopMakerRefetch,
})),
}))
vi.mock('react-i18next', () => ({
Trans: ({ children, i18nKey }: { children?: React.ReactNode; i18nKey?: string }) => (
<span>{children ?? i18nKey}</span>
),
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
}))
vi.mock('react-router-dom', () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
}))
vi.mock('sonner', () => ({
toast: {
dismiss: mocks.toastDismiss,
error: mocks.toastError,
info: mocks.toastInfo,
success: mocks.toastSuccess,
},
}))
vi.mock('@/components/settings/fees/FeeConfigDialog', () => ({
FeeConfigDialog: ({ open }: { open: boolean }) => <div>fee-config-dialog:{String(open)}</div>,
}))
vi.mock('@/components/ui/jam/FeeConfigErrorAlert', () => ({
FeeConfigErrorAlert: ({ onOpenFeeConfig }: { onOpenFeeConfig: () => void }) => (
<button onClick={onOpenFeeConfig}>open-fee-config</button>
),
}))
vi.mock('@/components/ui/jam/PageLoading', () => ({
PageLoading: () => <div>page-loading</div>,
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title, subtitle }: { title: string; subtitle: string }) => (
<h1>
{title}:{subtitle}
</h1>
),
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useJamWalletInfoContext: () => mocks.walletInfo,
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/hooks/useFeeConfigValidation', () => ({
useFeeConfigValidation: () => ({
isLoading: false,
maxFeesConfigMissing: mocks.feeConfigMissing,
}),
}))
vi.mock('@/hooks/useRefreshSession', () => ({
useRefreshSession: vi.fn(),
}))
vi.mock('@/lib/queryClient', () => ({
withQueryDelay: (queryFn: unknown) => queryFn,
}))
vi.mock('@/lib/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/utils')>()),
scrollToTop: mocks.scrollToTop,
}))
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: mocks.developerMode }),
}))
vi.mock('./CreateFidelityBondDialog', () => ({
CreateFidelityBondDialog: ({ open }: { open: boolean }) => <div>create-bond-dialog:{String(open)}</div>,
}))
vi.mock('./EarnForm', () => ({
EarnForm: ({
debug,
disabled,
onSubmit,
}: {
debug?: boolean
disabled?: boolean
onSubmit: (values: EarnFormValues) => Promise<void>
}) => (
<div>
earn-form:{String(disabled)}:{String(debug)}
<button
onClick={() =>
void onSubmit({
offerMinAmount: 50_000,
offerRelativeFeeInPercent: 0.5,
offerType: 'sw0reloffer',
})
}
>
submit-earn
</button>
</div>
),
}))
vi.mock('./FidelityBondCard', () => ({
FidelityBondCard: ({ children, value }: { children?: React.ReactNode; value: FidelityBondUtxo }) => (
<div>
fidelity-bond:{value.utxo}
{children}
</div>
),
}))
vi.mock('./MoveToJarDialog', () => ({
MoveToJarDialog: ({ open }: { open: boolean }) => <div>move-to-jar-dialog:{String(open)}</div>,
}))
vi.mock('./OfferCard', () => ({
OfferCard: ({ children, nickname }: { children?: React.ReactNode; nickname?: string }) => (
<div>
offer-card:{nickname}
{children}
</div>
),
}))
vi.mock('./RenewBondDialog', () => ({
RenewBondDialog: ({ open }: { open: boolean }) => <div>renew-bond-dialog:{String(open)}</div>,
}))
vi.mock('./report/EarnReportOverlay', () => ({
EarnReportOverlay: ({ open }: { open: boolean }) => <div>earn-report:{String(open)}</div>,
}))
const balanceSummary = {
calculatedAvailableBalanceInSats: 100_000_000,
calculatedConfirmedAvailableBalanceInSats: 100_000_000,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 100_000_000,
}
const expiredBond = {
address: 'bc1qbond',
confirmations: 12,
frozen: false,
label: '',
locktime: '1970-01-01 00:00:00',
path: "m/84'/1'/0':1",
tries_remaining: 3,
utxo: 'bond-tx:0',
value: 50_000,
} as unknown as FidelityBondUtxo
const setSession = (overrides: Record<string, unknown> = {}) => {
jmSessionStore.setState({
state: {
coinjoin_in_process: false,
maker_running: false,
nickname: 'maker-a',
offer_list: [],
rescanning: false,
session: true,
wallet_name: 'wallet.jmdat',
...overrides,
},
})
}
describe('EarnPage', () => {
beforeEach(() => {
mocks.developerMode = false
mocks.feeConfigMissing = false
mocks.scrollToTop.mockReset()
mocks.startMaker.mockReset()
mocks.startMaker.mockResolvedValue({})
mocks.startMutationState.isPending = false
mocks.startMutationState.isSuccess = false
mocks.stopMakerRefetch.mockReset()
mocks.stopMakerRefetch.mockResolvedValue({ data: {} })
mocks.stopMutationState.isPending = false
mocks.stopMutationState.isSuccess = false
mocks.toastDismiss.mockReset()
mocks.toastError.mockReset()
mocks.toastInfo.mockReset()
mocks.toastSuccess.mockReset()
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [] }
mocks.walletInfo.isFetching = false
mocks.walletInfo.isLoading = false
mocks.walletInfo.jars = [{ balanceSummary }]
mocks.walletInfo.maxJarAvailableBalance = 100_000_000
setSession()
})
it('shows loading until session and wallet info are ready', () => {
jmSessionStore.setState({ state: undefined })
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('page-loading')).toBeInTheDocument()
})
it('starts earning and opens fee/report dialogs', async () => {
const user = userEvent.setup()
mocks.feeConfigMissing = true
render(<EarnPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByText('open-fee-config'))
expect(screen.getByText('fee-config-dialog:true')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'earn.button_show_report' }))
expect(screen.getByText('earn-report:true')).toBeInTheDocument()
await user.click(screen.getByText('submit-earn'))
await waitFor(() => expect(mocks.startMaker).toHaveBeenCalled())
expect(mocks.startMaker).toHaveBeenCalledWith({
body: {
cjfee_a: '0',
cjfee_r: '0.005',
minsize: '50000',
ordertype: 'sw0reloffer',
txfee: '0',
},
path: { walletname: 'wallet.jmdat' },
})
expect(mocks.scrollToTop).toHaveBeenCalled()
})
it('shows running maker offer and stops it', async () => {
const user = userEvent.setup()
setSession({
maker_running: true,
offer_list: [{ cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }],
})
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('offer-card:maker-a')).toBeInTheDocument()
expect(screen.getByText('earn.alert_running')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'earn.button_stop' }))
expect(mocks.stopMakerRefetch).toHaveBeenCalledWith({ throwOnError: true })
})
it('shows waiting states while maker updates', () => {
mocks.startMutationState.isSuccess = true
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('Waiting for maker to start...')).toBeInTheDocument()
})
it('opens fidelity bond actions', async () => {
const user = userEvent.setup()
mocks.developerMode = true
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [expiredBond] }
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('fidelity-bond:bond-tx:0')).toBeInTheDocument()
expect(screen.getByText(/walletInfo\.fidelityBondSummary\.fbOutputs/u)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_spend/u }))
expect(screen.getByText('move-to-jar-dialog:true')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_renew/u }))
expect(screen.getByText('renew-bond-dialog:true')).toBeInTheDocument()
})
it('shows the coinjoin-in-progress alert', () => {
setSession({ coinjoin_in_process: true })
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('send.text_coinjoin_already_running')).toBeInTheDocument()
})
it('shows the waiting-to-stop alert', () => {
setSession({ maker_running: true })
mocks.stopMutationState.isSuccess = true
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('Waiting for maker to stop...')).toBeInTheDocument()
})
it('shows the loading-offer alert while the maker runs without an offer', () => {
setSession({ maker_running: true, offer_list: [] })
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.alert_loading_offer')).toBeInTheDocument()
})
it('warns when the spendable balance is only unconfirmed', () => {
mocks.walletInfo.jars = [{ balanceSummary: { ...balanceSummary, calculatedConfirmedAvailableBalanceInSats: 0 } }]
mocks.walletInfo.maxJarAvailableBalance = 100_000_000
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.alert_unconfirmed_balance_title')).toBeInTheDocument()
})
it('warns when there is no spendable balance at all', () => {
mocks.walletInfo.jars = [{ balanceSummary: { ...balanceSummary, calculatedConfirmedAvailableBalanceInSats: 0 } }]
mocks.walletInfo.maxJarAvailableBalance = 0
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.alert_no_spendable_balance_title')).toBeInTheDocument()
})
it('shows an error toast when starting the maker fails', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const user = userEvent.setup()
mocks.startMaker.mockRejectedValue(new Error('start boom'))
render(<EarnPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByText('submit-earn'))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
errorSpy.mockRestore()
})
it('shows an error toast when stopping the maker fails', async () => {
const user = userEvent.setup()
setSession({ maker_running: true, offer_list: [{ cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }] })
mocks.stopMakerRefetch.mockRejectedValue(new Error('stop boom'))
render(<EarnPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByRole('button', { name: 'earn.button_stop' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
})
it('announces success once the maker is running after a successful start', () => {
setSession({ maker_running: true })
mocks.startMutationState.isSuccess = true
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(mocks.toastSuccess).toHaveBeenCalledWith('earn.alert_running', expect.anything())
})
})

View file

@ -0,0 +1,97 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { FidelityBondCard } from './FidelityBondCard'
type UtxoLike = { isFb?: boolean; locked?: boolean; locktime?: string }
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: {
resolvedLanguage: 'en',
},
}),
Trans: ({ i18nKey }: { i18nKey?: string }) => <div data-testid="trans">{i18nKey}</div>,
}))
vi.mock('@/lib/utils', () => ({
time: {
humanReadableDuration: ({ to }: { to: string }) => `in ${to} days`,
},
cn: (...args: unknown[]) => args.join(' '),
}))
vi.mock('@/lib/fidelityBondUtils', () => ({
utxo: {
isFidelityBond: vi.fn((value: UtxoLike) => value.isFb),
isLocked: vi.fn((value: UtxoLike) => value.locked),
getLocktime: vi.fn((value: UtxoLike) => value.locktime),
},
}))
vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value?: string }) => <span data-testid="address">{value}</span>,
}))
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
}))
describe('FidelityBondCard', () => {
const activeUtxo = {
isFb: true,
locked: true,
locktime: '2025',
path: 'm/0/0',
utxo: 'txid:0',
value: 100000,
address: 'bc1...',
} as unknown as FidelityBondUtxo
const expiredUtxo = {
...activeUtxo,
locked: false,
}
const nonFbUtxo = {
...activeUtxo,
isFb: false,
}
it('renders nothing if utxo is not a fidelity bond', () => {
const { container } = render(<FidelityBondCard value={nonFbUtxo} />)
expect(container).toBeEmptyDOMElement()
})
it('renders active fidelity bond', () => {
render(<FidelityBondCard value={activeUtxo} />)
expect(screen.getByText('earn.fidelity_bond.existing.title_active')).toBeInTheDocument()
expect(screen.getByText('m/0/0')).toBeInTheDocument()
expect(screen.getByTestId('balance')).toHaveTextContent('100000')
expect(screen.getByText('earn.fidelity_bond.existing.label_locked_until')).toBeInTheDocument()
expect(screen.getByText('2025 (in 2025 days)')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.existing.label_address')).toBeInTheDocument()
expect(screen.getByTestId('address')).toHaveTextContent('bc1...')
})
it('renders expired fidelity bond', () => {
render(<FidelityBondCard value={expiredUtxo} />)
expect(screen.getByTestId('trans')).toHaveTextContent('earn.fidelity_bond.existing.title_expired')
expect(screen.getByText('earn.fidelity_bond.existing.label_expired_on')).toBeInTheDocument()
})
it('renders children in footer', () => {
render(
<FidelityBondCard value={activeUtxo}>
<div data-testid="child">Child Content</div>
</FidelityBondCard>,
)
expect(screen.getByTestId('child')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,210 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { MoveToJarDialog } from './MoveToJarDialog'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
type ValueProps = { value: string }
const h = vi.hoisted(() => ({
queryReturn: {
data: undefined as { address?: string } | undefined,
isLoading: false,
isError: false,
},
mutateAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }),
isPending: false,
refetch: vi.fn(),
extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: () => h.queryReturn,
useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({})),
freezeMutation: vi.fn(() => ({})),
getaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
}))
vi.mock('@/hooks/useApiClient', () => ({ useApiClient: () => ({}) }))
vi.mock('@/lib/errorReason', () => ({ getErrorReason: () => 'reason' }))
vi.mock('@/lib/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/utils')>()),
formatSats: (sats: number) => `${sats} sats`,
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useJamWalletInfoContext: () => ({
jars: [
{
jarIndex: 0,
name: 'Jar 0',
color: '#000',
balanceSummary: { calculatedAvailableBalanceInSats: 5000 },
utxos: [{ utxo: 'tx1:0', value: 1000, frozen: false }, ...h.extraUtxos],
},
{
jarIndex: 1,
name: 'Jar 1',
color: '#111',
balanceSummary: { calculatedAvailableBalanceInSats: 1000 },
utxos: [],
},
],
refetch: h.refetch,
}),
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>close-dialog</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogDescription: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogFooter: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
vi.mock('@/components/ui/switch', () => ({
Switch: ({ onCheckedChange }: { onCheckedChange?: (checked: boolean) => void }) => (
<button data-testid="confirm-switch" onClick={() => onCheckedChange?.(true)} />
),
}))
vi.mock('@/components/ui/jam/Address', () => ({
Address: ({ value }: ValueProps) => <div data-testid="address">{value}</div>,
}))
vi.mock('@/components/ui/jam/CopyButton', () => ({
CopyButton: ({ value }: ValueProps) => <div data-testid="copy">{value}</div>,
}))
const utxo = {
utxo: 'tx1:0',
value: 1000,
mixdepth: 0,
confirmations: 10,
address: 'bc1',
frozen: false,
} as unknown as FidelityBondUtxo
const renderDialog = (props?: { utxo?: FidelityBondUtxo; onOpenChange?: (open: boolean) => void }) =>
render(
<MoveToJarDialog
open
onOpenChange={props?.onOpenChange ?? vi.fn()}
walletFileName="test.jmdat"
utxo={props?.utxo ?? utxo}
/>,
)
const goToConfirm = () => {
fireEvent.click(screen.getByText('Jar 1'))
fireEvent.click(screen.getByText('earn.fidelity_bond.select_date.text_primary_button'))
}
describe('MoveToJarDialog', () => {
beforeEach(() => {
h.queryReturn = { data: { address: 'bc111' }, isLoading: false, isError: false }
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } })
h.isPending = false
h.refetch = vi.fn()
h.extraUtxos = []
})
it('renders the jar selection step', () => {
renderDialog()
expect(screen.getByText('earn.fidelity_bond.move.title')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.move.select_jar.description')).toBeInTheDocument()
expect(screen.getByText('Jar 1')).toBeInTheDocument()
})
it('moves to the confirm step and shows the destination address', () => {
renderDialog()
goToConfirm()
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_jar_n {"jar":1}')).toBeInTheDocument()
expect(screen.getByTestId('address')).toHaveTextContent('bc111')
})
it('shows a loading indicator while the destination address loads', () => {
h.queryReturn = { data: undefined, isLoading: true, isError: false }
renderDialog()
goToConfirm()
expect(screen.getByText('earn.fidelity_bond.move.text_loading')).toBeInTheDocument()
})
it('shows an error alert when the address query fails', () => {
h.queryReturn = { data: undefined, isLoading: false, isError: true }
renderDialog()
goToConfirm()
expect(screen.getByText('earn.fidelity_bond.move.error_loading_address')).toBeInTheDocument()
})
it('completes the move flow and reaches the success step', async () => {
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
renderDialog()
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.move.success_text')).toBeInTheDocument())
expect(screen.getAllByText('movetxid99').length).toBeGreaterThan(0)
expect(h.refetch).toHaveBeenCalled()
})
it('unfreezes a frozen fidelity bond before sending', async () => {
const frozenUtxo = { ...utxo, frozen: true } as unknown as FidelityBondUtxo
renderDialog({ utxo: frozenUtxo })
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.move.success_text')).toBeInTheDocument())
const calls = h.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
expect(calls.some((call) => call[0]?.body?.freeze === false)).toBe(true)
})
it('returns to the confirm step when sending fails', async () => {
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
renderDialog()
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
await waitFor(() =>
expect(screen.getByText('earn.fidelity_bond.move.confirm_send_modal.title')).toBeInTheDocument(),
)
})
it('navigates back from confirm to jar selection', () => {
renderDialog()
goToConfirm()
fireEvent.click(screen.getByText('global.back'))
expect(screen.getByText('earn.fidelity_bond.move.select_jar.description')).toBeInTheDocument()
})
it('closes and resets when the dialog is dismissed', () => {
const onOpenChange = vi.fn()
renderDialog({ onOpenChange })
fireEvent.click(screen.getByText('close-dialog'))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})

View file

@ -0,0 +1,82 @@
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OfferCard } from './OfferCard'
type Offer = NonNullable<SessionResponse['offer_list']>[number]
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
factorToPercentage: (val: number) => val * 100,
isAbsoluteOffer: (type: string) => type === 'sw0abso',
isRelativeOffer: (type: string) => type === 'sw0relo',
}))
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
}))
describe('OfferCard', () => {
const baseOffer = {
oid: 123,
cjfee: '1000',
txfee: '500',
minsize: '10000',
maxsize: '50000',
} as unknown as Offer
it('renders absolute offer', () => {
const offer = { ...baseOffer, ordertype: 'sw0abso' }
render(<OfferCard value={offer} nickname="JMBot" />)
expect(screen.getByText('earn.current.text_offer')).toBeInTheDocument()
expect(screen.getByText('earn.current.text_offer_type_absolute')).toBeInTheDocument()
expect(screen.getByText('JMBot:123')).toBeInTheDocument()
// Check balances
const balances = screen.getAllByTestId('balance')
expect(balances[0]).toHaveTextContent('1000') // cjfee
expect(balances[1]).toHaveTextContent('10000') // minsize
expect(balances[2]).toHaveTextContent('50000') // maxsize
expect(balances[3]).toHaveTextContent('500') // txfee
})
it('renders relative offer', () => {
const offer = { ...baseOffer, ordertype: 'sw0relo', cjfee: '0.005' }
render(<OfferCard value={offer} nickname="JMBot" />)
expect(screen.getByText('earn.current.text_offer_type_relative')).toBeInTheDocument()
// Relative fee should be percentage
expect(screen.getByText('0.5%')).toBeInTheDocument()
})
it('renders other offer type', () => {
const offer = { ...baseOffer, ordertype: 'sw0x' }
render(<OfferCard value={offer} nickname="JMBot" />)
expect(screen.getByText('sw0x')).toBeInTheDocument()
})
it('renders without txfee', () => {
const offer = { ...baseOffer, ordertype: 'sw0abso', txfee: undefined }
render(<OfferCard value={offer} nickname="JMBot" />)
expect(screen.queryByText('earn.current.text_txfee')).not.toBeInTheDocument()
})
it('renders children', () => {
render(
<OfferCard value={baseOffer} nickname="JMBot">
<div data-testid="child">Child Content</div>
</OfferCard>,
)
expect(screen.getByTestId('child')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,278 @@
import type { ReactNode } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { RenewBondDialog } from './RenewBondDialog'
type ChildrenProps = { children?: ReactNode }
type ValueProps = { value: string }
const h = vi.hoisted(() => ({
queryReturn: { data: undefined as { address: string } | undefined, isLoading: false, isError: false },
mutateAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }),
isPending: false,
refetch: vi.fn(),
utxoFrozen: false,
extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>,
selectHandlers: [] as Array<(value: string) => void>,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: () => h.queryReturn,
useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({})),
freezeMutation: vi.fn(() => ({})),
gettimelockaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
}))
vi.mock('@/hooks/useApiClient', () => ({ useApiClient: () => ({}) }))
vi.mock('@/store/jamSettingsStore', () => ({ useDeveloperMode: () => ({ enabled: false }) }))
vi.mock('@/context/JamWalletInfoContext', () => ({
useJamWalletInfoContext: () => ({
jars: [
{
jarIndex: 0,
name: 'Jar 0',
color: '#000',
balanceSummary: { calculatedAvailableBalanceInSats: 5000 },
utxos: [{ utxo: 'tx1:0', value: 1000, frozen: h.utxoFrozen }, ...h.extraUtxos],
},
],
refetch: h.refetch,
}),
}))
vi.mock('@/lib/errorReason', () => ({ getErrorReason: () => 'reason' }))
vi.mock('@/lib/fidelityBondUtils', () => ({
lockdate: { toTimestamp: () => 1_893_456_000_000 },
}))
vi.mock('@/lib/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/utils')>()),
formatSats: (sats: number) => `${sats} sats`,
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))
vi.mock('./CreateFidelityBondDialog/types', () => ({
generateLockdateOptions: () => [
{ value: '2026-07', label: 'Jul 2026' },
{ value: '2027-06', label: 'Jun 2027' },
],
getYearOptions: () => [
{ value: '2026', label: '2026' },
{ value: '2027', label: '2027' },
],
getMonthOptions: () => [
{ value: '06', label: 'Jun' },
{ value: '07', label: 'Jul' },
],
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({
children,
open,
onOpenChange,
}: ChildrenProps & { open?: boolean; onOpenChange?: (o: boolean) => void }) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>close-dialog</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogDescription: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogFooter: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
vi.mock('@/components/ui/select', () => ({
Select: ({ children, onValueChange }: ChildrenProps & { onValueChange?: (v: string) => void }) => {
if (onValueChange) h.selectHandlers.push(onValueChange)
return <div>{children}</div>
},
SelectContent: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectItem: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectTrigger: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectValue: () => <div />,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children, onClick, disabled }: ChildrenProps & { onClick?: () => void; disabled?: boolean }) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))
vi.mock('@/components/ui/switch', () => ({
Switch: ({ onCheckedChange }: { onCheckedChange?: (checked: boolean) => void }) => (
<button data-testid="confirm-switch" onClick={() => onCheckedChange?.(true)} />
),
}))
vi.mock('@/components/ui/alert', () => ({
Alert: ({ children }: ChildrenProps) => <div>{children}</div>,
AlertDescription: ({ children }: ChildrenProps) => <div>{children}</div>,
AlertTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
vi.mock('@/components/ui/spinner', () => ({ Spinner: () => <div data-testid="spinner" /> }))
vi.mock('@/components/ui/jam/CopyButton', () => ({
CopyButton: ({ value }: ValueProps) => <div data-testid="copy">{value}</div>,
}))
vi.mock('@/components/ui/label', () => ({ Label: ({ children }: ChildrenProps) => <label>{children}</label> }))
vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: ValueProps) => <div data-testid="address">{value}</div>,
}))
const utxo = {
utxo: 'tx1:0',
value: 1000,
mixdepth: 0,
confirmations: 10,
address: 'bc1',
frozen: false,
} as unknown as FidelityBondUtxo
const renderDialog = () =>
render(<RenewBondDialog open onOpenChange={vi.fn()} walletFileName={'test.jmdat'} utxo={utxo} />)
const selectMonth = (value: string) => {
const handler = h.selectHandlers.at(-2)
act(() => handler?.(value))
}
const selectYear = (value: string) => {
const handler = h.selectHandlers.at(-1)
act(() => handler?.(value))
}
const goToConfirm = () => {
selectMonth('06')
fireEvent.click(screen.getByText('earn.fidelity_bond.select_date.text_primary_button'))
}
describe('RenewBondDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
h.queryReturn = { data: { address: 'bc1timelock' }, isLoading: false, isError: false }
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } })
h.isPending = false
h.utxoFrozen = false
h.extraUtxos = []
h.selectHandlers = []
})
it('renders the date selection step', () => {
renderDialog()
expect(screen.getByText('earn.fidelity_bond.renew.title')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.select_date.description')).toBeInTheDocument()
})
it('selecting a month enables the next button and shows the chosen date', () => {
renderDialog()
selectMonth('06')
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
})
it('selecting a year computes a clamped lock date', () => {
renderDialog()
selectYear('2027')
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
})
it('selecting an early month bumps the year forward', () => {
renderDialog()
// a month earlier than the minimum month forces year = minYear + 1
selectMonth('06')
selectYear('2026')
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
})
it('moves to the confirm step and shows the timelock address', () => {
renderDialog()
goToConfirm()
expect(screen.getByTestId('address')).toHaveTextContent('bc1timelock')
})
it('shows a loading spinner while the timelock address loads', () => {
h.queryReturn = { data: undefined, isLoading: true, isError: false }
renderDialog()
goToConfirm()
expect(screen.getByText('earn.fidelity_bond.renew.text_loading')).toBeInTheDocument()
})
it('shows an error alert when the address query fails', () => {
h.queryReturn = { data: undefined, isLoading: false, isError: true }
renderDialog()
expect(screen.getByText('earn.fidelity_bond.error_loading_address')).toBeInTheDocument()
})
it('completes the renew flow and reaches the success step', async () => {
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
renderDialog()
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument())
expect(screen.getAllByText('abcd1234').length).toBeGreaterThan(0)
expect(h.refetch).toHaveBeenCalled()
})
it('unfreezes a frozen fidelity bond before sending', async () => {
const frozenUtxo = { ...utxo, frozen: true } as unknown as FidelityBondUtxo
render(<RenewBondDialog open onOpenChange={vi.fn()} walletFileName={'test.jmdat'} utxo={frozenUtxo} />)
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument())
const calls = h.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
expect(calls.some((call) => call[0]?.body?.freeze === false)).toBe(true)
})
it('returns to the confirm step when sending fails', async () => {
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
renderDialog()
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
await waitFor(() =>
expect(screen.getByText('earn.fidelity_bond.renew.confirm_send_modal.title')).toBeInTheDocument(),
)
})
it('closes and resets when the dialog is dismissed', () => {
const onOpenChange = vi.fn()
render(<RenewBondDialog open onOpenChange={onOpenChange} walletFileName={'test.jmdat'} utxo={utxo} />)
fireEvent.click(screen.getByText('close-dialog'))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('navigates back from confirm to date selection', () => {
renderDialog()
goToConfirm()
fireEvent.click(screen.getByText('global.back'))
expect(screen.getByText('earn.fidelity_bond.select_date.description')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { EarnReportEntry } from '@/components/earn/report/hooks/useQueryYieldgenReport'
import { EarnReportChart } from './EarnReportChart'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
}))
vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
const entry = (earnedAmount: number | null, timestamp: Date): EarnReportEntry =>
({ earnedAmount, timestamp }) as unknown as EarnReportEntry
describe('EarnReportChart', () => {
it('renders nothing when there is no earned data', () => {
const { container } = render(<EarnReportChart entries={[entry(0, new Date())]} />)
expect(container.firstChild).toBeNull()
})
it('renders nothing when days is negative', () => {
const { container } = render(<EarnReportChart entries={[entry(1000, new Date())]} days={-1} />)
expect(container.firstChild).toBeNull()
})
it('renders the chart with a title when earned data exists', () => {
render(<EarnReportChart entries={[entry(1000, new Date())]} days={7} />)
expect(screen.getByText(/earn.report.chart_title_days/)).toBeInTheDocument()
expect(screen.getByText('1000')).toBeInTheDocument()
})
it('ignores entries with null earnedAmount', () => {
const { container } = render(<EarnReportChart entries={[entry(null, new Date())]} />)
expect(container.firstChild).toBeNull()
})
})

View file

@ -0,0 +1,187 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EarnReportContent } from './EarnReportContent'
import type { EarnReportEntry } from './hooks/useQueryYieldgenReport'
const mocks = vi.hoisted(() => ({
createObjectURL: vi.fn<(blob: Blob) => string>(() => 'blob:earn-report'),
developerMode: false,
entries: [] as EarnReportEntry[],
isLoading: false,
isRefetching: false,
refetch: vi.fn<() => Promise<void>>(),
revokeObjectURL: vi.fn<(url: string) => void>(),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
}))
vi.mock('@/components/dev/DevBadge', () => ({
DevBadge: () => <span>dev-badge</span>,
}))
vi.mock('@/components/earn/report/hooks/useQueryYieldgenReport', () => ({
useQueryYieldgenReport: ({ enabled }: { enabled: boolean }) => ({
data: enabled ? mocks.entries : undefined,
isLoading: mocks.isLoading,
isRefetching: mocks.isRefetching,
refetch: mocks.refetch,
}),
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>balance:{valueString}</span>,
}))
vi.mock('@/components/ui/jam/SortIcon', () => ({
SortIcon: () => <span>sort-icon</span>,
}))
vi.mock('@/components/ui/jam/TablePagination', () => ({
TablePagination: ({
onItemsPerPageChange,
onPageChange,
totalItems,
}: {
onItemsPerPageChange: (itemsPerPage: number) => void
onPageChange: (page: number) => void
totalItems: number
}) => (
<div>
pagination:{totalItems}
<button type="button" onClick={() => onPageChange(1)}>
first-page
</button>
<button type="button" onClick={() => onItemsPerPageChange(-1)}>
show-all
</button>
<button type="button" onClick={() => onItemsPerPageChange(25)}>
show-page-size
</button>
</div>
),
}))
vi.mock('@/components/ui/spinner', () => ({
Spinner: () => <div>spinner</div>,
}))
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: mocks.developerMode }),
}))
vi.mock('./EarnReportChart', () => ({
EarnReportChart: ({ entries }: { entries: EarnReportEntry[] }) => <div>chart:{entries.length}</div>,
}))
const entry = (overrides: Partial<EarnReportEntry>): EarnReportEntry => ({
cjTotalAmount: 50_000,
confirmationDuration: 12,
earnedAmount: 100,
fee: 0,
inputAmount: 30_000,
inputCount: 2,
notes: null,
timestamp: new Date('2026-06-13T12:00:00.000Z'),
...overrides,
})
describe('EarnReportContent', () => {
beforeEach(() => {
vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-06-14T12:00:00.000Z').getTime())
mocks.developerMode = false
mocks.entries = [
entry({ earnedAmount: 100, notes: 'first maker note' }),
entry({ earnedAmount: 200, notes: 'second maker note', timestamp: new Date('2026-05-30T12:00:00.000Z') }),
entry({ earnedAmount: 300, notes: 'old maker note', timestamp: new Date('2026-01-01T12:00:00.000Z') }),
]
mocks.isLoading = false
mocks.isRefetching = false
mocks.refetch.mockReset()
mocks.refetch.mockResolvedValue(undefined)
mocks.createObjectURL.mockClear()
mocks.revokeObjectURL.mockClear()
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: mocks.createObjectURL,
})
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: mocks.revokeObjectURL,
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders loading and empty report states', () => {
mocks.isLoading = true
const { rerender } = render(<EarnReportContent enabled />)
expect(screen.getByText('spinner')).toBeInTheDocument()
mocks.isLoading = false
mocks.entries = []
rerender(<EarnReportContent enabled />)
expect(screen.getByText('earn.alert_empty_report')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /earn\.report\.text_button_download_csv/u })).toBeDisabled()
})
it('summarizes, filters, refreshes, paginates, and exports report rows', async () => {
const anchorClick = vi.fn()
const createElement = vi.spyOn(document, 'createElement')
createElement.mockImplementation((tagName: string) => {
const element = document.createElementNS('http://www.w3.org/1999/xhtml', tagName)
if (tagName === 'a') {
Object.defineProperty(element, 'click', {
configurable: true,
value: anchorClick,
})
}
return element
})
render(<EarnReportContent enabled className="custom-report" />)
expect(screen.getByText('chart:3')).toBeInTheDocument()
expect(screen.getByText('balance:600')).toBeInTheDocument()
expect(screen.getAllByText('balance:300').length).toBeGreaterThan(0)
expect(screen.getByText(/earn\.report\.text_report_summary:\{"count":3\}/u)).toBeInTheDocument()
expect(screen.getByText('first maker note')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('earn.report.placeholder_search'), {
target: { value: 'second' },
})
expect(screen.getByText(/earn\.report\.text_report_summary_filtered/u)).toBeInTheDocument()
expect(screen.queryByText('first maker note')).not.toBeInTheDocument()
expect(screen.getByText('second maker note')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /earn\.report\.text_button_download_csv/u }))
expect(mocks.createObjectURL).toHaveBeenCalled()
expect(anchorClick).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'first-page' }))
fireEvent.click(screen.getByRole('button', { name: 'show-all' }))
fireEvent.click(screen.getByRole('button', { name: 'show-page-size' }))
fireEvent.click(screen.getByRole('button', { name: '' }))
await waitFor(() => expect(mocks.refetch).toHaveBeenCalled())
createElement.mockRestore()
})
it('adds demo rows in developer mode', () => {
mocks.developerMode = true
render(<EarnReportContent enabled />)
fireEvent.click(screen.getByRole('button', { name: /earn\.report\.text_button_generate_demo_report/u }))
expect(screen.getByText('dev-badge')).toBeInTheDocument()
expect(screen.getByText(/earn\.report\.text_report_summary:\{"count":4\}/u)).toBeInTheDocument()
})
})

View file

@ -0,0 +1,66 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { EarnReportOverlay } from './EarnReportOverlay'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('./EarnReportContent', () => ({
EarnReportContent: ({ enabled, className }: { enabled: boolean; className?: string }) => (
<div data-testid="earn-report-content" data-enabled={enabled} className={className}>
earn-report-content
</div>
),
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <h1 data-testid="page-title">{title}</h1>,
}))
// Mock Dialog to avoid dealing with portals and radix UI internals
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
describe('EarnReportOverlay', () => {
it('renders dialog content when open', () => {
render(<EarnReportOverlay open={true} onOpenChange={vi.fn()} />)
expect(screen.getByTestId('page-title')).toHaveTextContent('earn.report.title')
const content = screen.getByTestId('earn-report-content')
expect(content).toBeInTheDocument()
expect(content).toHaveAttribute('data-enabled', 'true')
})
it('calls onOpenChange when closed', () => {
const onOpenChange = vi.fn()
render(<EarnReportOverlay open={true} onOpenChange={onOpenChange} />)
const closeButton = screen.getByText('Close')
fireEvent.click(closeButton)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('does not render when closed', () => {
render(<EarnReportOverlay open={false} onOpenChange={vi.fn()} />)
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,34 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { EarnReportPage } from './EarnReportPage'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('./EarnReportContent', () => ({
EarnReportContent: ({ enabled, className }: { enabled: boolean; className?: string }) => (
<div data-testid="earn-report-content" data-enabled={enabled} className={className}>
earn-report-content
</div>
),
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <h1 data-testid="page-title">{title}</h1>,
}))
describe('EarnReportPage', () => {
it('renders title and content', () => {
// @ts-expect-error test
render(<EarnReportPage walletFileName="test-wallet" />)
expect(screen.getByTestId('page-title')).toHaveTextContent('earn.report.title')
const content = screen.getByTestId('earn-report-content')
expect(content).toBeInTheDocument()
expect(content).toHaveAttribute('data-enabled', 'true')
})
})

View file

@ -0,0 +1,75 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useQueryYieldgenReport } from './useQueryYieldgenReport'
type QueryOptions = {
queryFn: (context: { signal: AbortSignal }) => Promise<unknown>
enabled?: boolean
}
const yieldgenreportMock = vi.fn<() => Promise<{ data: unknown }>>()
const parserMock = vi.fn((lines: string[]) => lines)
let capturedOptions: QueryOptions
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: QueryOptions) => {
capturedOptions = options
return { data: undefined }
},
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
yieldgenreportQueryKey: () => ['yieldgenreport'],
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
yieldgenreport: () => yieldgenreportMock(),
}))
vi.mock('@/components/earn/report/hooks/earnReportParser', () => ({
yieldgenReportToEarnReportEntries: (lines: string[]) => parserMock(lines),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
const runQueryFn = () => capturedOptions.queryFn({ signal: new AbortController().signal })
describe('useQueryYieldgenReport', () => {
it('parses the yigen_data wrapped format', async () => {
yieldgenreportMock.mockResolvedValue({ data: { yigen_data: ['a', 'b'] } })
renderHook(() => useQueryYieldgenReport())
await expect(runQueryFn()).resolves.toEqual(['a', 'b'])
expect(parserMock).toHaveBeenCalledWith(['a', 'b'])
})
it('parses a plain string array response', async () => {
yieldgenreportMock.mockResolvedValue({ data: ['x'] })
renderHook(() => useQueryYieldgenReport())
await expect(runQueryFn()).resolves.toEqual(['x'])
})
it('returns an empty array on a 404 error', async () => {
yieldgenreportMock.mockRejectedValue({ status: 404 })
renderHook(() => useQueryYieldgenReport())
await expect(runQueryFn()).resolves.toEqual([])
})
it('rethrows non-404 errors', async () => {
yieldgenreportMock.mockRejectedValue({ status: 500 })
renderHook(() => useQueryYieldgenReport())
await expect(runQueryFn()).rejects.toEqual({ status: 500 })
})
it('passes enabled through to useQuery', () => {
yieldgenreportMock.mockResolvedValue({ data: [] })
renderHook(() => useQueryYieldgenReport({ enabled: false }))
expect(capturedOptions.enabled).toBe(false)
})
})

View file

@ -0,0 +1,74 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ErrorPage from './ErrorPage'
const routeError = vi.fn<() => unknown>()
vi.mock('react-router-dom', () => ({
useRouteError: () => routeError(),
}))
vi.mock('i18next', () => ({
t: (key: string) => key,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
Trans: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
}))
vi.mock('@/components/ui/alert', () => ({
Alert: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title, subtitle }: { title: string; subtitle: string }) => (
<div>
<span>{title}</span>
<span>{subtitle}</span>
</div>
),
}))
describe('ErrorPage', () => {
it('renders error details when error is an Error instance', () => {
const error = new Error('boom')
error.stack = 'stacktrace-line'
routeError.mockReturnValue(error)
render(<ErrorPage />)
expect(screen.getByText('error_page.error_with_details.title')).toBeInTheDocument()
expect(screen.getByText('boom')).toBeInTheDocument()
expect(screen.getByText('stacktrace-line')).toBeInTheDocument()
})
it('renders unknown error reason and stacktrace for a non-Error object', () => {
routeError.mockReturnValue({ message: 'object-message', stack: 'object-stack' })
render(<ErrorPage />)
expect(screen.getByText('error_page.unknown_error.title')).toBeInTheDocument()
expect(screen.getByText('object-message')).toBeInTheDocument()
expect(screen.getByText('object-stack')).toBeInTheDocument()
})
it('falls back to reason_unknown for a non-object error', () => {
routeError.mockReturnValue(null)
render(<ErrorPage />)
expect(screen.getByText('error_page.unknown_error.title')).toBeInTheDocument()
expect(screen.getByText('global.errors.reason_unknown')).toBeInTheDocument()
})
it('falls back to reason_unknown when object has no message', () => {
routeError.mockReturnValue({ foo: 'bar' })
render(<ErrorPage />)
expect(screen.getByText('global.errors.reason_unknown')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,134 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { flushActUpdates } from '@/test/flushActUpdates'
import { ImportDetailsForm } from './ImportDetailsForm'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('../dev/DevBadge', () => ({ DevBadge: () => <span data-testid="dev-badge" /> }))
vi.mock('../ui/accordion', () => ({
Accordion: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AccordionItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AccordionTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AccordionContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../ui/alert', () => ({
Alert: ({ children, variant }: { children?: ReactNode; variant?: string }) => (
<div data-variant={variant}>{children}</div>
),
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AlertTitle: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/field', () => ({
Field: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
FieldDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
FieldLabel: ({ children }: { children?: ReactNode }) => <label>{children}</label>,
}))
vi.mock('../ui/input-group', () => ({
InputGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InputGroupAddon: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InputGroupInput: (props: Record<string, unknown>) => <input {...props} />,
}))
vi.mock('../ui/textarea', () => ({
Textarea: (props: Record<string, unknown>) => <textarea {...props} />,
}))
vi.mock('../ui/tooltip', () => ({
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled,
type,
}: {
children?: ReactNode
onClick?: () => void
disabled?: boolean
type?: 'button' | 'submit'
}) => (
<button onClick={onClick} disabled={disabled} type={type}>
{children}
</button>
),
}))
vi.mock('@/components/ui/spinner', () => ({ Spinner: () => <div data-testid="spinner" /> }))
const VALID_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const typeMnemonic = (value: string) => {
fireEvent.change(screen.getByPlaceholderText('import_wallet.import_details.placeholder_menmonic_phrase'), {
target: { value },
})
}
describe('ImportDetailsForm', () => {
it('renders the mnemonic, blockheight and gaplimit fields', async () => {
render(<ImportDetailsForm onSubmit={vi.fn()} />)
expect(screen.getByText('import_wallet.import_details.label_menmonic_phrase')).toBeInTheDocument()
expect(document.querySelector('#blockheight')).toBeInTheDocument()
expect(document.querySelector('#gaplimit')).toBeInTheDocument()
await flushActUpdates()
})
it('shows a success alert when the mnemonic is a valid BIP-39 phrase', async () => {
render(<ImportDetailsForm onSubmit={vi.fn()} />)
typeMnemonic(VALID_MNEMONIC)
expect(screen.getByText('Mnemonic phrase is valid')).toBeInTheDocument()
await flushActUpdates()
})
it('warns and does not submit when the mnemonic is not recognized', async () => {
const onSubmit = vi.fn()
render(<ImportDetailsForm onSubmit={onSubmit} />)
typeMnemonic('not a real seed phrase at all')
fireEvent.submit(document.querySelector('form')!)
await waitFor(() => expect(screen.getByText('Mnemonic phrase is not recognized')).toBeInTheDocument())
expect(onSubmit).not.toHaveBeenCalled()
})
it('submits when all values are valid', async () => {
const onSubmit = vi.fn()
render(
<ImportDetailsForm
onSubmit={onSubmit}
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700000, gaplimit: 50 }}
/>,
)
fireEvent.submit(document.querySelector('form')!)
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
})
it('shows a high-gaplimit warning above the threshold', async () => {
render(
<ImportDetailsForm
onSubmit={vi.fn()}
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700000, gaplimit: 9999 }}
/>,
)
expect(screen.getByText('import_wallet.import_details.alert_high_gaplimit_value')).toBeInTheDocument()
await flushActUpdates()
})
it('renders a disabled submit button when disabled', async () => {
render(<ImportDetailsForm onSubmit={vi.fn()} disabled />)
const submit = document.querySelector('button[type="submit"]')
expect(submit).toBeDisabled()
await flushActUpdates()
})
})

View file

@ -0,0 +1,213 @@
import type { PropsWithChildren } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { routes } from '@/constants/routes'
import { authStore } from '@/store/authStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import ImportWalletPage from './ImportWalletPage'
const mocks = vi.hoisted(() => ({
configGet: vi.fn(),
configSet: vi.fn(),
lockWallet: vi.fn(),
recoverWallet: vi.fn(),
rescanBlockchain: vi.fn(),
session: vi.fn(),
unlockWallet: vi.fn(),
hashPassword: vi.fn(),
navigate: vi.fn(),
toastDismiss: vi.fn(),
toastLoading: vi.fn(() => 'toast-id'),
toastSuccess: vi.fn(),
}))
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
configgetMutation: vi.fn(() => ({ mutationFn: mocks.configGet })),
configsettingMutation: vi.fn(() => ({ mutationFn: mocks.configSet })),
listwalletsOptions: vi.fn(() => ({ queryKey: ['wallets'], queryFn: vi.fn() })),
recoverwalletMutation: vi.fn(() => ({ mutationFn: mocks.recoverWallet })),
unlockwalletMutation: vi.fn(() => ({ mutationFn: mocks.unlockWallet })),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
lockwallet: mocks.lockWallet,
rescanblockchain: mocks.rescanBlockchain,
session: mocks.session,
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(() => ({
data: { wallets: ['existing.jmdat'] },
})),
useMutation: vi.fn((options: MutationOptions) => ({
isPending: false,
mutateAsync: async (input: unknown) => await options.mutationFn(input),
})),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('react-router-dom', () => ({
useNavigate: () => mocks.navigate,
}))
vi.mock('sonner', () => ({
toast: {
dismiss: mocks.toastDismiss,
error: vi.fn(),
loading: mocks.toastLoading,
success: mocks.toastSuccess,
},
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/lib/config', () => ({
buildAuthHeaderMap: (token: string) => ({ 'x-jm-authorization': `Bearer ${token}` }),
}))
vi.mock('@/lib/hash', () => ({
hashPassword: mocks.hashPassword,
}))
vi.mock('@/lib/utils', () => ({
cn: (...classes: Array<string | undefined | false>) => classes.filter(Boolean).join(' '),
parseSemanticVersion: (raw?: string) => {
const match = /^v?(\d+)\.(\d+)\.(\d+).*$/u.exec(raw ?? '')
return match
? { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), raw }
: { major: 0, minor: 0, patch: 0, raw: 'unknown' }
},
percentageToFactor: (value: number) => value / 100,
walletDisplayNameToFileName: (walletName: string) => `${walletName}.jmdat`,
}))
vi.mock('../layout/AuthPageShell', () => ({
AuthPageShell: ({ children }: PropsWithChildren) => <div>{children}</div>,
}))
vi.mock('../utils/PreventLeavingPageByMistake', () => ({
default: () => <div>prevent-leaving</div>,
}))
vi.mock('./ImportStepWalletDetails', () => ({
ImportStepWalletDetails: ({
onSubmit,
}: {
onSubmit: (values: { walletName: string; password: string; confirmPassword: string }) => void
}) => (
<button onClick={() => onSubmit({ walletName: 'restored', password: 'secret', confirmPassword: 'secret' })}>
wallet details
</button>
),
}))
vi.mock('./ImportStepImportDetails', () => ({
ImportStepImportDetails: ({
onSubmit,
onBack,
}: {
onSubmit: (values: { mnemonicPhrase: string; gaplimit: number; blockheight: number }) => void
onBack: () => void
}) => (
<>
<button
onClick={() =>
onSubmit({
mnemonicPhrase: 'abandon ability able about above absent absorb abstract absurd abuse access accident',
gaplimit: 12,
blockheight: 123,
})
}
>
import details
</button>
<button onClick={onBack}>back</button>
</>
),
}))
vi.mock('./ImportStepConfirm', () => ({
ImportStepConfirm: ({
value,
onConfirm,
onBack,
}: {
value: unknown
onConfirm: (value: unknown) => Promise<void>
onBack: () => void
}) => (
<>
<button onClick={() => void onConfirm(value)}>confirm import</button>
<button onClick={onBack}>confirm back</button>
</>
),
}))
describe('ImportWalletPage', () => {
beforeEach(() => {
mocks.configGet.mockReset()
mocks.configSet.mockReset()
mocks.lockWallet.mockReset()
mocks.recoverWallet.mockReset()
mocks.rescanBlockchain.mockReset()
mocks.session.mockReset()
mocks.unlockWallet.mockReset()
mocks.hashPassword.mockReset()
mocks.navigate.mockReset()
mocks.toastDismiss.mockReset()
mocks.toastLoading.mockClear()
mocks.toastSuccess.mockReset()
authStore.getState().clear()
jmSessionStore.setState({ state: undefined })
mocks.recoverWallet.mockResolvedValue({
walletname: 'restored.jmdat',
token: 'recover-token',
refresh_token: 'recover-refresh',
})
mocks.configGet.mockResolvedValue({ configvalue: '6' })
mocks.configSet.mockResolvedValue({})
mocks.lockWallet.mockResolvedValue({ data: {} })
mocks.unlockWallet.mockResolvedValue({
walletname: 'restored.jmdat',
token: 'unlock-token',
refresh_token: 'unlock-refresh',
})
mocks.rescanBlockchain.mockResolvedValue({ data: {} })
mocks.session.mockResolvedValue({ data: { wallet_name: 'restored.jmdat', session: true } })
mocks.hashPassword.mockResolvedValue('hashed-secret')
})
it('imports a wallet, restores gaplimit, starts rescan, and signs in', async () => {
const user = userEvent.setup()
render(<ImportWalletPage />)
await user.click(screen.getByRole('button', { name: 'wallet details' }))
await user.click(screen.getByRole('button', { name: 'import details' }))
await user.click(screen.getByRole('button', { name: 'confirm import' }))
await waitFor(() =>
expect(authStore.getState().state).toEqual({
walletFileName: 'restored.jmdat',
auth: { token: 'unlock-token', refresh_token: 'unlock-refresh' },
hashed_password: 'hashed-secret',
}),
)
expect(mocks.configSet).toHaveBeenCalledTimes(2)
expect(mocks.rescanBlockchain).toHaveBeenCalled()
expect(jmSessionStore.getState().state?.rescanning).toBe(true)
expect(mocks.navigate).toHaveBeenCalledWith(routes.home)
expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-id')
})
})

View file

@ -0,0 +1,138 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { POST_LOGIN_TOUR_EVENT } from '@/constants/onboarding'
import { AppSidebar } from './AppSidebar'
const mocks = vi.hoisted(() => ({
debugFeatures: new Set<string>(),
developerMode: false,
devMode: false,
logsFeature: false,
toggleSidebar: vi.fn(),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('react-router-dom', () => ({
Link: ({ children, onClick, to }: { children: ReactNode; onClick?: () => void; to: string }) => (
<a href={to} onClick={onClick}>
{children}
</a>
),
}))
vi.mock('@/components/ui/use-sidebar', () => ({
useSidebar: () => ({
toggleSidebar: mocks.toggleSidebar,
}),
}))
vi.mock('@/components/ui/sidebar', () => {
const Slot = ({ children }: { children: ReactNode }) => <div>{children}</div>
return {
Sidebar: ({ children, side }: { children: ReactNode; side?: string }) => <aside data-side={side}>{children}</aside>,
SidebarContent: Slot,
SidebarFooter: Slot,
SidebarGroup: Slot,
SidebarGroupAction: ({
children,
onClick,
title,
}: {
children: ReactNode
onClick?: () => void
title?: string
}) => (
<button title={title} type="button" onClick={onClick}>
{children}
</button>
),
SidebarGroupContent: Slot,
SidebarGroupLabel: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
SidebarMenu: Slot,
SidebarMenuButton: Slot,
SidebarMenuItem: Slot,
SidebarMenuSub: Slot,
SidebarMenuSubButton: Slot,
SidebarMenuSubItem: Slot,
}
})
vi.mock('@/constants/debugFeatures', () => ({
isDebugFeatureEnabled: (feature: string) => mocks.debugFeatures.has(feature),
isDevMode: () => mocks.devMode,
}))
vi.mock('@/hooks/useFeatures', () => ({
useFeatures: () => ({
isFeatureEnabled: (feature: string) => feature === 'logs' && mocks.logsFeature,
}),
}))
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: mocks.developerMode }),
}))
vi.mock('../dev/DevBadge', () => ({
DevBadge: () => <span>dev-badge</span>,
}))
describe('AppSidebar', () => {
beforeEach(() => {
mocks.debugFeatures = new Set()
mocks.developerMode = false
mocks.devMode = false
mocks.logsFeature = false
mocks.toggleSidebar.mockReset()
})
it('renders main and settings navigation and dispatches the tour event', () => {
const onTour = vi.fn()
window.addEventListener(POST_LOGIN_TOUR_EVENT, onTour)
render(<AppSidebar side="left" />)
expect(screen.getByText('navbar.title')).toBeInTheDocument()
expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('navbar.tab_receive')).toBeInTheDocument()
expect(screen.getByText('navbar.tab_send')).toBeInTheDocument()
expect(screen.getByText('navbar.tab_earn')).toBeInTheDocument()
expect(screen.getByText('Earn Report')).toBeInTheDocument()
expect(screen.getByText('Orderbook')).toBeInTheDocument()
expect(screen.getByText('navbar.menu_mobile_settings')).toBeInTheDocument()
expect(screen.getByText('settings.rescan_chain')).toBeInTheDocument()
expect(screen.queryByText('settings.show_logs')).not.toBeInTheDocument()
expect(screen.queryByText('Development')).not.toBeInTheDocument()
fireEvent.click(screen.getByTitle('global.close'))
expect(mocks.toggleSidebar).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('Tour'))
expect(mocks.toggleSidebar).toHaveBeenCalledTimes(2)
expect(onTour).toHaveBeenCalledTimes(1)
window.removeEventListener(POST_LOGIN_TOUR_EVENT, onTour)
})
it('shows optional logs and developer links when enabled', () => {
mocks.logsFeature = true
mocks.devMode = true
mocks.developerMode = true
mocks.debugFeatures = new Set(['devPage', 'devSetupPage', 'devErrorExamplePage'])
render(<AppSidebar side="right" />)
expect(screen.getByText('settings.show_logs')).toBeInTheDocument()
expect(screen.getByText('Development')).toBeInTheDocument()
expect(screen.getByText('Dev Page')).toBeInTheDocument()
expect(screen.getByText('Dev Setup')).toBeInTheDocument()
expect(screen.getByText('Example Error Page')).toBeInTheDocument()
expect(screen.getByText('dev-badge')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,17 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { AuthPageShell } from './AuthPageShell'
describe('AuthPageShell', () => {
it('renders children with background gradient', () => {
render(
<AuthPageShell>
<div data-testid="child">Child Content</div>
</AuthPageShell>,
)
const child = screen.getByTestId('child')
expect(child).toBeInTheDocument()
expect(child.parentElement).toHaveClass('from-background', 'to-muted', 'flex', 'min-h-screen')
})
})

View file

@ -0,0 +1,263 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WalletFileName } from '@/lib/utils'
import { Layout } from './Layout'
type SessionState = {
block_height?: number
}
const mocks = vi.hoisted(() => ({
cheatsheetOpen: false,
logsFeature: true,
navigate: vi.fn(),
onCheatsheetOpenChange: vi.fn<(open: boolean) => void>(),
pathname: '/',
setTheme: vi.fn(),
theme: 'dark',
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('react-router-dom', () => ({
useLocation: () => ({
pathname: mocks.pathname,
}),
useNavigate: () => mocks.navigate,
}))
vi.mock('next-themes', () => ({
useTheme: () => ({
resolvedTheme: mocks.theme,
setTheme: mocks.setTheme,
}),
}))
vi.mock('zustand', () => ({
useStore: (_store: unknown, selector?: (state: { state: SessionState }) => unknown) => {
const state = { state: { block_height: 123 } }
return selector ? selector(state) : state
},
}))
vi.mock('@/store/jmSessionStore', () => ({
jmSessionStore: 'jm-session-store',
}))
vi.mock('@/components/layout/AppNavbar', () => ({
AppNavbar: ({
onLockWallet,
onLogout,
sessionInfo,
sidebarTrigger,
theme,
toggleTheme,
totalBalance,
walletName,
}: {
onLockWallet: () => Promise<void>
onLogout: () => Promise<void>
sessionInfo?: SessionState
sidebarTrigger: ReactNode
theme: string
toggleTheme: () => void
totalBalance: number
walletName: string
}) => (
<nav>
navbar:{theme}:{walletName}:{totalBalance}:{sessionInfo?.block_height}
{sidebarTrigger}
<button type="button" onClick={toggleTheme}>
toggle-theme
</button>
<button type="button" onClick={() => void onLogout()}>
logout
</button>
<button type="button" onClick={() => void onLockWallet()}>
lock-wallet
</button>
</nav>
),
}))
vi.mock('@/components/layout/AppFooter', () => ({
AppFooter: ({
blockHeight,
joinmarketVersion,
onClickCheatsheet,
onClickLogs,
onClickOrderbook,
}: {
blockHeight?: number
joinmarketVersion?: string
onClickCheatsheet: () => void
onClickLogs?: () => void
onClickOrderbook: () => void
}) => (
<footer>
footer:{blockHeight}:{joinmarketVersion}
<button type="button" onClick={onClickCheatsheet}>
open-cheatsheet
</button>
<button type="button" onClick={onClickOrderbook}>
open-orderbook
</button>
{onClickLogs && (
<button type="button" onClick={onClickLogs}>
open-logs
</button>
)}
</footer>
),
}))
vi.mock('@/components/ui/sidebar', () => ({
SidebarProvider: ({ children, defaultOpen }: { children: ReactNode; defaultOpen: boolean }) => (
<div data-default-open={String(defaultOpen)}>{children}</div>
),
SidebarTrigger: ({ side }: { side: string }) => <button type="button">sidebar-trigger:{side}</button>,
}))
vi.mock('@/components/ui/use-sidebar', () => ({
useSidebar: () => ({
open: false,
toggleSidebar: vi.fn(),
}),
}))
vi.mock('@/context/JamSessionInfoContext', () => ({
useRescanStatus: () => ({
rescanInfo: undefined,
}),
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useJamWalletInfoContext: () => ({
isFetching: false,
isLoading: false,
walletBalanceSummary: {
calculatedTotalBalanceInSats: 9876,
},
walletName: 'test-wallet',
}),
}))
vi.mock('@/context/JmWebsocketContext', () => ({
useJmWebsocketContext: () => ({
websocket: { connected: true },
}),
}))
vi.mock('@/hooks/useCheatsheet', () => ({
useCheatsheet: () => ({
onOpenChange: mocks.onCheatsheetOpenChange,
open: mocks.cheatsheetOpen,
}),
}))
vi.mock('@/hooks/useFeatures', () => ({
useFeatures: () => ({
isFeatureEnabled: (feature: string) => feature === 'logs' && mocks.logsFeature,
}),
}))
vi.mock('@/hooks/useQueryJmInfo', () => ({
useQueryJmInfo: () => ({
version: 'jm-version',
}),
}))
vi.mock('@/components/ui/jam/Cheatsheet', () => ({
Cheatsheet: ({ open }: { open: boolean }) => <div>cheatsheet:{String(open)}</div>,
}))
vi.mock('@/components/orderbook/OrderbookOverlay', () => ({
OrderbookOverlay: ({ open }: { open: boolean }) => <div>orderbook-overlay:{String(open)}</div>,
}))
vi.mock('@/components/LogsOverlay', () => ({
LogsOverlay: ({ open }: { open: boolean }) => <div>logs-overlay:{String(open)}</div>,
}))
vi.mock('@/components/layout/AppSidebar', () => ({
AppSidebar: ({ side }: { side: string }) => <aside>sidebar:{side}</aside>,
}))
vi.mock('@/components/layout/PostLoginOnboardingTour', () => ({
PostLoginOnboardingTour: ({ enabled }: { enabled: boolean }) => <div>tour-enabled:{String(enabled)}</div>,
}))
const walletFileName = 'wallet.jmdat' as WalletFileName
describe('Layout', () => {
beforeEach(() => {
mocks.cheatsheetOpen = false
mocks.logsFeature = true
mocks.pathname = '/'
mocks.theme = 'dark'
mocks.navigate.mockReset()
mocks.onCheatsheetOpenChange.mockReset()
mocks.setTheme.mockReset()
})
it('wires navbar, footer actions, overlays, and shortcuts', async () => {
const onLogout = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const onLockWallet = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
render(
<Layout walletFileName={walletFileName} onLogout={onLogout} onLockWallet={onLockWallet}>
<section>page-content</section>
</Layout>,
)
expect(screen.getByText('page-content')).toBeInTheDocument()
expect(screen.getByText('navbar:dark:test-wallet:9876:123')).toBeInTheDocument()
expect(screen.getByText('footer:123:jm-version')).toBeInTheDocument()
expect(screen.getByText('sidebar:right')).toBeInTheDocument()
expect(screen.getByText('tour-enabled:true')).toBeInTheDocument()
fireEvent.click(screen.getByText('toggle-theme'))
expect(mocks.setTheme).toHaveBeenCalledWith('light')
fireEvent.click(screen.getByText('logout'))
fireEvent.click(screen.getByText('lock-wallet'))
await waitFor(() => {
expect(onLogout).toHaveBeenCalledTimes(1)
expect(onLockWallet).toHaveBeenCalledTimes(1)
})
fireEvent.click(screen.getByText('open-cheatsheet'))
expect(mocks.onCheatsheetOpenChange).toHaveBeenCalledWith(true)
fireEvent.click(screen.getByText('open-orderbook'))
expect(screen.getByText('orderbook-overlay:true')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'l', metaKey: true })
expect(screen.getByText('logs-overlay:true')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'l', ctrlKey: true })
expect(screen.getByText('logs-overlay:false')).toBeInTheDocument()
})
it('disables logs and onboarding when the route or feature state requires it', () => {
mocks.cheatsheetOpen = true
mocks.logsFeature = false
mocks.pathname = '/send'
render(
<Layout walletFileName={walletFileName} onLogout={vi.fn()} onLockWallet={vi.fn()}>
<section>send-content</section>
</Layout>,
)
expect(screen.queryByText('open-logs')).not.toBeInTheDocument()
expect(screen.getByText('cheatsheet:true')).toBeInTheDocument()
expect(screen.getByText('tour-enabled:false')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,88 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { POST_LOGIN_TOUR_DISMISSED_STORAGE_KEY, POST_LOGIN_TOUR_EVENT } from '@/constants/onboarding'
import { PostLoginOnboardingTour } from './PostLoginOnboardingTour'
const renderTargets = () => (
<>
<div data-tour-id="wallet-preview">wallet-preview</div>
<div data-tour-id="wallet-actions">wallet-actions</div>
<div data-tour-id="wallet-jars">wallet-jars</div>
<div data-tour-id="footer-tools">footer-tools</div>
<div data-tour-id="settings-button">settings-button</div>
<PostLoginOnboardingTour />
</>
)
describe('PostLoginOnboardingTour', () => {
beforeEach(() => {
window.localStorage.clear()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
})
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function getBoundingClientRect(
this: HTMLElement,
) {
const id = this.dataset.tourId
const top = id === 'settings-button' ? 520 : 80
return {
bottom: top + 48,
height: 48,
left: 120,
right: 280,
top,
width: 160,
x: 120,
y: top,
toJSON: () => undefined,
}
})
})
it('walks through the tour and persists dismissal', () => {
render(renderTargets())
expect(screen.getByText('Wallet Snapshot')).toBeInTheDocument()
expect(screen.getByText('Step 1 of 5')).toBeInTheDocument()
fireEvent.click(screen.getByText('Next'))
expect(screen.getByText('Primary Actions')).toBeInTheDocument()
fireEvent.click(screen.getByText('Back'))
expect(screen.getByText('Wallet Snapshot')).toBeInTheDocument()
fireEvent.click(screen.getByText('Skip tour'))
expect(screen.queryByText('Wallet Snapshot')).not.toBeInTheDocument()
expect(window.localStorage.getItem(POST_LOGIN_TOUR_DISMISSED_STORAGE_KEY)).toBe('1')
})
it('can be reopened by event and finished on the last step', () => {
window.localStorage.setItem(POST_LOGIN_TOUR_DISMISSED_STORAGE_KEY, '1')
render(renderTargets())
expect(screen.queryByText('Wallet Snapshot')).not.toBeInTheDocument()
fireEvent(window, new Event(POST_LOGIN_TOUR_EVENT))
expect(screen.getByText('Wallet Snapshot')).toBeInTheDocument()
fireEvent.click(screen.getByText('Next'))
fireEvent.click(screen.getByText('Next'))
fireEvent.click(screen.getByText('Next'))
fireEvent.click(screen.getByText('Next'))
expect(screen.getByText('Settings & Safety')).toBeInTheDocument()
fireEvent.click(screen.getByText('Finish'))
expect(screen.queryByText('Settings & Safety')).not.toBeInTheDocument()
expect(window.localStorage.getItem(POST_LOGIN_TOUR_DISMISSED_STORAGE_KEY)).toBe('1')
})
it('does not render when disabled', () => {
render(<PostLoginOnboardingTour enabled={false} />)
expect(screen.queryByText('Wallet Snapshot')).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,60 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { BetaWarningDialog } from './BetaWarningDialog'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open !== false ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogDescription: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogFooter: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
describe('BetaWarningDialog', () => {
it('renders correctly', () => {
render(
<BetaWarningDialog
open={true}
onOpenChange={vi.fn()}
jamVersion={{ raw: '1.0.0', major: 1, minor: 0, patch: 0 }}
joinmarketVersion={{ raw: '0.9.3', major: 0, minor: 9, patch: 3 }}
/>,
)
expect(screen.getByText('footer.warning_alert_title')).toBeInTheDocument()
expect(screen.getByText('footer.warning_alert_text')).toBeInTheDocument()
expect(screen.getByText('v1.0.0')).toBeInTheDocument()
expect(screen.getByText('v0.9.3')).toBeInTheDocument()
expect(screen.getByText('footer.warning_alert_button_ok')).toBeInTheDocument()
})
it('renders correctly with missing joinmarket version', () => {
render(
<BetaWarningDialog
open={true}
onOpenChange={vi.fn()}
jamVersion={{ raw: '1.0.0', major: 1, minor: 0, patch: 0 }}
/>,
)
expect(screen.getByText('v1.0.0')).toBeInTheDocument()
expect(screen.getByText('v_unknown')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,113 @@
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LogViewer } from './LogViewer'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/lib/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/utils')>()),
delayedPromise: vi.fn(() => Promise.resolve()),
}))
const createObjectURLMock = vi.fn(() => 'blob:log')
describe('LogViewer', () => {
beforeEach(() => {
createObjectURLMock.mockClear()
URL.createObjectURL = createObjectURLMock
URL.revokeObjectURL = vi.fn()
HTMLElement.prototype.scrollTo = vi.fn()
})
it('filters and clears matching log lines', async () => {
const user = userEvent.setup()
render(<LogViewer fileName="jmwalletd.log" value={'first line\nneedle found\nlast line'} refresh={vi.fn()} />)
await user.type(screen.getByLabelText('Search logs'), 'needle')
expect(screen.getByText('1 matching line.')).toBeInTheDocument()
expect(screen.getByText('needle')).toBeInTheDocument()
expect(screen.queryByText('first line')).not.toBeInTheDocument()
await user.click(screen.getByTitle('global.clear'))
expect(screen.queryByText('1 matching line.')).not.toBeInTheDocument()
expect(screen.getByText('first line')).toBeInTheDocument()
})
it('shows the empty search state', async () => {
const user = userEvent.setup()
render(<LogViewer fileName="jmwalletd.log" value={'alpha\nbeta'} refresh={vi.fn()} />)
await user.type(screen.getByLabelText('Search logs'), 'missing')
expect(screen.getByText('No matches for "missing".')).toBeInTheDocument()
})
it('refreshes without allowing duplicate in-flight refreshes', async () => {
const user = userEvent.setup()
const refresh = vi.fn(() => Promise.resolve())
render(<LogViewer fileName="jmwalletd.log" value="line" refresh={refresh} />)
await user.click(screen.getByRole('button', { name: 'global.refresh' }))
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1))
})
it('downloads the visible log content', async () => {
const user = userEvent.setup()
const appendSpy = vi.spyOn(document.body, 'append')
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
const removeSpy = vi.spyOn(HTMLAnchorElement.prototype, 'remove').mockImplementation(() => undefined)
render(<LogViewer fileName="jmwalletd.log" value="log body" refresh={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'global.download' }))
expect(createObjectURLMock).toHaveBeenCalled()
expect(appendSpy).toHaveBeenCalled()
expect(clickSpy).toHaveBeenCalled()
expect(removeSpy).toHaveBeenCalled()
appendSpy.mockRestore()
clickSpy.mockRestore()
removeSpy.mockRestore()
})
it('tracks scroll progress and can jump back to the bottom', async () => {
const user = userEvent.setup()
render(<LogViewer fileName="jmwalletd.log" value={'a\nb\nc'} refresh={vi.fn()} />)
const log = screen.getByText('a').closest('pre')
expect(log).toBeInTheDocument()
Object.defineProperties(log!, {
clientHeight: { configurable: true, value: 10 },
scrollHeight: { configurable: true, value: 100 },
scrollTop: { configurable: true, value: 0 },
})
const scrollTo = vi.fn()
log!.scrollTo = scrollTo
act(() => {
log!.dispatchEvent(new Event('scroll', { bubbles: true }))
})
const card = screen.getByText('jmwalletd.log').closest<HTMLElement>('[data-slot="card"]')!
await user.click(within(card).getByTitle('Scroll to bottom'))
expect(scrollTo).toHaveBeenCalledWith({
top: 100,
behavior: 'smooth',
})
})
})

View file

@ -0,0 +1,118 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchLog } from '@/lib/api/jam'
import { authStore } from '@/store/authStore'
import { useJmwalletdStdoutLog } from './useJmwalletdStdoutLog'
type QueryOptions = {
enabled?: boolean
queryFn?: (args: { signal: AbortSignal }) => Promise<string>
}
const mocks = vi.hoisted(() => ({
queryOptions: undefined as QueryOptions | undefined,
queryResult: {
data: undefined as string | undefined,
error: undefined as Error | undefined,
isFetched: false,
refetch: vi.fn(),
},
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn((options: QueryOptions) => {
mocks.queryOptions = options
return mocks.queryResult
}),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { reason?: string }) => (options?.reason ? `${key}:${options.reason}` : key),
}),
}))
vi.mock('@/lib/api/jam', () => ({
fetchLog: vi.fn(),
}))
describe('useJmwalletdStdoutLog', () => {
beforeEach(() => {
authStore.getState().clear()
mocks.queryOptions = undefined
mocks.queryResult.data = undefined
mocks.queryResult.error = undefined
mocks.queryResult.isFetched = false
mocks.queryResult.refetch.mockReset()
vi.mocked(fetchLog).mockReset()
})
it('reports missing authentication as an initialized alert state', () => {
const { result } = renderHook(() => useJmwalletdStdoutLog())
expect(result.current.fileName).toBe('jmwalletd_stdout.log')
expect(result.current.isInitialized).toBe(true)
expect(result.current.alert).toEqual({
variant: 'destructive',
message: 'No authentication token available. Please login again.',
})
expect(mocks.queryOptions?.enabled).toBe(false)
})
it('fetches logs with the current auth token', async () => {
authStore.getState().update({
auth: {
token: 'token',
refresh_token: 'refresh',
},
})
vi.mocked(fetchLog).mockResolvedValue(new Response('log body'))
const { result } = renderHook(() => useJmwalletdStdoutLog())
await expect(mocks.queryOptions?.queryFn?.({ signal: new AbortController().signal })).resolves.toBe('log body')
await result.current.refresh()
const fetchLogRequest = vi.mocked(fetchLog).mock.calls[0]?.[0]
expect(fetchLogRequest).toMatchObject({
token: 'token',
fileName: 'jmwalletd_stdout.log',
})
expect(fetchLogRequest?.signal).toBeInstanceOf(AbortSignal)
expect(mocks.queryResult.refetch).toHaveBeenCalled()
})
it('converts query errors into warning alerts', () => {
authStore.getState().update({
auth: {
token: 'token',
refresh_token: 'refresh',
},
})
mocks.queryResult.error = new Error('disk unavailable')
const { result } = renderHook(() => useJmwalletdStdoutLog())
expect(result.current.alert).toEqual({
variant: 'warning',
message: 'logs.error_loading_logs_failed:disk unavailable',
})
})
it('stays idle when explicitly disabled', async () => {
authStore.getState().update({
auth: {
token: 'token',
refresh_token: 'refresh',
},
})
const { result } = renderHook(() => useJmwalletdStdoutLog({ enabled: false }))
expect(result.current.alert).toBeUndefined()
expect(result.current.isInitialized).toBe(false)
await result.current.refresh()
expect(mocks.queryResult.refetch).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,153 @@
import type { PropsWithChildren } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { routes } from '@/constants/routes'
import { authStore } from '@/store/authStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import LoginPage from './LoginPage'
const mocks = vi.hoisted(() => ({
navigate: vi.fn(),
listWalletsRefetch: vi.fn(),
unlockWallet: vi.fn(),
hashPassword: vi.fn(),
}))
type MutationOptions = {
mutationFn: (input: unknown) => Promise<unknown>
onSuccess?: (result: unknown) => void
}
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
listwalletsOptions: vi.fn(() => ({ queryKey: ['wallets'], queryFn: vi.fn() })),
unlockwalletMutation: vi.fn(() => ({
mutationFn: mocks.unlockWallet,
})),
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(() => ({
data: { wallets: ['cold.jmdat', 'active.jmdat'] },
error: null,
isLoading: false,
isFetching: false,
refetch: mocks.listWalletsRefetch,
})),
useMutation: vi.fn((options: MutationOptions) => ({
isPending: false,
mutateAsync: async (input: unknown) => {
const result: unknown = await options.mutationFn(input)
options.onSuccess?.(result)
return result
},
})),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('react-router-dom', () => ({
useNavigate: () => mocks.navigate,
}))
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/lib/hash', () => ({
hashPassword: mocks.hashPassword,
}))
vi.mock('@/lib/queryClient', () => ({
withQueryDelay: (queryFn: unknown) => queryFn,
}))
vi.mock('../layout/AuthPageShell', () => ({
AuthPageShell: ({ children }: PropsWithChildren) => <div>{children}</div>,
}))
vi.mock('./LoginCard', () => ({
LoginCard: ({
wallets,
activeWallet,
makerRunning,
coinjoinInProgress,
onSubmit,
onReloadClick,
}: {
wallets: string[]
activeWallet?: string
makerRunning: boolean
coinjoinInProgress: boolean
onSubmit: (data: { walletFileName: string; password: string }) => Promise<void>
onReloadClick: () => Promise<void>
}) => (
<div>
<div>active:{activeWallet}</div>
<div>wallets:{wallets.join(',')}</div>
<div>maker:{String(makerRunning)}</div>
<div>coinjoin:{String(coinjoinInProgress)}</div>
<button onClick={() => void onSubmit({ walletFileName: 'active.jmdat', password: 'secret' })}>submit</button>
<button onClick={() => void onReloadClick()}>reload</button>
</div>
),
}))
describe('LoginPage', () => {
beforeEach(() => {
mocks.navigate.mockReset()
mocks.listWalletsRefetch.mockReset()
mocks.unlockWallet.mockResolvedValue({
walletname: 'active.jmdat',
token: 'token',
refresh_token: 'refresh',
})
mocks.hashPassword.mockResolvedValue('hashed-secret')
authStore.getState().clear()
jmSessionStore.setState({
state: {
session: true,
rescanning: false,
wallet_name: 'active.jmdat',
maker_running: true,
coinjoin_in_process: false,
schedule: [['pending coinjoin']],
},
})
})
it('passes wallet/session state to the card and unlocks the selected wallet', async () => {
const user = userEvent.setup()
render(<LoginPage />)
expect(screen.getByText('active:active.jmdat')).toBeInTheDocument()
expect(screen.getByText('maker:true')).toBeInTheDocument()
expect(screen.getByText('coinjoin:true')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit' }))
await waitFor(() =>
expect(authStore.getState().state).toEqual({
walletFileName: 'active.jmdat',
auth: { token: 'token', refresh_token: 'refresh' },
hashed_password: 'hashed-secret',
}),
)
expect(mocks.navigate).toHaveBeenCalledWith(routes.home)
await user.click(screen.getByRole('button', { name: 'reload' }))
expect(mocks.listWalletsRefetch).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import '@/i18n/config'
import { OnboardingDialog } from './OnboardingDialog'
describe('<OnboardingDialog />', () => {
it('walks through the intro and closes on the final step', () => {
const onOpenChange = vi.fn()
render(<OnboardingDialog open onOpenChange={onOpenChange} />)
expect(screen.getByRole('heading', { name: 'Jam' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Get started' }))
expect(screen.getAllByRole('heading', { name: 'Welcome to Jam for JoinMarket!' })).toHaveLength(2)
for (let index = 0; index < 4; index++) {
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
}
fireEvent.click(screen.getByRole('button', { name: "Let's go!" }))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('can go back to the splash screen and skip the intro', () => {
const onOpenChange = vi.fn()
render(<OnboardingDialog open onOpenChange={onOpenChange} />)
fireEvent.click(screen.getByRole('button', { name: 'Get started' }))
fireEvent.click(screen.getByRole('button', { name: 'Back' }))
expect(screen.getByRole('heading', { name: 'Jam' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Skip intro' }))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})

View file

@ -0,0 +1,44 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OrderbookChart } from './OrderbookChart'
import type { OrderTableEntry } from './OrderbookTable'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('@/components/ui/tooltip', () => ({
TooltipProvider: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
const offer = (feeValue: number, isAbsolute: boolean): OrderTableEntry =>
({ type: { isAbsolute }, fee: { value: feeValue } }) as unknown as OrderTableEntry
describe('OrderbookChart', () => {
it('renders nothing when there are no absolute offers', () => {
const { container } = render(<OrderbookChart entries={[offer(50, false)]} />)
expect(container.firstChild).toBeNull()
})
it('renders nothing when entries is empty', () => {
const { container } = render(<OrderbookChart entries={[]} />)
expect(container.firstChild).toBeNull()
})
it('renders fee buckets for absolute offers across ranges', () => {
render(<OrderbookChart entries={[offer(50, true), offer(20_000, true)]} />)
expect(screen.getByText('orderbook.chart_title')).toBeInTheDocument()
expect(screen.getByText('10,000+ sats')).toBeInTheDocument()
})
it('renders a single bucket without a trailing range label', () => {
render(<OrderbookChart entries={[offer(50, true)]} />)
expect(screen.getByText('orderbook.chart_title')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,66 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OrderbookOverlay } from './OrderbookOverlay'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/components/orderbook/OrderbookContent', () => ({
OrderbookContent: ({ enabled, className }: { enabled: boolean; className?: string }) => (
<div data-testid="orderbook-content" data-enabled={enabled} className={className}>
orderbook-content
</div>
),
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <h1 data-testid="page-title">{title}</h1>,
}))
// Mock Dialog to avoid dealing with portals and radix UI internals
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
describe('OrderbookOverlay', () => {
it('renders dialog content when open', () => {
render(<OrderbookOverlay open={true} onOpenChange={vi.fn()} />)
expect(screen.getByTestId('page-title')).toHaveTextContent('orderbook.title')
const content = screen.getByTestId('orderbook-content')
expect(content).toBeInTheDocument()
expect(content).toHaveAttribute('data-enabled', 'true')
})
it('calls onOpenChange when closed', () => {
const onOpenChange = vi.fn()
render(<OrderbookOverlay open={true} onOpenChange={onOpenChange} />)
const closeButton = screen.getByText('Close')
fireEvent.click(closeButton)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('does not render when closed', () => {
render(<OrderbookOverlay open={false} onOpenChange={vi.fn()} />)
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { OrderbookPage } from './OrderbookPage'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/components/orderbook/OrderbookContent', () => ({
OrderbookContent: ({ enabled, className }: { enabled: boolean; className?: string }) => (
<div data-testid="orderbook-content" data-enabled={enabled} className={className}>
orderbook-content
</div>
),
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <h1 data-testid="page-title">{title}</h1>,
}))
describe('OrderbookPage', () => {
it('renders title and content', () => {
render(<OrderbookPage />)
expect(screen.getByTestId('page-title')).toHaveTextContent('orderbook.title')
const content = screen.getByTestId('orderbook-content')
expect(content).toBeInTheDocument()
expect(content).toHaveAttribute('data-enabled', 'true')
})
})

View file

@ -0,0 +1,175 @@
import type React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { OrderbookTable, type OrderTableEntry } from './OrderbookTable'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
}))
vi.mock('@/components/ui/jam/TablePagination', () => ({
TablePagination: ({
currentPage,
itemsPerPage,
onItemsPerPageChange,
onPageChange,
totalItems,
totalPages,
}: {
currentPage: number
itemsPerPage: number
onItemsPerPageChange: (itemsPerPage: number) => void
onPageChange: (page: number) => void
totalItems: number
totalPages: number
}) => (
<div>
pagination:{currentPage}/{totalPages}:{itemsPerPage}:{totalItems}
<button onClick={() => onPageChange(2)}>page-two</button>
<button onClick={() => onItemsPerPageChange(-1)}>show-all</button>
<button onClick={() => onItemsPerPageChange(10)}>page-size-ten</button>
</div>
),
}))
const makeEntry = (overrides: Partial<OrderTableEntry>): OrderTableEntry => ({
bondValue: {
amount: 0,
displayValue: '0',
value: 0,
},
counterparty: 'maker-a',
fee: {
displayValue: '250',
value: 250,
},
maximumSize: '200000',
minerFeeContribution: '0',
minimumSize: '5000',
orderId: '0',
type: {
badgeColor: 'default',
displayValue: 'absolute',
isAbsolute: true,
isRelative: false,
tooltip: 'Native SW Absolute Fee',
value: 'sw0absoffer',
},
...overrides,
})
const absoluteEntry = makeEntry({
bondValue: {
amount: 21_000,
displayExpiresIn: 'tomorrow',
displayLocktime: 'locktime',
displayValue: '42',
value: 42,
},
counterparty: 'maker-a',
orderId: '2',
})
const relativeEntry = makeEntry({
counterparty: 'maker-b',
fee: {
displayValue: '0.0005%',
value: 0.0005,
},
maximumSize: '500000',
minerFeeContribution: '100',
minimumSize: '10000',
orderId: '1',
type: {
badgeColor: 'secondary',
displayValue: 'relative',
isAbsolute: false,
isRelative: true,
tooltip: 'Native SW Relative Fee',
value: 'sw0reloffer',
},
})
describe('OrderbookTable', () => {
it('renders, pins, highlights, filters, and notifies table changes', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<OrderbookTable
globalFilter="maker"
tableEntries={[relativeEntry, absoluteEntry]}
selectedEntries={[relativeEntry]}
pinnedEntries={[absoluteEntry]}
onChange={onChange}
/>,
)
expect(screen.getByText('maker-a')).toBeInTheDocument()
expect(screen.getByText('maker-b')).toBeInTheDocument()
expect(screen.getByText('0.0005%')).toBeInTheDocument()
expect(screen.getByText('Native SW Absolute Fee')).toBeInTheDocument()
expect(screen.getByText('pagination:1/1:25:2')).toBeInTheDocument()
expect(onChange).toHaveBeenCalled()
await user.click(screen.getByText('orderbook.table.heading_counterparty'))
await user.click(screen.getByText('show-all'))
await waitFor(() => expect(screen.getByText('pagination:1/1:-1:2')).toBeInTheDocument())
await user.click(screen.getByText('page-size-ten'))
await waitFor(() => expect(screen.getByText('pagination:1/1:10:2')).toBeInTheDocument())
})
it('sorts by each sortable column and changes pages', async () => {
const user = userEvent.setup()
render(<OrderbookTable tableEntries={[relativeEntry, absoluteEntry]} selectedEntries={[]} pinnedEntries={[]} />)
for (const heading of [
'orderbook.table.heading_fee',
'orderbook.table.heading_minimum_size',
'orderbook.table.heading_maximum_size',
'orderbook.table.heading_bond_value',
'orderbook.table.heading_order_id',
]) {
const header = screen.queryByText(heading)
if (header) {
await user.click(header)
}
}
await user.click(screen.getByText('page-two'))
expect(screen.getByText('orderbook.table.heading_counterparty')).toBeInTheDocument()
})
it('filters rows and handles empty pinned row models', () => {
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined)
render(
<OrderbookTable
globalFilter="maker-b"
tableEntries={[relativeEntry]}
selectedEntries={[]}
pinnedEntries={[absoluteEntry]}
/>,
)
expect(screen.queryByText('maker-a')).not.toBeInTheDocument()
expect(screen.getByText('maker-b')).toBeInTheDocument()
expect(debugSpy).not.toHaveBeenCalled()
debugSpy.mockRestore()
})
})

View file

@ -0,0 +1,105 @@
import type { PropsWithChildren } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import { ReceiveForm } from './ReceiveForm'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useWalletBalanceSummary: () => ({
walletBalanceSummary: {
calculatedTotalBalanceInSats: 20_000,
},
}),
}))
vi.mock('@/components/ui/jam/SelectableJar', () => ({
SelectableJar: ({ name, isSelected, onClick }: { name: string; isSelected: boolean; onClick: () => void }) => (
<button type="button" aria-pressed={isSelected} onClick={onClick}>
{name}
</button>
),
}))
vi.mock('../dev/DevBadge', () => ({
DevBadge: () => <span>dev</span>,
}))
vi.mock('../ui/jam/CurrencySymbol', () => ({
SatSymbol: () => <span>sats</span>,
}))
vi.mock('../ui/card', () => ({
Card: ({ children }: PropsWithChildren) => <div>{children}</div>,
CardContent: ({ children }: PropsWithChildren) => <div>{children}</div>,
CardHeader: ({ children }: PropsWithChildren) => <div>{children}</div>,
}))
const jars = [
{
jarIndex: 0,
name: 'Zero',
color: '#e2b86a',
balanceSummary: {
calculatedTotalBalanceInSats: 10_000,
calculatedAvailableBalanceInSats: 9_000,
calculatedFrozenOrLockedBalanceInSats: 1_000,
},
},
{
jarIndex: 1,
name: 'One',
color: '#3b5ba9',
balanceSummary: {
calculatedTotalBalanceInSats: 10_000,
calculatedAvailableBalanceInSats: 8_000,
calculatedFrozenOrLockedBalanceInSats: 2_000,
},
},
] as unknown as Jar[]
describe('ReceiveForm', () => {
it('submits selected jar and amount changes', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(
<ReceiveForm
jars={jars}
defaultValues={{ source: { fromJar: 0 }, amount: undefined }}
onSubmit={onSubmit}
debug={true}
/>,
)
await user.click(screen.getByRole('button', { name: 'One' }))
await user.type(screen.getByLabelText('receive.label_amount_input'), '2100')
await waitFor(() =>
expect(onSubmit).toHaveBeenLastCalledWith(
expect.objectContaining({
source: { fromJar: 1 },
amount: 2100,
}),
expect.anything(),
),
)
expect(screen.getByText(/"fromJar": 1/u)).toBeInTheDocument()
})
it('shows validation feedback for invalid amounts', async () => {
const user = userEvent.setup()
render(<ReceiveForm jars={jars} defaultValues={{ source: { fromJar: 0 } }} onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText('receive.label_amount_input'), '0')
expect(await screen.findByText('receive.feedback_invalid_amount')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,193 @@
import { getaddress } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import { flushActUpdates } from '@/test/flushActUpdates'
import { ReceivePage } from './ReceivePage'
const mocks = vi.hoisted(() => ({
developerMode: false,
getAddress: vi.fn(),
share: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
type MutationOptions = {
mutationFn: () => Promise<unknown>
onError?: (error: unknown) => void
}
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
getaddressQueryKey: vi.fn(() => ['getaddress']),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
getaddress: mocks.getAddress,
}))
vi.mock('@tanstack/react-query', async () => {
const React = await import('react')
return {
useMutation: vi.fn((options: MutationOptions) => {
const [data, setData] = React.useState<unknown>()
const [isPending, setPending] = React.useState(false)
const [hasRun, setHasRun] = React.useState(false)
return {
data,
isIdle: !hasRun,
isPending,
mutateAsync: async () => {
setPending(true)
setHasRun(true)
try {
const result: unknown = await options.mutationFn()
setData(result)
return result
} catch (error) {
options.onError?.(error)
throw error
} finally {
setPending(false)
}
},
}
}),
}
})
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('sonner', () => ({
toast: {
error: mocks.toastError,
success: mocks.toastSuccess,
},
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useJars: () => ({
jars: [
{
jarIndex: 0,
name: 'Zero',
color: '#e2b86a',
balanceSummary: {
calculatedTotalBalanceInSats: 10_000,
calculatedAvailableBalanceInSats: 9_000,
calculatedConfirmedAvailableBalanceInSats: 9_000,
calculatedFrozenOrLockedBalanceInSats: 1_000,
},
},
{
jarIndex: 1,
name: 'One',
color: '#3b5ba9',
balanceSummary: {
calculatedTotalBalanceInSats: 8_000,
calculatedAvailableBalanceInSats: 8_000,
calculatedConfirmedAvailableBalanceInSats: 8_000,
calculatedFrozenOrLockedBalanceInSats: 0,
},
},
] as unknown as Jar[],
}),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/lib/queryClient', () => ({
withMutationDelay: (mutationFn: unknown) => mutationFn,
}))
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({
enabled: mocks.developerMode,
}),
}))
vi.mock('../ui/jam/BitcoinQrCode', () => ({
BitcoinAddressQrCode: ({ address, amount }: { address: string; amount?: number }) => (
<div>
qr:{address}:{amount ?? 'none'}
</div>
),
}))
vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>{value}</span>,
}))
vi.mock('../ui/jam/CopyButton', () => ({
CopyButton: ({ value, disabled }: { value: string; disabled?: boolean }) => (
<button disabled={disabled}>copy:{value}</button>
),
}))
vi.mock('./ReceiveForm', () => ({
ReceiveForm: ({ onSubmit }: { onSubmit: (values: { source?: { fromJar?: number }; amount?: number }) => void }) => (
<button onClick={() => onSubmit({ source: { fromJar: 1 }, amount: 2100 })}>update receive form</button>
),
}))
describe('ReceivePage', () => {
beforeEach(() => {
mocks.developerMode = false
mocks.getAddress.mockReset()
mocks.getAddress.mockResolvedValue({ data: { address: 'bc1qexample' } })
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
mocks.share.mockReset()
Object.assign(navigator, { share: mocks.share })
})
it('reveals a fresh address and refreshes it on demand', async () => {
render(<ReceivePage walletFileName="wallet.jmdat" />)
fireEvent.click(screen.getByRole('button', { name: 'receive.button_reveal_address' }))
await waitFor(() => expect(screen.getByText('qr:bc1qexample:none')).toBeInTheDocument())
expect(getaddress).toHaveBeenCalledWith(
expect.objectContaining({
path: { walletname: 'wallet.jmdat', mixdepth: '0' },
throwOnError: true,
}),
)
fireEvent.click(screen.getByRole('button', { name: 'receive.button_new_address' }))
expect(mocks.getAddress).toHaveBeenCalledTimes(2)
await flushActUpdates()
})
it('uses receive form changes for the next address request and sharing', async () => {
const user = userEvent.setup()
mocks.share.mockRejectedValue(new Error('cancelled'))
render(<ReceivePage walletFileName="wallet.jmdat" />)
await user.click(screen.getByRole('button', { name: 'receive.button_settings' }))
await user.click(screen.getByRole('button', { name: 'update receive form' }))
await user.click(screen.getByRole('button', { name: 'receive.button_reveal_address' }))
await waitFor(() => expect(screen.getByText('qr:bc1qexample:2100')).toBeInTheDocument())
const lastAddressRequest = mocks.getAddress.mock.calls.at(-1)?.[0] as { path?: { mixdepth?: string } }
expect(lastAddressRequest?.path?.mixdepth).toBe('1')
await user.click(screen.getByRole('button', { name: 'receive.button_share_address' }))
expect(mocks.share).toHaveBeenCalledWith({
title: 'Bitcoin Address',
text: 'bc1qexample',
})
expect(mocks.toastError).toHaveBeenCalledWith('receive.error_share_address_failed')
})
})

View file

@ -0,0 +1,147 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { BalanceSummary } from '@/lib/balanceSummary'
import JarSelectorDialog from './JarSelectorDialog'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('../ui/dialog', () => ({
Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@radix-ui/react-dialog', () => ({
DialogTitle: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../ui/button', () => ({
Button: ({ children, onClick, disabled }: { children?: ReactNode; onClick?: () => void; disabled?: boolean }) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))
vi.mock('../ui/jam/SelectableJar', () => ({
SelectableJar: ({
name,
onClick,
disabled,
isSelected,
}: {
name?: string
onClick?: () => void
disabled?: boolean
isSelected?: boolean
}) => (
<button onClick={onClick} disabled={disabled} data-selected={isSelected}>
{name}
</button>
),
}))
vi.mock('../ui/spinner', () => ({ Spinner: () => <div data-testid="spinner" /> }))
const jars = [
{
jarIndex: 0,
name: 'Jar 0',
color: '#000',
balanceSummary: {
calculatedTotalBalanceInSats: 5000,
calculatedAvailableBalanceInSats: 5000,
calculatedFrozenOrLockedBalanceInSats: 0,
},
},
{
jarIndex: 1,
name: 'Jar 1',
color: '#111',
balanceSummary: {
calculatedTotalBalanceInSats: 0,
calculatedAvailableBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
},
},
] as unknown as Jar[]
const walletBalanceSummary = { calculatedTotalBalanceInSats: 5000 } as unknown as BalanceSummary
const renderDialog = (overrides?: {
onConfirm?: (jar: number) => Promise<void>
onOpenChange?: (o: boolean) => void
subtitle?: string
}) =>
render(
<JarSelectorDialog
open
onOpenChange={overrides?.onOpenChange ?? vi.fn()}
title="Pick a jar"
subtitle={overrides?.subtitle}
jars={jars}
disabledJars={[jars[1]]}
walletBalanceSummary={walletBalanceSummary}
onConfirm={overrides?.onConfirm ?? vi.fn().mockResolvedValue(undefined)}
/>,
)
describe('JarSelectorDialog', () => {
it('renders the jars and an optional subtitle', () => {
renderDialog({ subtitle: 'choose wisely' })
expect(screen.getByText('Pick a jar')).toBeInTheDocument()
expect(screen.getByText('choose wisely')).toBeInTheDocument()
expect(screen.getByText('Jar 0')).toBeInTheDocument()
})
it('does not render when closed', () => {
render(
<JarSelectorDialog
open={false}
onOpenChange={vi.fn()}
title="Pick a jar"
jars={jars}
disabledJars={[]}
walletBalanceSummary={walletBalanceSummary}
onConfirm={vi.fn().mockResolvedValue(undefined)}
/>,
)
expect(screen.queryByText('Pick a jar')).not.toBeInTheDocument()
})
it('disables the confirm button until a jar is selected', () => {
renderDialog()
const confirm = screen.getByText('modal.confirm_button_accept').closest('button')
expect(confirm).toBeDisabled()
fireEvent.click(screen.getByText('Jar 0'))
expect(confirm).not.toBeDisabled()
})
it('confirms the selected jar', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
renderDialog({ onConfirm })
fireEvent.click(screen.getByText('Jar 0'))
fireEvent.click(screen.getByText('modal.confirm_button_accept'))
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(0))
})
it('closes and resets via the reject button', () => {
const onOpenChange = vi.fn()
renderDialog({ onOpenChange })
fireEvent.click(screen.getByText('modal.confirm_button_reject'))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('does nothing when confirm is clicked with no selection', () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
renderDialog({ onConfirm })
fireEvent.click(screen.getByText('modal.confirm_button_accept'))
expect(onConfirm).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,55 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { PaymentAbortDialog } from './PaymentAbortDialog'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('../ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogDescription: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogFooter: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
describe('PaymentAbortDialog', () => {
it('renders correctly', () => {
render(<PaymentAbortDialog open={true} onOpenChange={vi.fn()} onConfirm={vi.fn()} isConfirming={false} />)
expect(screen.getByText('send.confirm_abort_modal.title')).toBeInTheDocument()
expect(screen.getByText('send.confirm_abort_modal.text_body')).toBeInTheDocument()
expect(screen.getByText('modal.confirm_button_reject')).toBeInTheDocument()
expect(screen.getByText('global.abort')).toBeInTheDocument()
})
it('renders correctly when confirming', () => {
render(<PaymentAbortDialog open={true} onOpenChange={vi.fn()} onConfirm={vi.fn()} isConfirming={true} />)
// The button has a spinner and text when confirming
expect(screen.getByText('global.abort')).toBeInTheDocument()
expect(screen.getByText('modal.confirm_button_reject')).toBeDisabled()
})
it('calls onConfirm when abort button is clicked', () => {
const onConfirm = vi.fn()
render(<PaymentAbortDialog open={true} onOpenChange={vi.fn()} onConfirm={onConfirm} isConfirming={false} />)
fireEvent.click(screen.getByText('global.abort'))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,177 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { JamFeeConfigValues } from '@/lib/feeConfig'
import { TX_FEE_UNITS } from '@/lib/feeConfig'
import PaymentConfirmDialog from './PaymentConfirmDialog'
import type { SendFormValues } from './types'
vi.mock('@radix-ui/react-dialog', () => ({
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}))
vi.mock('react-i18next', () => ({
Trans: ({ children, i18nKey }: { children?: ReactNode; i18nKey: string }) => (
<span>
{i18nKey}
{children}
</span>
),
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
}))
vi.mock('../dev/DevBadge', () => ({
DevBadge: () => <span>dev-badge</span>,
}))
vi.mock('../ui/dialog', () => ({
Dialog: ({ children, open }: { children: ReactNode; open: boolean }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children: ReactNode }) => <section>{children}</section>,
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
DialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
}))
vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>address:{value}</span>,
}))
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>balance:{valueString}</span>,
}))
vi.mock('../ui/spinner', () => ({
Spinner: () => <span>spinner</span>,
}))
vi.mock('../ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <span>{children}</span>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}))
const feeConfigValues: JamFeeConfigValues = {
maxCjAbsoluteFee: 500,
maxCjRelativeFee: 0.01,
txFeeFactor: 0.25,
txFee: {
txFeeInBlocks: 6,
txFeeUnit: TX_FEE_UNITS.BLOCKS,
},
}
const sourceJar: Jar = {
balanceSummary: {
calculatedAvailableBalanceInSats: 50_000,
calculatedTotalBalanceInSats: 50_000,
calculatedConfirmedAvailableBalanceInSats: 50_000,
calculatedFrozenOrLockedBalanceInSats: 0,
},
color: '#e2b86a',
jarIndex: 0,
name: 'Source jar',
utxos: [],
}
const destinationJar: Jar = {
...sourceJar,
jarIndex: 1,
name: 'Destination jar',
}
const baseValues: SendFormValues = {
amount: {
amount: 12_000,
isSweep: false,
sweepAmount: undefined,
},
destination: {
address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq',
fromJar: undefined,
},
isCoinJoin: false,
source: {
fromJar: 0,
},
txFee: {
txFeeInBlocks: 6,
txFeeInSatsPerVbyte: undefined,
txFeeUnit: TX_FEE_UNITS.BLOCKS,
},
}
describe('PaymentConfirmDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('confirms and closes a direct send', async () => {
const onConfirm = vi.fn<(values: SendFormValues) => Promise<void>>().mockResolvedValue(undefined)
const onOpenChange = vi.fn()
render(
<PaymentConfirmDialog
open
onOpenChange={onOpenChange}
onConfirm={onConfirm}
values={baseValues}
meta={{ feeConfigValues, sourceJar }}
/>,
)
expect(screen.getByText('send.confirm_send_modal.text_collaborative_tx_disabled')).toBeInTheDocument()
expect(screen.getByText('Source jar')).toBeInTheDocument()
expect(screen.getByText(`address:${baseValues.destination.address}`)).toBeInTheDocument()
expect(screen.getByText('balance:12000')).toBeInTheDocument()
expect(
screen.getByText('send.confirm_send_modal.text_miner_fee_in_targeted_blocks:{"count":6}'),
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'modal.confirm_button_reject' }))
expect(onOpenChange).toHaveBeenCalledWith(false)
fireEvent.click(screen.getByRole('button', { name: 'modal.confirm_button_accept' }))
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(baseValues))
})
it('shows CoinJoin sweep fee details and debug payloads', () => {
const values: SendFormValues = {
...baseValues,
amount: {
amount: undefined,
isSweep: true,
sweepAmount: 20_000,
},
destination: {
address: baseValues.destination.address,
fromJar: 1,
},
isCoinJoin: true,
numCollaborators: 3,
}
render(
<PaymentConfirmDialog
debug
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
values={values}
meta={{ destinationJar, feeConfigValues, sourceJar }}
/>,
)
expect(screen.getByText('send.confirm_send_modal.text_collaborative_tx_enabled')).toBeInTheDocument()
expect(screen.getByText('Destination jar')).toBeInTheDocument()
expect(screen.getByText(/send\.confirm_send_modal\.text_sweep_balance/u)).toBeInTheDocument()
expect(screen.getAllByText('balance:20000')[0]).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('balance:1500')).toBeInTheDocument()
expect(screen.getByText('(7.5%)')).toBeInTheDocument()
expect(screen.getByText('dev-badge')).toBeInTheDocument()
expect(screen.getByText(/"isCoinJoin": true/u)).toBeInTheDocument()
})
})

View file

@ -0,0 +1,166 @@
import { Network } from 'bitcoin-address-validation'
import type { TFunction } from 'i18next'
import { describe, expect, it, vi } from 'vitest'
import type { AddressSummary, Jar } from '@/context/JamWalletInfoContext'
import { TX_FEE_UNITS, toJamFeeConfigValues } from '@/lib/feeConfig'
import {
createSendFormSchema,
initialNumberOfCollaborators,
MIN_SEND_AMOUNT,
toSendFormDefaultValues,
} from './SendForm.schema'
const t = vi.fn((key: string) => key) as unknown as TFunction<'translation', undefined>
const validMainnetAddress = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT'
const sourceJarAddress = '1CounterpartyXXXXXXXXXXXXXXXUWLpVr'
const jars = [
{
jarIndex: 0,
balanceSummary: {
calculatedAvailableBalanceInSats: 10_000,
},
},
{
jarIndex: 1,
balanceSummary: {
calculatedAvailableBalanceInSats: 0,
},
},
] as Jar[]
const addressSummary = {
[validMainnetAddress]: {
address: validMainnetAddress,
jarIndex: 2,
used: false,
},
[sourceJarAddress]: {
address: sourceJarAddress,
jarIndex: 0,
used: false,
},
} as unknown as AddressSummary
const validFormValues = {
source: { fromJar: 0 },
destination: { address: validMainnetAddress },
amount: { isSweep: false, amount: MIN_SEND_AMOUNT },
isCoinJoin: true,
numCollaborators: 4,
txFee: {
txFeeUnit: TX_FEE_UNITS.BLOCKS,
txFeeInBlocks: 6,
},
}
describe('initialNumberOfCollaborators', () => {
it('keeps the default collaborator count within supported bounds', () => {
expect(initialNumberOfCollaborators(2)).toBeGreaterThanOrEqual(8)
expect(initialNumberOfCollaborators(2)).toBeLessThanOrEqual(10)
expect(initialNumberOfCollaborators(9)).toBeGreaterThanOrEqual(9)
})
})
describe('toSendFormDefaultValues', () => {
it('combines tx fee defaults with collaborator defaults', () => {
const defaults = toSendFormDefaultValues({
feeConfigValues: toJamFeeConfigValues({ tx_fees: '2500' }),
minNumberOfCollaborators: 3,
})
expect(defaults.txFee).toEqual({
txFeeUnit: TX_FEE_UNITS.SATS_PER_VBYTE,
txFeeInBlocks: undefined,
txFeeInSatsPerVbyte: 2.5,
})
expect(defaults.isCoinJoin).toBe(true)
expect(defaults.numCollaborators).toBeGreaterThanOrEqual(8)
})
})
describe('createSendFormSchema', () => {
const schema = createSendFormSchema(jars, addressSummary, 3, Network.mainnet, t)
it('accepts a valid coinjoin send', async () => {
await expect(schema.validate(validFormValues)).resolves.toMatchObject(validFormValues)
})
it('rejects unavailable source jars', async () => {
await expect(
schema.validate({
...validFormValues,
source: { fromJar: 1 },
amount: { isSweep: true, sweepAmount: MIN_SEND_AMOUNT },
}),
).rejects.toThrow('send.feedback_invalid_source_jar')
})
it('rejects reused or source-jar destination addresses', async () => {
await expect(
schema.validate({
...validFormValues,
destination: { address: validMainnetAddress },
}),
).resolves.toBeDefined()
await expect(
schema.validate({
...validFormValues,
destination: { address: validMainnetAddress },
}),
).resolves.toMatchObject({ destination: { address: validMainnetAddress } })
await expect(
createSendFormSchema(
jars,
{
...addressSummary,
[validMainnetAddress]: { ...addressSummary[validMainnetAddress], used: true },
},
3,
Network.mainnet,
t,
).validate(validFormValues),
).rejects.toThrow('send.feedback_reused_address')
await expect(
schema.validate({
...validFormValues,
destination: { address: sourceJarAddress },
}),
).rejects.toThrow('send.feedback_address_from_source_jar')
})
it('rejects invalid amounts and clears collaborators for direct sends', async () => {
await expect(
schema.validate({
...validFormValues,
amount: { isSweep: false, amount: 11_000 },
}),
).rejects.toThrow('send.feedback_amount_exceeds_balance')
await expect(
schema.validate({
...validFormValues,
isCoinJoin: false,
numCollaborators: 4,
}),
).resolves.toMatchObject({
isCoinJoin: false,
numCollaborators: null,
})
})
it('supports sweep sends with a sweep amount', async () => {
await expect(
schema.validate({
...validFormValues,
amount: { isSweep: true, sweepAmount: 9_000, amount: 500 },
}),
).resolves.toMatchObject({
amount: { isSweep: true, sweepAmount: 9_000, amount: null },
})
})
})

View file

@ -0,0 +1,261 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { AddressSummary, Jar } from '@/context/JamWalletInfoContext'
import type { BalanceSummary } from '@/lib/balanceSummary'
import type { JamFeeConfigValues } from '@/lib/feeConfig'
import { flushActUpdates } from '@/test/flushActUpdates'
import { SendForm } from './SendForm'
const h = vi.hoisted(() => ({
getaddressResult: {
data: { address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' },
error: undefined as { message: string } | undefined,
},
toastSuccess: vi.fn<(message: string) => void>(),
toastError: vi.fn<(message: string) => void>(),
}))
vi.mock('sonner', () => ({
toast: {
success: (message: string) => {
h.toastSuccess(message)
},
error: (message: string) => {
h.toastError(message)
},
},
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useDetectNetwork: () => ({ network: 'mainnet' }),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/lib/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/utils')>()),
delayedPromise: () => Promise.resolve(),
}))
vi.mock('../ui/jam/Address', () => ({
Address: () => <div data-testid="address" />,
}))
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString?: string }) => <div data-testid="balance">{valueString}</div>,
}))
vi.mock('../ui/jam/SelectableJar', () => ({
SelectableJar: ({ name, onClick, disabled }: { name?: string; onClick?: () => void; disabled?: boolean }) => (
<button onClick={onClick} disabled={disabled}>
{name}
</button>
),
}))
vi.mock('@/components/send/SendCoinjoinPreconditionAlert', () => ({
SendCoinjoinPreconditionAlert: () => <div data-testid="precondition-alert" />,
}))
vi.mock('./TxFeeForm', () => ({
TxFeeForm: () => <div data-testid="tx-fee-form" />,
}))
vi.mock('@/components/ui/QrScannerDialog', () => ({
default: ({
open,
onScan,
}: {
open?: boolean
onScan?: (r: { address: string; amount?: number; message?: string }) => void
}) =>
open ? (
<button data-testid="qr-scan" onClick={() => onScan?.({ address: 'bc1qscan', amount: 0.001, message: 'note' })}>
scan
</button>
) : null,
}))
vi.mock('./JarSelectorDialog', () => ({
default: ({ open, onConfirm }: { open?: boolean; onConfirm?: (jarIndex: number) => void }) =>
open ? (
<button data-testid="jar-confirm" onClick={() => void onConfirm?.(1)}>
confirm-jar
</button>
) : null,
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
getaddress: () => Promise.resolve(h.getaddressResult),
}))
describe('SendForm', () => {
const mockJars = [
{
jarIndex: 0,
name: 'Jar 0',
color: '#000',
balanceSummary: {
calculatedAvailableBalanceInSats: 5000,
calculatedTotalBalanceInSats: 5000,
calculatedFrozenOrLockedBalanceInSats: 0,
},
utxos: [],
},
{
jarIndex: 1,
name: 'Jar 1',
color: '#111',
balanceSummary: {
calculatedAvailableBalanceInSats: 0,
calculatedTotalBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
},
utxos: [],
},
] as unknown as Jar[]
const mockAddressSummary: AddressSummary = {}
const mockBalanceSummary = { calculatedTotalBalanceInSats: 5000 } as unknown as BalanceSummary
const mockFeeConfigValues = {
txFeeFactor: 0.1,
maxCjAbsoluteFee: 100,
txFee: { txFeeUnit: 'blocks', txFeeInBlocks: 3, txFeeInSatsPerVbyte: undefined },
} as unknown as JamFeeConfigValues
const renderForm = (extra?: { disabled?: boolean; debug?: boolean }) =>
render(
<SendForm
onSubmit={vi.fn()}
walletFileName="test.jmdat"
jars={mockJars}
walletBalanceSummary={mockBalanceSummary}
addressSummary={mockAddressSummary}
feeConfigValues={mockFeeConfigValues}
disabled={extra?.disabled}
debug={extra?.debug}
/>,
)
beforeEach(() => {
vi.clearAllMocks()
h.getaddressResult = { data: { address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' }, error: undefined }
h.toastSuccess = vi.fn<(message: string) => void>()
h.toastError = vi.fn<(message: string) => void>()
})
it('renders the core fields', () => {
renderForm()
expect(screen.getByText('send.label_source_jar')).toBeInTheDocument()
expect(screen.getByText('send.label_recipient')).toBeInTheDocument()
expect(screen.getByText('send.label_amount_input')).toBeInTheDocument()
})
it('selects a source jar', async () => {
renderForm()
fireEvent.click(screen.getByRole('button', { name: 'Jar 0' }))
expect(screen.getByRole('button', { name: 'Jar 0' })).toBeInTheDocument()
await flushActUpdates()
})
it('enables sweep, then clears it, then reselecting the jar resets sweep', async () => {
renderForm()
fireEvent.click(screen.getByRole('button', { name: 'Jar 0' }))
fireEvent.click(document.querySelector('#btn-sweep-trigger')!)
expect(document.querySelector('#btn-sweep-clear-trigger')).toBeInTheDocument()
// reselect the same jar while sweep is active -> hits the sweep-reset branch
fireEvent.click(screen.getByRole('button', { name: 'Jar 0' }))
fireEvent.click(document.querySelector('#btn-sweep-trigger')!)
fireEvent.click(document.querySelector('#btn-sweep-clear-trigger')!)
await flushActUpdates()
})
it('applies a scanned bip21 result', async () => {
renderForm()
fireEvent.click(document.querySelector('#show-qr-scanner-trigger')!)
fireEvent.click(await screen.findByTestId('qr-scan'))
expect(screen.getByText('note')).toBeInTheDocument()
await flushActUpdates()
})
it('selects a destination address from the jar selector', async () => {
renderForm()
fireEvent.click(document.querySelector('#show-address-from-jar-selector-trigger')!)
fireEvent.click(await screen.findByTestId('jar-confirm'))
await waitFor(() => expect(screen.getByTestId('address')).toBeInTheDocument())
})
it('shows an error when the jar selector address lookup fails', async () => {
h.getaddressResult = {
data: { address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' },
error: { message: 'boom' },
}
renderForm()
fireEvent.click(document.querySelector('#show-address-from-jar-selector-trigger')!)
fireEvent.click(await screen.findByTestId('jar-confirm'))
await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('receive.error_loading_address_failed'))
})
it('applies a pasted bip21 uri', async () => {
renderForm()
const input = document.querySelector('#send-destination') as HTMLInputElement
fireEvent.paste(input, {
clipboardData: { getData: () => 'bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.5' },
})
expect(h.toastSuccess).toHaveBeenCalledWith('send.qr_scan_bip21_applied')
await flushActUpdates()
})
it('ignores a non-bitcoin paste', () => {
renderForm()
const input = document.querySelector('#send-destination') as HTMLInputElement
fireEvent.paste(input, { clipboardData: { getData: () => 'just some text' } })
expect(h.toastSuccess).not.toHaveBeenCalled()
})
it('renders the debug panel when debug is enabled', () => {
renderForm({ debug: true })
expect(screen.getByText('isValid:')).toBeInTheDocument()
})
it('renders disabled state', () => {
renderForm({ disabled: true })
const submit = document.querySelector('button[type="submit"]')
expect(submit).toBeDisabled()
})
it('submits the form', async () => {
const onSubmit = vi.fn()
render(
<SendForm
onSubmit={onSubmit}
walletFileName="test.jmdat"
jars={mockJars}
walletBalanceSummary={mockBalanceSummary}
addressSummary={mockAddressSummary}
feeConfigValues={mockFeeConfigValues}
/>,
)
fireEvent.submit(document.querySelector('form')!)
await flushActUpdates()
})
it('shows a network badge for a non-mainnet destination address', async () => {
renderForm()
const input = document.querySelector('#send-destination') as HTMLInputElement
fireEvent.change(input, { target: { value: 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx' } })
expect(screen.getByText('testnet')).toBeInTheDocument()
await flushActUpdates()
})
})

View file

@ -0,0 +1,577 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { Utxo } from '@/hooks/useQueryUtxos'
import type { JamFeeConfigValues } from '@/lib/feeConfig'
import { TX_FEE_UNITS } from '@/lib/feeConfig'
import { jmSessionStore } from '@/store/jmSessionStore'
import { jmTxStore } from '@/store/jmTxStore'
import { flushActUpdates } from '@/test/flushActUpdates'
import { SendPage } from './SendPage'
import type { SendFormValues } from './types'
const mocks = vi.hoisted(() => ({
clearCurrentPaymentAttempt: vi.fn(),
currentPaymentAttemptPresent: false,
directSend: vi.fn(),
fetchIfMissing: vi.fn(),
feeConfigMissing: false,
getFeeConfigValues: vi.fn<() => JamFeeConfigValues>(),
onOpenUtxoSelector: vi.fn(),
scrollToTop: vi.fn(),
setCurrentPaymentAttempt: vi.fn(),
setWaitForUtxosToBeSpent: vi.fn(),
startCoinjoin: vi.fn(),
stopCoinjoinRefetch: vi.fn(),
takerRunning: false,
toastError: vi.fn(),
toastInfo: vi.fn(),
toastSuccess: vi.fn(),
utxoSelectorDisabled: false,
walletInfoIsFetching: false,
walletInfoIsLoading: false,
waitForUtxosToBeSpent: [] as string[],
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({ mutationFn: mocks.directSend })),
docoinjoinMutation: vi.fn(() => ({ mutationFn: mocks.startCoinjoin })),
stopcoinjoinOptions: vi.fn(() => ({ queryKey: ['stopcoinjoin'], queryFn: vi.fn() })),
}))
type MutationOptions = {
mutationFn: (input?: unknown) => Promise<unknown>
onError?: (error: unknown) => void
onMutate?: () => void
onSuccess?: (result: unknown, input?: unknown) => void
}
vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options: MutationOptions) => ({
error: undefined,
isPending: false,
isSuccess: false,
mutateAsync: async (input?: unknown) => {
options.onMutate?.()
try {
const result = await options.mutationFn(input)
options.onSuccess?.(result, input)
return result
} catch (error) {
options.onError?.(error)
throw error
}
},
reset: vi.fn(),
})),
useQuery: vi.fn(() => ({
refetch: mocks.stopCoinjoinRefetch,
})),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
}))
vi.mock('sonner', () => ({
toast: {
error: mocks.toastError,
info: mocks.toastInfo,
success: mocks.toastSuccess,
},
}))
vi.mock('@/components/settings/fees/FeeConfigDialog', () => ({
FeeConfigDialog: ({ open }: { open: boolean }) => <div>fee-config-dialog:{String(open)}</div>,
}))
vi.mock('@/components/ui/jam/FeeConfigErrorAlert', () => ({
FeeConfigErrorAlert: ({ onOpenFeeConfig }: { onOpenFeeConfig: () => void }) => (
<button onClick={onOpenFeeConfig}>open-fee-config</button>
),
}))
vi.mock('@/components/ui/jam/PageLoading', () => ({
PageLoading: () => <div>page-loading</div>,
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title, subtitle }: { title: string; subtitle: string }) => (
<h1>
{title}:{subtitle}
</h1>
),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/hooks/useFeeConfigValidation', () => ({
useFeeConfigValidation: () => ({
feeConfigValues: mocks.getFeeConfigValues(),
// getter so a captured reference reflects later toggles of mocks.feeConfigMissing
get maxFeesConfigMissing() {
return mocks.feeConfigMissing
},
}),
}))
vi.mock('@/hooks/useJmConfig', () => ({
useJmConfig: () => ({
fetchIfMissing: mocks.fetchIfMissing,
}),
}))
vi.mock('@/hooks/useRefreshSession', () => ({
useRefreshSession: vi.fn(),
}))
vi.mock('@/hooks/useUtxoSelectionDialog', () => ({
useUtxoSelectionDialog: () => ({
dialogProps: { open: false },
isSubmitting: false,
onOpenUtxoSelector: mocks.onOpenUtxoSelector,
utxoSelectorDisabled: mocks.utxoSelectorDisabled,
}),
}))
vi.mock('@/lib/queryClient', () => ({
withMutationDelay: (mutationFn: unknown) => mutationFn,
}))
vi.mock('@/lib/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/utils')>()),
scrollToTop: mocks.scrollToTop,
}))
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: true }),
}))
vi.mock('@/context/JamSessionInfoContext', () => ({
useJamSessionInfoContext: () => ({
clearCurrentPaymentAttempt: mocks.clearCurrentPaymentAttempt,
rescanInfo: { rescanning: false },
setCurrentPaymentAttempt: mocks.setCurrentPaymentAttempt,
takerInfo: {
currentPaymentAttempt: mocks.takerRunning || mocks.currentPaymentAttemptPresent ? collaborativeValues : undefined,
running: mocks.takerRunning,
},
}),
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useAddressSummary: () => ({ addressSummary: {} }),
useJamWalletInfoContext: () => ({
isFetching: mocks.walletInfoIsFetching,
isLoading: mocks.walletInfoIsLoading,
setWaitForUtxosToBeSpent: mocks.setWaitForUtxosToBeSpent,
utxosHashHex: 'hash-before',
waitForUtxosToBeSpent: mocks.waitForUtxosToBeSpent,
}),
useJars: () => ({ jars }),
useWalletBalanceSummary: () => ({ walletBalanceSummary: balanceSummary }),
}))
vi.mock('./PaymentAbortDialog', () => ({
PaymentAbortDialog: ({ open, onConfirm }: { open: boolean; onConfirm: () => Promise<void> }) => (
<div>
abort-dialog:{String(open)}
{open ? <button onClick={() => void onConfirm()}>confirm-abort</button> : null}
</div>
),
}))
vi.mock('./PaymentConfirmDialog', () => ({
default: ({
open,
onConfirm,
values,
}: {
open: boolean
onConfirm: (values: SendFormValues) => Promise<void>
values: SendFormValues
}) => (
<div>
payment-confirm:{String(open)}
{open ? <button onClick={() => void onConfirm(values).catch(() => {})}>confirm-payment</button> : null}
</div>
),
}))
vi.mock('./SendForm', () => ({
SendForm: ({
disabled,
onSourceJarChange,
onSubmit,
sourceJarLabelButton,
}: {
disabled?: boolean
onSourceJarChange?: (jarIndex: number | undefined) => void
onSubmit: (values: SendFormValues) => void
sourceJarLabelButton?: React.ReactElement
}) => (
<div>
send-form:{String(disabled)}
<div>{sourceJarLabelButton}</div>
<button
onClick={() => {
onSourceJarChange?.(0)
onSubmit(directValues)
}}
>
submit-direct
</button>
<button
onClick={() => {
onSourceJarChange?.(0)
onSubmit(collaborativeValues.data)
}}
>
submit-coinjoin
</button>
</div>
),
}))
vi.mock('./UtxoSelectionDialog', () => ({
UtxoSelectionDialog: () => <div>utxo-selection-dialog</div>,
}))
const balanceSummary = {
calculatedAvailableBalanceInSats: 20_000,
calculatedConfirmedAvailableBalanceInSats: 20_000,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 20_000,
}
const utxo = {
address: 'bc1qsource',
confirmations: 3,
frozen: false,
label: '',
locktime: undefined,
tries_remaining: 3,
utxo: 'source-tx:0',
value: 20_000,
} as unknown as Utxo
const jars: Jar[] = [
{
balanceSummary,
color: '#e2b86a',
jarIndex: 0,
name: 'Zero',
utxos: [utxo],
},
{
balanceSummary,
color: '#3b5ba9',
jarIndex: 1,
name: 'One',
utxos: [],
},
]
const directValues: SendFormValues = {
amount: { amount: 1_000, isSweep: false, sweepAmount: undefined },
destination: { address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', fromJar: undefined },
isCoinJoin: false,
source: { fromJar: 0 },
txFee: { txFeeInBlocks: 3, txFeeInSatsPerVbyte: undefined, txFeeUnit: TX_FEE_UNITS.BLOCKS },
}
const collaborativeValues = {
createdAt: 1,
data: {
...directValues,
isCoinJoin: true,
numCollaborators: 4,
},
utxosHashHex: 'hash-before',
walletFileName: 'wallet.jmdat',
}
describe('SendPage', () => {
beforeEach(() => {
mocks.clearCurrentPaymentAttempt.mockReset()
mocks.currentPaymentAttemptPresent = false
mocks.directSend.mockReset()
mocks.fetchIfMissing.mockReset()
mocks.fetchIfMissing.mockResolvedValue({ value: '4' })
mocks.feeConfigMissing = false
mocks.getFeeConfigValues.mockReset()
mocks.getFeeConfigValues.mockReturnValue({
maxCjAbsoluteFee: 10_000,
maxCjRelativeFee: 0.01,
txFee: directValues.txFee,
})
mocks.onOpenUtxoSelector.mockReset()
mocks.scrollToTop.mockReset()
mocks.setCurrentPaymentAttempt.mockReset()
mocks.setWaitForUtxosToBeSpent.mockReset()
mocks.startCoinjoin.mockReset()
mocks.stopCoinjoinRefetch.mockReset()
mocks.stopCoinjoinRefetch.mockResolvedValue({ data: {} })
mocks.takerRunning = false
mocks.toastError.mockReset()
mocks.toastInfo.mockReset()
mocks.toastSuccess.mockReset()
mocks.utxoSelectorDisabled = false
mocks.walletInfoIsFetching = false
mocks.walletInfoIsLoading = false
mocks.waitForUtxosToBeSpent = []
jmSessionStore.setState({
state: {
coinjoin_in_process: false,
maker_running: false,
session: true,
wallet_name: 'wallet.jmdat',
rescanning: false,
},
})
jmTxStore.getState().clear()
})
it('shows loading until session and wallet data are ready', async () => {
jmSessionStore.setState({ state: undefined })
render(<SendPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('page-loading')).toBeInTheDocument()
await flushActUpdates()
})
it('confirms a direct send and stores the transaction result', async () => {
const user = userEvent.setup()
mocks.directSend.mockResolvedValue({
txinfo: {
inputs: [{ outpoint: 'source-tx:0' }],
outputs: [{ address: directValues.destination.address, value_sats: 1_000 }],
txid: 'txid-success',
},
})
render(<SendPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByText('submit-direct'))
await user.click(screen.getByText('confirm-payment'))
await waitFor(() => expect(mocks.directSend).toHaveBeenCalled())
expect(jmTxStore.getState().get('txid-success')).toBeDefined()
expect(mocks.setWaitForUtxosToBeSpent).toHaveBeenCalledWith(['source-tx:0'])
expect(mocks.toastSuccess).toHaveBeenCalledWith('Successfully sent non-collaborative transaction.')
expect(screen.getByText(/send.alert_payment_successful/u)).toBeInTheDocument()
})
it('opens fee config instead of starting CoinJoin when max fees are missing', async () => {
const user = userEvent.setup()
mocks.feeConfigMissing = true
render(<SendPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByText('submit-coinjoin'))
expect(mocks.startCoinjoin).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('send.taker_error_message_max_fees_config_missing')
expect(screen.getByText('fee-config-dialog:true')).toBeInTheDocument()
})
it('shows the running CoinJoin state and confirms abort', async () => {
const user = userEvent.setup()
mocks.takerRunning = true
render(<SendPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('send.text_coinjoin_already_running')).toBeInTheDocument()
expect(screen.getByText('send-form:true')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'global.abort' }))
expect(screen.getByText('abort-dialog:true')).toBeInTheDocument()
await user.click(screen.getByText('confirm-abort'))
expect(mocks.stopCoinjoinRefetch).toHaveBeenCalledWith({ throwOnError: true })
})
it('confirms and starts a collaborative transaction', async () => {
const user = userEvent.setup()
mocks.startCoinjoin.mockResolvedValue({})
render(<SendPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByText('submit-coinjoin'))
await user.click(screen.getByText('confirm-payment'))
await waitFor(() => expect(mocks.startCoinjoin).toHaveBeenCalled())
expect(mocks.setCurrentPaymentAttempt).toHaveBeenCalled()
})
it('rejects confirmed CoinJoin when max fees config goes missing after submit', async () => {
const user = userEvent.setup()
render(<SendPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByText('submit-coinjoin'))
mocks.feeConfigMissing = true
await user.click(screen.getByText('confirm-payment'))
expect(mocks.startCoinjoin).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('send.taker_error_message_max_fees_config_missing')
})
it('handles errors thrown by the collaborative transaction', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const user = userEvent.setup()
mocks.startCoinjoin.mockRejectedValue({ message: 'coinjoin boom' })
render(<SendPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByText('submit-coinjoin'))
await user.click(screen.getByText('confirm-payment'))
await waitFor(() => expect(screen.getByText(/send.error_starting_collaborative_transaction/u)).toBeInTheDocument())
expect(mocks.setCurrentPaymentAttempt).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('handles errors thrown by the direct transaction', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const user = userEvent.setup()
mocks.directSend.mockRejectedValue({ message: 'direct boom' })
render(<SendPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByText('submit-direct'))
await user.click(screen.getByText('confirm-payment'))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('direct boom')))
consoleError.mockRestore()
})
it('succeeds without a matching output or input outpoints', async () => {
const user = userEvent.setup()
mocks.directSend.mockResolvedValue({
txinfo: {
inputs: [{ outpoint: undefined }, undefined],
outputs: [{ address: 'bc1qother', value_sats: 5 }],
txid: 'txid-no-match',
},
})
render(<SendPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByText('submit-direct'))
await user.click(screen.getByText('confirm-payment'))
await waitFor(() => expect(mocks.directSend).toHaveBeenCalled())
expect(mocks.setWaitForUtxosToBeSpent).toHaveBeenCalledWith([])
})
it('succeeds when txinfo has no inputs array', async () => {
const user = userEvent.setup()
mocks.directSend.mockResolvedValue({
txinfo: {
outputs: [{ address: directValues.destination.address, value_sats: 1_000 }],
txid: 'txid-no-inputs',
},
})
render(<SendPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByText('submit-direct'))
await user.click(screen.getByText('confirm-payment'))
await waitFor(() => expect(mocks.setWaitForUtxosToBeSpent).toHaveBeenCalledWith([]))
})
it('shows the fee config error alert and opens the dialog', async () => {
const user = userEvent.setup()
mocks.feeConfigMissing = true
render(<SendPage walletFileName="wallet.jmdat" />)
await user.click(screen.getByText('open-fee-config'))
expect(screen.getByText('fee-config-dialog:true')).toBeInTheDocument()
})
it('shows the maker running warning', async () => {
jmSessionStore.setState({
state: {
coinjoin_in_process: false,
maker_running: true,
session: true,
wallet_name: 'wallet.jmdat',
rescanning: false,
},
})
render(<SendPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('send.text_maker_running')).toBeInTheDocument()
expect(screen.getByText('send-form:true')).toBeInTheDocument()
await flushActUpdates()
})
it('shows the waiting-for-utxos alert and disables the form', async () => {
mocks.waitForUtxosToBeSpent = ['spent-tx:0']
render(<SendPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('Waiting for utxos to be marked as spent...')).toBeInTheDocument()
expect(screen.getByText('send-form:true')).toBeInTheDocument()
await flushActUpdates()
})
it('shows the collaborative awaiting completion alert while wallet info is fetching', async () => {
mocks.currentPaymentAttemptPresent = true
mocks.walletInfoIsFetching = true
render(<SendPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('send.alert_collaborative_awaiting_completion')).toBeInTheDocument()
await flushActUpdates()
})
it('shows the collaborative ended alert when utxos are unchanged and clears the attempt', async () => {
const user = userEvent.setup()
mocks.currentPaymentAttemptPresent = true
render(<SendPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('send.alert_collaborative_ended_title')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'global.done' }))
expect(mocks.clearCurrentPaymentAttempt).toHaveBeenCalled()
})
it('handles errors loading minimum makers config', async () => {
mocks.fetchIfMissing.mockRejectedValue({ message: 'config boom' })
render(<SendPage walletFileName="wallet.jmdat" />)
await waitFor(() =>
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('send.error_loading_min_makers_failed')),
)
})
it('ignores an invalid minimum makers config value', async () => {
mocks.fetchIfMissing.mockResolvedValue({ value: '0' })
render(<SendPage walletFileName="wallet.jmdat" />)
await waitFor(() => expect(mocks.fetchIfMissing).toHaveBeenCalled())
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('ignores a missing minimum makers config value', async () => {
mocks.fetchIfMissing.mockResolvedValue({ value: undefined })
render(<SendPage walletFileName="wallet.jmdat" />)
await waitFor(() => expect(mocks.fetchIfMissing).toHaveBeenCalled())
expect(mocks.toastError).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,41 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { type FieldValues, FormProvider, useForm } from 'react-hook-form'
import { describe, expect, it, vi } from 'vitest'
import { TX_FEE_UNITS } from '@/lib/feeConfig'
import { TxFeeForm } from './TxFeeForm'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const Wrapper = ({ children, defaultValues = {} }: { children: ReactNode; defaultValues?: FieldValues }) => {
const methods = useForm({ defaultValues })
return <FormProvider {...methods}>{children}</FormProvider>
}
describe('TxFeeForm', () => {
it('renders blocks input when unit is blocks', () => {
render(
<Wrapper defaultValues={{ txFee: { txFeeUnit: TX_FEE_UNITS.BLOCKS } }}>
<TxFeeForm />
</Wrapper>,
)
expect(screen.getByText('send.label_tx_fees')).toBeInTheDocument()
expect(screen.getByText('settings.fees.description_tx_fees_blocks')).toBeInTheDocument()
})
it('renders sats per vbyte input when unit is sats per vbyte', () => {
render(
<Wrapper defaultValues={{ txFee: { txFeeUnit: TX_FEE_UNITS.SATS_PER_VBYTE } }}>
<TxFeeForm />
</Wrapper>,
)
expect(screen.getByText('send.label_tx_fees')).toBeInTheDocument()
expect(screen.getByText('settings.fees.description_tx_fees_satspervbyte')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,101 @@
import type { ChangeEvent, ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { UtxoSelectionDialog } from './UtxoSelectionDialog'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('../ui/dialog', () => ({
Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../ui/button', () => ({
Button: ({ children, onClick, disabled }: { children?: ReactNode; onClick?: () => void; disabled?: boolean }) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))
vi.mock('../ui/input', () => ({
Input: ({
value,
onChange,
disabled,
}: {
value?: string
onChange?: (event: ChangeEvent<HTMLInputElement>) => void
disabled?: boolean
}) => <input value={value} onChange={onChange} disabled={disabled} data-testid="filter-input" />,
}))
vi.mock('../ui/spinner', () => ({
Spinner: () => <div data-testid="spinner" />,
}))
vi.mock('../wallet/JarUtxosTable', () => ({
JarUtxosTable: () => <div data-testid="jar-utxos-table" />,
}))
const baseProps = {
open: true,
isSubmitting: false,
selectedCount: 2,
filter: '',
tableEntries: [],
initialRowSelection: {},
enableRowSelection: true,
onOpenChange: vi.fn(),
onFilterChange: vi.fn(),
onRowSelectionChange: vi.fn(),
onSubmit: vi.fn().mockResolvedValue(undefined),
}
describe('UtxoSelectionDialog', () => {
it('renders the dialog with the utxos table when open', () => {
render(<UtxoSelectionDialog {...baseProps} />)
expect(screen.getByText('show_utxos.title')).toBeInTheDocument()
expect(screen.getByTestId('jar-utxos-table')).toBeInTheDocument()
})
it('does not render content when closed', () => {
render(<UtxoSelectionDialog {...baseProps} open={false} />)
expect(screen.queryByText('show_utxos.title')).not.toBeInTheDocument()
})
it('calls onFilterChange when typing in the filter input', () => {
const onFilterChange = vi.fn()
render(<UtxoSelectionDialog {...baseProps} onFilterChange={onFilterChange} />)
fireEvent.change(screen.getByTestId('filter-input'), { target: { value: 'abc' } })
expect(onFilterChange).toHaveBeenCalledWith('abc')
})
it('closes via the reject button and submits via the accept button', () => {
const onOpenChange = vi.fn()
const onSubmit = vi.fn().mockResolvedValue(undefined)
render(<UtxoSelectionDialog {...baseProps} onOpenChange={onOpenChange} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('modal.confirm_button_reject'))
expect(onOpenChange).toHaveBeenCalledWith(false)
fireEvent.click(screen.getByText('modal.confirm_button_accept'))
expect(onSubmit).toHaveBeenCalled()
})
it('shows a spinner while submitting', () => {
render(<UtxoSelectionDialog {...baseProps} isSubmitting />)
expect(screen.getByTestId('spinner')).toBeInTheDocument()
})
})

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { TX_FEE_UNITS } from '@/lib/feeConfig'
import { buildCollaborativeSendRequest } from './collaborativeSend'
import { buildCollaborativeSendRequest, buildNonCollaborativeSendRequest } from './collaborativeSend'
import type { SendFormValues } from './types'
const validAddress = 'bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx' // regtest eater address
@ -82,4 +82,140 @@ describe('buildCollaborativeSendRequest', () => {
}),
).toThrowError('Invalid number of collaborators.')
})
it('throws for invalid source jars', () => {
expect(() =>
buildCollaborativeSendRequest({
...baseValues(),
source: { fromJar: undefined },
} as unknown as SendFormValues),
).toThrowError('Invalid source jar.')
expect(() =>
buildCollaborativeSendRequest({
...baseValues(),
source: { fromJar: -1 },
}),
).toThrowError('Invalid source jar.')
})
it('throws for invalid send amounts', () => {
expect(() =>
buildCollaborativeSendRequest({
...baseValues(),
amount: { isSweep: false, amount: 0, sweepAmount: undefined },
}),
).toThrowError('Invalid amount.')
expect(() =>
buildCollaborativeSendRequest({
...baseValues(),
amount: { isSweep: false, amount: 1.5, sweepAmount: undefined },
}),
).toThrowError('Invalid amount.')
})
it('throws for invalid transaction fees', () => {
expect(() =>
buildCollaborativeSendRequest({
...baseValues(),
txFee: {
txFeeUnit: TX_FEE_UNITS.SATS_PER_VBYTE,
txFeeInSatsPerVbyte: 0,
},
}),
).toThrowError('Invalid transaction fee.')
})
})
describe('buildNonCollaborativeSendRequest', () => {
it('builds request for standard direct send', () => {
const request = buildNonCollaborativeSendRequest({
...baseValues(),
isCoinJoin: false,
})
expect(request).toEqual({
amount_sats: 100_000,
destination: validAddress,
mixdepth: 0,
txfee: undefined,
})
})
it('maps sweep direct sends to amount_sats=0', () => {
const request = buildNonCollaborativeSendRequest({
...baseValues(),
isCoinJoin: false,
amount: {
isSweep: true,
amount: undefined,
sweepAmount: 500_000,
},
})
expect(request.amount_sats).toBe(0)
})
it('passes direct-send txfee values', () => {
expect(
buildNonCollaborativeSendRequest({
...baseValues(),
isCoinJoin: false,
txFee: {
txFeeUnit: TX_FEE_UNITS.BLOCKS,
txFeeInBlocks: 2,
},
}).txfee,
).toBe(2)
expect(
buildNonCollaborativeSendRequest({
...baseValues(),
isCoinJoin: false,
txFee: {
txFeeUnit: TX_FEE_UNITS.SATS_PER_VBYTE,
txFeeInSatsPerVbyte: 1.25,
},
}).txfee,
).toBe(1_250)
})
it('throws for invalid direct-send input', () => {
expect(() =>
buildNonCollaborativeSendRequest({
...baseValues(),
isCoinJoin: false,
amount: undefined,
} as unknown as SendFormValues),
).toThrowError('Invalid amount given.')
expect(() =>
buildNonCollaborativeSendRequest({
...baseValues(),
isCoinJoin: false,
destination: { address: 'invalid', fromJar: undefined },
}),
).toThrowError('Invalid bitcoin address given.')
expect(() =>
buildNonCollaborativeSendRequest({
...baseValues(),
isCoinJoin: false,
source: { fromJar: undefined },
} as unknown as SendFormValues),
).toThrowError('Invalid source jar given.')
expect(() =>
buildNonCollaborativeSendRequest({
...baseValues(),
isCoinJoin: false,
amount: {
isSweep: true,
amount: 100_000,
sweepAmount: 500_000,
},
} as unknown as SendFormValues),
).toThrowError('Invalid amount given for sweep.')
})
})

View file

@ -0,0 +1,291 @@
import type { ReactNode } from 'react'
import type { UseQueryResult } from '@tanstack/react-query'
import { render, screen, fireEvent } from '@testing-library/react'
import { Network } from 'bitcoin-address-validation'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { AccountXpubsAccordion, AccountXpubsDialog } from './AccountXpubsDialog'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
type SeedQueryState = {
data: string[] | undefined
error: { message: string } | null
isFetching: boolean
dataUpdatedAt: number
}
type XpubsQueryState = {
data: unknown[] | undefined
error: { message: string } | null
isFetching: boolean
dataUpdatedAt: number
}
// Mutable holder for mock return values, reset in beforeEach.
const h = vi.hoisted(() => ({
network: 'mainnet',
jars: [] as unknown[],
seed: {
data: undefined as string[] | undefined,
error: null as { message: string } | null,
isFetching: false,
dataUpdatedAt: 0,
},
xpubs: {
data: [] as unknown[] | undefined,
error: null as { message: string } | null,
isFetching: false,
dataUpdatedAt: 0,
},
seedRefetch: vi.fn(() => Promise.resolve()),
removeQueries: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn(),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
getseedOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
}))
vi.mock('sonner', () => ({
toast: {
success: (message: string) => {
h.toastSuccess(message)
},
error: (message: string) => {
h.toastError(message)
},
},
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
// The component calls useQuery twice: the seed query (has `select`) and the
// accountXpubs query (has `queryFn` but no `select`). Distinguish by `select`.
useQuery: (options: { select?: unknown; queryKey?: unknown }) => {
if (options.select !== undefined) {
return {
data: h.seed.data,
error: h.seed.error,
isFetching: h.seed.isFetching,
dataUpdatedAt: h.seed.dataUpdatedAt,
refetch: h.seedRefetch,
} as unknown as UseQueryResult
}
return {
data: h.xpubs.data,
error: h.xpubs.error,
isFetching: h.xpubs.isFetching,
dataUpdatedAt: h.xpubs.dataUpdatedAt,
} as unknown as UseQueryResult
},
useQueryClient: () => ({ removeQueries: h.removeQueries }),
}
})
vi.mock('@/context/JamWalletInfoContext', () => ({
useJars: () => ({ jars: h.jars }),
useDetectNetwork: () => ({ network: h.network }),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('../utils/PasswordVerificationForm', () => ({
PasswordVerificationForm: ({ onSubmit, onCancel }: { onSubmit: () => void; onCancel: () => void }) => (
<>
<button onClick={onSubmit}>Verify Password</button>
<button onClick={onCancel}>Cancel Verification</button>
</>
),
}))
vi.mock('../ui/jam/BitcoinQrCode', () => ({
BitcoinXpubQrCode: ({ xpub }: { xpub: string }) => <div data-testid="qr-code">{xpub}</div>,
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogDescription: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogFooter: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
const setSeed = (state: Partial<SeedQueryState>) => {
Object.assign(h.seed, state)
}
const setXpubs = (state: Partial<XpubsQueryState>) => {
Object.assign(h.xpubs, state)
}
const verify = () => {
fireEvent.click(screen.getByText('Verify Password'))
}
beforeEach(() => {
h.network = 'mainnet'
h.jars = []
h.seed = { data: undefined, error: null, isFetching: false, dataUpdatedAt: 0 }
h.xpubs = { data: [], error: null, isFetching: false, dataUpdatedAt: 0 }
h.seedRefetch.mockClear()
h.removeQueries.mockClear()
h.toastSuccess.mockClear()
h.toastError.mockClear()
})
describe('AccountXpubsAccordion', () => {
it('renders multiple accounts and their xpubs', () => {
const mockValues = [
{
accountIndex: 0,
accountName: 'Default',
path: "m/84'/0'/0'",
xpubs: [
{
name: 'zpub',
network: Network.mainnet,
path: "m/84'/0'/0'",
xpub: 'zpub123',
},
],
},
]
render(<AccountXpubsAccordion values={mockValues} />)
expect(screen.getByText('Default')).toBeInTheDocument()
fireEvent.click(screen.getByText('Default'))
expect(screen.getByText('zpub123')).toBeInTheDocument()
})
})
describe('AccountXpubsDialog', () => {
it('renders password verification form initially', () => {
render(
<AccountXpubsDialog
open={true}
onOpenChange={vi.fn()}
walletFileName="test.jmdat"
hashedPassword="hash"
autoCloseTimeout={5000}
/>,
)
expect(screen.getByText('settings.xpubs_modal.verification.title')).toBeInTheDocument()
expect(screen.getByText('Verify Password')).toBeInTheDocument()
})
it('renders xpubs after password is verified', () => {
render(
<AccountXpubsDialog
open={true}
onOpenChange={vi.fn()}
walletFileName="test.jmdat"
hashedPassword="hash"
autoCloseTimeout={5000}
/>,
)
fireEvent.click(screen.getByText('Verify Password'))
expect(screen.getByText('settings.xpubs_modal.title')).toBeInTheDocument()
})
const renderDialog = (onOpenChange = vi.fn()) =>
render(
<AccountXpubsDialog
open={true}
onOpenChange={onOpenChange}
walletFileName="test.jmdat"
hashedPassword="hash"
autoCloseTimeout={5000}
/>,
)
it('shows a loading spinner while the xpubs are being derived', () => {
setSeed({ data: ['word'] })
setXpubs({ data: undefined, isFetching: true })
renderDialog()
verify()
expect(screen.getByText('global.loading')).toBeInTheDocument()
})
it('renders the derived account xpubs once loaded', () => {
setSeed({ data: ['word'] })
setXpubs({
data: [
{
accountIndex: 0,
accountName: 'Default',
path: "m/84'/0'/0'",
xpubs: [{ name: 'zpub', network: Network.mainnet, path: "m/84'/0'/0'", xpub: 'zpub-loaded' }],
},
],
})
renderDialog()
verify()
expect(screen.getByText('settings.xpubs_modal.text_info_title')).toBeInTheDocument()
expect(screen.getByText('Default')).toBeInTheDocument()
})
it('shows the no-data message when there are no xpubs', () => {
setSeed({ data: ['word'] })
setXpubs({ data: [] })
renderDialog()
verify()
expect(screen.getByText('settings.xpubs_modal.text_error_no_data')).toBeInTheDocument()
})
it('shows an error alert when the seed query fails', () => {
setSeed({ data: undefined, error: { message: 'seed boom' } })
setXpubs({ data: undefined })
renderDialog()
verify()
expect(screen.getByText('seed boom')).toBeInTheDocument()
})
it('shows an error alert when the xpubs query fails', () => {
setSeed({ data: ['word'] })
setXpubs({ data: undefined, error: { message: 'xpub boom' } })
renderDialog()
verify()
expect(screen.getByText('xpub boom')).toBeInTheDocument()
})
it('closes and clears cached queries via the close button', () => {
setSeed({ data: ['word'] })
setXpubs({ data: [] })
const onOpenChange = vi.fn()
renderDialog(onOpenChange)
verify()
fireEvent.click(screen.getByText('global.close'))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(h.removeQueries).toHaveBeenCalled()
})
it('clears cached queries when verification is cancelled', () => {
const onOpenChange = vi.fn()
renderDialog(onOpenChange)
fireEvent.click(screen.getByText('Cancel Verification'))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(h.removeQueries).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,67 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { LanguageSelector } from './LanguageSelector'
const changeLanguageMock = vi.fn()
type ChildrenProps = { children: ReactNode }
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: {
changeLanguage: changeLanguageMock,
resolvedLanguage: 'en',
},
}),
}))
vi.mock('@/i18n/languages', () => ({
default: [
{ key: 'en', description: 'English' },
{ key: 'es', description: 'Spanish' },
],
}))
vi.mock('@/components/ui/select', () => ({
Select: ({
children,
value,
onValueChange,
}: ChildrenProps & { value: string; onValueChange: (value: string) => void }) => (
<div data-testid="select" data-value={value}>
<button onClick={() => onValueChange('es')}>Change to Spanish</button>
{children}
</div>
),
SelectTrigger: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectValue: ({ placeholder }: { placeholder?: string }) => <div data-testid="select-value">{placeholder}</div>,
SelectContent: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectItem: ({ children, value }: ChildrenProps & { value: string }) => (
<div data-testid={`select-item-${value}`}>{children}</div>
),
}))
describe('LanguageSelector', () => {
it('renders correctly with current language', () => {
render(<LanguageSelector />)
expect(screen.getByText('settings.label_select_language')).toBeInTheDocument()
expect(screen.getByTestId('select-value')).toHaveTextContent('English')
// Check languages are rendered
expect(screen.getByTestId('select-item-en')).toHaveTextContent('English')
expect(screen.getByTestId('select-item-es')).toHaveTextContent('Spanish')
})
it('changes language when selected', async () => {
render(<LanguageSelector />)
fireEvent.click(screen.getByText('Change to Spanish'))
await waitFor(() => {
expect(changeLanguageMock).toHaveBeenCalledWith('es')
})
})
})

View file

@ -0,0 +1,143 @@
import type { ReactNode } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { RescanInfo } from '@/context/JamSessionInfoContext'
import type { WalletFileName } from '@/lib/utils'
import { RescanChainPage } from './RescanChainPage'
type MutationConfig = {
mutationFn: (blockHeight: number) => Promise<unknown>
onSuccess: () => void
onError: (error: unknown) => void
}
const mutateAsync = vi.fn<(blockHeight: number) => Promise<unknown>>().mockResolvedValue(undefined)
const rescanblockchainMock = vi.fn<() => Promise<{ data: unknown }>>().mockResolvedValue({ data: 'ok' })
const navigateMock = vi.fn()
const setRescanInfo = vi.fn()
const toastSuccess = vi.fn()
const toastError = vi.fn()
let mutationConfig: MutationConfig
let rescanInfo: RescanInfo
vi.mock('@tanstack/react-query', () => ({
useMutation: (config: MutationConfig) => {
mutationConfig = config
return { mutateAsync, isPending: false }
},
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
rescanblockchain: () => rescanblockchainMock(),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
}))
vi.mock('sonner', () => ({
toast: {
success: (m: string) => {
toastSuccess(m)
},
error: (m: string) => {
toastError(m)
},
},
}))
vi.mock('@/context/JamSessionInfoContext', () => ({
useRescanStatus: () => ({ rescanInfo, setRescanInfo }),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/lib/errorReason', () => ({
getErrorReason: () => 'reason',
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <div>{title}</div>,
}))
vi.mock('@/components/ui/card', () => ({
Card: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
CardContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
const walletFileName = 'wallet.jmdat' as WalletFileName
// react-hook-form runs validation after mount; flushing inside async act keeps
// that deferred state update from triggering a "not wrapped in act" warning.
const renderPage = async () => {
await act(async () => {
render(<RescanChainPage walletFileName={walletFileName} />)
await Promise.resolve()
})
}
describe('RescanChainPage', () => {
beforeEach(() => {
vi.clearAllMocks()
rescanInfo = { updatedAt: 0, rescanning: false, progress: undefined }
})
it('renders the form and navigates back', async () => {
await renderPage()
expect(screen.getByText('rescan_chain.title')).toBeInTheDocument()
fireEvent.click(screen.getByTitle('global.back'))
expect(navigateMock).toHaveBeenCalled()
})
it('shows the in-progress alert without progress', async () => {
rescanInfo = { updatedAt: 0, rescanning: true, progress: undefined }
await renderPage()
expect(screen.getByText('app.alert_rescan_in_progress')).toBeInTheDocument()
})
it('shows the in-progress alert with progress', async () => {
rescanInfo = { updatedAt: 0, rescanning: true, progress: 42 }
await renderPage()
expect(screen.getByText(/app.alert_rescan_in_progress_with_progress/)).toBeInTheDocument()
})
it('submits a valid block height through the mutation', async () => {
await renderPage()
const input = screen.getByPlaceholderText('rescan_chain.placeholder_blockheight')
fireEvent.change(input, { target: { value: '700000' } })
fireEvent.submit(input.closest('form')!)
await waitFor(() => expect(mutateAsync).toHaveBeenCalledWith(700000))
})
it('mutationFn calls the rescan API and returns data', async () => {
await renderPage()
await expect(mutationConfig.mutationFn(700000)).resolves.toBe('ok')
expect(rescanblockchainMock).toHaveBeenCalled()
})
it('onSuccess shows a toast and updates rescan info', async () => {
await renderPage()
mutationConfig.onSuccess()
expect(toastSuccess).toHaveBeenCalled()
expect(setRescanInfo).toHaveBeenCalledWith(expect.objectContaining({ rescanning: true }))
})
it('onError shows an error toast and resets rescan info', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await renderPage()
mutationConfig.onError(new Error('boom'))
expect(toastError).toHaveBeenCalled()
expect(setRescanInfo).toHaveBeenCalledWith(expect.objectContaining({ rescanning: false }))
consoleSpy.mockRestore()
})
})

View file

@ -0,0 +1,133 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { WalletFileName } from '@/lib/utils'
import { SeedPhraseDialog } from './SeedPhraseDialog'
type SeedQuery = {
data?: string[]
error?: { message: string }
isFetching: boolean
refetch: () => Promise<unknown>
dataUpdatedAt: number
}
const refetch = vi.fn<() => Promise<unknown>>().mockResolvedValue(undefined)
const removeQueries = vi.fn()
let seedQuery: SeedQuery
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
getseedOptions: () => ({ queryKey: ['seed'] }),
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: () => seedQuery,
useQueryClient: () => ({ removeQueries }),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('../ui/jam/SeedPhraseGrid', () => ({
SeedPhraseGrid: ({ value }: { value: string[] }) => <div data-testid="seed-grid">{value.join(' ')}</div>,
}))
vi.mock('../ui/spinner', () => ({
Spinner: () => <div data-testid="spinner" />,
}))
vi.mock('../ui/switch', () => ({
Switch: ({ onCheckedChange }: { onCheckedChange?: (checked: boolean) => void }) => (
<button data-testid="reveal-switch" onClick={() => onCheckedChange?.(true)} />
),
}))
vi.mock('../utils/PasswordVerificationForm', () => ({
PasswordVerificationForm: ({ onSubmit, onCancel }: { onSubmit: () => void; onCancel: () => void }) => (
<div>
<button data-testid="verify" onClick={onSubmit} />
<button data-testid="cancel" onClick={onCancel} />
</div>
),
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/alert', () => ({
Alert: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AlertTitle: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
<button onClick={onClick}>{children}</button>
),
}))
vi.mock('@/components/ui/label', () => ({
Label: ({ children }: { children?: ReactNode }) => <label>{children}</label>,
}))
const baseProps = {
open: true,
walletFileName: 'wallet.jmdat' as WalletFileName,
hashedPassword: 'hash',
autoCloseTimeout: 60_000,
}
const verify = () => fireEvent.click(screen.getByTestId('verify'))
describe('SeedPhraseDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
seedQuery = { data: undefined, error: undefined, isFetching: false, refetch, dataUpdatedAt: 0 }
})
it('shows the password verification form before verification', () => {
render(<SeedPhraseDialog {...baseProps} onOpenChange={vi.fn()} />)
expect(screen.getByText('settings.seed_modal.verification.title')).toBeInTheDocument()
})
it('shows a spinner while fetching after verification', () => {
seedQuery = { ...seedQuery, isFetching: true }
render(<SeedPhraseDialog {...baseProps} onOpenChange={vi.fn()} />)
verify()
expect(screen.getByTestId('spinner')).toBeInTheDocument()
})
it('renders the seed grid and toggles reveal after verification', () => {
seedQuery = { ...seedQuery, data: ['word1', 'word2'] }
render(<SeedPhraseDialog {...baseProps} onOpenChange={vi.fn()} />)
verify()
expect(screen.getByTestId('seed-grid')).toHaveTextContent('word1 word2')
fireEvent.click(screen.getByTestId('reveal-switch'))
})
it('renders an error alert when the seed query fails', () => {
seedQuery = { ...seedQuery, error: { message: 'failed' } }
render(<SeedPhraseDialog {...baseProps} onOpenChange={vi.fn()} />)
verify()
expect(screen.getByText('failed')).toBeInTheDocument()
})
it('closes and clears the cached seed query', () => {
const onOpenChange = vi.fn()
render(<SeedPhraseDialog {...baseProps} onOpenChange={onOpenChange} />)
fireEvent.click(screen.getByTestId('cancel'))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(removeQueries).toHaveBeenCalledWith({ queryKey: ['seed'] })
})
})

View file

@ -0,0 +1,299 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WalletFileName } from '@/lib/utils'
import { SettingsPage } from './SettingsPage'
type AuthStoreState = {
state?: {
hashed_password?: string
}
}
type JamSettingsStoreState = {
state: {
developerMode: boolean
}
update: (value: { developerMode: boolean }) => void
}
type StoreSelector<TStore, TResult> = (state: TStore) => TResult
const mocks = vi.hoisted(() => ({
addressChunkingEnabled: true,
debugFeatures: new Set<string>(),
developerMode: false,
feeConfigValidation: { isLoading: false },
hashedPassword: undefined as string | undefined,
isPrivate: false,
lockWalletPending: false,
logsFeature: false,
navigate: vi.fn(),
open: vi.fn(),
setTheme: vi.fn(),
theme: 'dark',
toggleAddressChunking: vi.fn(),
toggleCurrencyUnit: vi.fn(),
togglePrivacyMode: vi.fn(),
updateJamSettings: vi.fn<(value: { developerMode: boolean }) => void>(),
useMutationSpy: vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: (options: { mutationFn: (input: unknown) => Promise<unknown> }) => {
mocks.useMutationSpy(options)
return {
isPending: mocks.lockWalletPending,
mutateAsync: options.mutationFn,
}
},
}))
vi.mock('next-themes', () => ({
useTheme: () => ({
resolvedTheme: mocks.theme,
setTheme: mocks.setTheme,
}),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('react-router-dom', () => ({
useNavigate: () => mocks.navigate,
}))
vi.mock('zustand', () => ({
useStore: (store: string, selector?: StoreSelector<AuthStoreState, unknown>) => {
if (store === 'auth-store') {
const state: AuthStoreState = {
state: mocks.hashedPassword !== undefined ? { hashed_password: mocks.hashedPassword } : undefined,
}
return selector ? selector(state) : state
}
const jamSettingsState: JamSettingsStoreState = {
state: {
developerMode: mocks.developerMode,
},
update: mocks.updateJamSettings,
}
return jamSettingsState
},
}))
vi.mock('@/store/authStore', () => ({
authStore: 'auth-store',
}))
vi.mock('@/store/jamSettingsStore', () => ({
jamSettingsStore: 'jam-settings-store',
}))
vi.mock('@/components/settings/fees/FeeConfigDialog', () => ({
FeeConfigDialog: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) =>
open ? (
<button type="button" onClick={() => onOpenChange(false)}>
fee-config-dialog
</button>
) : null,
}))
vi.mock('@/components/settings/SeedPhraseDialog', () => ({
SeedPhraseDialog: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) =>
open ? (
<button type="button" onClick={() => onOpenChange(false)}>
seed-dialog
</button>
) : null,
}))
vi.mock('@/components/settings/AccountXpubsDialog', () => ({
AccountXpubsDialog: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) =>
open ? (
<button type="button" onClick={() => onOpenChange(false)}>
xpubs-dialog
</button>
) : null,
}))
vi.mock('@/components/settings/LanguageSelector', () => ({
LanguageSelector: () => <div>language-selector</div>,
}))
vi.mock('@/components/ui/jam/CurrencySymbol', () => ({
CurrencySymbol: ({ className }: { className?: string }) => <span className={className}>currency-symbol</span>,
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <h1>{title}</h1>,
}))
vi.mock('@/components/ui/spinner', () => ({
Spinner: ({ className }: { className?: string }) => <span className={className}>spinner</span>,
}))
vi.mock('@/components/ui/switch', () => ({
Switch: ({
checked,
disabled,
onCheckedChange,
}: {
checked?: boolean
disabled?: boolean
onCheckedChange?: (checked: boolean) => void
}) => (
<button type="button" disabled={disabled} onClick={() => onCheckedChange?.(!checked)}>
switch:{String(checked)}
</button>
),
}))
vi.mock('@/constants/debugFeatures', () => ({
isDebugFeatureEnabled: (feature: string) => mocks.debugFeatures.has(feature),
isDevMode: () => true,
}))
vi.mock('@/context/JamDisplayContext', () => ({
useJamDisplayContext: () => ({
addressChunkingEnabled: mocks.addressChunkingEnabled,
currency: 'btc',
isPrivate: mocks.isPrivate,
toggleAddressChunking: mocks.toggleAddressChunking,
toggleCurrencyUnit: mocks.toggleCurrencyUnit,
togglePrivacyMode: mocks.togglePrivacyMode,
}),
}))
vi.mock('@/hooks/useFeatures', () => ({
useFeatures: () => ({
isFeatureEnabled: (feature: string) => feature === 'logs' && mocks.logsFeature,
}),
}))
vi.mock('@/hooks/useFeeConfigValidation', () => ({
useFeeConfigValidation: () => mocks.feeConfigValidation,
}))
const walletFileName = 'wallet.jmdat' as WalletFileName
const renderSettingsPage = (onLockWallet = vi.fn<() => Promise<void>>()) => {
render(<SettingsPage walletFileName={walletFileName} onLockWallet={onLockWallet} />)
return { onLockWallet }
}
const clickItem = (label: string) => {
const item = screen.getByText(label).closest('div')
expect(item).not.toBeNull()
fireEvent.click(item as HTMLElement)
}
describe('SettingsPage', () => {
beforeEach(() => {
mocks.addressChunkingEnabled = true
mocks.debugFeatures = new Set()
mocks.developerMode = false
mocks.hashedPassword = undefined
mocks.isPrivate = false
mocks.lockWalletPending = false
mocks.logsFeature = false
mocks.theme = 'dark'
mocks.navigate.mockReset()
mocks.open.mockReset()
mocks.setTheme.mockReset()
mocks.toggleAddressChunking.mockReset()
mocks.toggleCurrencyUnit.mockReset()
mocks.togglePrivacyMode.mockReset()
mocks.updateJamSettings.mockReset()
mocks.useMutationSpy.mockReset()
vi.stubGlobal('open', mocks.open)
})
it('renders display and market settings and handles their actions', () => {
renderSettingsPage()
expect(screen.getByRole('heading', { name: 'navbar.menu_mobile_settings' })).toBeInTheDocument()
expect(screen.getByText('settings.section_title_display')).toBeInTheDocument()
expect(screen.getByText('settings.section_title_market')).toBeInTheDocument()
expect(screen.getByText('language-selector')).toBeInTheDocument()
clickItem('settings.hide_balance')
expect(mocks.togglePrivacyMode).toHaveBeenCalledTimes(1)
clickItem('settings.use_btc')
expect(mocks.toggleCurrencyUnit).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('switch:true'))
expect(mocks.toggleAddressChunking).toHaveBeenCalledWith(false)
clickItem('settings.use_light_theme')
expect(mocks.setTheme).toHaveBeenCalledWith('light')
clickItem('settings.show_fee_config')
expect(screen.getByText('fee-config-dialog')).toBeInTheDocument()
})
it('opens protected wallet dialogs and locks the wallet when a password is available', async () => {
mocks.hashedPassword = 'hashed-password'
const onLockWallet = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
renderSettingsPage(onLockWallet)
clickItem('settings.show_seed')
expect(screen.getByText('seed-dialog')).toBeInTheDocument()
clickItem('settings.show_xpubs')
expect(screen.getByText('xpubs-dialog')).toBeInTheDocument()
clickItem('settings.button_lock_wallet')
await waitFor(() => {
expect(onLockWallet).toHaveBeenCalledTimes(1)
})
})
it('handles internal, external, feature-gated, and developer links', async () => {
mocks.debugFeatures = new Set(['devPage'])
mocks.developerMode = true
mocks.logsFeature = true
renderSettingsPage()
clickItem('settings.rescan_chain')
await waitFor(() => {
expect(mocks.navigate).toHaveBeenCalledWith('/settings/rescan')
})
clickItem('settings.show_logs')
await waitFor(() => {
expect(mocks.navigate).toHaveBeenCalledWith('/logs')
})
clickItem('settings.documentation')
expect(mocks.open).toHaveBeenCalledWith('https://jamdocs.org', '_blank', 'noreferrer,noopener')
clickItem('Enable developer mode')
expect(mocks.updateJamSettings).toHaveBeenCalledWith({ developerMode: false })
expect(screen.getByText('Developer Mode')).toBeInTheDocument()
clickItem('Dev page')
await waitFor(() => {
expect(mocks.navigate).toHaveBeenCalledWith('/dev')
})
})
it('shows a spinner while wallet locking is pending', () => {
mocks.lockWalletPending = true
renderSettingsPage()
expect(screen.getByText('spinner')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,62 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { useForm } from 'react-hook-form'
import { describe, expect, it, vi } from 'vitest'
import { CollaboratorFeesForm } from './CollaboratorFeesForm'
import type { CollaboratorFeesFormValues } from './CollaboratorFeesForm.schema'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/components/ui/alert', () => ({
Alert: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../../ui/field', () => ({
Field: ({ children, 'data-invalid': invalid }: { children?: ReactNode; 'data-invalid'?: boolean }) => (
<div data-invalid={invalid}>{children}</div>
),
FieldDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
FieldLabel: ({ children }: { children?: ReactNode }) => <label>{children}</label>,
}))
vi.mock('../../ui/input-group', () => ({
InputGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InputGroupAddon: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InputGroupInput: (props: Record<string, unknown>) => <input {...props} />,
}))
vi.mock('../../ui/jam/CurrencySymbol', () => ({ SatSymbol: () => <span data-testid="sat" /> }))
const Host = ({ withErrors }: { withErrors?: boolean }) => {
const form = useForm<CollaboratorFeesFormValues, unknown, CollaboratorFeesFormValues>({
defaultValues: { maxCjFeeAbs: 1000, maxCjFeeRelInPercent: 0.5 },
})
const setErrors = () => {
form.setError('maxCjFeeAbs', { message: 'abs too high' })
form.setError('maxCjFeeRelInPercent', { message: 'rel too high' })
}
return (
<>
{withErrors && <button onClick={setErrors}>set-errors</button>}
<CollaboratorFeesForm form={form} />
</>
)
}
describe('CollaboratorFeesForm', () => {
it('renders both collaborator fee inputs', () => {
render(<Host />)
expect(document.querySelector('#collaborator-fees-max-cj-fee-abs')).toBeInTheDocument()
expect(document.querySelector('#collaborator-fees-max-cj-fee-rel')).toBeInTheDocument()
})
it('shows validation error messages when the fields are invalid', () => {
render(<Host withErrors />)
fireEvent.click(screen.getByText('set-errors'))
expect(screen.getByText('abs too high')).toBeInTheDocument()
expect(screen.getByText('rel too high')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,327 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
import { flushActUpdates } from '@/test/flushActUpdates'
import { FeeConfigDialog } from './FeeConfigDialog'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
type FeeConfigValidation = ReturnType<typeof useFeeConfigValidation>
const h = vi.hoisted(() => ({
developerModeEnabled: false,
mutateAsync: vi.fn<(...arguments_: unknown[]) => Promise<unknown>>(),
toastSuccess: vi.fn(),
toastError: vi.fn(),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ children }: ChildrenProps) => <div data-testid="trans">{children}</div>,
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({
mutateAsync: (...arguments_: unknown[]) => h.mutateAsync(...arguments_),
isPending: false,
}),
}))
vi.mock('sonner', () => ({
toast: {
success: (message: string) => {
h.toastSuccess(message)
},
error: (message: string) => {
h.toastError(message)
},
},
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
configsettingMutation: vi.fn(() => ({})),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: h.developerModeEnabled }),
}))
vi.mock('@/components/dev/DevBadge', () => ({
DevBadge: () => <div data-testid="dev-badge" />,
}))
vi.mock('./CollaboratorFeesForm', () => ({
CollaboratorFeesForm: () => <div data-testid="collaborator-form" />,
}))
vi.mock('./MiningFeesForm', () => ({
MiningFeesForm: () => <div data-testid="mining-form" />,
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogDescription: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogFooter: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
const makeValidValidation = (overrides: Record<string, unknown> = {}): FeeConfigValidation =>
({
feeConfigValues: {
txFeeFactor: 0.1,
maxSweepFeeChangeFactor: 0.8,
maxCjAbsoluteFee: 100,
maxCjRelativeFee: 0.001,
txFeesBlocks: 3,
txFee: {
txFeeUnit: 'blocks',
txFeeInBlocks: 3,
txFeeInSatsPerVbyte: undefined,
},
},
isLoading: false,
refetchAll: vi.fn(),
...overrides,
}) as unknown as FeeConfigValidation
const renderDialog = (validation: FeeConfigValidation, onOpenChange: (open: boolean) => void = vi.fn(), open = true) =>
render(
<FeeConfigDialog
open={open}
onOpenChange={onOpenChange}
walletFileName="test.jmdat"
feeConfigValidation={validation}
/>,
)
describe('FeeConfigDialog', () => {
beforeEach(() => {
h.developerModeEnabled = false
h.mutateAsync = vi.fn()
h.toastSuccess = vi.fn()
h.toastError = vi.fn()
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders correctly', async () => {
renderDialog(makeValidValidation())
expect(screen.getByText('settings.fees.title')).toBeInTheDocument()
expect(screen.getByText('settings.fees.title_max_cj_fee_settings')).toBeInTheDocument()
expect(screen.getByText('settings.fees.title_general_fee_settings')).toBeInTheDocument()
expect(screen.getByText('settings.fees.text_button_cancel')).toBeInTheDocument()
expect(screen.getByText('settings.fees.text_button_submit')).toBeInTheDocument()
await flushActUpdates()
})
it('renders nothing when closed', async () => {
renderDialog(makeValidValidation(), vi.fn(), false)
expect(screen.queryByText('settings.fees.title')).not.toBeInTheDocument()
await flushActUpdates()
})
it('can open accordions', async () => {
renderDialog(makeValidValidation())
fireEvent.click(screen.getByText('settings.fees.title_max_cj_fee_settings'))
expect(screen.getByTestId('collaborator-form')).toBeInTheDocument()
fireEvent.click(screen.getByText('settings.fees.title_general_fee_settings'))
expect(screen.getByTestId('mining-form')).toBeInTheDocument()
await flushActUpdates()
})
it('shows loading spinners and disables buttons when loading', () => {
renderDialog(makeValidValidation({ isLoading: true }))
fireEvent.click(screen.getByText('settings.fees.title_max_cj_fee_settings'))
expect(screen.getAllByText('global.loading').length).toBeGreaterThan(0)
const cancelButton = screen.getByText('settings.fees.text_button_cancel')
expect(cancelButton).toBeDisabled()
})
it('shows validation warning icon when forms are invalid', async () => {
renderDialog(
makeValidValidation({
feeConfigValues: {
txFee: { txFeeUnit: 'blocks', txFeeInBlocks: undefined, txFeeInSatsPerVbyte: undefined },
},
}),
)
await waitFor(() => {
expect(screen.getByText('settings.fees.title_max_cj_fee_settings')).toBeInTheDocument()
})
})
it('renders developer mode controls when enabled and can toggle validation', async () => {
h.developerModeEnabled = true
renderDialog(makeValidValidation())
const switchElement = document.querySelector('#fee-limit-form-validation-switch')
expect(switchElement).not.toBeNull()
expect(screen.getByTestId('dev-badge')).toBeInTheDocument()
fireEvent.click(switchElement as Element)
await flushActUpdates()
})
it('calls onOpenChange when cancel is clicked', async () => {
const onOpenChange = vi.fn()
renderDialog(makeValidValidation(), onOpenChange)
fireEvent.click(screen.getByText('settings.fees.text_button_cancel'))
expect(onOpenChange).toHaveBeenCalledWith(false)
await flushActUpdates()
})
it('resets form values when reset is clicked', async () => {
renderDialog(makeValidValidation())
fireEvent.click(screen.getByText('Reset'))
await waitFor(() => {
expect(screen.getByText('Reset')).toBeInTheDocument()
})
})
it('submits successfully and closes the dialog', async () => {
h.mutateAsync = vi.fn().mockResolvedValue({})
const refetchAll = vi.fn().mockResolvedValue(undefined)
const onOpenChange = vi.fn()
renderDialog(makeValidValidation({ refetchAll }), onOpenChange)
fireEvent.click(screen.getByText('settings.fees.text_button_submit'))
await waitFor(() => {
expect(h.toastSuccess).toHaveBeenCalledWith('settings.fees.success_message')
})
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(refetchAll).toHaveBeenCalled()
})
it('submits using sats per vbyte unit', async () => {
h.mutateAsync = vi.fn().mockResolvedValue({})
const refetchAll = vi.fn().mockResolvedValue(undefined)
renderDialog(
makeValidValidation({
refetchAll,
feeConfigValues: {
txFeeFactor: 0.1,
maxSweepFeeChangeFactor: 0.8,
maxCjAbsoluteFee: 100,
maxCjRelativeFee: 0.001,
txFee: {
txFeeUnit: 'sats/vbyte',
txFeeInBlocks: undefined,
txFeeInSatsPerVbyte: 5,
},
},
}),
)
fireEvent.click(screen.getByText('settings.fees.text_button_submit'))
await waitFor(() => {
expect(h.toastSuccess).toHaveBeenCalled()
})
})
it('skips validation when form validation is disabled in developer mode', async () => {
h.developerModeEnabled = true
h.mutateAsync = vi.fn().mockResolvedValue({})
const refetchAll = vi.fn().mockResolvedValue(undefined)
renderDialog(
makeValidValidation({
refetchAll,
feeConfigValues: {
txFee: { txFeeUnit: 'blocks', txFeeInBlocks: undefined, txFeeInSatsPerVbyte: undefined },
},
}),
)
const switchElement = document.querySelector('#fee-limit-form-validation-switch')
fireEvent.click(switchElement as Element)
fireEvent.click(screen.getByText('settings.fees.text_button_submit'))
await waitFor(() => {
expect(h.toastSuccess).toHaveBeenCalled()
})
})
it('does not submit when validation fails', async () => {
h.mutateAsync = vi.fn().mockResolvedValue({})
renderDialog(
makeValidValidation({
feeConfigValues: {
txFee: { txFeeUnit: 'blocks', txFeeInBlocks: undefined, txFeeInSatsPerVbyte: undefined },
},
}),
)
fireEvent.click(screen.getByText('settings.fees.text_button_submit'))
await waitFor(() => {
expect(h.mutateAsync).not.toHaveBeenCalled()
})
expect(h.toastSuccess).not.toHaveBeenCalled()
})
it('shows an error toast when the mutation fails', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
h.mutateAsync = vi.fn().mockRejectedValue(new Error('boom'))
const refetchAll = vi.fn().mockResolvedValue(undefined)
renderDialog(makeValidValidation({ refetchAll }))
fireEvent.click(screen.getByText('settings.fees.text_button_submit'))
await waitFor(() => {
expect(h.toastError).toHaveBeenCalledWith('settings.fees.error_saving_fee_config_failed')
})
errorSpy.mockRestore()
})
it('shows an error toast when refetch fails after a successful save', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
h.mutateAsync = vi.fn().mockResolvedValue({})
const refetchAll = vi.fn().mockRejectedValue(new Error('refetch boom'))
renderDialog(makeValidValidation({ refetchAll }))
fireEvent.click(screen.getByText('settings.fees.text_button_submit'))
await waitFor(() => {
expect(h.toastSuccess).toHaveBeenCalled()
})
await waitFor(() => {
expect(h.toastError).toHaveBeenCalled()
})
errorSpy.mockRestore()
})
it('applies footer border styling when an accordion is open', async () => {
renderDialog(makeValidValidation())
fireEvent.click(screen.getByText('settings.fees.title_max_cj_fee_settings'))
expect(screen.getByTestId('collaborator-form')).toBeInTheDocument()
await flushActUpdates()
})
})

View file

@ -0,0 +1,58 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { useForm } from 'react-hook-form'
import { describe, expect, it, vi } from 'vitest'
import { MiningFeesForm } from './MiningFeesForm'
import type { MiningFeesFormValues } from './MiningFeesForm.schema'
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('../../send/TxFeeForm', () => ({ TxFeeForm: () => <div data-testid="tx-fee-form" /> }))
vi.mock('../../ui/field', () => ({
Field: ({ children, 'data-invalid': invalid }: { children?: ReactNode; 'data-invalid'?: boolean }) => (
<div data-invalid={invalid}>{children}</div>
),
FieldDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
FieldLabel: ({ children }: { children?: ReactNode }) => <label>{children}</label>,
}))
vi.mock('../../ui/input-group', () => ({
InputGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InputGroupAddon: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InputGroupInput: (props: Record<string, unknown>) => <input {...props} />,
}))
const Host = ({ withErrors }: { withErrors?: boolean }) => {
const form = useForm<MiningFeesFormValues, unknown, MiningFeesFormValues>({
defaultValues: { txFeesFactorInPercent: 20, maxSweepFeeChangeInPercent: 10 },
})
const setErrors = () => {
form.setError('txFeesFactorInPercent', { message: 'factor too high' })
form.setError('maxSweepFeeChangeInPercent', { message: 'sweep too high' })
}
return (
<>
{withErrors && <button onClick={setErrors}>set-errors</button>}
<MiningFeesForm form={form} />
</>
)
}
describe('MiningFeesForm', () => {
it('renders both fee inputs and the tx fee form', () => {
render(<Host />)
expect(screen.getByTestId('tx-fee-form')).toBeInTheDocument()
expect(document.querySelector('#mining-fees-tx-fees-factor')).toBeInTheDocument()
expect(document.querySelector('#mining-fees-sweep-fee-change')).toBeInTheDocument()
})
it('shows validation error messages when the fields are invalid', () => {
render(<Host withErrors />)
fireEvent.click(screen.getByText('set-errors'))
expect(screen.getByText('factor too high')).toBeInTheDocument()
expect(screen.getByText('sweep too high')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,94 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { SendCoinjoinPreconditionAlert } from '@/components/send/SendCoinjoinPreconditionAlert'
import type { Utxo } from '@/hooks/useQueryUtxos'
import { SweepPreconditionAlert } from './SweepPreconditionAlert'
import type { SweepPreconditionSummary } from './preconditions'
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const retryLockedUtxo = {
address: 'bc1qretry',
confirmations: 12,
frozen: false,
label: '',
locktime: undefined,
mixdepth: 0,
path: '',
tries_remaining: 0,
utxo: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:0',
value: 123_456,
} as unknown as Utxo
const makeSummary = (overrides: Partial<SweepPreconditionSummary> = {}): SweepPreconditionSummary => ({
isFulfilled: false,
numberOfMissingConfirmations: 0,
numberOfMissingUtxos: 0,
options: {
minConfirmations: 5,
minNumberOfUtxos: 1,
},
retryLockedUtxos: [],
...overrides,
})
describe('precondition alerts', () => {
it('renders nothing when preconditions are fulfilled', () => {
const summary = makeSummary({ isFulfilled: true })
const { container: sendContainer } = render(<SendCoinjoinPreconditionAlert summary={summary} />)
const { container: sweepContainer } = render(<SweepPreconditionAlert summary={summary} />)
expect(sendContainer).toBeEmptyDOMElement()
expect(sweepContainer).toBeEmptyDOMElement()
})
it('shows missing utxo warnings for send and sweep flows', () => {
const summary = makeSummary({ numberOfMissingUtxos: 1 })
render(
<>
<SendCoinjoinPreconditionAlert summary={summary} />
<SweepPreconditionAlert summary={summary} />
</>,
)
expect(screen.getByText('send.coinjoin_precondition.hint_missing_utxos')).toBeInTheDocument()
expect(screen.getByText('scheduler.precondition.hint_missing_utxos')).toBeInTheDocument()
})
it('shows missing confirmation warnings for send and sweep flows', () => {
const summary = makeSummary({ numberOfMissingConfirmations: 3 })
render(
<>
<SendCoinjoinPreconditionAlert summary={summary} />
<SweepPreconditionAlert summary={summary} />
</>,
)
expect(screen.getByText('send.coinjoin_precondition.hint_missing_confirmations')).toBeInTheDocument()
expect(screen.getByText('scheduler.precondition.hint_missing_confirmations')).toBeInTheDocument()
})
it('lists retry-locked utxos when retries are the remaining blocker', () => {
const summary = makeSummary({ retryLockedUtxos: [retryLockedUtxo] })
render(
<>
<SendCoinjoinPreconditionAlert summary={summary} />
<SweepPreconditionAlert summary={summary} />
</>,
)
expect(screen.getByText('send.coinjoin_precondition.hint_missing_retries')).toBeInTheDocument()
expect(screen.getByText('scheduler.precondition.hint_missing_retries')).toBeInTheDocument()
expect(screen.getAllByText('UTXOs')).toHaveLength(2)
expect(screen.getAllByText('jar 0')).toHaveLength(2)
expect(screen.getAllByText('123,456 sats')).toHaveLength(2)
})
})

View file

@ -0,0 +1,80 @@
import { render, screen } from '@testing-library/react'
import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form'
import { describe, expect, it, vi } from 'vitest'
import { SweepDestinationInputs } from './SweepDestinationInputs'
import type { SweepFormValues } from './SweepFormSchema'
type SweepForm = UseFormReturn<SweepFormValues, unknown, SweepFormValues>
type SweepFields = Array<FieldArrayWithId<SweepFormValues, 'destinations', 'id'>>
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key} ${JSON.stringify(options)}` : key),
}),
}))
describe('SweepDestinationInputs', () => {
const defaultFormMock = {
formState: {
errors: {},
isSubmitted: false,
touchedFields: {},
},
register: vi.fn((name: string) => ({ name, onBlur: vi.fn(), onChange: vi.fn(), ref: vi.fn() })),
} as unknown as SweepForm
const fields = [
{ id: '1', address: '' },
{ id: '2', address: '' },
] as unknown as SweepFields
it('renders input fields correctly', () => {
render(<SweepDestinationInputs form={defaultFormMock} fields={fields} disabled={false} />)
// Should render 2 inputs
const inputs = screen.getAllByRole('textbox')
expect(inputs).toHaveLength(2)
// Labels should have the index
expect(screen.getByText('scheduler.label_destination_input {"destination":1}')).toBeInTheDocument()
expect(screen.getByText('scheduler.label_destination_input {"destination":2}')).toBeInTheDocument()
// Inputs should not be disabled
expect(inputs[0]).not.toBeDisabled()
expect(inputs[1]).not.toBeDisabled()
})
it('renders disabled input fields', () => {
render(<SweepDestinationInputs form={defaultFormMock} fields={fields} disabled={true} />)
const inputs = screen.getAllByRole('textbox')
expect(inputs[0]).toBeDisabled()
expect(inputs[1]).toBeDisabled()
})
it('shows error messages when submitted and there is an error', () => {
const errorFormMock = {
...defaultFormMock,
formState: {
errors: {
destinations: [
{ address: { message: 'Invalid address 1' } },
undefined, // No error for second field
],
},
isSubmitted: true,
touchedFields: {},
},
} as unknown as SweepForm
render(<SweepDestinationInputs form={errorFormMock} fields={fields} disabled={false} />)
expect(screen.getByText('Invalid address 1')).toBeInTheDocument()
// Check data-invalid attribute on Field (which is just a div in the real implementation but we can check the DOM)
// The closest div to the label with data-invalid
const label = screen.getByText('scheduler.label_destination_input {"destination":1}')
const fieldDiv = label.closest('div[data-invalid="true"]')
expect(fieldDiv).toBeInTheDocument()
})
})

View file

@ -0,0 +1,525 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { UseFormReturn } from 'react-hook-form'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Jar, useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import type { Utxo } from '@/hooks/useQueryUtxos'
import { jmSessionStore } from '@/store/jmSessionStore'
import { flushActUpdates } from '@/test/flushActUpdates'
import type { SweepFormValues } from './SweepFormSchema'
import { SweepPage } from './SweepPage'
import type { Schedule } from './scheduleUtils'
const VALID_DESTINATIONS = [
'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq',
'1BoatSLRHtKNngkdXEeobR76b53LETtpyT',
'3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy',
]
type WalletInfo = ReturnType<typeof useJamWalletInfoContext>
const activeSchedule: Schedule = [
[0, 0, 8, 'INTERNAL', 5, 16, 1],
[1, 0, 8, VALID_DESTINATIONS[0], 0, 16, 0],
]
const mocks = vi.hoisted(() => ({
debugFeatureEnabled: false,
feeConfigMissing: false,
feeConfigLoading: false,
getScheduleResult: undefined as { schedule?: Schedule } | null | undefined,
runSchedule: vi.fn<(input?: unknown) => Promise<{ schedule: Schedule }>>(),
scheduleQuery: { data: undefined as { schedule?: Schedule } | null | undefined },
startReset: vi.fn(),
startState: { isPending: false, isSuccess: false },
stopScheduleRefetch: vi.fn<() => Promise<{ data: unknown }>>(),
stopReset: vi.fn(),
stopState: { isPending: false, isSuccess: false },
toastError: vi.fn(),
walletInfo: undefined as WalletInfo | undefined,
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
runscheduleMutation: vi.fn(() => ({ mutationFn: mocks.runSchedule })),
stopcoinjoinOptions: vi.fn(() => ({ queryKey: ['stopcoinjoin'], queryFn: vi.fn() })),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
getschedule: vi.fn(() => Promise.resolve({ data: mocks.getScheduleResult })),
}))
type MutationOptions = {
mutationFn: (input?: unknown) => Promise<unknown>
onError?: (error: Error) => void
onMutate?: () => void
onSuccess?: (result: { schedule?: Schedule }) => void
}
type QueryOptions = {
queryKey?: unknown
}
vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options: MutationOptions) => {
const isStartSchedule = options.mutationFn === mocks.runSchedule
const state = isStartSchedule ? mocks.startState : mocks.stopState
return {
error: undefined,
isPending: state.isPending,
isSuccess: state.isSuccess,
mutateAsync: async (input?: unknown) => {
options.onMutate?.()
try {
const result = (await options.mutationFn(input)) as { schedule?: Schedule }
options.onSuccess?.(result)
return result
} catch (error) {
options.onError?.(error instanceof Error ? error : new Error(String(error)))
// do not rethrow: the component fires these mutations without catching,
// and the UI is driven entirely by the onError handler above
return undefined
}
},
reset: isStartSchedule ? mocks.startReset : mocks.stopReset,
}
}),
useQuery: vi.fn((options: QueryOptions) => {
if (Array.isArray(options.queryKey) && options.queryKey[0] === 'sweep-get-schedule') {
return mocks.scheduleQuery
}
return {
refetch: mocks.stopScheduleRefetch,
}
}),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('sonner', () => ({
toast: {
error: mocks.toastError,
},
}))
vi.mock('@/components/settings/fees/FeeConfigDialog', () => ({
FeeConfigDialog: ({ open }: { open: boolean }) => (open ? <div>fee-config-dialog</div> : null),
}))
vi.mock('@/components/sweep/SweepDestinationInputs', () => ({
SweepDestinationInputs: ({ disabled, form }: { disabled: boolean; form: UseFormReturn<SweepFormValues> }) => (
<button
type="button"
disabled={disabled}
onClick={() => {
VALID_DESTINATIONS.forEach((address, index) => {
form.setValue(`destinations.${index}.address`, address, { shouldDirty: true, shouldValidate: true })
})
void form.trigger('destinations')
}}
>
fill-destinations
</button>
),
}))
vi.mock('@/components/sweep/SweepPreconditionAlert', () => ({
SweepPreconditionAlert: ({ summary }: { summary: { isFulfilled: boolean } }) => (
<div>preconditions:{String(summary.isFulfilled)}</div>
),
}))
vi.mock('@/components/sweep/SweepScheduleProgress', () => ({
SweepScheduleProgress: ({ isStopping, onStop }: { isStopping: boolean; onStop: () => void }) => (
<div>
schedule-progress:{String(isStopping)}
<button type="button" onClick={onStop}>
stop-sweep
</button>
</div>
),
}))
vi.mock('@/components/sweep/SweepStartConfirmDialog', () => ({
SweepStartConfirmDialog: ({
disabled,
onConfirm,
open,
}: {
disabled: boolean
onConfirm: () => void
open: boolean
}) =>
open ? (
<button type="button" disabled={disabled} onClick={onConfirm}>
confirm-sweep
</button>
) : null,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
}))
vi.mock('@/components/ui/jam/FeeConfigErrorAlert', () => ({
FeeConfigErrorAlert: ({ onOpenFeeConfig }: { onOpenFeeConfig: () => void }) => (
<button type="button" onClick={onOpenFeeConfig}>
open-fee-config
</button>
),
}))
vi.mock('@/components/ui/jam/PageLoading', () => ({
PageLoading: () => <div>page-loading</div>,
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ subtitle, title }: { subtitle: string; title: string }) => (
<h1>
{title}:{subtitle}
</h1>
),
}))
vi.mock('@/constants/debugFeatures', () => ({
isDebugFeatureEnabled: () => mocks.debugFeatureEnabled,
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useJamWalletInfoContext: () => mocks.walletInfo,
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/hooks/useFeeConfigValidation', () => ({
useFeeConfigValidation: () => ({
isLoading: mocks.feeConfigLoading,
maxFeesConfigMissing: mocks.feeConfigMissing,
}),
}))
vi.mock('@/hooks/useRefreshSession', () => ({
useRefreshSession: vi.fn(),
}))
const makeUtxo = (overrides: Partial<Utxo> = {}): Utxo =>
({
address: 'bc1qsource',
confirmations: 6,
frozen: false,
label: '',
locktime: undefined,
mixdepth: 0,
path: '',
tries_remaining: 3,
utxo: 'source-tx:0',
value: 100_000,
...overrides,
}) as Utxo
const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
const jar: Jar = {
balanceSummary: {
calculatedAvailableBalanceInSats: 100_000,
calculatedTotalBalanceInSats: 100_000,
calculatedConfirmedAvailableBalanceInSats: 100_000,
calculatedFrozenOrLockedBalanceInSats: 0,
},
color: '#e2b86a',
jarIndex: 0,
name: 'Jar 0',
utxos: [makeUtxo()],
}
return {
accountSummary: {},
addressSummary: {},
detectedNetwork: null,
error: null,
fidelityBondSummary: { fbOutputs: [] },
isFetching: false,
isLoading: false,
jars: [jar],
maxJarAvailableBalance: 100_000,
refetch: vi.fn(),
setWaitForUtxosToBeSpent: vi.fn(),
utxosHashHex: 'hash',
waitForUtxosToBeSpent: [],
walletBalanceSummary: {
calculatedAvailableBalanceInSats: 100_000,
calculatedTotalBalanceInSats: 100_000,
calculatedConfirmedAvailableBalanceInSats: 100_000,
calculatedFrozenOrLockedBalanceInSats: 0,
},
walletName: 'wallet.jmdat',
...overrides,
}
}
const setSession = (overrides: Record<string, unknown> = {}) => {
jmSessionStore.setState({
state: {
coinjoin_in_process: false,
maker_running: false,
rescanning: false,
session: true,
wallet_name: 'wallet.jmdat',
...overrides,
},
})
}
describe('SweepPage', () => {
beforeEach(() => {
mocks.debugFeatureEnabled = false
mocks.feeConfigLoading = false
mocks.feeConfigMissing = false
mocks.getScheduleResult = undefined
mocks.runSchedule.mockReset()
mocks.runSchedule.mockResolvedValue({ schedule: activeSchedule })
mocks.scheduleQuery = { data: undefined }
mocks.startReset.mockReset()
mocks.startState = { isPending: false, isSuccess: false }
mocks.stopScheduleRefetch.mockReset()
mocks.stopScheduleRefetch.mockResolvedValue({ data: null })
mocks.stopReset.mockReset()
mocks.stopState = { isPending: false, isSuccess: false }
mocks.toastError.mockReset()
mocks.walletInfo = makeWalletInfo()
setSession()
})
it('shows loading while session, fee config, or wallet info is loading', () => {
jmSessionStore.setState({ state: undefined })
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('page-loading')).toBeInTheDocument()
})
it('opens the fee config dialog when required fees are missing', () => {
mocks.feeConfigMissing = true
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('preconditions:true')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'open-fee-config' }))
expect(screen.getByText('fee-config-dialog')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'scheduler.button_start' })).toBeDisabled()
})
it('builds and submits a sweep schedule after confirmation', async () => {
render(<SweepPage walletFileName="wallet.jmdat" />)
fireEvent.click(screen.getByRole('button', { name: 'fill-destinations' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled())
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_start' }))
fireEvent.click(await screen.findByRole('button', { name: 'confirm-sweep' }))
await waitFor(() => expect(mocks.runSchedule).toHaveBeenCalledTimes(1))
expect(mocks.runSchedule).toHaveBeenCalledWith({
body: {
destination_addresses: VALID_DESTINATIONS,
},
path: { walletname: 'wallet.jmdat' },
})
})
it('stops a running sweep schedule', async () => {
setSession({ coinjoin_in_process: true, schedule: activeSchedule })
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('schedule-progress:false')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'stop-sweep' }))
await waitFor(() => expect(mocks.stopScheduleRefetch).toHaveBeenCalledWith({ throwOnError: true }))
})
it('shows blocking alerts while other collaborative operations are running', () => {
setSession({ coinjoin_in_process: true, maker_running: true })
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('send.text_coinjoin_already_running')).toBeInTheDocument()
expect(screen.getByText('send.text_maker_running')).toBeInTheDocument()
})
it('shows the single coinjoin running alert when coinjoin runs without a schedule', () => {
setSession({ coinjoin_in_process: true })
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('send.text_coinjoin_already_running')).toBeInTheDocument()
expect(screen.queryByText('send.text_maker_running')).not.toBeInTheDocument()
})
it('shows loading while fee config is loading', () => {
mocks.feeConfigLoading = true
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('page-loading')).toBeInTheDocument()
})
it('shows loading while wallet info is loading', () => {
mocks.walletInfo = makeWalletInfo({ isLoading: true })
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('page-loading')).toBeInTheDocument()
})
it('disables operations while the wallet is rescanning', () => {
setSession({ rescanning: true })
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByRole('button', { name: 'scheduler.button_start' })).toBeDisabled()
})
it('shows the start waiting alert while the schedule start is pending', () => {
mocks.startState = { isPending: true, isSuccess: false }
render(<SweepPage walletFileName="wallet.jmdat" />)
// the key is shown both on the button and in the waiting alert title
expect(screen.getAllByText('scheduler.button_start').length).toBeGreaterThan(1)
})
it('shows the stop waiting alert while the schedule stop is pending', () => {
setSession({ coinjoin_in_process: true, schedule: activeSchedule })
mocks.stopState = { isPending: true, isSuccess: false }
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('schedule-progress:true')).toBeInTheDocument()
})
it('renders the schedule from the get-schedule query when no session schedule is set', () => {
setSession({ coinjoin_in_process: true })
mocks.scheduleQuery = { data: { schedule: activeSchedule } }
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('schedule-progress:false')).toBeInTheDocument()
})
it('shows an alert when starting the schedule fails', async () => {
mocks.runSchedule.mockRejectedValue(new Error('boom'))
render(<SweepPage walletFileName="wallet.jmdat" />)
fireEvent.click(screen.getByRole('button', { name: 'fill-destinations' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled())
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_start' }))
fireEvent.click(await screen.findByRole('button', { name: 'confirm-sweep' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
expect(screen.getByText('global.error')).toBeInTheDocument()
})
it('shows an alert when stopping the schedule fails', async () => {
setSession({ coinjoin_in_process: true, schedule: activeSchedule })
mocks.stopScheduleRefetch.mockRejectedValue(new Error('stop-boom'))
render(<SweepPage walletFileName="wallet.jmdat" />)
fireEvent.click(screen.getByRole('button', { name: 'stop-sweep' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1))
expect(screen.getByText('global.error')).toBeInTheDocument()
})
it('does not store a schedule when the start result has no valid schedule', async () => {
mocks.runSchedule.mockResolvedValue({ schedule: undefined } as unknown as { schedule: Schedule })
render(<SweepPage walletFileName="wallet.jmdat" />)
fireEvent.click(screen.getByRole('button', { name: 'fill-destinations' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled())
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_start' }))
fireEvent.click(await screen.findByRole('button', { name: 'confirm-sweep' }))
await waitFor(() => expect(mocks.runSchedule).toHaveBeenCalledTimes(1))
expect(screen.queryByText('schedule-progress:false')).not.toBeInTheDocument()
})
describe('with insecure testing toggle enabled', () => {
beforeEach(() => {
mocks.debugFeatureEnabled = true
})
it('uses an existing new default-jar address when toggled on and submits tumbler options', async () => {
mocks.walletInfo = makeWalletInfo({
addressSummary: {
[VALID_DESTINATIONS[0]]: {
address: VALID_DESTINATIONS[0],
status: 'new',
jarIndex: 0,
},
} as unknown as WalletInfo['addressSummary'],
})
render(<SweepPage walletFileName="wallet.jmdat" />)
fireEvent.click(screen.getByRole('switch'))
await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled())
fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_start' }))
fireEvent.click(await screen.findByRole('button', { name: 'confirm-sweep' }))
await waitFor(() => expect(mocks.runSchedule).toHaveBeenCalledTimes(1))
const callArgument = mocks.runSchedule.mock.calls[0][0] as { body: { tumbler_options?: unknown } }
expect(callArgument.body.tumbler_options).toBeDefined()
})
it('falls back to any new address when no default-jar new address exists', async () => {
mocks.walletInfo = makeWalletInfo({
addressSummary: {
[VALID_DESTINATIONS[1]]: {
address: VALID_DESTINATIONS[1],
status: 'new',
jarIndex: 2,
},
} as unknown as WalletInfo['addressSummary'],
})
render(<SweepPage walletFileName="wallet.jmdat" />)
fireEvent.click(screen.getByRole('switch'))
expect(screen.getByRole('switch')).toBeChecked()
await flushActUpdates()
})
it('uses an empty address when no new address is available', async () => {
render(<SweepPage walletFileName="wallet.jmdat" />)
fireEvent.click(screen.getByRole('switch'))
expect(screen.getByRole('switch')).toBeChecked()
await flushActUpdates()
})
it('restores production destinations when toggled off', async () => {
render(<SweepPage walletFileName="wallet.jmdat" />)
const toggle = screen.getByRole('switch')
fireEvent.click(toggle)
expect(toggle).toBeChecked()
fireEvent.click(toggle)
expect(toggle).not.toBeChecked()
await flushActUpdates()
})
})
})

View file

@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { SweepScheduleProgress } from './SweepScheduleProgress'
import type { Schedule } from './scheduleUtils'
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey, values }: { i18nKey: string; values?: Record<string, unknown> }) => (
<span>
{i18nKey}
{values ? `:${JSON.stringify(values)}` : ''}
</span>
),
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
}))
describe('SweepScheduleProgress', () => {
it('renders active schedule progress and stops it', async () => {
const user = userEvent.setup()
const onStop = vi.fn().mockResolvedValue(undefined)
const schedule: Schedule = [
[0, 0, 8, 'INTERNAL', 80, 16, 1],
[1, 0, 8, 'tx-destination', 5, 16, '8'.repeat(64)],
[2, 0, 8, 'final-destination', 0, 16, 0],
]
render(<SweepScheduleProgress schedule={schedule} isStopping={false} onStop={onStop} />)
expect(screen.getByText(/scheduler.progress_tldr_hours/u)).toBeInTheDocument()
expect(screen.getByText(/scheduler.progress_current_state_waiting_confirmation/u)).toBeInTheDocument()
expect(screen.getByText(/scheduler.progress_entry_state_confirmed/u)).toBeInTheDocument()
expect(screen.getByText(/scheduler.progress_entry_state_waiting_confirmation/u)).toBeInTheDocument()
expect(screen.getByText(/scheduler.progress_entry_wait_final/u)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'scheduler.button_stop' }))
expect(onStop).toHaveBeenCalledTimes(1)
})
it('shows done and stopping states', () => {
const schedule: Schedule = [
[0, 0, 8, 'INTERNAL', 0.25, 16, 1],
[1, 0, 8, 'final-destination', 0, 16, 1],
]
const { rerender } = render(<SweepScheduleProgress schedule={schedule} isStopping={false} onStop={vi.fn()} />)
expect(screen.getByText(/scheduler.progress_tldr_seconds/u)).toBeInTheDocument()
expect(screen.getByText('scheduler.progress_done')).toBeInTheDocument()
rerender(<SweepScheduleProgress schedule={schedule} isStopping onStop={vi.fn()} />)
expect(screen.getByRole('button', { name: /scheduler\.button_stop/u })).toBeDisabled()
})
})

View file

@ -0,0 +1,106 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { SweepStartConfirmDialog } from './SweepStartConfirmDialog'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock Dialog
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogFooter: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
vi.mock('../ui/spinner', () => ({
Spinner: () => <span data-testid="spinner">spinner</span>,
}))
describe('SweepStartConfirmDialog', () => {
it('renders content correctly when open', () => {
render(
<SweepStartConfirmDialog
open={true}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
disabled={false}
isStarting={false}
/>,
)
expect(screen.getByText('scheduler.confirm_modal.title')).toBeInTheDocument()
expect(screen.getByText('scheduler.confirm_modal.body')).toBeInTheDocument()
expect(screen.getByText('modal.confirm_button_reject')).toBeInTheDocument()
expect(screen.getByText('modal.confirm_button_accept')).toBeInTheDocument()
})
it('renders spinner when isStarting is true', () => {
render(
<SweepStartConfirmDialog
open={true}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
disabled={true}
isStarting={true}
/>,
)
expect(screen.getByTestId('spinner')).toBeInTheDocument()
expect(screen.getByText('scheduler.button_start')).toBeInTheDocument()
expect(screen.queryByText('modal.confirm_button_accept')).not.toBeInTheDocument()
// Check buttons are disabled
const rejectButton = screen.getByText('modal.confirm_button_reject')
expect(rejectButton).toBeDisabled()
const acceptButton = screen.getByText('scheduler.button_start').closest('button')
expect(acceptButton).toBeDisabled()
})
it('calls onConfirm when accept is clicked', () => {
const onConfirm = vi.fn()
render(
<SweepStartConfirmDialog
open={true}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
disabled={false}
isStarting={false}
/>,
)
fireEvent.click(screen.getByText('modal.confirm_button_accept'))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('calls onOpenChange when reject is clicked', () => {
const onOpenChange = vi.fn()
render(
<SweepStartConfirmDialog
open={true}
onOpenChange={onOpenChange}
onConfirm={vi.fn()}
disabled={false}
isStarting={false}
/>,
)
fireEvent.click(screen.getByText('modal.confirm_button_reject'))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})

View file

@ -0,0 +1,168 @@
import type { ReactNode } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { Bip21ParseResult } from '@/lib/bip21'
import QrScannerDialog from './QrScannerDialog'
const h = vi.hoisted(() => ({
toastError: vi.fn<(message: string) => void>(),
parseResult: undefined as Bip21ParseResult | null | undefined,
startImpl: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
scanFileImpl: vi.fn<() => Promise<string>>().mockResolvedValue('decoded-text'),
decodeCallbacks: [] as Array<(text: string) => void>,
}))
vi.mock('sonner', () => ({
toast: {
error: (message: string) => {
h.toastError(message)
},
},
}))
vi.mock('@/lib/bip21', () => ({
parseBip21Uri: () => h.parseResult,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('html5-qrcode', () => ({
Html5Qrcode: class {
isScanning = false
start(_config: unknown, _scanConfig: unknown, onDecode: (text: string) => void) {
h.decodeCallbacks.push(onDecode)
this.isScanning = true
return h.startImpl()
}
stop() {
this.isScanning = false
return Promise.resolve()
}
scanFile() {
return h.scanFileImpl()
}
},
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
<button onClick={onClick}>{children}</button>
),
}))
const bip21: Bip21ParseResult = { address: 'bc1qscan' } as Bip21ParseResult
const renderDialog = (overrides?: { onScan?: (r: Bip21ParseResult) => void; onOpenChange?: (o: boolean) => void }) =>
render(
<QrScannerDialog open onOpenChange={overrides?.onOpenChange ?? vi.fn()} onScan={overrides?.onScan ?? vi.fn()} />,
)
describe('QrScannerDialog', () => {
beforeEach(() => {
h.toastError = vi.fn<(message: string) => void>()
h.parseResult = bip21
h.startImpl = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
h.scanFileImpl = vi.fn<() => Promise<string>>().mockResolvedValue('decoded-text')
h.decodeCallbacks = []
Object.assign(navigator, {
clipboard: { readText: vi.fn<() => Promise<string>>().mockResolvedValue('bitcoin:bc1qscan') },
})
})
it('renders the scanner UI when open', () => {
renderDialog()
expect(screen.getByText('send.qr_scan_title')).toBeInTheDocument()
expect(screen.getByText('send.qr_paste_button')).toBeInTheDocument()
})
it('starts the camera scanner after the render delay', async () => {
renderDialog()
await waitFor(() => expect(h.startImpl).toHaveBeenCalled())
})
it('invokes onScan and closes when a valid code is decoded', async () => {
const onScan = vi.fn()
const onOpenChange = vi.fn()
renderDialog({ onScan, onOpenChange })
await waitFor(() => expect(h.decodeCallbacks.length).toBeGreaterThan(0))
await act(async () => {
h.decodeCallbacks.at(-1)?.('bitcoin:bc1qscan')
await Promise.resolve()
})
await waitFor(() => expect(onScan).toHaveBeenCalledWith(bip21))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('shows an error toast when a decoded code is not a valid bip21 uri', async () => {
h.parseResult = null
renderDialog()
await waitFor(() => expect(h.decodeCallbacks.length).toBeGreaterThan(0))
act(() => {
h.decodeCallbacks.at(-1)?.('not-a-uri')
})
expect(h.toastError).toHaveBeenCalledWith('send.qr_scan_invalid_address')
})
it('shows a camera error when both camera facings fail', async () => {
h.startImpl = vi.fn<() => Promise<void>>().mockRejectedValue(new Error('no camera'))
renderDialog()
await waitFor(() => expect(screen.getByText('send.qr_scan_camera_error')).toBeInTheDocument())
})
it('applies a pasted bip21 uri from the clipboard', async () => {
const onScan = vi.fn()
renderDialog({ onScan })
fireEvent.click(screen.getByText('send.qr_paste_button'))
await waitFor(() => expect(onScan).toHaveBeenCalledWith(bip21))
})
it('shows an error when the clipboard contains an invalid address', async () => {
h.parseResult = null
renderDialog()
fireEvent.click(screen.getByText('send.qr_paste_button'))
await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('send.qr_scan_invalid_address'))
})
it('shows an error when reading the clipboard fails', async () => {
Object.assign(navigator, {
clipboard: { readText: vi.fn<() => Promise<string>>().mockRejectedValue(new Error('denied')) },
})
renderDialog()
fireEvent.click(screen.getByText('send.qr_paste_button'))
await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('send.qr_paste_failed'))
})
it('decodes an uploaded image file', async () => {
const onScan = vi.fn()
renderDialog({ onScan })
const file = new File(['x'], 'qr.png', { type: 'image/png' })
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } })
await waitFor(() => expect(onScan).toHaveBeenCalledWith(bip21))
})
it('shows an error when the uploaded image has no readable qr code', async () => {
h.scanFileImpl = vi.fn<() => Promise<string>>().mockRejectedValue(new Error('no qr'))
renderDialog()
const file = new File(['x'], 'qr.png', { type: 'image/png' })
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } })
await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('send.qr_scan_file_no_qr'))
})
it('closes via the reject button', () => {
const onOpenChange = vi.fn()
renderDialog({ onOpenChange })
fireEvent.click(screen.getByText('modal.confirm_button_reject'))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})

View file

@ -2,7 +2,7 @@ import { StrictMode } from 'react'
import '@testing-library/jest-dom/vitest'
import { render as reactRender, screen, type RenderOptions } from '@testing-library/react'
import user from '@testing-library/user-event'
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { JamDisplayContextProvider } from '@/context/JamDisplayContextProvider'
import { Balance } from './Balance'
@ -19,8 +19,11 @@ const render = (ui: React.ReactNode, options?: Omit<RenderOptions, 'queries'>) =
describe('<Balance />', () => {
it('should render invalid param as given', () => {
// the component intentionally warns for invalid input; silence it here
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
render(<Balance valueString={'NaN'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByText(`NaN`)).toBeInTheDocument()
warnSpy.mockRestore()
})
it('should render balance in BTC', () => {

View file

@ -0,0 +1,85 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { LockWalletConfirmDialog } from './LockWalletConfirmDialog'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: ({ mutationFn }: MutationOptions) => ({
mutateAsync: mutationFn,
isPending: false,
}),
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogFooter: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
describe('LockWalletConfirmDialog', () => {
it('renders correctly', () => {
render(
<LockWalletConfirmDialog
open={true}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
makerRunning={false}
coinjoinInProgress={false}
/>,
)
expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_alternative_action_text')).toBeInTheDocument()
expect(screen.getByText('global.cancel')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
})
it('renders alerts when maker or coinjoin is running', () => {
render(
<LockWalletConfirmDialog
open={true}
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
makerRunning={true}
coinjoinInProgress={true}
/>,
)
expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_maker_running_text')).toBeInTheDocument()
expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_coinjoin_in_progress_text')).toBeInTheDocument()
})
it('calls onConfirm when lock button is clicked', () => {
const onConfirm = vi.fn()
render(
<LockWalletConfirmDialog
open={true}
onOpenChange={vi.fn()}
onConfirm={onConfirm}
makerRunning={false}
coinjoinInProgress={false}
/>,
)
fireEvent.click(screen.getByText('wallets.wallet_preview.button_lock'))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,117 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import '@/i18n/config'
import { TablePagination } from './TablePagination'
const prototypeMethods = ['hasPointerCapture', 'releasePointerCapture', 'scrollIntoView'] as const
const originalPrototypeDescriptors = Object.fromEntries(
prototypeMethods.map((method) => [method, Object.getOwnPropertyDescriptor(HTMLElement.prototype, method)]),
) as Record<(typeof prototypeMethods)[number], PropertyDescriptor | undefined>
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
configurable: true,
value: vi.fn(() => false),
})
Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', {
configurable: true,
value: vi.fn(),
})
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
})
})
afterAll(() => {
for (const method of prototypeMethods) {
const descriptor = originalPrototypeDescriptors[method]
if (descriptor) {
Object.defineProperty(HTMLElement.prototype, method, descriptor)
} else {
Reflect.deleteProperty(HTMLElement.prototype, method)
}
}
})
describe('<TablePagination />', () => {
it('renders the current range and calls page navigation handlers', () => {
const onPageChange = vi.fn()
render(
<TablePagination
currentPage={2}
totalPages={5}
itemsPerPage={25}
totalItems={125}
onPageChange={onPageChange}
onItemsPerPageChange={vi.fn()}
/>,
)
expect(screen.getByText('26-50 of 125')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '2' })).toHaveAttribute('aria-current', 'page')
fireEvent.click(screen.getByRole('button', { name: 'First' }))
fireEvent.click(screen.getByRole('button', { name: 'Previous' }))
fireEvent.click(screen.getByRole('button', { name: '3' }))
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
fireEvent.click(screen.getByRole('button', { name: 'Last' }))
expect(onPageChange.mock.calls.map(([page]) => page as number)).toEqual([1, 1, 3, 3, 5])
})
it('handles empty and show-all states', () => {
const { rerender } = render(
<TablePagination
currentPage={1}
totalPages={1}
totalItems={0}
onPageChange={vi.fn()}
onItemsPerPageChange={vi.fn()}
/>,
)
expect(screen.queryByText(/of/)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'First' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Previous' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Last' })).toBeDisabled()
rerender(
<TablePagination
currentPage={1}
totalPages={1}
itemsPerPage={-1}
totalItems={42}
allowShowAll={false}
pageSizes={[10]}
onPageChange={vi.fn()}
onItemsPerPageChange={vi.fn()}
/>,
)
expect(screen.getByText('1-42 of 42')).toBeInTheDocument()
})
it('notifies when the page size changes', async () => {
const onItemsPerPageChange = vi.fn()
render(
<TablePagination
currentPage={1}
totalPages={3}
itemsPerPage={25}
totalItems={75}
onPageChange={vi.fn()}
onItemsPerPageChange={onItemsPerPageChange}
/>,
)
await userEvent.click(screen.getByRole('combobox'))
await userEvent.click(await screen.findByRole('option', { name: '50' }))
expect(onItemsPerPageChange).toHaveBeenCalledWith(50)
})
})

View file

@ -0,0 +1,174 @@
import type React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
} from './sidebar'
const mocks = vi.hoisted(() => ({
isMobile: false,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/hooks/use-mobile', () => ({
useIsMobile: () => mocks.isMobile,
}))
vi.mock('@/components/ui/sheet', () => ({
Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) => (
<div data-open={String(open)}>{children}</div>
),
SheetContent: ({ children, side }: { children: React.ReactNode; side?: string }) => (
<div data-side={side}>{children}</div>
),
SheetDescription: ({ children }: { children: React.ReactNode }) => <p>{children}</p>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
TooltipContent: ({ children, hidden }: { children: React.ReactNode; hidden?: boolean }) => (
<span hidden={hidden}>{children}</span>
),
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
const SidebarFixture = () => (
<SidebarProvider defaultOpen>
<Sidebar side="left" variant="floating" collapsible="icon">
<SidebarHeader>
<SidebarInput aria-label="Search menu" />
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupAction aria-label="Add item">+</SidebarGroupAction>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Dashboard tooltip" isActive size="lg" variant="outline">
<span>Dashboard</span>
</SidebarMenuButton>
<SidebarMenuAction aria-label="More actions" showOnHover>
...
</SidebarMenuAction>
<SidebarMenuBadge>3</SidebarMenuBadge>
<SidebarMenuSkeleton showIcon />
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton href="#child" isActive size="sm">
Child
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>Footer</SidebarFooter>
<SidebarRail />
</Sidebar>
<SidebarTrigger side="left" />
<SidebarInset>Main content</SidebarInset>
</SidebarProvider>
)
describe('sidebar components', () => {
beforeEach(() => {
mocks.isMobile = false
document.cookie = 'sidebar_state=; Max-Age=0; path=/'
})
it('renders desktop sidebar parts and toggles collapsed state', async () => {
const user = userEvent.setup()
const { container } = render(<SidebarFixture />)
const sidebar = container.querySelector('[data-slot="sidebar"]')
expect(sidebar).toHaveAttribute('data-state', 'expanded')
expect(screen.getByText('Navigation')).toBeInTheDocument()
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Dashboard tooltip')).toBeInTheDocument()
expect(screen.getByLabelText('Search menu')).toBeInTheDocument()
await user.click(screen.getAllByRole('button', { name: 'Toggle Sidebar' })[0])
expect(sidebar).toHaveAttribute('data-state', 'collapsed')
expect(document.cookie).toContain('sidebar_state=false')
await user.keyboard('{Control>}b{/Control}')
expect(sidebar).toHaveAttribute('data-state', 'expanded')
})
it('supports mobile and non-collapsible variants', async () => {
const user = userEvent.setup()
mocks.isMobile = true
const { container, rerender } = render(
<SidebarProvider defaultOpen={false}>
<Sidebar side="right">Mobile body</Sidebar>
<SidebarTrigger side="right" />
</SidebarProvider>,
)
expect(container.querySelector('[data-open="false"]')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Toggle Sidebar' }))
expect(container.querySelector('[data-open="true"]')).toBeInTheDocument()
expect(screen.getByText('Mobile body')).toBeInTheDocument()
mocks.isMobile = false
rerender(
<SidebarProvider>
<Sidebar collapsible="none">Always visible</Sidebar>
</SidebarProvider>,
)
expect(screen.getByText('Always visible')).toBeInTheDocument()
})
it('supports controlled open state and rejects missing providers', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
render(
<SidebarProvider open onOpenChange={onOpenChange}>
<SidebarTrigger side="left" />
</SidebarProvider>,
)
await user.click(screen.getByRole('button', { name: 'Toggle Sidebar' }))
expect(onOpenChange).toHaveBeenCalledWith(false)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() => render(<SidebarTrigger side="left" />)).toThrow('useSidebar must be used within a SidebarProvider.')
consoleErrorSpy.mockRestore()
})
})

View file

@ -0,0 +1,131 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { WalletFileName } from '@/lib/utils'
import { PasswordVerificationForm } from './PasswordVerificationForm'
const h = vi.hoisted(() => ({ hashResult: 'correct-hash', hashThrows: false }))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('@/lib/hash', () => ({
hashPassword: () => {
if (h.hashThrows) return Promise.reject(new Error('hash boom'))
return Promise.resolve(h.hashResult)
},
}))
vi.mock('@/lib/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/utils')>()),
debounce: (function_: (...args: unknown[]) => unknown) => function_,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled,
type,
}: {
children?: ReactNode
onClick?: () => void
disabled?: boolean
type?: 'button' | 'submit'
}) => (
<button onClick={onClick} disabled={disabled} type={type}>
{children}
</button>
),
}))
vi.mock('../ui/field', () => ({
Field: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
FieldLabel: ({ children }: { children?: ReactNode }) => <label>{children}</label>,
}))
vi.mock('../ui/input-group', () => ({
InputGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InputGroupAddon: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InputGroupInput: (props: Record<string, unknown>) => <input {...props} />,
}))
vi.mock('../ui/spinner', () => ({ Spinner: () => <div data-testid="spinner" /> }))
const walletFileName = 'wallet.jmdat' as WalletFileName
const renderForm = (overrides?: { onSubmit?: () => void; onCancel?: () => void }) =>
render(
<PasswordVerificationForm
walletFileName={walletFileName}
hashedPassword="correct-hash"
onSubmit={overrides?.onSubmit ?? vi.fn()}
onCancel={overrides?.onCancel}
i18nKeyPrefix="settings.seed_modal.verification"
/>,
)
const typePassword = (value: string) => {
fireEvent.change(screen.getByPlaceholderText('settings.seed_modal.verification.placeholder_password'), {
target: { value },
})
}
describe('PasswordVerificationForm', () => {
beforeEach(() => {
h.hashResult = 'correct-hash'
h.hashThrows = false
})
it('renders the password field and submit button', () => {
renderForm()
expect(screen.getByText('settings.seed_modal.verification.label_password')).toBeInTheDocument()
expect(screen.getByText('settings.seed_modal.verification.text_button_submit')).toBeInTheDocument()
})
it('toggles password visibility', () => {
renderForm()
const input = screen.getByPlaceholderText('settings.seed_modal.verification.placeholder_password')
expect(input).toHaveAttribute('type', 'password')
fireEvent.click(screen.getByRole('button', { name: '' }))
expect(input).toHaveAttribute('type', 'text')
})
it('submits when the password matches', async () => {
const onSubmit = vi.fn()
renderForm({ onSubmit })
typePassword('hunter2')
fireEvent.submit(document.querySelector('form')!)
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
})
it('shows an incorrect-password error and does not submit', async () => {
h.hashResult = 'different-hash'
const onSubmit = vi.fn()
renderForm({ onSubmit })
typePassword('wrong')
fireEvent.submit(document.querySelector('form')!)
await waitFor(() =>
expect(screen.getByText('settings.seed_modal.verification.text_error_password_incorrect')).toBeInTheDocument(),
)
expect(onSubmit).not.toHaveBeenCalled()
})
it('shows a generic error when hashing throws', async () => {
h.hashThrows = true
renderForm()
typePassword('hunter2')
fireEvent.submit(document.querySelector('form')!)
await waitFor(() => expect(screen.getByText(/settings.seed_modal.verification.text_error/)).toBeInTheDocument())
})
it('invokes onCancel when the cancel button is clicked', () => {
const onCancel = vi.fn()
renderForm({ onCancel })
fireEvent.click(screen.getByText('global.cancel'))
expect(onCancel).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,38 @@
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PreventLeavingPageByMistake from './PreventLeavingPageByMistake'
describe('PreventLeavingPageByMistake', () => {
it('adds and removes beforeunload event listener', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
const abortCtrlSpy = vi.spyOn(AbortController.prototype, 'abort')
const { unmount } = render(<PreventLeavingPageByMistake />)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'beforeunload',
expect.any(Function),
expect.objectContaining({ signal: expect.any(AbortSignal) as unknown }),
)
// Simulate beforeunload event
const handler = addEventListenerSpy.mock.calls.find((call) => call[0] === 'beforeunload')?.[1] as EventListener
const preventDefaultSpy = vi.fn()
const mockEvent = { preventDefault: preventDefaultSpy, returnValue: undefined }
const result = handler(mockEvent as unknown as Event)
expect(preventDefaultSpy).toHaveBeenCalled()
expect(mockEvent.returnValue).toBe('')
expect(result).toBe('')
unmount()
expect(abortCtrlSpy).toHaveBeenCalled()
addEventListenerSpy.mockRestore()
removeEventListenerSpy.mockRestore()
abortCtrlSpy.mockRestore()
})
})

View file

@ -0,0 +1,96 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { AccountMeta } from '@/context/JamWalletInfoContext'
import { AccountDetailsTabContent } from './AccountDetailsTabContent'
const h = vi.hoisted(() => ({ tableProps: [] as Array<{ tableEntries: unknown[] }> }))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('../ui/accordion', () => ({
Accordion: ({ children, defaultValue }: { children?: ReactNode; defaultValue?: string[] }) => (
<div data-testid="accordion" data-default={JSON.stringify(defaultValue)}>
{children}
</div>
),
AccordionItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AccordionTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AccordionContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/lib/tags', () => ({ statusTags: (status: string) => (status ? [status] : []) }))
vi.mock('./BranchEntryTable', () => ({
BranchEntryTable: ({ tableEntries }: { tableEntries: unknown[] }) => {
h.tableProps.push({ tableEntries })
return <div data-testid="branch-table">{tableEntries.length}</div>
},
}))
const makeBranch = (type: string, entries?: Array<Record<string, unknown>>) => ({
type,
derivation: "m/84'/0'/0'/0",
__raw: { entries },
})
const makeAccount = (branches: ReturnType<typeof makeBranch>[]): AccountMeta => ({ branches }) as unknown as AccountMeta
describe('AccountDetailsTabContent', () => {
it('renders external and internal branch headings and skips empty branches', () => {
const account = makeAccount([
makeBranch('external addresses', [
{ hd_path: "m/84'/0'/0'/0/3", address: 'bc1a', amount: '0.5', status: 'used' },
]),
makeBranch('internal addresses', [{ hd_path: "m/84'/0'/0'/1/0", address: 'bc1b', amount: '0', status: '' }]),
makeBranch('external addresses', []),
])
render(<AccountDetailsTabContent value={account} />)
expect(screen.getByText('current_wallet.account_heading_external_addresses')).toBeInTheDocument()
expect(screen.getByText('current_wallet.account_heading_internal_addresses')).toBeInTheDocument()
// empty branch is filtered out -> only two branch tables
expect(screen.getAllByTestId('branch-table')).toHaveLength(2)
})
it('passes the unknown branch type through as the heading', () => {
const account = makeAccount([
makeBranch('mixdepth deposit', [{ hd_path: "m/84'/0'/0'/0/0", address: 'bc1c', amount: '1', status: 'new' }]),
])
render(<AccountDetailsTabContent value={account} />)
expect(screen.getByText('mixdepth deposit')).toBeInTheDocument()
})
it('sets the first non-empty branch as the default open accordion item', () => {
const account = makeAccount([
makeBranch('external addresses', []),
makeBranch('internal addresses', [{ hd_path: "m/84'/0'/0'/1/0", address: 'bc1d', amount: '0', status: '' }]),
])
render(<AccountDetailsTabContent value={account} />)
// index 1 is the first displayed branch
expect(screen.getByTestId('accordion')).toHaveAttribute('data-default', '["1"]')
})
it('renders an empty accordion when there are no branches', () => {
render(<AccountDetailsTabContent value={makeAccount([])} />)
expect(screen.getByTestId('accordion')).not.toHaveAttribute('data-default')
expect(screen.queryByTestId('branch-table')).not.toBeInTheDocument()
})
it('maps hd_path with a colon and falls back for malformed values', () => {
h.tableProps = []
const account = makeAccount([
makeBranch('external addresses', [
{ hd_path: 'm/84:1777593600', address: 'bc1e', amount: '0.1', status: 'used' },
{ hd_path: 'm/', address: 'bc1f', amount: '', status: '' },
]),
])
render(<AccountDetailsTabContent value={account} />)
const entries = h.tableProps.at(-1)?.tableEntries as Array<{ derivationIndex: number }>
expect(entries).toHaveLength(2)
expect(entries[0].derivationIndex).toBe(84)
expect(entries[1].derivationIndex).toBe(-1)
})
})

View file

@ -0,0 +1,120 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BranchEntryTableRow } from './BranchEntryTable'
import { BranchEntryTable } from './BranchEntryTable'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>{value}</span>,
}))
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
}))
const makeEntry = (overrides: Partial<BranchEntryTableRow>): BranchEntryTableRow =>
({
address: 'bc1qbranch-a',
balance: 4_000,
derivationIndex: 0,
derivationPath: "m/84'/0'/0'/0/0",
imported: false,
labels: [],
scripts: '',
tags: [{ displayValue: 'new', value: 'new', variant: 'new' }],
used: false,
__raw: {},
...overrides,
}) as BranchEntryTableRow
const firstEntry = makeEntry({
address: 'bc1qbranch-a',
balance: 4_000,
derivationIndex: 0,
tags: [{ displayValue: 'new', value: 'new', variant: 'new' }],
})
const secondEntry = makeEntry({
address: 'bc1qbranch-b',
balance: 9_000,
derivationIndex: 1,
tags: [{ displayValue: 'deposit', value: 'deposit', variant: 'deposit' }],
})
describe('BranchEntryTable', () => {
beforeEach(() => {
vi.spyOn(console, 'debug').mockImplementation(() => undefined)
})
it('renders branch entries, highlighted entries, and pinned entries', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<BranchEntryTable
tableEntries={[firstEntry, secondEntry]}
selectedEntries={[secondEntry]}
pinnedEntries={[secondEntry]}
onChange={onChange}
/>,
)
expect(screen.getByText('bc1qbranch-a')).toBeInTheDocument()
expect(screen.getByText('bc1qbranch-b')).toBeInTheDocument()
expect(screen.getByText('deposit')).toBeInTheDocument()
expect(onChange).toHaveBeenCalled()
await user.click(screen.getByText('jar_details.utxo_list.column_title_balance'))
expect(screen.getByText('9000')).toBeInTheDocument()
})
it('filters rows and renders the filtered pagination state', () => {
render(
<BranchEntryTable
globalFilter="branch-b"
tableEntries={[firstEntry, secondEntry]}
selectedEntries={[]}
pinnedEntries={[]}
/>,
)
expect(screen.queryByText('bc1qbranch-a')).not.toBeInTheDocument()
expect(screen.getByText('bc1qbranch-b')).toBeInTheDocument()
expect(screen.getByText('1-1 of 1')).toBeInTheDocument()
})
it('sorts by the address column and the derivation index column', async () => {
const user = userEvent.setup()
render(<BranchEntryTable tableEntries={[firstEntry, secondEntry]} selectedEntries={[]} pinnedEntries={[]} />)
await user.click(screen.getByText('jar_details.utxo_list.column_title_address'))
expect(screen.getByText('bc1qbranch-a')).toBeInTheDocument()
// the derivation-index column has an empty header; click it via the column header role
const headers = screen.getAllByRole('columnheader')
await user.click(headers[0])
expect(screen.getByText('bc1qbranch-b')).toBeInTheDocument()
})
it('applies sorting tie-breaks for equal balances and addresses', async () => {
const user = userEvent.setup()
const tieA = makeEntry({ address: 'bc1qsame', balance: 5_000, derivationIndex: 2 })
const tieB = makeEntry({ address: 'bc1qsame', balance: 5_000, derivationIndex: 5 })
render(<BranchEntryTable tableEntries={[tieB, tieA]} selectedEntries={[]} pinnedEntries={[]} />)
// balance column: equal balances fall back to derivationIndex
await user.click(screen.getByText('jar_details.utxo_list.column_title_balance'))
// address column: equal addresses fall back to derivationIndex
await user.click(screen.getByText('jar_details.utxo_list.column_title_address'))
expect(screen.getAllByText('bc1qsame')).toHaveLength(2)
})
})

View file

@ -0,0 +1,183 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Utxo } from '@/hooks/useQueryUtxos'
import { JarUtxosTable, type UtxoTableEntry } from './JarUtxosTable'
const mocks = vi.hoisted(() => ({
toastDismiss: vi.fn(),
toastWarning: vi.fn<(message: string, options: { description: string }) => void>(),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('sonner', () => ({
toast: {
dismiss: mocks.toastDismiss,
warning: mocks.toastWarning,
},
}))
vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>{value}</span>,
}))
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
}))
const makeUtxo = (overrides: Partial<Utxo>): Utxo =>
({
address: 'bc1qaddress-a',
confirmations: 5,
frozen: false,
label: '',
locktime: undefined,
tries_remaining: 3,
utxo: 'txid-a:0',
value: 10_000,
...overrides,
}) as Utxo
const firstEntry: UtxoTableEntry = {
utxo: makeUtxo({ address: 'bc1qshared', label: 'cold', utxo: 'txid-a:0', value: 10_000 }),
tags: [{ displayValue: 'used', value: 'used', variant: 'used' }],
}
const secondEntry: UtxoTableEntry = {
utxo: makeUtxo({ address: 'bc1qshared', confirmations: 2, utxo: 'txid-b:1', value: 5_000 }),
tags: [{ displayValue: 'frozen', value: 'frozen', variant: 'frozen' }],
}
const frozenEntry: UtxoTableEntry = {
utxo: makeUtxo({
address: 'bc1qfrozen',
confirmations: 7,
frozen: true,
utxo: 'txid-c:2',
value: 20_000,
}),
tags: [{ displayValue: 'bond', value: 'bond', variant: 'fidelity-bond' }],
}
const findFirstDataRowCheckbox = () => {
const firstDataRow = screen.getAllByRole('row').find((row) => within(row).queryByText('10000'))!
return within(firstDataRow).getByRole('checkbox')
}
describe('JarUtxosTable', () => {
beforeEach(() => {
mocks.toastDismiss.mockReset()
mocks.toastWarning.mockReset()
})
it('renders UTXO rows, sorting, tags, and expanded details', () => {
const onChange = vi.fn()
render(
<JarUtxosTable
tableEntries={[firstEntry, secondEntry, frozenEntry]}
pinnedEntries={[frozenEntry]}
onChange={onChange}
/>,
)
expect(screen.getAllByText('bc1qshared')).toHaveLength(2)
expect(screen.getByText('bc1qfrozen')).toBeInTheDocument()
expect(screen.getByText('used')).toBeInTheDocument()
expect(screen.getByText('bond')).toBeInTheDocument()
expect(onChange).toHaveBeenCalled()
fireEvent.click(screen.getByText('jar_details.utxo_list.column_title_balance'))
expect(screen.getAllByText('20000')[0]).toBeInTheDocument()
fireEvent.click(screen.getAllByRole('button', { name: 'jar_details.utxo_list.row_button_details' })[0])
expect(screen.getByText(/"utxo": "txid-c:2"/u)).toBeInTheDocument()
})
it('selects related UTXOs from the same address and reports selection changes', async () => {
const user = userEvent.setup()
const onRowSelectionChange = vi.fn()
render(
<JarUtxosTable
tableEntries={[firstEntry, secondEntry]}
pinnedEntries={[]}
onRowSelectionChange={onRowSelectionChange}
/>,
)
const rows = screen.getAllByRole('row')
const firstDataRow = rows.find((row) => within(row).queryByText('10000'))!
await user.click(within(firstDataRow).getByRole('checkbox'))
await waitFor(() => expect(onRowSelectionChange).toHaveBeenCalled())
expect(mocks.toastWarning).toHaveBeenCalledOnce()
const [message, options] = mocks.toastWarning.mock.calls[0]
expect(message).toBe('Security measure: Selection changed')
expect(options.description).toContain('Automatically selected 1 more UTXOs')
})
it('supports disabled selection and empty pagination state', () => {
render(<JarUtxosTable tableEntries={[]} pinnedEntries={[]} enableRowSelection={false} />)
expect(screen.getByText('global.table.pagination.items_per_page.label')).toBeInTheDocument()
expect(screen.queryByRole('checkbox')).toBeDisabled()
})
it('deselects related UTXOs from the same address', async () => {
const user = userEvent.setup()
render(<JarUtxosTable tableEntries={[firstEntry, secondEntry]} pinnedEntries={[]} />)
await user.click(findFirstDataRowCheckbox()) // select (auto-selects the sibling)
mocks.toastWarning.mockClear()
await user.click(findFirstDataRowCheckbox()) // deselect both
expect(mocks.toastWarning).toHaveBeenCalledOnce()
const [, options] = mocks.toastWarning.mock.calls[0]
expect(options.description).toContain('Automatically deselected 1 more UTXOs')
})
it('dismisses the toast for a single unique-address selection', async () => {
const user = userEvent.setup()
const uniqueEntry: UtxoTableEntry = {
utxo: makeUtxo({ address: 'bc1qunique', utxo: 'txid-d:0', value: 7_000 }),
tags: [],
}
render(<JarUtxosTable tableEntries={[firstEntry, secondEntry, uniqueEntry]} pinnedEntries={[]} />)
const rows = screen.getAllByRole('row')
const uniqueRow = rows.find((row) => within(row).queryByText('7000'))!
await user.click(within(uniqueRow).getByRole('checkbox'))
expect(mocks.toastWarning).not.toHaveBeenCalled()
expect(mocks.toastDismiss).toHaveBeenCalled()
})
it('toggles all rows via the header checkbox', async () => {
const user = userEvent.setup()
render(<JarUtxosTable tableEntries={[firstEntry, secondEntry]} pinnedEntries={[]} />)
const headerCheckbox = screen.getAllByRole('checkbox')[0]
await user.click(headerCheckbox)
expect(mocks.toastDismiss).toHaveBeenCalled()
})
it('sorts by the address and confirmations columns', () => {
render(<JarUtxosTable tableEntries={[firstEntry, secondEntry, frozenEntry]} pinnedEntries={[]} />)
fireEvent.click(screen.getByText('jar_details.utxo_list.column_title_address'))
fireEvent.click(screen.getByText('jar_details.utxo_list.column_title_confirmations'))
// toggle the balance column through its sort states
const balanceHeader = screen.getByText('jar_details.utxo_list.column_title_balance')
fireEvent.click(balanceHeader)
fireEvent.click(balanceHeader)
expect(screen.getByText('bc1qfrozen')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,221 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AccountSummary, AddressSummary, Jar } from '@/context/JamWalletInfoContext'
import type { Utxo } from '@/hooks/useQueryUtxos'
import { WalletJarsDetailsContent } from './WalletJarsDetailsContent'
const walletInfoRefetch = vi.hoisted(() => vi.fn())
const toastMocks = vi.hoisted(() => ({ success: vi.fn(), warning: vi.fn() }))
vi.mock('sonner', () => ({
toast: {
success: (message: string) => {
toastMocks.success(message)
},
warning: (message: string) => {
toastMocks.warning(message)
},
dismiss: () => {},
},
}))
vi.mock('react-i18next', () => ({
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
freezeMutation: vi.fn(() => ({ mutationFn: vi.fn() })),
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options: { mutationFn?: (input: unknown) => Promise<unknown> }) => ({
isPending: false,
mutateAsync: async (input: unknown) => await (options.mutationFn?.(input) ?? Promise.resolve()),
})),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>{value}</span>,
}))
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString}</span>,
}))
const makeUtxo = (overrides: Partial<Utxo>): Utxo =>
({
address: 'bc1qwallet-a',
confirmations: 5,
frozen: false,
label: 'label-a',
locktime: undefined,
tries_remaining: 3,
utxo: 'wallet-tx-a:0',
value: 12_000,
...overrides,
}) as Utxo
const jars: Jar[] = [
{
balanceSummary: {
calculatedAvailableBalanceInSats: 12_000,
calculatedConfirmedAvailableBalanceInSats: 12_000,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 12_000,
},
color: '#e2b86a',
jarIndex: 0,
name: 'Zero',
utxos: [makeUtxo({ address: 'bc1qwallet-a', utxo: 'wallet-tx-a:0' })],
},
{
balanceSummary: {
calculatedAvailableBalanceInSats: 5_000,
calculatedConfirmedAvailableBalanceInSats: 5_000,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 5_000,
},
color: '#3b5ba9',
jarIndex: 1,
name: 'One',
utxos: [makeUtxo({ address: 'bc1qwallet-b', label: '', utxo: 'wallet-tx-b:1', value: 5_000 })],
},
{
balanceSummary: {
calculatedAvailableBalanceInSats: 8_000,
calculatedConfirmedAvailableBalanceInSats: 8_000,
calculatedFrozenOrLockedBalanceInSats: 8_000,
calculatedTotalBalanceInSats: 8_000,
},
color: '#5ba93b',
jarIndex: 2,
name: 'Two',
utxos: [makeUtxo({ address: 'bc1qwallet-c', frozen: true, label: '', utxo: 'wallet-tx-c:2', value: 8_000 })],
},
]
const addressSummary: AddressSummary = {
'bc1qwallet-a': {
__raw: {},
address: 'bc1qwallet-a',
info: undefined,
jarIndex: 0,
status: 'reused',
used: true,
},
'bc1qwallet-b': {
__raw: {},
address: 'bc1qwallet-b',
info: undefined,
jarIndex: 1,
status: 'deposit',
used: false,
},
}
const accountSummary: AccountSummary = {
0: {
__raw: {},
branches: [],
jarIndex: 0,
},
}
vi.mock('@/context/JamWalletInfoContext', async (importOriginal) => ({
...(await importOriginal<typeof import('@/context/JamWalletInfoContext')>()),
useAccountSummary: () => ({ accountSummary }),
useAddressSummary: () => ({ addressSummary }),
useJamWalletInfoContext: () => ({
isFetching: false,
refetch: walletInfoRefetch,
}),
useJars: () => ({ jars }),
}))
describe('WalletJarsDetailsContent', () => {
beforeEach(() => {
walletInfoRefetch.mockReset()
walletInfoRefetch.mockResolvedValue({})
toastMocks.success.mockReset()
toastMocks.warning.mockReset()
})
it('renders selected jar UTXOs and debug details', async () => {
const user = userEvent.setup()
render(<WalletJarsDetailsContent enabled walletFileName="wallet.jmdat" selectedJarIndex={1} debug />)
expect(screen.getByRole('tab', { name: 'One' })).toHaveAttribute('data-state', 'active')
expect(screen.getByText('bc1qwallet-b')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: /Dev/u }))
expect(screen.getByText('activeJar:')).toBeInTheDocument()
})
it('switches jars with keyboard shortcuts and shows missing account information', async () => {
const user = userEvent.setup()
render(<WalletJarsDetailsContent enabled walletFileName="wallet.jmdat" selectedJarIndex={1} />)
expect(screen.getByText('bc1qwallet-b')).toBeInTheDocument()
await user.keyboard('{ArrowLeft}')
expect(screen.getByText('bc1qwallet-a')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'One' }))
await user.click(screen.getByRole('tab', { name: 'jar_details.title_tab_jar_details' }))
expect(screen.getByText('jar_details.utxo_list.alert_no_account_info_title')).toBeInTheDocument()
})
it('does not register jar keyboard navigation when disabled', async () => {
const user = userEvent.setup()
render(<WalletJarsDetailsContent enabled={false} walletFileName="wallet.jmdat" selectedJarIndex={0} />)
await user.keyboard('{ArrowRight}')
expect(screen.getByText('bc1qwallet-a')).toBeInTheDocument()
})
it('shows the reused-address alert for a reused jar', () => {
render(<WalletJarsDetailsContent enabled walletFileName="wallet.jmdat" selectedJarIndex={0} />)
expect(screen.getByText(/jar_details\.utxo_list\.alert_reused_address/u)).toBeInTheDocument()
})
it('refreshes wallet info from the utxos tab', async () => {
const user = userEvent.setup()
render(<WalletJarsDetailsContent enabled walletFileName="wallet.jmdat" selectedJarIndex={1} />)
await user.click(screen.getByRole('button', { name: 'global.refresh' }))
expect(walletInfoRefetch).toHaveBeenCalled()
})
it('freezes the selected (unfrozen) utxos', async () => {
const user = userEvent.setup()
render(<WalletJarsDetailsContent enabled walletFileName="wallet.jmdat" selectedJarIndex={1} />)
const dataRow = screen.getAllByRole('row').find((row) => within(row).queryByText('bc1qwallet-b'))!
await user.click(within(dataRow).getByRole('checkbox'))
await user.click(screen.getByRole('button', { name: 'jar_details.utxo_list.button_freeze' }))
await waitFor(() => expect(toastMocks.success).toHaveBeenCalled())
expect(walletInfoRefetch).toHaveBeenCalled()
})
it('unfreezes the selected (frozen) utxos', async () => {
const user = userEvent.setup()
render(<WalletJarsDetailsContent enabled walletFileName="wallet.jmdat" selectedJarIndex={2} />)
const dataRow = screen.getAllByRole('row').find((row) => within(row).queryByText('bc1qwallet-c'))!
await user.click(within(dataRow).getByRole('checkbox'))
await user.click(screen.getByRole('button', { name: 'jar_details.utxo_list.button_unfreeze' }))
await waitFor(() => expect(toastMocks.success).toHaveBeenCalled())
})
})

View file

@ -0,0 +1,100 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { WalletJarsDetailsOverlay } from './WalletJarsDetailsOverlay'
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
type WalletJarsDetailsContentProps = {
enabled?: boolean
walletFileName?: string
debug?: boolean
selectedJarIndex?: number
className?: string
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: true }),
}))
vi.mock('./WalletJarsDetailsContent', () => ({
WalletJarsDetailsContent: ({
enabled,
walletFileName,
debug,
selectedJarIndex,
className,
}: WalletJarsDetailsContentProps) => (
<div
data-testid="wallet-jars-details-content"
data-enabled={enabled}
data-debug={debug}
data-wallet={walletFileName}
data-index={selectedJarIndex}
className={className}
>
wallet-jars-details-content
</div>
),
}))
vi.mock('../ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <h1 data-testid="page-title">{title}</h1>,
}))
// Mock Dialog to avoid dealing with portals and radix UI internals
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
<div data-testid="dialog">
<button onClick={() => onOpenChange?.(false)}>Close</button>
{children}
</div>
) : null,
DialogContent: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogHeader: ({ children }: ChildrenProps) => <div>{children}</div>,
DialogTitle: ({ children }: ChildrenProps) => <div>{children}</div>,
}))
describe('WalletJarsDetailsOverlay', () => {
it('renders dialog content when open', () => {
render(
<WalletJarsDetailsOverlay
open={true}
onOpenChange={vi.fn()}
walletFileName="test-wallet.jmdat"
selectedJarIndex={2}
/>,
)
expect(screen.getByTestId('page-title')).toHaveTextContent('Wallet Jars Details')
const content = screen.getByTestId('wallet-jars-details-content')
expect(content).toBeInTheDocument()
expect(content).toHaveAttribute('data-enabled', 'true')
expect(content).toHaveAttribute('data-debug', 'true')
expect(content).toHaveAttribute('data-wallet', 'test-wallet.jmdat')
expect(content).toHaveAttribute('data-index', '2')
})
it('calls onOpenChange when closed', () => {
const onOpenChange = vi.fn()
render(<WalletJarsDetailsOverlay open={true} onOpenChange={onOpenChange} walletFileName="test-wallet.jmdat" />)
const closeButton = screen.getByText('Close')
fireEvent.click(closeButton)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it('does not render when closed', () => {
render(<WalletJarsDetailsOverlay open={false} onOpenChange={vi.fn()} walletFileName="test-wallet.jmdat" />)
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { WalletJarsDetailsPage } from './WalletJarsDetailsPage'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: true }),
}))
type WalletJarsDetailsContentProps = {
enabled?: boolean
walletFileName?: string
debug?: boolean
className?: string
}
vi.mock('./WalletJarsDetailsContent', () => ({
WalletJarsDetailsContent: ({ enabled, walletFileName, debug, className }: WalletJarsDetailsContentProps) => (
<div
data-testid="wallet-jars-details-content"
data-enabled={enabled}
data-debug={debug}
data-wallet={walletFileName}
className={className}
>
wallet-jars-details-content
</div>
),
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title }: { title: string }) => <h1 data-testid="page-title">{title}</h1>,
}))
describe('WalletJarsDetailsPage', () => {
it('renders title and content', () => {
// @ts-expect-error test
render(<WalletJarsDetailsPage walletFileName="test-wallet" />)
expect(screen.getByTestId('page-title')).toHaveTextContent('Wallet Jars Details')
const content = screen.getByTestId('wallet-jars-details-content')
expect(content).toBeInTheDocument()
expect(content).toHaveAttribute('data-enabled', 'true')
expect(content).toHaveAttribute('data-debug', 'true')
expect(content).toHaveAttribute('data-wallet', 'test-wallet')
})
})

View file

@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { jamSettingsStore } from '@/store/jamSettingsStore'
import { useJamDisplayContext } from './JamDisplayContext'
import { JamDisplayContextProvider } from './JamDisplayContextProvider'
const DisplayProbe = () => {
const displayContext = useJamDisplayContext()
return (
<div>
<span data-testid="default-amount">{displayContext.formatAmount({ amount: 123_456_789 })}</span>
<span data-testid="btc-amount">{displayContext.formatAmount({ amount: 123_456_789, convertToUnit: 'btc' })}</span>
<span data-testid="hidden-amount">{displayContext.formatAmount({ amount: 123_456_789, hidden: true })}</span>
<span data-testid="currency-symbol">{displayContext.currencySymbol()}</span>
</div>
)
}
describe('JamDisplayContextProvider', () => {
beforeEach(() => {
localStorage.clear()
jamSettingsStore.getState().clear()
})
it('should format amounts with the current display settings', () => {
render(
<JamDisplayContextProvider>
<DisplayProbe />
</JamDisplayContextProvider>,
)
expect(screen.getByTestId('default-amount')).toHaveTextContent('123,456,789')
expect(screen.getByTestId('btc-amount')).toHaveTextContent('1.23456789')
expect(screen.getByTestId('hidden-amount')).toHaveTextContent('*****')
expect(screen.getByTestId('sats-symbol')).toBeInTheDocument()
})
it('should hide default amounts when privacy mode is enabled', () => {
jamSettingsStore.getState().update({ privateMode: true })
render(
<JamDisplayContextProvider>
<DisplayProbe />
</JamDisplayContextProvider>,
)
expect(screen.getByTestId('default-amount')).toHaveTextContent('*****')
expect(screen.getByTestId('btc-amount')).toHaveTextContent('*****')
expect(screen.queryByTestId('sats-symbol')).not.toBeInTheDocument()
expect(screen.queryByTestId('bitcoin-symbol')).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,109 @@
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { act, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SendFormValues } from '@/components/send/types'
import { jmSessionStore } from '@/store/jmSessionStore'
import { useJamSessionInfoContext, type PaymentAttempt } from './JamSessionInfoContext'
import { JamSessionInfoContextProvider } from './JamSessionInfoContextProvider'
const mocks = vi.hoisted(() => ({
rescanData: undefined as { rescanning: boolean; progress?: number } | undefined,
dataUpdatedAt: 0,
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
getrescaninfoOptions: vi.fn(() => ({
queryKey: ['rescan-info'],
queryFn: vi.fn(),
})),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: vi.fn(() => ({
data: mocks.rescanData,
dataUpdatedAt: mocks.dataUpdatedAt,
})),
}
})
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
const paymentAttempt: PaymentAttempt = {
createdAt: 1,
utxosHashHex: 'hash',
walletFileName: 'wallet.jmdat',
data: { isCoinJoin: true } as SendFormValues,
}
const Consumer = () => {
const context = useJamSessionInfoContext()
return (
<div>
<span>{context.blockHeight}</span>
<span>{context.takerInfo.running ? 'running' : 'idle'}</span>
<span>{context.takerInfo.currentPaymentAttempt?.walletFileName ?? 'no-payment'}</span>
<span>{context.rescanInfo.progress ?? 0}</span>
<button type="button" onClick={() => context.setCurrentPaymentAttempt(paymentAttempt)}>
set
</button>
<button type="button" onClick={() => context.clearCurrentPaymentAttempt()}>
clear
</button>
</div>
)
}
describe('<JamSessionInfoContextProvider />', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
mocks.rescanData = undefined
mocks.dataUpdatedAt = 0
jmSessionStore.getState().update({
block_height: 123,
coinjoin_in_process: true,
rescanning: false,
} as SessionResponse)
})
it('provides session state and payment attempt helpers', () => {
render(
<JamSessionInfoContextProvider walletFileName="wallet.jmdat">
<Consumer />
</JamSessionInfoContextProvider>,
)
expect(screen.getByText('123')).toBeInTheDocument()
expect(screen.getByText('running')).toBeInTheDocument()
expect(screen.getByText('no-payment')).toBeInTheDocument()
act(() => screen.getByRole('button', { name: 'set' }).click())
expect(screen.getByText('wallet.jmdat')).toBeInTheDocument()
act(() => screen.getByRole('button', { name: 'clear' }).click())
expect(screen.getByText('no-payment')).toBeInTheDocument()
})
it('uses newer rescan query data when available', () => {
mocks.rescanData = { rescanning: false, progress: 0.5 }
mocks.dataUpdatedAt = 100
jmSessionStore.getState().update({
block_height: 123,
coinjoin_in_process: false,
rescanning: true,
} as SessionResponse)
render(
<JamSessionInfoContextProvider walletFileName="wallet.jmdat">
<Consumer />
</JamSessionInfoContextProvider>,
)
expect(screen.getByText('1')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,271 @@
import { render, screen, waitFor } from '@testing-library/react'
import { Network } from 'bitcoin-address-validation'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WalletInfoApiObject } from '@/hooks/useQueryDisplayWallet'
import type { Utxo } from '@/hooks/useQueryUtxos'
import { useJamWalletInfoContext } from './JamWalletInfoContext'
import { JamWalletInfoContextProvider } from './JamWalletInfoContextProvider'
const mocks = vi.hoisted(() => ({
utxos: [] as Utxo[],
walletInfo: undefined as WalletInfoApiObject | undefined,
utxosRefetch: vi.fn(),
displayWalletRefetch: vi.fn(),
waitQueryError: null as Error | null,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: vi.fn(
(options: { mutationFn?: (variables?: { delayBefore?: number; signal?: AbortSignal }) => Promise<unknown> }) => ({
isPending: false,
mutateAsync: options.mutationFn,
}),
),
useQuery: vi.fn(() => ({
error: mocks.waitQueryError,
})),
}
})
vi.mock('@/hooks/useQueryUtxos', () => ({
useQueryUtxos: () => ({
utxos: mocks.utxos,
queryResult: {
data: { utxos: mocks.utxos },
error: null,
isFetching: false,
isLoading: false,
refetch: mocks.utxosRefetch,
},
}),
}))
vi.mock('@/hooks/useQueryDisplayWallet', () => ({
useQueryDisplayWallet: () => ({
walletInfo: mocks.walletInfo,
queryResult: {
data: { walletinfo: mocks.walletInfo },
error: null,
isFetching: false,
isLoading: false,
refetch: mocks.displayWalletRefetch,
},
}),
}))
const txid = (char: string) => char.repeat(64)
const address = 'bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx'
const utxo = (overrides: Partial<Utxo>): Utxo => ({
utxo: `${txid('a')}:0`,
address,
path: "m/84'/1'/0'/0/0",
label: '',
value: 100_000,
tries: 0,
tries_remaining: 3,
external: false,
mixdepth: 0,
confirmations: 6,
frozen: false,
locktime: undefined,
...overrides,
})
const walletInfo = (): WalletInfoApiObject =>
({
wallet_name: 'testing.jmdat',
total_balance: '0',
accounts: [
{
account: '0',
branches: [
{
branch: "external addresses\tm/84'/1'/0'/0",
entries: [
{
address,
hd_path: "m/84'/1'/0'/0/0",
status: 'new',
label: '',
balance: 0,
used_count: 0,
},
],
},
],
},
],
}) as unknown as WalletInfoApiObject
const CaptureWalletInfo = ({
onContext,
}: {
onContext: (context: ReturnType<typeof useJamWalletInfoContext>) => void
}) => {
const context = useJamWalletInfoContext()
onContext(context)
return <div>{context.walletName}</div>
}
describe('<JamWalletInfoContextProvider />', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.waitQueryError = null
mocks.utxos = [
utxo({ utxo: `${txid('a')}:0`, value: 50_000, mixdepth: 0 }),
utxo({ utxo: `${txid('b')}:1`, value: 75_000, mixdepth: 7, label: 'unknown jar' }),
utxo({
utxo: `${txid('c')}:2`,
value: 150_000,
mixdepth: 0,
locktime: '2999-01-01 00:00:00',
path: "m/84'/1'/0'/0/2:32503680000",
}),
]
mocks.walletInfo = walletInfo()
mocks.utxosRefetch.mockResolvedValue({ data: { utxos: [utxo({ value: 25_000 })] } })
mocks.displayWalletRefetch.mockResolvedValue({ data: { walletinfo: walletInfo() } })
})
it('combines wallet display data with utxos into jar, address, and account summaries', () => {
let context: ReturnType<typeof useJamWalletInfoContext> | undefined
render(
<JamWalletInfoContextProvider walletFileName="testing.jmdat">
<CaptureWalletInfo onContext={(value) => (context = value)} />
</JamWalletInfoContextProvider>,
)
expect(screen.getByText('testing')).toBeInTheDocument()
expect(context?.walletName).toBe('testing')
expect(context?.jars.map((jar) => jar.jarIndex)).toEqual([0, 1, 2, 3, 4, 7])
expect(context?.jars.find((jar) => jar.jarIndex === 7)?.name).toBe('Jar #7')
expect(context?.fidelityBondSummary.fbOutputs.map((entry) => entry.utxo)).toEqual([`${txid('c')}:2`])
expect(context?.accountSummary[0].branches[0]).toMatchObject({
type: 'external addresses',
derivation: "m/84'/1'/0'/0",
})
expect(context?.addressSummary[address]).toMatchObject({
jarIndex: 0,
used: false,
status: 'new',
})
expect(context?.detectedNetwork).toBe(Network.regtest)
expect(context?.isLoading).toBe(false)
expect(context?.isFetching).toBe(false)
expect(context?.error).toBeNull()
})
it('handles malformed accounts, missing branches, and entries without a status', () => {
let context: ReturnType<typeof useJamWalletInfoContext> | undefined
mocks.walletInfo = {
accounts: [
{ account: undefined, branches: [] },
{ account: '0', branches: undefined },
{
account: '1',
branches: [
{
branch: "external addresses\tm/84'/1'/1'/0",
entries: [
{ address, hd_path: "m/84'/1'/1'/0/0", status: undefined, label: '', balance: 0, used_count: 0 },
{ address: '', hd_path: "m/84'/1'/1'/0/1", status: 'new', label: '', balance: 0, used_count: 0 },
],
},
],
},
],
} as unknown as WalletInfoApiObject
render(
<JamWalletInfoContextProvider walletFileName="testing.jmdat">
<CaptureWalletInfo onContext={(value) => (context = value)} />
</JamWalletInfoContextProvider>,
)
// entry without a status and entry without an address are both skipped
expect(context?.addressSummary[address]).toBeUndefined()
})
it('sorts multiple fidelity bonds by lock state and value', () => {
let context: ReturnType<typeof useJamWalletInfoContext> | undefined
mocks.utxos = [
utxo({
utxo: `${txid('d')}:0`,
value: 100_000,
locktime: '2999-01-01 00:00:00',
path: "m/84'/1'/0'/0/0:32503680000",
}),
utxo({
utxo: `${txid('e')}:1`,
value: 300_000,
locktime: '2999-01-01 00:00:00',
path: "m/84'/1'/0'/0/1:32503680000",
}),
utxo({
utxo: `${txid('f')}:2`,
value: 200_000,
locktime: '2000-01-01 00:00:00',
path: "m/84'/1'/0'/0/2:946684800",
}),
]
render(
<JamWalletInfoContextProvider walletFileName="testing.jmdat">
<CaptureWalletInfo onContext={(value) => (context = value)} />
</JamWalletInfoContextProvider>,
)
const fbUtxos = context?.fidelityBondSummary.fbOutputs.map((entry) => entry.utxo)
// both locked bonds come before the expired one; higher value wins among locked
expect(fbUtxos).toHaveLength(3)
expect(fbUtxos?.slice(0, 2)).toEqual([`${txid('e')}:1`, `${txid('d')}:0`])
})
it('detects the network from a utxo sample when no address info is available', () => {
let context: ReturnType<typeof useJamWalletInfoContext> | undefined
mocks.walletInfo = undefined
mocks.utxos = [utxo({ utxo: `${txid('a')}:0`, external: false })]
render(
<JamWalletInfoContextProvider walletFileName="testing.jmdat">
<CaptureWalletInfo onContext={(value) => (context = value)} />
</JamWalletInfoContextProvider>,
)
expect(context?.detectedNetwork).toBe(Network.regtest)
})
it('applies a delay before refetching when requested', async () => {
let context: ReturnType<typeof useJamWalletInfoContext> | undefined
render(
<JamWalletInfoContextProvider walletFileName="testing.jmdat">
<CaptureWalletInfo onContext={(value) => (context = value)} />
</JamWalletInfoContextProvider>,
)
await context?.refetch({ delayBefore: 1 })
expect(mocks.utxosRefetch).toHaveBeenCalledWith({ throwOnError: true })
})
it('refetches utxos before refreshing display wallet data', async () => {
let context: ReturnType<typeof useJamWalletInfoContext> | undefined
render(
<JamWalletInfoContextProvider walletFileName="testing.jmdat">
<CaptureWalletInfo onContext={(value) => (context = value)} />
</JamWalletInfoContextProvider>,
)
const balanceSummary = await context?.refetch()
await waitFor(() => expect(mocks.utxosRefetch).toHaveBeenCalledWith({ throwOnError: true }))
expect(mocks.displayWalletRefetch).toHaveBeenCalledWith({ throwOnError: true })
expect(balanceSummary?.calculatedTotalBalanceInSats).toBe(25_000)
})
})

View file

@ -0,0 +1,102 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { jmTxStore } from '@/store/jmTxStore'
import { useJmWebsocketContext } from './JmWebsocketContext'
import { JmWebsocketContextProvider } from './JmWebsocketContextProvider'
const mocks = vi.hoisted(() => ({
useJmWebsocket: vi.fn(),
websocket: {
isOpen: true,
isAuthenticated: true,
readyState: 1,
getWebSocket: vi.fn(),
lastJsonMessage: null,
lastMessage: null,
messageHistory: [],
readyStateFromUrl: {},
},
}))
vi.mock('@/hooks/useJmWebsocket', () => ({
useJmWebsocket: mocks.useJmWebsocket,
}))
const Consumer = () => {
const { websocket } = useJmWebsocketContext()
return <div>{websocket.isOpen ? 'open' : 'closed'}</div>
}
const lastOnMessage = () => {
const config = mocks.useJmWebsocket.mock.calls.at(-1)?.[0] as {
options: { onMessage: (event: MessageEvent) => void }
}
return config.options.onMessage
}
describe('<JmWebsocketContextProvider />', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
jmTxStore.getState().clear()
mocks.useJmWebsocket.mockReturnValue(mocks.websocket)
})
it('provides websocket state to consumers', () => {
render(
<JmWebsocketContextProvider>
<Consumer />
</JmWebsocketContextProvider>,
)
expect(screen.getByText('open')).toBeInTheDocument()
expect(mocks.useJmWebsocket).toHaveBeenCalledWith(
expect.objectContaining({
config: {
enableAuthentication: true,
enableHeartbeat: true,
},
}),
)
})
it('stores valid transaction updates from websocket messages', () => {
render(
<JmWebsocketContextProvider>
<Consumer />
</JmWebsocketContextProvider>,
)
const onMessage = lastOnMessage()
const txid = 'a'.repeat(64)
onMessage(
new MessageEvent('message', {
data: JSON.stringify({
txid,
txdetails: { txid, outs: [] },
}),
}),
)
onMessage(new MessageEvent('message', { data: JSON.stringify({ txid: 'short', txdetails: { txid: 'short' } }) }))
expect(jmTxStore.getState().get(txid)).toEqual({ txid, outs: [] })
expect(jmTxStore.getState().get('short')).toBeUndefined()
})
it('ignores malformed websocket payloads', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
render(
<JmWebsocketContextProvider>
<Consumer />
</JmWebsocketContextProvider>,
)
const onMessage = lastOnMessage()
expect(() => onMessage(new MessageEvent('message', { data: '{bad-json' }))).not.toThrow()
expect(warn).toHaveBeenCalledWith('Error parsing websocket message', '{bad-json')
warn.mockRestore()
})
})

View file

@ -0,0 +1,64 @@
import { renderHook, act } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'
import { useIsMobile } from './use-mobile'
describe('useIsMobile', () => {
let innerWidthSpy: MockInstance<() => number>
let matchMediaSpy: MockInstance<(query: string) => MediaQueryList>
let mqlAddEventListener: ReturnType<typeof vi.fn>
beforeEach(() => {
innerWidthSpy = vi.spyOn(window, 'innerWidth', 'get')
mqlAddEventListener = vi.fn()
window.matchMedia = vi.fn()
matchMediaSpy = vi.spyOn(window, 'matchMedia').mockImplementation(
() =>
({
matches: false,
media: '',
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: mqlAddEventListener,
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}) as unknown as MediaQueryList,
)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns true when window innerWidth is below breakpoint', () => {
innerWidthSpy.mockReturnValue(500) // MOBILE_BREAKPOINT is 768
const { result } = renderHook(() => useIsMobile())
expect(result.current).toBe(true)
})
it('returns false when window innerWidth is at or above breakpoint', () => {
innerWidthSpy.mockReturnValue(800)
const { result } = renderHook(() => useIsMobile())
expect(result.current).toBe(false)
})
it('updates state when window resizes', () => {
innerWidthSpy.mockReturnValue(800)
const { result } = renderHook(() => useIsMobile())
expect(result.current).toBe(false)
expect(matchMediaSpy).toHaveBeenCalledWith('(max-width: 767px)')
// Simulate resize event
const resizeHandler = mqlAddEventListener.mock.calls[0][1] as () => void
innerWidthSpy.mockReturnValue(500)
act(() => {
resizeHandler()
})
expect(result.current).toBe(true)
})
})

View file

@ -0,0 +1,37 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { jamSettingsStore } from '@/store/jamSettingsStore'
import { useCheatsheet } from './useCheatsheet'
vi.mock('@/lib/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/utils')>()),
pseudoRandomFloat: vi.fn(() => 45),
}))
describe('useCheatsheet', () => {
beforeEach(() => {
localStorage.clear()
jamSettingsStore.getState().clear()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('opens by default and schedules the next display time when changed', async () => {
const { result } = renderHook(() => useCheatsheet())
expect(result.current.open).toBe(true)
act(() => result.current.onOpenChange(false))
expect(result.current.open).toBe(false)
await act(() => vi.advanceTimersByTime(4))
expect(jamSettingsStore.getState().state.cheatsheetForceOpenAt).toBe(
Date.UTC(2026, 0, 1) + 4 + 45 * 24 * 60 * 60 * 1000,
)
})
})

View file

@ -0,0 +1,48 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { jamSettingsStore } from '@/store/jamSettingsStore'
import { useDisplaySettings } from './useDisplaySettings'
describe('useDisplaySettings', () => {
beforeEach(() => {
localStorage.clear()
jamSettingsStore.getState().clear()
})
it('should expose default display settings', () => {
const { result } = renderHook(() => useDisplaySettings())
expect(result.current.currency).toBe('sats')
expect(result.current.isPrivate).toBe(false)
expect(result.current.addressChunkingEnabled).toBe(true)
})
it('should toggle individual display settings', () => {
const { result } = renderHook(() => useDisplaySettings())
act(() => result.current.toggleCurrencyUnit())
expect(result.current.currency).toBe('btc')
act(() => result.current.togglePrivacyMode())
expect(result.current.isPrivate).toBe(true)
act(() => result.current.toggleAddressChunking())
expect(result.current.addressChunkingEnabled).toBe(false)
})
it('should cycle through sats, btc, and private display modes', () => {
const { result } = renderHook(() => useDisplaySettings())
act(() => result.current.toggleDisplayMode())
expect(result.current.currency).toBe('btc')
expect(result.current.isPrivate).toBe(false)
act(() => result.current.toggleDisplayMode())
expect(result.current.currency).toBe('btc')
expect(result.current.isPrivate).toBe(true)
act(() => result.current.toggleDisplayMode())
expect(result.current.currency).toBe('sats')
expect(result.current.isPrivate).toBe(false)
})
})

View file

@ -0,0 +1,75 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { authStore } from '@/store/authStore'
import { useFeatures } from './useFeatures'
const mocks = vi.hoisted(() => ({
developerMode: false,
queryData: undefined as
| { features?: Record<string, boolean> | Array<{ name: string; enabled: boolean }> }
| undefined,
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(
(options: {
enabled: boolean
select: (data: { features?: Record<string, boolean> | Array<{ name: string; enabled: boolean }> }) => unknown
}) => ({
data: options.enabled && mocks.queryData ? options.select(mocks.queryData) : undefined,
error: null,
isFetching: false,
isLoading: false,
}),
),
}))
vi.mock('@/lib/api/jam', () => ({
fetchFeatures: vi.fn(),
}))
vi.mock('@/store/jamSettingsStore', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/jamSettingsStore')>()),
useDeveloperMode: () => ({ enabled: mocks.developerMode }),
}))
describe('useFeatures', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
mocks.developerMode = false
mocks.queryData = undefined
authStore.getState().clear()
})
it('normalizes object feature responses', () => {
authStore.getState().update({ auth: { token: 'token', refresh_token: 'refresh' } })
mocks.queryData = { features: { logs: true } }
const { result } = renderHook(() => useFeatures())
expect(result.current.features).toEqual([{ name: 'logs', enabled: true }])
expect(result.current.isFeatureSupported('logs')).toBe(true)
expect(result.current.isFeatureEnabled('logs')).toBe(true)
})
it('supports array responses and developer-mode fallback', () => {
authStore.getState().update({ auth: { token: 'token', refresh_token: 'refresh' } })
mocks.developerMode = true
mocks.queryData = { features: [{ name: 'logs', enabled: false }] }
const { result } = renderHook(() => useFeatures())
expect(result.current.isFeatureSupported('logs')).toBe(false)
expect(result.current.isFeatureEnabled('logs')).toBe(true)
})
it('keeps features empty without an auth token', () => {
mocks.queryData = { features: { logs: true } }
const { result } = renderHook(() => useFeatures())
expect(result.current.features).toBeUndefined()
expect(result.current.isFeatureEnabled('logs')).toBe(false)
})
})

View file

@ -0,0 +1,105 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FEE_CONFIG_KEYS, type ConfigKey, type ConfigValue } from '@/constants/jm'
import { TX_FEE_UNITS } from '@/lib/feeConfig'
import { useFeeConfigValidation } from './useFeeConfigValidation'
const mocks = vi.hoisted(() => ({
useJmConfig: vi.fn(),
refetch: vi.fn(),
fetchIfMissing: vi.fn(),
}))
vi.mock('./useJmConfig', () => ({
useJmConfig: mocks.useJmConfig,
}))
const completeConfigState = {
POLICY: {
max_cj_fee_abs: '1500',
max_cj_fee_rel: '0.00025',
tx_fees: '2500',
tx_fees_factor: '0.2',
max_sweep_fee_change: '0.8',
},
}
const configValue = (key: ConfigKey): ConfigValue => ({ key, value: `${key.section}.${key.field}` })
describe('useFeeConfigValidation', () => {
beforeEach(() => {
mocks.useJmConfig.mockReset()
mocks.refetch.mockReset()
mocks.fetchIfMissing.mockReset()
mocks.refetch.mockImplementation((key: ConfigKey) => Promise.resolve(configValue(key)))
mocks.fetchIfMissing.mockImplementation((key: ConfigKey) => Promise.resolve(configValue(key)))
mocks.useJmConfig.mockReturnValue({
state: completeConfigState,
refetch: mocks.refetch,
fetchIfMissing: mocks.fetchIfMissing,
})
})
it('maps raw JoinMarket config values into Jam fee config values', () => {
const { result } = renderHook(() =>
useFeeConfigValidation({ walletFileName: 'Satoshi.jmdat', forceFeeConfigMissing: false }),
)
expect(mocks.useJmConfig).toHaveBeenCalledWith({ walletFileName: 'Satoshi.jmdat' })
expect(result.current.jmRawFeeConfigValues).toEqual({
max_cj_fee_abs: '1500',
max_cj_fee_rel: '0.00025',
tx_fees: '2500',
tx_fees_factor: '0.2',
max_sweep_fee_change: '0.8',
})
expect(result.current.feeConfigValues).toMatchObject({
maxCjAbsoluteFee: 1500,
maxCjRelativeFee: 0.00025,
txFeeFactor: 0.2,
maxSweepFeeChangeFactor: 0.8,
txFee: {
txFeeUnit: TX_FEE_UNITS.SATS_PER_VBYTE,
txFeeInSatsPerVbyte: 2.5,
},
})
expect(result.current.maxFeesConfigMissing).toBe(false)
})
it('reports missing max fee config when values are absent or forced', () => {
mocks.useJmConfig.mockReturnValueOnce({
state: { POLICY: { tx_fees: '3' } },
refetch: mocks.refetch,
fetchIfMissing: mocks.fetchIfMissing,
})
const missing = renderHook(() =>
useFeeConfigValidation({ walletFileName: 'Satoshi.jmdat', forceFeeConfigMissing: false }),
)
const forced = renderHook(() =>
useFeeConfigValidation({ walletFileName: 'Satoshi.jmdat', forceFeeConfigMissing: true }),
)
expect(missing.result.current.maxFeesConfigMissing).toBe(true)
expect(forced.result.current.maxFeesConfigMissing).toBe(true)
})
it('refetches and fetches every fee config key', async () => {
const { result } = renderHook(() =>
useFeeConfigValidation({ walletFileName: 'Satoshi.jmdat', forceFeeConfigMissing: false }),
)
await act(async () => {
await result.current.refetchAll()
await result.current.fetchMissing()
})
const feeConfigKeys = Object.values(FEE_CONFIG_KEYS)
expect(mocks.refetch).toHaveBeenCalledTimes(feeConfigKeys.length)
expect(mocks.fetchIfMissing).toHaveBeenCalledTimes(feeConfigKeys.length)
expect(mocks.refetch.mock.calls.map(([key]) => key as ConfigKey)).toEqual(feeConfigKeys)
expect(mocks.fetchIfMissing.mock.calls.map(([key]) => key as ConfigKey)).toEqual(feeConfigKeys)
expect(result.current.isLoading).toBe(false)
})
})

View file

@ -0,0 +1,62 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConfigKey } from '@/constants/jm'
import { jmConfigStore } from '@/store/jmConfigStore'
import { useJmConfig } from './useJmConfig'
const mocks = vi.hoisted(() => ({
fetchConfig: vi.fn(),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
configgetMutation: vi.fn(() => ({})),
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn(() => ({
mutateAsync: mocks.fetchConfig,
})),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
const txFeesKey: ConfigKey = { section: 'POLICY', field: 'tx_fees' }
describe('useJmConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
jmConfigStore.getState().clear()
mocks.fetchConfig.mockResolvedValue({ configvalue: '3' })
})
it('fetches missing config values and stores them', async () => {
const { result } = renderHook(() => useJmConfig({ walletFileName: 'wallet.jmdat' }))
// refetch updates the config store, which re-renders the hook wrapper; wrap
// it in act so that state update is flushed inside act(...)
await act(async () => {
await expect(result.current.refetch(txFeesKey)).resolves.toEqual({ key: txFeesKey, value: '3' })
})
expect(mocks.fetchConfig).toHaveBeenCalledWith({
path: { walletname: 'wallet.jmdat' },
body: {
section: 'POLICY',
field: 'tx_fees',
},
})
expect(result.current.get(txFeesKey)).toEqual({ key: txFeesKey, value: '3' })
})
it('reuses cached config values before fetching from the API', async () => {
const { result } = renderHook(() => useJmConfig({ walletFileName: 'wallet.jmdat' }))
act(() => jmConfigStore.getState().set({ key: txFeesKey, value: '5' }))
await expect(result.current.fetchIfMissing(txFeesKey)).resolves.toEqual({ key: txFeesKey, value: '5' })
expect(mocks.fetchConfig).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,39 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useQueryJmInfo } from './useQueryJmInfo'
const mocks = vi.hoisted(() => ({
data: undefined as { version: string } | undefined,
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
versionOptions: vi.fn(() => ({ queryKey: ['version'] })),
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(() => ({
data: mocks.data,
})),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
describe('useQueryJmInfo', () => {
it('parses semantic version responses', () => {
mocks.data = { version: '0.9.12' }
const { result } = renderHook(() => useQueryJmInfo())
expect(result.current.version).toEqual({ major: 0, minor: 9, patch: 12, raw: '0.9.12' })
})
it('returns undefined version before data is available', () => {
mocks.data = undefined
const { result } = renderHook(() => useQueryJmInfo())
expect(result.current.version).toBeUndefined()
})
})

View file

@ -0,0 +1,62 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { WalletFileName } from '@/lib/utils'
import { useQueryUtxos } from './useQueryUtxos'
const queryMock = vi.fn<(options: unknown) => unknown>()
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: unknown) => queryMock(options),
}))
vi.mock('zustand', () => ({
useStore: () => ({ session: 'mock-session' }),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/constants/debugFeatures', () => ({
isDevMode: () => false,
}))
vi.mock('@/lib/queryClient', () => ({
withQueryDelay: (function_: unknown) => function_,
}))
vi.mock('@/store/jmSessionStore', () => ({
jmSessionStore: {},
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
listutxosOptions: () => ({ queryKey: ['utxos'], queryFn: vi.fn() }),
}))
describe('useQueryUtxos', () => {
it('returns utxos and queryResult when data exists', () => {
const mockUtxos = [{ utxo: 'tx1:0', value: 1000 }]
queryMock.mockReturnValue({ data: { utxos: mockUtxos } })
const { result } = renderHook(() => useQueryUtxos({ walletFileName: 'wallet.jmdat' }))
expect(result.current.utxos).toEqual(mockUtxos)
expect(result.current.queryResult.data?.utxos).toEqual(mockUtxos)
})
it('returns empty array when data does not exist', () => {
queryMock.mockReturnValue({ data: null })
const { result } = renderHook(() => useQueryUtxos({ walletFileName: 'wallet.jmdat' }))
expect(result.current.utxos).toEqual([])
})
it('is not enabled if walletFileName is empty', () => {
queryMock.mockImplementation((options) => options)
const { result } = renderHook(() => useQueryUtxos({ walletFileName: undefined as unknown as WalletFileName }))
expect((result.current.queryResult as { enabled?: boolean }).enabled).toBe(false)
})
})

View file

@ -0,0 +1,95 @@
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { authStore } from '@/store/authStore'
import { jamSettingsStore } from '@/store/jamSettingsStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import { useRefreshSession } from './useRefreshSession'
const mocks = vi.hoisted(() => ({
queryData: undefined as SessionResponse | undefined,
refetchSessionData: vi.fn(() => Promise.resolve()),
toastError: vi.fn(),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
sessionOptions: vi.fn(() => ({
queryKey: ['session'],
queryFn: vi.fn(),
})),
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(() => ({
data: mocks.queryData,
refetch: mocks.refetchSessionData,
})),
}))
vi.mock('sonner', () => ({
toast: {
error: mocks.toastError,
},
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
vi.mock('@/lib/queryClient', () => ({
withQueryDelay: (queryFn: unknown) => queryFn,
}))
const sessionData = {
wallet_name: 'wallet.jmdat',
maker_running: false,
coinjoin_in_process: false,
} as SessionResponse
describe('useRefreshSession', () => {
beforeEach(() => {
mocks.queryData = undefined
mocks.refetchSessionData.mockReset()
mocks.refetchSessionData.mockResolvedValue(undefined)
mocks.toastError.mockReset()
authStore.getState().clear()
jamSettingsStore.getState().clear()
jmSessionStore.setState({ state: undefined })
})
it('stores refreshed session data', async () => {
mocks.queryData = sessionData
const { result } = renderHook(() => useRefreshSession({ enabled: true, refetchInterval: 5_000 }))
expect(result.current.data).toBe(sessionData)
await waitFor(() => expect(jmSessionStore.getState().state).toBe(sessionData))
})
it('refetches when the active wallet changes', () => {
const { rerender } = renderHook(() => useRefreshSession({ enabled: true, refetchInterval: 5_000 }))
expect(mocks.refetchSessionData).toHaveBeenCalledTimes(1)
// the store update re-renders the hook, so it must run inside act(...)
act(() => {
authStore.getState().update({ walletFileName: 'second-wallet.jmdat' })
})
rerender()
expect(mocks.refetchSessionData).toHaveBeenCalledTimes(2)
})
it('shows a developer-mode toast when refetch fails', async () => {
jamSettingsStore.getState().update({ developerMode: true })
mocks.refetchSessionData.mockRejectedValue(new Error('network down'))
renderHook(() => useRefreshSession({ enabled: true, refetchInterval: 5_000 }))
await waitFor(() =>
expect(mocks.toastError).toHaveBeenCalledWith('[DEV] Error while refreshing session data.', {
id: 'jm-session-refresh-error',
}),
)
})
})

View file

@ -0,0 +1,190 @@
import type { RowSelectionState } from '@tanstack/react-table'
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AddressSummary, Jar } from '@/context/JamWalletInfoContext'
import type { Utxo } from '@/hooks/useQueryUtxos'
import { useUtxoSelectionDialog } from './useUtxoSelectionDialog'
const mocks = vi.hoisted(() => ({
freezeOrUnfreeze: vi.fn(),
walletInfoRefetch: vi.fn(),
toastDismiss: vi.fn(),
toastSuccess: vi.fn(),
toastWarning: vi.fn(),
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
freezeMutation: vi.fn(() => ({})),
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options?: { mutationFn?: (variables: unknown) => Promise<unknown> }) => {
if (options?.mutationFn) {
return { mutateAsync: options.mutationFn, isPending: false }
}
return { mutateAsync: mocks.freezeOrUnfreeze, isPending: false }
}),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: { count?: number }) => (values?.count === undefined ? key : `${key}:${values.count}`),
}),
}))
vi.mock('sonner', () => ({
toast: {
dismiss: mocks.toastDismiss,
success: mocks.toastSuccess,
warning: mocks.toastWarning,
},
}))
vi.mock('@/context/JamWalletInfoContext', async (importOriginal) => ({
...(await importOriginal<typeof import('@/context/JamWalletInfoContext')>()),
useJamWalletInfoContext: () => ({ refetch: mocks.walletInfoRefetch }),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
const utxo = (overrides: Partial<Utxo>): Utxo => ({
utxo: 'tx:0',
address: 'bcrt1qaddress',
path: "m/84'/1'/0'/0/0",
label: '',
value: 100_000,
tries: 0,
tries_remaining: 3,
external: false,
mixdepth: 0,
confirmations: 6,
frozen: false,
locktime: undefined,
...overrides,
})
const addressSummary: AddressSummary = {
bcrt1qaddressa: { status: 'cj-out' },
bcrt1qaddressb: { status: 'change-out' },
} as unknown as AddressSummary
const sourceJar = {
jarIndex: 0,
name: 'Apricot',
color: '#e2b86a',
balanceSummary: {},
utxos: [
utxo({ utxo: 'a:0', address: 'bcrt1qaddressa', frozen: false, label: 'keep together' }),
utxo({ utxo: 'a:1', address: 'bcrt1qaddressa', frozen: true }),
utxo({ utxo: 'b:0', address: 'bcrt1qaddressb', frozen: false }),
utxo({
utxo: 'fb:0',
address: 'bcrt1qaddressfb',
locktime: '2999-01-01 00:00:00',
path: "m/84'/1'/0'/0/9:32503680000",
}),
],
} as unknown as Jar
describe('useUtxoSelectionDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.freezeOrUnfreeze.mockResolvedValue(undefined)
mocks.walletInfoRefetch.mockResolvedValue(undefined)
})
it('keeps the selector disabled when no source jar is available', () => {
const { result } = renderHook(() =>
useUtxoSelectionDialog({
walletFileName: 'wallet.jmdat',
sourceJar: undefined,
addressSummary,
}),
)
expect(result.current.utxoSelectorDisabled).toBe(true)
expect(result.current.dialogProps.open).toBe(false)
act(() => result.current.onOpenUtxoSelector())
expect(result.current.dialogProps.open).toBe(false)
})
it('opens with spendable non-fidelity-bond utxos selected', () => {
const { result } = renderHook(() =>
useUtxoSelectionDialog({
walletFileName: 'wallet.jmdat',
sourceJar,
addressSummary,
}),
)
act(() => result.current.onOpenUtxoSelector())
expect(mocks.toastDismiss).toHaveBeenCalledWith('send.utxo.selection_changed_automatically')
expect(result.current.dialogProps.open).toBe(true)
expect(result.current.dialogProps.initialRowSelection).toEqual({ 'a:0': true, 'b:0': true })
expect(result.current.dialogProps.selectedCount).toBe(2)
expect(result.current.dialogProps.tableEntries.map((entry) => entry.utxo.utxo)).toEqual([
'a:0',
'a:1',
'b:0',
'fb:0',
])
})
it('freezes deselected addresses and unfreezes grouped selected addresses', async () => {
const { result } = renderHook(() =>
useUtxoSelectionDialog({
walletFileName: 'wallet.jmdat',
sourceJar,
addressSummary,
}),
)
act(() => result.current.onOpenUtxoSelector())
act(() => result.current.dialogProps.onRowSelectionChange?.({ 'a:0': true } as RowSelectionState))
await act(async () => result.current.dialogProps.onSubmit())
expect(mocks.toastWarning).toHaveBeenCalledWith(
'Security measure: Selection changed',
expect.objectContaining({
description: 'Automatically selected 1 additional UTXOs with matching addresses.',
}),
)
expect(mocks.freezeOrUnfreeze).toHaveBeenCalledWith({
path: { walletname: 'wallet.jmdat' },
body: { 'utxo-string': 'b:0', freeze: true },
})
expect(mocks.freezeOrUnfreeze).toHaveBeenCalledWith({
path: { walletname: 'wallet.jmdat' },
body: { 'utxo-string': 'a:1', freeze: false },
})
expect(mocks.toastSuccess).toHaveBeenCalledWith('jar_details.utxo_list.toast_freeze_success:1')
expect(mocks.toastSuccess).toHaveBeenCalledWith('jar_details.utxo_list.toast_unfreeze_success:1')
await waitFor(() => expect(result.current.dialogProps.open).toBe(false))
})
it('closes without mutations when selection does not change freeze state', async () => {
const jarWithoutFrozenGroupedUtxos = {
...sourceJar,
utxos: sourceJar.utxos.filter((entry) => entry.utxo !== 'a:1'),
}
const { result } = renderHook(() =>
useUtxoSelectionDialog({
walletFileName: 'wallet.jmdat',
sourceJar: jarWithoutFrozenGroupedUtxos,
addressSummary,
}),
)
act(() => result.current.onOpenUtxoSelector())
await act(async () => result.current.dialogProps.onSubmit())
expect(mocks.freezeOrUnfreeze).not.toHaveBeenCalled()
expect(mocks.walletInfoRefetch).not.toHaveBeenCalled()
expect(result.current.dialogProps.open).toBe(false)
})
})

48
src/lib/api/jam.test.ts Normal file
View file

@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchFeatures, fetchLog } from './jam'
vi.mock('../config', () => ({
buildAuthHeaderMap: (token: string) => ({ 'x-jm-authorization': `Bearer ${token}` }),
}))
describe('Jam API helpers', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
it('fetches feature flags with auth headers', async () => {
const response = Response.json({ features: { logs: true } })
vi.mocked(fetch).mockResolvedValue(response)
await expect(fetchFeatures({ token: 'token-123' })).resolves.toBe(response)
expect(fetch).toHaveBeenCalledWith('/jam/api/v0/features', {
headers: { 'x-jm-authorization': 'Bearer token-123' },
signal: undefined,
})
})
it('fetches encoded log filenames and validates plain text responses', async () => {
const response = new Response('log content', {
headers: { 'content-type': 'text/plain; charset=utf-8' },
})
vi.mocked(fetch).mockResolvedValue(response)
await expect(fetchLog({ token: 'token-123', fileName: 'wallet log.txt' })).resolves.toBe(response)
expect(fetch).toHaveBeenCalledWith('/jam/api/v0/log/wallet%20log.txt', {
headers: { 'x-jm-authorization': 'Bearer token-123' },
signal: undefined,
})
})
it('throws when a response status or content type is unexpected', async () => {
vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 500 }))
await expect(fetchFeatures({ token: 'token-123' })).rejects.toThrow('Request failed with status 500')
vi.mocked(fetch).mockResolvedValueOnce(new Response('{}', { headers: { 'content-type': 'text/plain' } }))
await expect(fetchFeatures({ token: 'token-123' })).rejects.toThrow(
'Expected content type application/json, got text/plain',
)
})
})

View file

@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchOrderbook, refreshOrderbook } from './orderbook'
describe('orderbook API helpers', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
it('returns orderbook data when the response has offers', async () => {
const orderbook = {
offers: [{ counterparty: 'maker', oid: 0, ordertype: 'sw0reloffer' }],
fidelitybonds: [{ counterparty: 'maker', amount: 1000, locktime: 123 }],
}
vi.mocked(fetch).mockResolvedValue(Response.json(orderbook))
await expect(fetchOrderbook()).resolves.toEqual(orderbook)
expect(fetch).toHaveBeenCalledWith('/obwatch/orderbook.json')
})
it('falls back to an empty orderbook for unexpected response shapes', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
vi.mocked(fetch).mockResolvedValue(Response.json({ error: 'not ready' }))
await expect(fetchOrderbook()).resolves.toEqual({ offers: [], fidelitybonds: [] })
expect(warn).toHaveBeenCalledWith('Unexpected orderbook response structure:', { error: 'not ready' })
warn.mockRestore()
})
it('throws for failed orderbook fetches', async () => {
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 }))
await expect(fetchOrderbook()).rejects.toThrow('Failed to fetch orderbook: 503')
})
it('refreshes the orderbook without following local redirects', async () => {
const response = new Response(null, { status: 204 })
vi.mocked(fetch).mockResolvedValue(response)
await expect(refreshOrderbook()).resolves.toBe(response)
expect(fetch).toHaveBeenCalledWith('/obwatch/refreshorderbook', {
method: 'POST',
redirect: 'manual',
})
})
})

View file

@ -73,6 +73,15 @@ describe('xpubs', () => {
).toBe(vector0)
})
it('throws for an unknown target format', () => {
const vector0 = BIP32_TEST_VECTOR_1[0]
expect(() => convertExtendedPublicKey(vector0, 'notaformat' as never)).toThrow(/Invalid target format/)
})
it('throws a descriptive error for an invalid extended public key', () => {
expect(() => convertExtendedPublicKey('not-a-valid-xpub', 'zpub')).toThrow(/Invalid extended public key/)
})
it('convert xpub to zpub from dummy seed phrase', () => {
const seed = mnemonicToSeedSync(DUMMY_SEED_PHRASE.join(' '))
const root = HDKey.fromMasterSeed(seed)

View file

@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { jamSettingsStore } from './jamSettingsStore'
describe('jamSettingsStore', () => {
beforeEach(() => {
localStorage.clear()
jamSettingsStore.getState().clear()
})
it('should update display settings without replacing existing state', () => {
jamSettingsStore.getState().update({ currencyUnit: 'btc', privateMode: true })
expect(jamSettingsStore.getState().state).toMatchObject({
addressChunking: true,
currencyUnit: 'btc',
privateMode: true,
})
})
it('should reset display settings to their initial values', () => {
jamSettingsStore.getState().update({ addressChunking: false, currencyUnit: 'btc', privateMode: true })
jamSettingsStore.getState().clear()
expect(jamSettingsStore.getState().state).toMatchObject({
addressChunking: true,
currencyUnit: 'sats',
privateMode: false,
})
})
})

Some files were not shown because too many files have changed in this diff Show more