-
Notifications
You must be signed in to change notification settings - Fork 14
fix(add-money): stop dead-ending ready users behind the 'You're all set' modal #2191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| /* eslint-disable @typescript-eslint/no-explicit-any -- jest.mock factories stub component props with `any`; matches the sibling add-money-states.test.tsx style. */ | ||
| /** | ||
| * Regression coverage for the deposit/withdraw method list's bank gate. | ||
| * | ||
| * P0 (2026-06-01 → 06-06): a user whose own-country bank rail was ENABLED | ||
| * (scoped gate = `ready`) but who ALSO had a sibling bank rail in `pending` | ||
| * (a second-country enrollment / a still-provisioning rail) got intercepted | ||
| * by an unscoped `isBankRailUnderReview` check and dead-ended behind a | ||
| * "You're all set / Go back" modal — unable to deposit. The gate already | ||
| * ranks `ready` above `pending`; the extra check re-litigated that and lost. | ||
| * | ||
| * Fix: the gate's `kind` is the sole go/no-go signal (matching the sibling | ||
| * /add-money/[country]/bank page). These tests assert (1) a `ready` user with | ||
| * a pending sibling rail PROCEEDS, and (2) gating is still enforced when the | ||
| * gate is NOT ready — so the fix didn't just delete the guard wholesale. | ||
| */ | ||
| import React from 'react' | ||
| import { render, screen, fireEvent } from '@testing-library/react' | ||
| import AddWithdrawCountriesList from '../AddWithdrawCountriesList' | ||
|
|
||
| // ---- routing ---- | ||
| const mockPush = jest.fn() | ||
| const mockParams: Record<string, string> = { country: 'testland' } | ||
| jest.mock('next/navigation', () => ({ | ||
| useRouter: () => ({ push: mockPush }), | ||
| useParams: () => mockParams, | ||
| useSearchParams: () => new URLSearchParams(), | ||
| })) | ||
|
|
||
| // ---- consts: one country ('testland', id 'US') with a bank add-method and a | ||
| // Bridge bank withdraw-method (the withdraw path also runs checkBridgeGate). ---- | ||
| jest.mock('@/components/AddMoney/consts', () => ({ | ||
| countryData: [{ type: 'country', path: 'testland', id: 'US', title: 'Testland', currency: 'usd' }], | ||
| COUNTRY_SPECIFIC_METHODS: { | ||
| US: { | ||
| add: [ | ||
| { | ||
| id: 'bank-add', | ||
| title: 'Bank', | ||
| description: 'Add via bank transfer', | ||
| icon: 'bank', | ||
| path: '/add-money/testland/bank', | ||
| }, | ||
| ], | ||
| // id contains 'default-bank-withdraw' → routes through checkBridgeGate | ||
| // (not the Manteca direct path), so it exercises the same gate. | ||
| withdraw: [ | ||
| { | ||
| id: 'us-default-bank-withdraw', | ||
| title: 'To Bank', | ||
| description: 'Withdraw to your bank', | ||
| icon: 'bank', | ||
| isSoon: false, | ||
| }, | ||
| ], | ||
| }, | ||
| }, | ||
| })) | ||
|
|
||
| // ---- capability gate (the unit under test reads gateFor) ---- | ||
| // `setCapabilities` lets each test pick the gate kind + the rail set so we can | ||
| // reproduce the exact bug fixture: ready gate + a pending sibling rail. | ||
| const mockUseCapabilities = jest.fn() | ||
| jest.mock('@/hooks/useCapabilities', () => ({ | ||
| useCapabilities: () => mockUseCapabilities(), | ||
| })) | ||
| function setCapabilities(gateKind: string, rails: Array<{ status: string; channel?: string; country?: string }>) { | ||
| mockUseCapabilities.mockReturnValue({ | ||
| isKycApproved: rails.some((r) => r.status === 'enabled'), | ||
| gateFor: () => ({ kind: gateKind }), | ||
| // bankRails is intentionally NOT consumed by the component any more; | ||
| // expose a faithful (scope-honoring) impl so a future re-introduction | ||
| // of an unscoped read is caught rather than silently passing. | ||
| bankRails: (opts?: { country?: string }) => | ||
| rails.filter((r) => r.channel === 'bank' && (!opts?.country || r.country === opts.country)), | ||
| }) | ||
| } | ||
|
|
||
| // ---- light mocks for everything else the component imports ---- | ||
| jest.mock('@/context/authContext', () => ({ | ||
| useAuth: () => ({ user: { accounts: [] }, fetchUser: jest.fn() }), | ||
| })) | ||
| jest.mock('@/context/WithdrawFlowContext', () => ({ | ||
| useWithdrawFlow: () => ({ | ||
| setSelectedBankAccount: jest.fn(), | ||
| amountToWithdraw: '', | ||
| setSelectedMethod: jest.fn(), | ||
| setAmountToWithdraw: jest.fn(), | ||
| }), | ||
| })) | ||
| jest.mock('@/context/ModalsContext', () => ({ | ||
| useModalsContext: () => ({ setIsSupportModalOpen: jest.fn() }), | ||
| })) | ||
| jest.mock('@/hooks/useTosGuard', () => ({ | ||
| useTosGuard: () => ({ guardWithTos: jest.fn(), showBridgeTos: false, hideTos: jest.fn() }), | ||
| })) | ||
| jest.mock('@/hooks/useMultiPhaseKycFlow', () => ({ | ||
| useMultiPhaseKycFlow: () => ({ | ||
| handleInitiateKyc: jest.fn(), | ||
| handleSelfHealResubmit: jest.fn(), | ||
| isLoading: false, | ||
| error: null, | ||
| showWrapper: false, | ||
| }), | ||
| })) | ||
| jest.mock('@/hooks/useSafeBack', () => ({ useSafeBack: () => jest.fn() })) | ||
| jest.mock('@/hooks/useGetDeviceType', () => ({ | ||
| DeviceType: { IOS: 'IOS', ANDROID: 'ANDROID', WEB: 'WEB' }, | ||
| useDeviceType: () => ({ deviceType: 'WEB' }), | ||
| })) | ||
| jest.mock('@/redux/hooks', () => ({ useAppDispatch: () => jest.fn() })) | ||
| jest.mock('@/redux/slices/bank-form-slice', () => ({ bankFormActions: { clearFormData: () => ({ type: 'noop' }) } })) | ||
| jest.mock('@/app/actions/users', () => ({ addBankAccount: jest.fn() })) | ||
| jest.mock('@/utils/native-routes', () => ({ | ||
| rewriteMethodPath: (p: string) => p, | ||
| withdrawBankUrl: (p: string) => `/withdraw/${p}`, | ||
| })) | ||
| jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false })) | ||
| jest.mock('@/utils/color.utils', () => ({ getColorForUsername: () => ({ lightShade: '#fff' }) })) | ||
| jest.mock('@/utils/withdraw.utils', () => ({ getCountryCodeForWithdraw: (id: string) => id })) | ||
| // bridge.utils + regions.utils are direct util collaborators that transitively | ||
| // pull the heavy @/components/AddMoney/consts barrel (regions.utils computes a | ||
| // top-level `Object.values(BRIDGE_ALPHA3_TO_ALPHA2)` at import time, which throws | ||
| // under jest when consts is stubbed). The gate is mocked, so neither return value | ||
| // affects these assertions — stub both so the real consts is never evaluated. | ||
| jest.mock('@/utils/bridge.utils', () => ({ railJurisdictionForBank: () => 'US' })) | ||
| jest.mock('@/utils/regions.utils', () => ({ getRegionIntent: () => 'STANDARD' })) | ||
|
|
||
| jest.mock('@/components/ActionListCard', () => ({ | ||
| ActionListCard: (props: any) => ( | ||
| <button data-testid={`method-${props.title?.toLowerCase()}`} onClick={props.onClick}> | ||
| {props.title} | ||
| </button> | ||
|
Comment on lines
+130
to
+133
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace
Typed mock props (minimal)+type ActionListCardMockProps = { title?: string; onClick?: () => void }
jest.mock('`@/components/ActionListCard`', () => ({
- ActionListCard: (props: any) => (
+ ActionListCard: (props: ActionListCardMockProps) => (
<button data-testid={`method-${props.title?.toLowerCase()}`} onClick={props.onClick}>
{props.title}
</button>
),
}))
@@
+type InitiateKycModalMockProps = { visible: boolean }
jest.mock('`@/components/Kyc/InitiateKycModal`', () => ({
- InitiateKycModal: (props: any) => (props.visible ? <div data-testid="initiate-kyc-modal" /> : null),
+ InitiateKycModal: (props: InitiateKycModalMockProps) =>
+ props.visible ? <div data-testid="initiate-kyc-modal" /> : null,
}))Also applies to: 128-129 🧰 Tools🪛 ESLint[error] 110-110: Unexpected any. Specify a different type. ( 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| ), | ||
| })) | ||
| jest.mock('@/components/Global/NavHeader', () => ({ | ||
| __esModule: true, | ||
| default: () => <div data-testid="nav-header" />, | ||
| })) | ||
| jest.mock('@/components/Global/Badges/StatusBadge', () => ({ __esModule: true, default: () => <span /> })) | ||
| jest.mock('@/components/Profile/AvatarWithBadge', () => ({ __esModule: true, default: () => <span /> })) | ||
| jest.mock('@/components/Global/EmptyStates/EmptyState', () => ({ __esModule: true, default: () => <div /> })) | ||
| jest.mock('@/components/AddWithdraw/DynamicBankAccountForm', () => ({ DynamicBankAccountForm: () => <div /> })) | ||
| jest.mock('@/components/Global/TokenAndNetworkConfirmationModal', () => ({ __esModule: true, default: () => null })) | ||
| jest.mock('@/components/Kyc/SumsubKycModals', () => ({ SumsubKycModals: () => null })) | ||
| jest.mock('@/components/Kyc/BridgeTosStep', () => ({ BridgeTosStep: () => null })) | ||
| jest.mock('@/components/Kyc/InitiateKycModal', () => ({ | ||
| InitiateKycModal: (props: any) => (props.visible ? <div data-testid="initiate-kyc-modal" /> : null), | ||
| })) | ||
| jest.mock('next/image', () => ({ __esModule: true, default: () => null })) | ||
|
|
||
| describe('AddWithdrawCountriesList — bank gate', () => { | ||
| beforeEach(() => { | ||
| mockPush.mockClear() | ||
| }) | ||
|
|
||
| it('P0 regression: ready gate + a pending sibling bank rail still lets the user proceed', () => { | ||
| // own-country (US) rail enabled → scoped gate = ready; a *second* bank | ||
| // rail elsewhere is pending. Pre-fix this opened the dead-end modal. | ||
| setCapabilities('ready', [ | ||
| { status: 'enabled', channel: 'bank', country: 'US' }, | ||
| { status: 'pending', channel: 'bank', country: 'EU' }, | ||
| ]) | ||
|
|
||
| render(<AddWithdrawCountriesList flow="add" />) | ||
| fireEvent.click(screen.getByTestId('method-bank')) | ||
|
|
||
| // navigates to the bank deposit page; no KYC/status modal intercept | ||
| expect(mockPush).toHaveBeenCalledWith('/add-money/testland/bank') | ||
| expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull() | ||
| }) | ||
|
|
||
| it('also proceeds when the pending sibling rail is in the SAME country (country-scoping alone would not fix this)', () => { | ||
| // The kyc-2.0 case the documented one-liner missed: a working Manteca- | ||
| // style rail and a pending rail share the user's own country. | ||
| setCapabilities('ready', [ | ||
| { status: 'enabled', channel: 'bank', country: 'US' }, | ||
| { status: 'pending', channel: 'bank', country: 'US' }, | ||
| ]) | ||
|
|
||
| render(<AddWithdrawCountriesList flow="add" />) | ||
| fireEvent.click(screen.getByTestId('method-bank')) | ||
|
|
||
| expect(mockPush).toHaveBeenCalledWith('/add-money/testland/bank') | ||
| expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull() | ||
| }) | ||
|
|
||
| it('still gates: a non-ready gate blocks navigation and surfaces the KYC modal', () => { | ||
| setCapabilities('needs-identity', []) | ||
|
|
||
| render(<AddWithdrawCountriesList flow="add" />) | ||
| fireEvent.click(screen.getByTestId('method-bank')) | ||
|
|
||
| expect(mockPush).not.toHaveBeenCalled() | ||
| expect(screen.getByTestId('initiate-kyc-modal')).toBeInTheDocument() | ||
| }) | ||
|
|
||
| // checkBridgeGate is shared by BOTH flows — cover the withdraw entry too so | ||
| // the removal can't silently regress bank withdrawals. | ||
| it('withdraw flow: ready gate + pending sibling proceeds to /withdraw (no dead-end modal)', () => { | ||
| setCapabilities('ready', [ | ||
| { status: 'enabled', channel: 'bank', country: 'US' }, | ||
| { status: 'pending', channel: 'bank', country: 'EU' }, | ||
| ]) | ||
|
|
||
| render(<AddWithdrawCountriesList flow="withdraw" />) | ||
| fireEvent.click(screen.getByText('To Bank')) | ||
|
|
||
| expect(mockPush).toHaveBeenCalledWith('/withdraw') | ||
| expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull() | ||
| }) | ||
|
|
||
| it('withdraw flow: a non-ready gate still blocks + surfaces the KYC modal', () => { | ||
| setCapabilities('needs-identity', []) | ||
|
|
||
| render(<AddWithdrawCountriesList flow="withdraw" />) | ||
| fireEvent.click(screen.getByText('To Bank')) | ||
|
|
||
| expect(mockPush).not.toHaveBeenCalled() | ||
| expect(screen.getByTestId('initiate-kyc-modal')).toBeInTheDocument() | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
gateFormock currently hides scope-wiring regressions.gateForignoresop/scope, so these tests can still pass even if the component stops passing a country-scoped bank gate (the key fix in this PR). Please spy ongateForand assert the scoped call shape.Proposed test hardening
Also applies to: 145-176
🤖 Prompt for AI Agents