mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
test: expand full-project coverage (#1276)
This commit is contained in:
parent
945ece0df8
commit
6e3c905ddc
106 changed files with 12727 additions and 10 deletions
|
|
@ -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
246
src/App.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
69
src/components/LogsContent.test.tsx
Normal file
69
src/components/LogsContent.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
66
src/components/LogsOverlay.test.tsx
Normal file
66
src/components/LogsOverlay.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
33
src/components/LogsPage.test.tsx
Normal file
33
src/components/LogsPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
142
src/components/MainWalletPage.test.tsx
Normal file
142
src/components/MainWalletPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
63
src/components/create/CreateStepVerifyMnemonic.test.tsx
Normal file
63
src/components/create/CreateStepVerifyMnemonic.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
85
src/components/create/CreateWalletForm.test.tsx
Normal file
85
src/components/create/CreateWalletForm.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
178
src/components/create/CreateWalletPage.test.tsx
Normal file
178
src/components/create/CreateWalletPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
10
src/components/dev/DevBadge.test.tsx
Normal file
10
src/components/dev/DevBadge.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
16
src/components/dev/DevErrorThrowingComponent.test.tsx
Normal file
16
src/components/dev/DevErrorThrowingComponent.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
60
src/components/dev/DevPage.test.tsx
Normal file
60
src/components/dev/DevPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
37
src/components/dev/DevSetupPage.test.tsx
Normal file
37
src/components/dev/DevSetupPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
63
src/components/dev/FeeConfigTestComponent.test.tsx
Normal file
63
src/components/dev/FeeConfigTestComponent.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
106
src/components/earn/EarnForm.test.tsx
Normal file
106
src/components/earn/EarnForm.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
414
src/components/earn/EarnPage.test.tsx
Normal file
414
src/components/earn/EarnPage.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
97
src/components/earn/FidelityBondCard.test.tsx
Normal file
97
src/components/earn/FidelityBondCard.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
210
src/components/earn/MoveToJarDialog.test.tsx
Normal file
210
src/components/earn/MoveToJarDialog.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
82
src/components/earn/OfferCard.test.tsx
Normal file
82
src/components/earn/OfferCard.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
278
src/components/earn/RenewBondDialog.test.tsx
Normal file
278
src/components/earn/RenewBondDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
48
src/components/earn/report/EarnReportChart.test.tsx
Normal file
48
src/components/earn/report/EarnReportChart.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
187
src/components/earn/report/EarnReportContent.test.tsx
Normal file
187
src/components/earn/report/EarnReportContent.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
66
src/components/earn/report/EarnReportOverlay.test.tsx
Normal file
66
src/components/earn/report/EarnReportOverlay.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
34
src/components/earn/report/EarnReportPage.test.tsx
Normal file
34
src/components/earn/report/EarnReportPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
74
src/components/error/ErrorPage.test.tsx
Normal file
74
src/components/error/ErrorPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
134
src/components/import/ImportDetailsForm.test.tsx
Normal file
134
src/components/import/ImportDetailsForm.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
213
src/components/import/ImportWalletPage.test.tsx
Normal file
213
src/components/import/ImportWalletPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
138
src/components/layout/AppSidebar.test.tsx
Normal file
138
src/components/layout/AppSidebar.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
17
src/components/layout/AuthPageShell.test.tsx
Normal file
17
src/components/layout/AuthPageShell.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
263
src/components/layout/Layout.test.tsx
Normal file
263
src/components/layout/Layout.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
88
src/components/layout/PostLoginOnboardingTour.test.tsx
Normal file
88
src/components/layout/PostLoginOnboardingTour.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
60
src/components/layout/footer/BetaWarningDialog.test.tsx
Normal file
60
src/components/layout/footer/BetaWarningDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
113
src/components/logging/LogViewer.test.tsx
Normal file
113
src/components/logging/LogViewer.test.tsx
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
118
src/components/logging/useJmwalletdStdoutLog.test.ts
Normal file
118
src/components/logging/useJmwalletdStdoutLog.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
153
src/components/login/LoginPage.test.tsx
Normal file
153
src/components/login/LoginPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
38
src/components/login/OnboardingDialog.test.tsx
Normal file
38
src/components/login/OnboardingDialog.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
44
src/components/orderbook/OrderbookChart.test.tsx
Normal file
44
src/components/orderbook/OrderbookChart.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
66
src/components/orderbook/OrderbookOverlay.test.tsx
Normal file
66
src/components/orderbook/OrderbookOverlay.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
33
src/components/orderbook/OrderbookPage.test.tsx
Normal file
33
src/components/orderbook/OrderbookPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
175
src/components/orderbook/OrderbookTable.test.tsx
Normal file
175
src/components/orderbook/OrderbookTable.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
105
src/components/receive/ReceiveForm.test.tsx
Normal file
105
src/components/receive/ReceiveForm.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
193
src/components/receive/ReceivePage.test.tsx
Normal file
193
src/components/receive/ReceivePage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
147
src/components/send/JarSelectorDialog.test.tsx
Normal file
147
src/components/send/JarSelectorDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
55
src/components/send/PaymentAbortDialog.test.tsx
Normal file
55
src/components/send/PaymentAbortDialog.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
177
src/components/send/PaymentConfirmDialog.test.tsx
Normal file
177
src/components/send/PaymentConfirmDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
166
src/components/send/SendForm.schema.test.ts
Normal file
166
src/components/send/SendForm.schema.test.ts
Normal 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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
261
src/components/send/SendForm.test.tsx
Normal file
261
src/components/send/SendForm.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
577
src/components/send/SendPage.test.tsx
Normal file
577
src/components/send/SendPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
41
src/components/send/TxFeeForm.test.tsx
Normal file
41
src/components/send/TxFeeForm.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
101
src/components/send/UtxoSelectionDialog.test.tsx
Normal file
101
src/components/send/UtxoSelectionDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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.')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
291
src/components/settings/AccountXpubsDialog.test.tsx
Normal file
291
src/components/settings/AccountXpubsDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
67
src/components/settings/LanguageSelector.test.tsx
Normal file
67
src/components/settings/LanguageSelector.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
143
src/components/settings/RescanChainPage.test.tsx
Normal file
143
src/components/settings/RescanChainPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
133
src/components/settings/SeedPhraseDialog.test.tsx
Normal file
133
src/components/settings/SeedPhraseDialog.test.tsx
Normal 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'] })
|
||||
})
|
||||
})
|
||||
299
src/components/settings/SettingsPage.test.tsx
Normal file
299
src/components/settings/SettingsPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
62
src/components/settings/fees/CollaboratorFeesForm.test.tsx
Normal file
62
src/components/settings/fees/CollaboratorFeesForm.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
327
src/components/settings/fees/FeeConfigDialog.test.tsx
Normal file
327
src/components/settings/fees/FeeConfigDialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
58
src/components/settings/fees/MiningFeesForm.test.tsx
Normal file
58
src/components/settings/fees/MiningFeesForm.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
94
src/components/sweep/PreconditionAlerts.test.tsx
Normal file
94
src/components/sweep/PreconditionAlerts.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
80
src/components/sweep/SweepDestinationInputs.test.tsx
Normal file
80
src/components/sweep/SweepDestinationInputs.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
525
src/components/sweep/SweepPage.test.tsx
Normal file
525
src/components/sweep/SweepPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
55
src/components/sweep/SweepScheduleProgress.test.tsx
Normal file
55
src/components/sweep/SweepScheduleProgress.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
106
src/components/sweep/SweepStartConfirmDialog.test.tsx
Normal file
106
src/components/sweep/SweepStartConfirmDialog.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
168
src/components/ui/QrScannerDialog.test.tsx
Normal file
168
src/components/ui/QrScannerDialog.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
85
src/components/ui/jam/LockWalletConfirmDialog.test.tsx
Normal file
85
src/components/ui/jam/LockWalletConfirmDialog.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
117
src/components/ui/jam/TablePagination.test.tsx
Normal file
117
src/components/ui/jam/TablePagination.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
174
src/components/ui/sidebar.test.tsx
Normal file
174
src/components/ui/sidebar.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
131
src/components/utils/PasswordVerificationForm.test.tsx
Normal file
131
src/components/utils/PasswordVerificationForm.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
38
src/components/utils/PreventLeavingPageByMistake.test.tsx
Normal file
38
src/components/utils/PreventLeavingPageByMistake.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
96
src/components/wallet/AccountDetailsTabContent.test.tsx
Normal file
96
src/components/wallet/AccountDetailsTabContent.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
120
src/components/wallet/BranchEntryTable.test.tsx
Normal file
120
src/components/wallet/BranchEntryTable.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
183
src/components/wallet/JarUtxosTable.test.tsx
Normal file
183
src/components/wallet/JarUtxosTable.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
221
src/components/wallet/WalletJarsDetailsContent.test.tsx
Normal file
221
src/components/wallet/WalletJarsDetailsContent.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
100
src/components/wallet/WalletJarsDetailsOverlay.test.tsx
Normal file
100
src/components/wallet/WalletJarsDetailsOverlay.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
53
src/components/wallet/WalletJarsDetailsPage.test.tsx
Normal file
53
src/components/wallet/WalletJarsDetailsPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
53
src/context/JamDisplayContextProvider.test.tsx
Normal file
53
src/context/JamDisplayContextProvider.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
109
src/context/JamSessionInfoContextProvider.test.tsx
Normal file
109
src/context/JamSessionInfoContextProvider.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
271
src/context/JamWalletInfoContextProvider.test.tsx
Normal file
271
src/context/JamWalletInfoContextProvider.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
102
src/context/JmWebsocketContextProvider.test.tsx
Normal file
102
src/context/JmWebsocketContextProvider.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
64
src/hooks/use-mobile.test.ts
Normal file
64
src/hooks/use-mobile.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
37
src/hooks/useCheatsheet.test.ts
Normal file
37
src/hooks/useCheatsheet.test.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
})
|
||||
48
src/hooks/useDisplaySettings.test.ts
Normal file
48
src/hooks/useDisplaySettings.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
75
src/hooks/useFeatures.test.ts
Normal file
75
src/hooks/useFeatures.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
105
src/hooks/useFeeConfigValidation.test.ts
Normal file
105
src/hooks/useFeeConfigValidation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
62
src/hooks/useJmConfig.test.ts
Normal file
62
src/hooks/useJmConfig.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
39
src/hooks/useQueryJmInfo.test.ts
Normal file
39
src/hooks/useQueryJmInfo.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
62
src/hooks/useQueryUtxos.test.ts
Normal file
62
src/hooks/useQueryUtxos.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
95
src/hooks/useRefreshSession.test.ts
Normal file
95
src/hooks/useRefreshSession.test.ts
Normal 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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
190
src/hooks/useUtxoSelectionDialog.test.ts
Normal file
190
src/hooks/useUtxoSelectionDialog.test.ts
Normal 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
48
src/lib/api/jam.test.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
46
src/lib/api/orderbook.test.ts
Normal file
46
src/lib/api/orderbook.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
30
src/store/jamSettingsStore.test.ts
Normal file
30
src/store/jamSettingsStore.test.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue