diff --git a/package.json b/package.json index 3b0ac46a..3ba2755b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 00000000..b6314dc8 --- /dev/null +++ b/src/App.test.tsx @@ -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 + 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>().mockResolvedValue(undefined), + refetchWalletBalance: vi.fn<() => Promise>().mockResolvedValue(undefined), + mutateAsync: vi.fn<() => Promise>().mockResolvedValue(undefined), + fetchMissing: vi.fn<() => Promise>().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 }) =>
{children}
, + 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 }) =>
{children}
, +})) + +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()), + 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 }) =>
{children}
+} + +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 + onLockWallet: (n: () => Promise, t: (k: string) => string) => void + }) => ( +
+ + + {children} +
+ ), +})) + +function stub(name: string) { + return () =>
{name}
+} + +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: () =>
})) +vi.mock('./components/ui/spinner', () => ({ Spinner: () =>
})) + +vi.mock('./components/ui/jam/LockWalletConfirmDialog', () => ({ + LockWalletConfirmDialog: ({ onConfirm }: { onConfirm: () => void }) => ( + + ), +})) + +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() + 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() + await waitFor(() => expect(screen.getByText('login-page')).toBeInTheDocument()) + }) + + it('opens the lock wallet dialog when maker is running and confirms locking', async () => { + render() + 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() + 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() + await waitFor(() => expect(screen.getByText('main-wallet-page')).toBeInTheDocument()) + }) +}) diff --git a/src/components/LogsContent.test.tsx b/src/components/LogsContent.test.tsx new file mode 100644 index 00000000..c1752934 --- /dev/null +++ b/src/components/LogsContent.test.tsx @@ -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 }) => ( +
+ viewer:{fileName}:{value} +
+ ), +})) + +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() + + expect(screen.getByText('global.loading')).toBeInTheDocument() + }) + + it('renders alerts and log content after initialization', () => { + mocks.logState.alert = { + variant: 'warning', + message: 'log loading failed', + } + + render() + + 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() + + expect(screen.queryByText(/viewer:/u)).not.toBeInTheDocument() + }) +}) diff --git a/src/components/LogsOverlay.test.tsx b/src/components/LogsOverlay.test.tsx new file mode 100644 index 00000000..fce70603 --- /dev/null +++ b/src/components/LogsOverlay.test.tsx @@ -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 }) => ( +
+ logs-content +
+ ), +})) + +vi.mock('@/components/ui/jam/PageTitle', () => ({ + default: ({ title }: { title: string }) =>

{title}

, +})) + +// 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 ? ( +
+ + {children} +
+ ) : null, + DialogContent: ({ children }: ChildrenProps) =>
{children}
, + DialogHeader: ({ children }: ChildrenProps) =>
{children}
, + DialogTitle: ({ children }: ChildrenProps) =>
{children}
, +})) + +describe('LogsOverlay', () => { + it('renders dialog content when open', () => { + render() + + 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() + + const closeButton = screen.getByText('Close') + fireEvent.click(closeButton) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/LogsPage.test.tsx b/src/components/LogsPage.test.tsx new file mode 100644 index 00000000..2e20a649 --- /dev/null +++ b/src/components/LogsPage.test.tsx @@ -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 }) => ( +
+ logs-content +
+ ), +})) + +vi.mock('@/components/ui/jam/PageTitle', () => ({ + default: ({ title }: { title: string }) =>

{title}

, +})) + +describe('LogsPage', () => { + it('renders title and content', () => { + render() + + expect(screen.getByTestId('page-title')).toHaveTextContent('logs.title') + + const content = screen.getByTestId('logs-content') + expect(content).toBeInTheDocument() + expect(content).toHaveAttribute('data-enabled', 'true') + }) +}) diff --git a/src/components/MainWalletPage.test.tsx b/src/components/MainWalletPage.test.tsx new file mode 100644 index 00000000..cfb44ec8 --- /dev/null +++ b/src/components/MainWalletPage.test.tsx @@ -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) => 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 }) => , +})) + +vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({ + WalletJarsDetailsOverlay: ({ open }: { open: boolean }) => ( +
{open ? 'open' : 'closed'}
+ ), +})) + +vi.mock('./ui/jam/Balance', () => ({ + Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => ( + {valueString} + ), +})) + +vi.mock('./ui/spinner', () => ({ + Spinner: () =>
, +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children?: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/ui/alert', () => ({ + Alert: ({ children }: { children?: ReactNode }) =>
{children}
, + AlertDescription: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => ( + + ), +})) + +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() + expect(screen.getAllByTestId('spinner').length).toBeGreaterThan(0) + }) + + it('renders balance and jars, and opens the jar overlay on click', () => { + render() + 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() + 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() + 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() + expect(screen.getByTestId('spinner')).toBeInTheDocument() + fireEvent.click(screen.getByText('global.refresh')) + expect(refetch).toHaveBeenCalled() + }) + + it('toggles display mode when clicking the balance', () => { + render() + fireEvent.click(screen.getByText('5000')) + expect(toggleDisplayMode).toHaveBeenCalled() + }) +}) diff --git a/src/components/create/CreateStepVerifyMnemonic.test.tsx b/src/components/create/CreateStepVerifyMnemonic.test.tsx new file mode 100644 index 00000000..45e8b442 --- /dev/null +++ b/src/components/create/CreateStepVerifyMnemonic.test.tsx @@ -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 + onBack?: () => void +} = {}) => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }) + + render( + + + , + ) + + return { onVerified, onBack } +} + +describe('', () => { + 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) + }) +}) diff --git a/src/components/create/CreateWalletForm.test.tsx b/src/components/create/CreateWalletForm.test.tsx new file mode 100644 index 00000000..55eed446 --- /dev/null +++ b/src/components/create/CreateWalletForm.test.tsx @@ -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 + +type RenderCreateWalletFormOptions = { + wallets?: string[] + onSubmit?: ReturnType + disabled?: boolean +} + +const renderCreateWalletForm = ({ + wallets = ['existing.jmdat'], + onSubmit = vi.fn(), + disabled = false, +}: RenderCreateWalletFormOptions = {}) => { + render( + (isSubmitting ? 'Creating' : 'Create')} + />, + ) + + return { onSubmit } +} + +describe('', () => { + 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') + }) +}) diff --git a/src/components/create/CreateWalletPage.test.tsx b/src/components/create/CreateWalletPage.test.tsx new file mode 100644 index 00000000..6faadfd1 --- /dev/null +++ b/src/components/create/CreateWalletPage.test.tsx @@ -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 } + +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) => 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) =>
{children}
, +})) + +vi.mock('../utils/PreventLeavingPageByMistake', () => ({ + default: () =>
prevent-leaving
, +})) + +vi.mock('./CreateStepWalletDetails', () => ({ + CreateStepWalletDetails: ({ + onSubmit, + }: { + onSubmit: (values: { walletName: string; password: string; confirmPassword: string }) => Promise + }) => ( + + ), +})) + +vi.mock('./CreateStepConfirm', () => ({ + CreateStepConfirm: ({ walletFileName, onConfirm }: { walletFileName: string; onConfirm: () => Promise }) => ( + + ), +})) + +vi.mock('./CreateStepVerifyMnemonic', () => ({ + CreateStepVerifyMnemonic: ({ onVerified }: { onVerified: () => Promise }) => ( + + ), +})) + +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() + + 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') + }) +}) diff --git a/src/components/dev/DevBadge.test.tsx b/src/components/dev/DevBadge.test.tsx new file mode 100644 index 00000000..eae10976 --- /dev/null +++ b/src/components/dev/DevBadge.test.tsx @@ -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() + expect(screen.getByText('dev')).toBeInTheDocument() + }) +}) diff --git a/src/components/dev/DevErrorThrowingComponent.test.tsx b/src/components/dev/DevErrorThrowingComponent.test.tsx new file mode 100644 index 00000000..504a2874 --- /dev/null +++ b/src/components/dev/DevErrorThrowingComponent.test.tsx @@ -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() + }).toThrow('This error is thrown on purpose. Only to be used for testing.') + + consoleSpy.mockRestore() + }) +}) diff --git a/src/components/dev/DevPage.test.tsx b/src/components/dev/DevPage.test.tsx new file mode 100644 index 00000000..6f35c16c --- /dev/null +++ b/src/components/dev/DevPage.test.tsx @@ -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 }) => {children}, +})) + +vi.mock('zustand', async (importOriginal) => { + const actual = await importOriginal() + 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: () =>
, +})) + +vi.mock('./FeeConfigTestComponent', () => ({ + FeeConfigTestComponent: () =>
, +})) + +describe('DevPage', () => { + it('renders correctly', () => { + render() + + 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() + + // 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() + }) +}) diff --git a/src/components/dev/DevSetupPage.test.tsx b/src/components/dev/DevSetupPage.test.tsx new file mode 100644 index 00000000..b759bd52 --- /dev/null +++ b/src/components/dev/DevSetupPage.test.tsx @@ -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 }) => ( +
+
{title}
+
{subtitle}
+
+ ), +})) + +vi.mock('./DevBadge', () => ({ + DevBadge: () => dev-badge, +})) + +describe('DevSetupPage', () => { + it('renders development setup information', () => { + render() + + 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() + }) +}) diff --git a/src/components/dev/FeeConfigTestComponent.test.tsx b/src/components/dev/FeeConfigTestComponent.test.tsx new file mode 100644 index 00000000..3ae268d1 --- /dev/null +++ b/src/components/dev/FeeConfigTestComponent.test.tsx @@ -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 }) =>
, +})) + +vi.mock('@/components/ui/jam/FeeConfigErrorAlert', () => ({ + FeeConfigErrorAlert: ({ onOpenFeeConfig }: { onOpenFeeConfig: () => void }) => ( +
+ +
+ ), +})) + +describe('FeeConfigTestComponent', () => { + it('renders correctly', () => { + render() + + 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() + + 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() + + 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() + + 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') + }) +}) diff --git a/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialog.test.tsx b/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialog.test.tsx new file mode 100644 index 00000000..d3ed1334 --- /dev/null +++ b/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialog.test.tsx @@ -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: () =>
Steps Component
, +})) + +vi.mock('./StepProgress', () => ({ + StepProgress: ({ currentStep }: { currentStep: number }) =>
{currentStep}
, +})) + +describe('CreateFidelityBondDialog', () => { + it('renders correctly on first step', () => { + render() + + 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() + + 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() + + 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() + + expect(screen.getByText('earn.fidelity_bond.freeze_utxos.text_primary_button_all_frozen')).toBeInTheDocument() + + mockWizard.utxosToFreeze = [{}] + render() + + expect(screen.getByText('earn.fidelity_bond.freeze_utxos.text_primary_button')).toBeInTheDocument() + }) +}) diff --git a/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialogSteps.test.tsx b/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialogSteps.test.tsx new file mode 100644 index 00000000..0be8d438 --- /dev/null +++ b/src/components/earn/CreateFidelityBondDialog/CreateFidelityBondDialogSteps.test.tsx @@ -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 + +vi.mock('react-i18next', () => ({ + Trans: ({ i18nKey, children }: { i18nKey?: string; children?: ReactNode }) => ( +
{children}
+ ), + useTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock('@/components/ui/jam/BitcoinQrCode', () => ({ + BitcoinAddressQrCode: () =>
QR
, +})) + +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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + 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() + 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() + + 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() + 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() + expect(screen.getByText('earn.fidelity_bond.create_fidelity_bond.label_utxos_to_unfreeze')).toBeInTheDocument() + }) +}) diff --git a/src/components/earn/CreateFidelityBondDialog/StepProgress.test.tsx b/src/components/earn/CreateFidelityBondDialog/StepProgress.test.tsx new file mode 100644 index 00000000..825a33e5 --- /dev/null +++ b/src/components/earn/CreateFidelityBondDialog/StepProgress.test.tsx @@ -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() + const steps = container.firstChild?.childNodes + + expect(steps).toHaveLength(3) + }) + + it('applies correct classes based on currentStep', () => { + const { container } = render() + const steps = container.firstChild?.childNodes as NodeListOf + + // 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') + }) +}) diff --git a/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.test.ts b/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.test.ts new file mode 100644 index 00000000..6e09b64b --- /dev/null +++ b/src/components/earn/CreateFidelityBondDialog/useCreateFidelityBondWizard.test.ts @@ -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()), + useJamWalletInfoContext: () => mocks.walletInfo, +})) + +vi.mock('@/hooks/useApiClient', () => ({ + useApiClient: () => ({}), +})) + +vi.mock('@/store/jamSettingsStore', async (importOriginal) => ({ + ...(await importOriginal()), + useDeveloperMode: () => ({ enabled: false }), +})) + +const utxo = (overrides: Partial): 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) => { + 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') + }) + }) +}) diff --git a/src/components/earn/EarnForm.test.tsx b/src/components/earn/EarnForm.test.tsx new file mode 100644 index 00000000..f1e5b9c3 --- /dev/null +++ b/src/components/earn/EarnForm.test.tsx @@ -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) => sat-symbol, +})) + +vi.mock('../dev/DevBadge', () => ({ + DevBadge: ({ className }: { className?: string }) => dev-badge, +})) + +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() + + 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() + + 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() + + 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() + 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() + }) +}) diff --git a/src/components/earn/EarnPage.test.tsx b/src/components/earn/EarnPage.test.tsx new file mode 100644 index 00000000..3468a6de --- /dev/null +++ b/src/components/earn/EarnPage.test.tsx @@ -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 + 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 }) => ( + {children ?? i18nKey} + ), + useTranslation: () => ({ + t: (key: string, options?: Record) => (options ? `${key}:${JSON.stringify(options)}` : key), + }), +})) + +vi.mock('react-router-dom', () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => {children}, +})) + +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 }) =>
fee-config-dialog:{String(open)}
, +})) + +vi.mock('@/components/ui/jam/FeeConfigErrorAlert', () => ({ + FeeConfigErrorAlert: ({ onOpenFeeConfig }: { onOpenFeeConfig: () => void }) => ( + + ), +})) + +vi.mock('@/components/ui/jam/PageLoading', () => ({ + PageLoading: () =>
page-loading
, +})) + +vi.mock('@/components/ui/jam/PageTitle', () => ({ + default: ({ title, subtitle }: { title: string; subtitle: string }) => ( +

+ {title}:{subtitle} +

+ ), +})) + +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()), + scrollToTop: mocks.scrollToTop, +})) + +vi.mock('@/store/jamSettingsStore', () => ({ + useDeveloperMode: () => ({ enabled: mocks.developerMode }), +})) + +vi.mock('./CreateFidelityBondDialog', () => ({ + CreateFidelityBondDialog: ({ open }: { open: boolean }) =>
create-bond-dialog:{String(open)}
, +})) + +vi.mock('./EarnForm', () => ({ + EarnForm: ({ + debug, + disabled, + onSubmit, + }: { + debug?: boolean + disabled?: boolean + onSubmit: (values: EarnFormValues) => Promise + }) => ( +
+ earn-form:{String(disabled)}:{String(debug)} + +
+ ), +})) + +vi.mock('./FidelityBondCard', () => ({ + FidelityBondCard: ({ children, value }: { children?: React.ReactNode; value: FidelityBondUtxo }) => ( +
+ fidelity-bond:{value.utxo} + {children} +
+ ), +})) + +vi.mock('./MoveToJarDialog', () => ({ + MoveToJarDialog: ({ open }: { open: boolean }) =>
move-to-jar-dialog:{String(open)}
, +})) + +vi.mock('./OfferCard', () => ({ + OfferCard: ({ children, nickname }: { children?: React.ReactNode; nickname?: string }) => ( +
+ offer-card:{nickname} + {children} +
+ ), +})) + +vi.mock('./RenewBondDialog', () => ({ + RenewBondDialog: ({ open }: { open: boolean }) =>
renew-bond-dialog:{String(open)}
, +})) + +vi.mock('./report/EarnReportOverlay', () => ({ + EarnReportOverlay: ({ open }: { open: boolean }) =>
earn-report:{String(open)}
, +})) + +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 = {}) => { + 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() + + expect(screen.getByText('page-loading')).toBeInTheDocument() + }) + + it('starts earning and opens fee/report dialogs', async () => { + const user = userEvent.setup() + mocks.feeConfigMissing = true + + render() + + 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() + + 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() + + 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() + + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + expect(mocks.toastSuccess).toHaveBeenCalledWith('earn.alert_running', expect.anything()) + }) +}) diff --git a/src/components/earn/FidelityBondCard.test.tsx b/src/components/earn/FidelityBondCard.test.tsx new file mode 100644 index 00000000..60b17a50 --- /dev/null +++ b/src/components/earn/FidelityBondCard.test.tsx @@ -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 }) =>
{i18nKey}
, +})) + +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 }) => {value}, +})) + +vi.mock('../ui/jam/Balance', () => ({ + Balance: ({ valueString }: { valueString?: string }) => {valueString}, +})) + +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() + expect(container).toBeEmptyDOMElement() + }) + + it('renders active fidelity bond', () => { + render() + + 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() + + 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( + +
Child Content
+
, + ) + + expect(screen.getByTestId('child')).toBeInTheDocument() + }) +}) diff --git a/src/components/earn/MoveToJarDialog.test.tsx b/src/components/earn/MoveToJarDialog.test.tsx new file mode 100644 index 00000000..6f03b3c4 --- /dev/null +++ b/src/components/earn/MoveToJarDialog.test.tsx @@ -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>().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) => 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()), + 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 ? ( +
+ + {children} +
+ ) : null, + DialogContent: ({ children }: ChildrenProps) =>
{children}
, + DialogHeader: ({ children }: ChildrenProps) =>
{children}
, + DialogTitle: ({ children }: ChildrenProps) =>
{children}
, + DialogDescription: ({ children }: ChildrenProps) =>
{children}
, + DialogFooter: ({ children }: ChildrenProps) =>
{children}
, +})) + +vi.mock('@/components/ui/switch', () => ({ + Switch: ({ onCheckedChange }: { onCheckedChange?: (checked: boolean) => void }) => ( + + {children} +
+ ) : null, + DialogContent: ({ children }: ChildrenProps) =>
{children}
, + DialogHeader: ({ children }: ChildrenProps) =>
{children}
, + DialogTitle: ({ children }: ChildrenProps) =>
{children}
, + DialogDescription: ({ children }: ChildrenProps) =>
{children}
, + DialogFooter: ({ children }: ChildrenProps) =>
{children}
, +})) + +vi.mock('@/components/ui/select', () => ({ + Select: ({ children, onValueChange }: ChildrenProps & { onValueChange?: (v: string) => void }) => { + if (onValueChange) h.selectHandlers.push(onValueChange) + return
{children}
+ }, + SelectContent: ({ children }: ChildrenProps) =>
{children}
, + SelectItem: ({ children }: ChildrenProps) =>
{children}
, + SelectTrigger: ({ children }: ChildrenProps) =>
{children}
, + SelectValue: () =>
, +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, onClick, disabled }: ChildrenProps & { onClick?: () => void; disabled?: boolean }) => ( + + ), +})) + +vi.mock('@/components/ui/switch', () => ({ + Switch: ({ onCheckedChange }: { onCheckedChange?: (checked: boolean) => void }) => ( + + + +
+ ), +})) + +vi.mock('@/components/ui/spinner', () => ({ + Spinner: () =>
spinner
, +})) + +vi.mock('@/store/jamSettingsStore', () => ({ + useDeveloperMode: () => ({ enabled: mocks.developerMode }), +})) + +vi.mock('./EarnReportChart', () => ({ + EarnReportChart: ({ entries }: { entries: EarnReportEntry[] }) =>
chart:{entries.length}
, +})) + +const entry = (overrides: Partial): 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() + + expect(screen.getByText('spinner')).toBeInTheDocument() + + mocks.isLoading = false + mocks.entries = [] + rerender() + + 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() + + 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() + + 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() + }) +}) diff --git a/src/components/earn/report/EarnReportOverlay.test.tsx b/src/components/earn/report/EarnReportOverlay.test.tsx new file mode 100644 index 00000000..88e34cb1 --- /dev/null +++ b/src/components/earn/report/EarnReportOverlay.test.tsx @@ -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 }) => ( +
+ earn-report-content +
+ ), +})) + +vi.mock('@/components/ui/jam/PageTitle', () => ({ + default: ({ title }: { title: string }) =>

{title}

, +})) + +// Mock Dialog to avoid dealing with portals and radix UI internals +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children, open, onOpenChange }: DialogProps) => + open ? ( +
+ + {children} +
+ ) : null, + DialogContent: ({ children }: ChildrenProps) =>
{children}
, + DialogHeader: ({ children }: ChildrenProps) =>
{children}
, + DialogTitle: ({ children }: ChildrenProps) =>
{children}
, +})) + +describe('EarnReportOverlay', () => { + it('renders dialog content when open', () => { + render() + + 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() + + const closeButton = screen.getByText('Close') + fireEvent.click(closeButton) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/earn/report/EarnReportPage.test.tsx b/src/components/earn/report/EarnReportPage.test.tsx new file mode 100644 index 00000000..06376e25 --- /dev/null +++ b/src/components/earn/report/EarnReportPage.test.tsx @@ -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 }) => ( +
+ earn-report-content +
+ ), +})) + +vi.mock('@/components/ui/jam/PageTitle', () => ({ + default: ({ title }: { title: string }) =>

{title}

, +})) + +describe('EarnReportPage', () => { + it('renders title and content', () => { + // @ts-expect-error test + render() + + 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') + }) +}) diff --git a/src/components/earn/report/hooks/useQueryYieldgenReport.test.ts b/src/components/earn/report/hooks/useQueryYieldgenReport.test.ts new file mode 100644 index 00000000..2469a5c0 --- /dev/null +++ b/src/components/earn/report/hooks/useQueryYieldgenReport.test.ts @@ -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 + 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) + }) +}) diff --git a/src/components/error/ErrorPage.test.tsx b/src/components/error/ErrorPage.test.tsx new file mode 100644 index 00000000..79b89ee4 --- /dev/null +++ b/src/components/error/ErrorPage.test.tsx @@ -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 }) => {children}, +})) + +vi.mock('@/components/ui/alert', () => ({ + Alert: ({ children }: { children?: ReactNode }) =>
{children}
, + AlertDescription: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/ui/jam/PageTitle', () => ({ + default: ({ title, subtitle }: { title: string; subtitle: string }) => ( +
+ {title} + {subtitle} +
+ ), +})) + +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() + + 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() + + 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() + + 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() + + expect(screen.getByText('global.errors.reason_unknown')).toBeInTheDocument() + }) +}) diff --git a/src/components/import/ImportDetailsForm.test.tsx b/src/components/import/ImportDetailsForm.test.tsx new file mode 100644 index 00000000..f6ca64c5 --- /dev/null +++ b/src/components/import/ImportDetailsForm.test.tsx @@ -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) => key + (options ? ' ' + JSON.stringify(options) : ''), + }), +})) + +vi.mock('../dev/DevBadge', () => ({ DevBadge: () => })) + +vi.mock('../ui/accordion', () => ({ + Accordion: ({ children }: { children?: ReactNode }) =>
{children}
, + AccordionItem: ({ children }: { children?: ReactNode }) =>
{children}
, + AccordionTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, + AccordionContent: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('../ui/alert', () => ({ + Alert: ({ children, variant }: { children?: ReactNode; variant?: string }) => ( +
{children}
+ ), + AlertDescription: ({ children }: { children?: ReactNode }) =>
{children}
, + AlertTitle: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/ui/field', () => ({ + Field: ({ children }: { children?: ReactNode }) =>
{children}
, + FieldDescription: ({ children }: { children?: ReactNode }) =>
{children}
, + FieldLabel: ({ children }: { children?: ReactNode }) => , +})) + +vi.mock('../ui/input-group', () => ({ + InputGroup: ({ children }: { children?: ReactNode }) =>
{children}
, + InputGroupAddon: ({ children }: { children?: ReactNode }) =>
{children}
, + InputGroupInput: (props: Record) => , +})) + +vi.mock('../ui/textarea', () => ({ + Textarea: (props: Record) =>