From 276995f5336b7601a8b7b60bb1e18d250ed89a27 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Wed, 1 Jul 2026 14:53:50 +0200 Subject: [PATCH 1/2] test(maturity): cover disburse-maturity address-book routing --- .../components/DisburseMaturityModal.test.tsx | 158 +++++++++++++++++ .../stakes/hooks/useDisburseMaturity.test.tsx | 160 ++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 src/governance-app-frontend/src/features/stakes/components/DisburseMaturityModal.test.tsx create mode 100644 src/governance-app-frontend/src/features/stakes/hooks/useDisburseMaturity.test.tsx diff --git a/src/governance-app-frontend/src/features/stakes/components/DisburseMaturityModal.test.tsx b/src/governance-app-frontend/src/features/stakes/components/DisburseMaturityModal.test.tsx new file mode 100644 index 000000000..d86052337 --- /dev/null +++ b/src/governance-app-frontend/src/features/stakes/components/DisburseMaturityModal.test.tsx @@ -0,0 +1,158 @@ +import type { NeuronInfo } from '@icp-sdk/canisters/nns'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { E8S } from '@constants/extra'; +import { renderWithProviders } from '@utils/unitTest'; +import { mockNeuron } from '@fixtures/neuron'; + +import { DisburseMaturityModal } from './DisburseMaturityModal'; + +// ─── Module mocks ──────────────────────────────────────────────── + +const mocks = vi.hoisted(() => ({ + mutateAsync: vi.fn().mockResolvedValue(undefined), + useAccountSelection: vi.fn(), + useAccounts: vi.fn(), + useAddressBook: vi.fn(), +})); + +// The modal renders inside MutationDialog's NavigationBlockerDialog, which calls useBlocker. +// Stub just the blocker so we don't need a RouterProvider; keep the rest of the module real. +vi.mock('@tanstack/react-router', async (importOriginal) => ({ + ...(await importOriginal()), + useBlocker: () => ({ status: 'idle', proceed: vi.fn(), reset: vi.fn() }), +})); + +// Force the desktop (Radix Dialog) branch of ResponsiveDialog; the mobile branch uses vaul, +// whose pointer handling throws under jsdom. +vi.mock('@hooks/useMediaQuery', () => ({ + useMediaQuery: () => true, +})); + +vi.mock('../hooks/useDisburseMaturity', () => ({ + useDisburseMaturity: () => ({ mutateAsync: mocks.mutateAsync }), +})); + +vi.mock('@features/accounts/hooks/useAccountSelection', () => ({ + useAccountSelection: mocks.useAccountSelection, +})); + +vi.mock('@features/accounts/hooks/useAccounts', () => ({ + useAccounts: mocks.useAccounts, +})); + +vi.mock('@hooks/addressBook/useAddressBook', () => ({ + useAddressBook: mocks.useAddressBook, +})); + +// Stub the pickers — the modal's own wiring is under test, not the child components. +vi.mock('@features/accounts/components/AccountSelect', () => ({ + AccountSelect: () =>
, +})); + +vi.mock('@features/addressBook/components/AddressBookSelect', () => ({ + AddressBookSelect: ({ + selectedName, + onSelect, + }: { + selectedName: string; + onSelect: (name: string) => void; + }) => ( + + ), +})); + +// ─── Fixtures ──────────────────────────────────────────────────── + +const MAIN_ACCOUNT_ID = 'main-account-id'; + +const neuronWithMaturity = (): NeuronInfo => + mockNeuron({ fullNeuron: { maturityE8sEquivalent: BigInt(2 * E8S) } }); + +const setAccounts = () => + mocks.useAccounts.mockReturnValue({ + data: { + accounts: [ + { + name: 'Main', + accountId: MAIN_ACCOUNT_ID, + type: 'main', + status: 'ready', + balanceE8s: BigInt(5 * E8S), + }, + ], + mainAccountId: MAIN_ACCOUNT_ID, + totalBalanceE8s: 0n, + hasSubaccounts: false, + }, + }); + +const setAccountSelection = (subaccountsEnabled: boolean) => + mocks.useAccountSelection.mockReturnValue({ + selectedAccountId: undefined, + setSelectedAccountId: vi.fn(), + resolvedAccountId: 'resolved-account-id', + subaccountsEnabled, + }); + +const withEntries = { name: 'Alice', address: { Icp: 'alice-icp-account' } }; + +const setAddressBook = ({ + isLoading = false, + hasEntries = true, +}: { isLoading?: boolean; hasEntries?: boolean } = {}) => + mocks.useAddressBook.mockReturnValue({ + isLoading, + data: { response: { named_addresses: hasEntries ? [withEntries] : [] } }, + }); + +const renderModal = () => + renderWithProviders( + , + ); + +describe('DisburseMaturityModal', () => { + beforeEach(() => { + mocks.mutateAsync.mockClear(); + setAccounts(); + setAccountSelection(false); + setAddressBook(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('shows the noDestination error when the address book is toggled on but no entry is picked', async () => { + setAccountSelection(true); + renderModal(); + + await userEvent.click(screen.getByTestId('disburse-maturity-address-book-toggle')); + await userEvent.click(screen.getByRole('button', { name: /disburse/i })); + + const error = await screen.findByTestId('disburse-maturity-amount-error'); + expect(error.textContent).toContain('Pick a destination address from your address book.'); + expect(mocks.mutateAsync).not.toHaveBeenCalled(); + }); + + it('shows the read-only main-account field when the address book is off and subaccounts are disabled', () => { + setAccountSelection(false); + renderModal(); + + const mainAccount = screen.getByTestId('disburse-maturity-main-account'); + expect(mainAccount.textContent).toContain('Main'); + expect(screen.queryByTestId('disburse-maturity-account-select')).toBeFalsy(); + }); + + it('disables the Disburse button while the address book query is loading', () => { + setAddressBook({ isLoading: true }); + renderModal(); + + const disburseButton = screen.getByRole('button', { name: /disburse/i }) as HTMLButtonElement; + expect(disburseButton.disabled).toBe(true); + }); +}); diff --git a/src/governance-app-frontend/src/features/stakes/hooks/useDisburseMaturity.test.tsx b/src/governance-app-frontend/src/features/stakes/hooks/useDisburseMaturity.test.tsx new file mode 100644 index 000000000..7c8705643 --- /dev/null +++ b/src/governance-app-frontend/src/features/stakes/hooks/useDisburseMaturity.test.tsx @@ -0,0 +1,160 @@ +import { AccountIdentifier, SubAccount } from '@icp-sdk/canisters/ledger/icp'; +import { Principal } from '@icp-sdk/core/principal'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { QUERY_KEYS } from '@utils/query'; + +import { useDisburseMaturity } from './useDisburseMaturity'; + +// ─── Module mocks ──────────────────────────────────────────────── + +const disburseMaturity = vi.fn().mockResolvedValue(undefined); + +vi.mock('@hooks/governance', () => ({ + useNnsGovernance: () => ({ canister: { disburseMaturity } }), +})); + +vi.mock('ic-use-internet-identity', () => ({ + useInternetIdentity: () => ({ identity: { getPrincipal: () => Principal.anonymous() } }), +})); + +// ─── Helpers ───────────────────────────────────────────────────── + +const OWNER = Principal.fromText('rrkah-fqaaa-aaaaa-aaaaq-cai'); + +const createWrapper = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { wrapper, invalidateQueries }; +}; + +const renderDisburse = () => { + const { wrapper, invalidateQueries } = createWrapper(); + const { result } = renderHook(() => useDisburseMaturity(), { wrapper }); + return { result, invalidateQueries }; +}; + +const balanceKey = (accountId: string) => ({ + queryKey: [QUERY_KEYS.ICP_LEDGER.ACCOUNT_BALANCE, accountId], +}); + +describe('useDisburseMaturity', () => { + beforeEach(() => { + disburseMaturity.mockClear(); + }); + + describe('ICP destination', () => { + it('forwards toAccountIdentifier and omits toAccount', async () => { + const { result } = renderDisburse(); + + await result.current.mutateAsync({ + neuronId: 1n, + destination: { kind: 'icp', accountIdentifier: 'account-id-hex' }, + }); + + expect(disburseMaturity).toHaveBeenCalledWith({ + neuronId: 1n, + percentageToDisburse: 100, + toAccountIdentifier: 'account-id-hex', + }); + expect(disburseMaturity.mock.calls[0][0]).not.toHaveProperty('toAccount'); + }); + + it('invalidates the balance query for the ICP account identifier', async () => { + const { result, invalidateQueries } = renderDisburse(); + + await result.current.mutateAsync({ + neuronId: 1n, + destination: { kind: 'icp', accountIdentifier: 'account-id-hex' }, + }); + + expect(invalidateQueries).toHaveBeenCalledWith(balanceKey('account-id-hex')); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: [QUERY_KEYS.NNS_GOVERNANCE.NEURONS], + }); + }); + }); + + describe('ICRC-1 destination', () => { + it('forwards a structured toAccount with the subaccount as number[] and omits toAccountIdentifier', async () => { + const { result } = renderDisburse(); + const subaccount = Uint8Array.from({ length: 32 }, (_, i) => i); + + await result.current.mutateAsync({ + neuronId: 2n, + destination: { kind: 'icrc1', owner: OWNER, subaccount }, + }); + + expect(disburseMaturity).toHaveBeenCalledWith({ + neuronId: 2n, + percentageToDisburse: 100, + toAccount: { owner: OWNER, subaccount: Array.from(subaccount) }, + }); + expect(disburseMaturity.mock.calls[0][0]).not.toHaveProperty('toAccountIdentifier'); + }); + + it('forwards undefined subaccount when none is provided', async () => { + const { result } = renderDisburse(); + + await result.current.mutateAsync({ + neuronId: 2n, + destination: { kind: 'icrc1', owner: OWNER }, + }); + + expect(disburseMaturity).toHaveBeenCalledWith({ + neuronId: 2n, + percentageToDisburse: 100, + toAccount: { owner: OWNER, subaccount: undefined }, + }); + }); + + it('treats a non-32-byte subaccount as the default subaccount', async () => { + const { result } = renderDisburse(); + const shortSubaccount = Uint8Array.from({ length: 16 }, () => 7); + + await result.current.mutateAsync({ + neuronId: 2n, + destination: { kind: 'icrc1', owner: OWNER, subaccount: shortSubaccount }, + }); + + expect(disburseMaturity.mock.calls[0][0].toAccount.subaccount).toBeUndefined(); + }); + + it('invalidates the balance query for the derived account identifier (with subaccount)', async () => { + const { result, invalidateQueries } = renderDisburse(); + const subaccount = Uint8Array.from({ length: 32 }, (_, i) => i); + + await result.current.mutateAsync({ + neuronId: 2n, + destination: { kind: 'icrc1', owner: OWNER, subaccount }, + }); + + const expectedAccountId = AccountIdentifier.fromPrincipal({ + principal: OWNER, + subAccount: SubAccount.fromBytes(subaccount) as SubAccount, + }).toHex(); + + expect(invalidateQueries).toHaveBeenCalledWith(balanceKey(expectedAccountId)); + }); + + it('invalidates the balance query for the default account identifier when the subaccount is invalid', async () => { + const { result, invalidateQueries } = renderDisburse(); + const shortSubaccount = Uint8Array.from({ length: 16 }, () => 7); + + await result.current.mutateAsync({ + neuronId: 2n, + destination: { kind: 'icrc1', owner: OWNER, subaccount: shortSubaccount }, + }); + + const expectedAccountId = AccountIdentifier.fromPrincipal({ principal: OWNER }).toHex(); + + expect(invalidateQueries).toHaveBeenCalledWith(balanceKey(expectedAccountId)); + }); + }); +}); From f4fb1ab27a2a769af658fee92e82232d979d547c Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Wed, 1 Jul 2026 15:39:20 +0200 Subject: [PATCH 2/2] test(maturity): import i18n config in modal test for determinism --- .../features/stakes/components/DisburseMaturityModal.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/governance-app-frontend/src/features/stakes/components/DisburseMaturityModal.test.tsx b/src/governance-app-frontend/src/features/stakes/components/DisburseMaturityModal.test.tsx index d86052337..3b26aa23d 100644 --- a/src/governance-app-frontend/src/features/stakes/components/DisburseMaturityModal.test.tsx +++ b/src/governance-app-frontend/src/features/stakes/components/DisburseMaturityModal.test.tsx @@ -1,3 +1,7 @@ +// Initialize i18n so the button/error labels this test locates by text are translated, +// regardless of vitest file ordering. +import '@/i18n/config'; + import type { NeuronInfo } from '@icp-sdk/canisters/nns'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event';