Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,11 @@ jest.mock('@/utils/general.utils', () => ({
formatNumberForDisplay: jest.fn((v: any) => v ?? '0'),
}))

const mockGetCountryFromAccount = jest.fn(
() => ({ iso2: 'US', path: 'us' }) as { iso2: string; path: string } | undefined
)
jest.mock('@/utils/bridge.utils', () => ({
getCountryFromAccount: jest.fn(() => ({ iso2: 'US', path: 'us' })),
getCountryFromAccount: mockGetCountryFromAccount,
getCountryFromPath: jest.fn(() => ({ iso2: 'US', id: 'US' })),
getMinimumAmount: jest.fn(() => 1),
railJurisdictionForBank: jest.fn(() => 'US'),
Expand Down Expand Up @@ -245,7 +248,8 @@ function applyDefaults() {
mockWithdrawFlow.selectedBankAccount = null

mockUseWallet.mockReturnValue({
balance: parseUnits('100', 6),
// component destructures `spendableBalance` (not `balance`) — CodeRabbit nit
spendableBalance: parseUnits('100', 6),
})

mockUseGetExchangeRate.mockReturnValue({
Expand All @@ -266,6 +270,10 @@ beforeEach(() => {
jest.clearAllMocks()
mockSearchParams.clear()
applyDefaults()
// clearAllMocks() resets call history but not implementations, so restore
// the default country resolution here — tests that override it (GROUP 6)
// then don't leak into later tests regardless of order or early failure.
mockGetCountryFromAccount.mockReturnValue({ iso2: 'US', path: 'us' })
})

// ============================================================
Expand Down Expand Up @@ -472,3 +480,48 @@ describe('GROUP 5: Navigation', () => {
expect(mockSetSelectedBankAccount).toHaveBeenCalledWith(null)
})
})

// ============================================================
// GROUP 6: Continue must never silently die (regression)
// ============================================================
describe('GROUP 6: Continue never dead-buttons', () => {
test('Unresolved bank-account country shows an error instead of throwing (dead button)', () => {
// Regression for the "press Continue, nothing happens" report: when
// getCountryFromAccount can't resolve a country, the handler used to
// `throw` inside onClick — aborting the router transition with no UI
// feedback (Sentry: incomplete-app-router-transaction, 6 users/14d).
mockGetCountryFromAccount.mockReturnValue(undefined)

mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6) })
mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' }
mockWithdrawFlow.selectedBankAccount = { type: 'iban', details: { countryName: '', countryCode: '' } }
mockWithdrawFlow.amountToWithdraw = '50'

renderWithdraw()

// Pressing Continue must NOT throw and must NOT navigate...
expect(() => fireEvent.click(screen.getByText('Continue'))).not.toThrow()
expect(mockRouterPush).not.toHaveBeenCalled()
// ...it surfaces a recoverable error instead.
expect(mockSetError).toHaveBeenCalledWith({
showError: true,
errorMessage: "We couldn't determine this account's country. Please contact support.",
})
})

test('Manteca account routes to the Manteca flow, not the bank branch', () => {
// Manteca (AR/BR) accounts set selectedBankAccount too; the manteca
// method check must win over the generic bank branch so they reach
// /withdraw/manteca rather than the Bridge bank page (or the throw).
mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6) })
mockWithdrawFlow.selectedMethod = { type: 'manteca', countryPath: 'argentina', title: 'Bank Transfer' }
mockWithdrawFlow.selectedBankAccount = { type: 'manteca', details: { countryName: 'argentina' } }
mockWithdrawFlow.amountToWithdraw = '50'

renderWithdraw()

fireEvent.click(screen.getByText('Continue'))
expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining('/withdraw/manteca'))
expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining('country=argentina'))
})
})
44 changes: 36 additions & 8 deletions src/app/(mobile-ui)/withdraw/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,21 +273,37 @@ export default function WithdrawPage() {
if (selectedMethod.type === 'crypto') {
const queryParams = isFromSendFlow ? `?${methodQueryParam}` : ''
router.push(`/withdraw/crypto${queryParams}`)
} else if (selectedMethod.type === 'manteca') {
// Manteca (AR/BR) accounts route to the Manteca flow. Checked BEFORE
// the generic saved-bank-account branch below — that branch targets
// the Bridge bank page via getCountryFromAccount and would both
// mis-route a Manteca account and throw when its country can't be
// resolved. Route directly with method + country params instead.
const mantecaMethodParam = selectedMethod.title?.toLowerCase().replace(/\s+/g, '-') || 'bank-transfer'
const additionalParams = isFromSendFlow ? `&${methodQueryParam}` : ''
router.push(
`/withdraw/manteca?method=${mantecaMethodParam}&country=${selectedMethod.countryPath}${additionalParams}`
)
} else if (selectedBankAccount) {
const country = getCountryFromAccount(selectedBankAccount)
if (country) {
const queryParams = isFromSendFlow ? `?${methodQueryParam}` : ''
router.push(withdrawBankUrl(country.path, queryParams))
} else {
throw new Error('Failed to get country from bank account')
// Never throw inside the click handler: a synchronous throw aborts
// the router transition with no UI feedback, so the button silently
// dies ("press Continue, nothing happens"). Surface a recoverable
// error and log for observability instead.
console.error('[withdraw] could not resolve country from saved bank account', {
type: selectedBankAccount.type,
countryName: selectedBankAccount.details?.countryName,
countryCode: selectedBankAccount.details?.countryCode,
})
setError({
showError: true,
errorMessage: "We couldn't determine this account's country. Please contact support.",
})
}
} else if (selectedMethod.type === 'manteca') {
// Route directly to Manteca with method and country params
const mantecaMethodParam = selectedMethod.title?.toLowerCase().replace(/\s+/g, '-') || 'bank-transfer'
const additionalParams = isFromSendFlow ? `&${methodQueryParam}` : ''
router.push(
`/withdraw/manteca?method=${mantecaMethodParam}&country=${selectedMethod.countryPath}${additionalParams}`
)
} else if (selectedMethod.type === 'bridge' && selectedMethod.countryPath) {
// Bridge countries go to country page for bank account form
const queryParams = isFromSendFlow ? `?${methodQueryParam}` : ''
Expand All @@ -296,6 +312,18 @@ export default function WithdrawPage() {
// Other countries go to their country pages
const queryParams = isFromSendFlow ? `?${methodQueryParam}` : ''
router.push(withdrawCountryUrl(selectedMethod.countryPath, queryParams))
} else {
// No branch matched the selected method — surface an error rather
// than leaving the user with a silently-dead Continue button.
console.error('[withdraw] no route matched for selected method', {
type: selectedMethod.type,
countryPath: selectedMethod.countryPath,
hasBankAccount: !!selectedBankAccount,
})
setError({
showError: true,
errorMessage: 'Something went wrong setting up your withdrawal. Please contact support.',
})
}
}
}
Expand Down
Loading