Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
Copyright 2022-2025 Pera Wallet, LDA
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License
*/

import { renderHook, act } from '@test-utils/render'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Decimal } from 'decimal.js'
import { useAccountAssetList } from '../useAccountAssetList'
import { UserRejectedSigningError } from '@perawallet/wallet-core-signing'
import type { WalletAccount } from '@perawallet/wallet-core-accounts'

const mockAccount = {
address: 'test-address',
name: 'Test Account',
} as WalletAccount

const mockT = (key: string) => key

vi.mock('@react-navigation/native', () => ({
useNavigation: () => ({ navigate: vi.fn() }),
}))

vi.mock('@react-navigation/native-stack', () => ({}))

vi.mock('@perawallet/wallet-core-accounts', async importOriginal => {
const actual =
await importOriginal<
typeof import('@perawallet/wallet-core-accounts')
>()
return {
...actual,
useAccountBalancesQuery: vi.fn(() => ({
accountBalances: new Map([
[
'test-address',
{
assetBalances: [
{
assetId: '123',
amount: new Decimal(0),
algoValue: new Decimal(0),
},
],
},
],
]),
isPending: false,
})),
useAccountLogicalType: vi.fn(() => 'Algo25'),
isSigningLogicalType: vi.fn(() => true),
useSortedAssetBalances: vi.fn(() => ({
sortedBalances: [],
assetSortMode: 'balanceDesc',
})),
}
})

const { mockOptOut } = vi.hoisted(() => ({
mockOptOut: vi.fn().mockResolvedValue({ txIds: ['tx1'] }),
}))

vi.mock('@perawallet/wallet-core-transactions', () => ({
useAssetOptOutMutation: () => ({
optOut: mockOptOut,
isLoading: false,
}),
}))

const { mockShowToast } = vi.hoisted(() => ({
mockShowToast: vi.fn(),
}))

vi.mock('@hooks/useToast', () => ({
useToast: () => ({ showToast: mockShowToast }),
}))

vi.mock('@hooks/useAlgodErrorMessage', () => ({
useAlgodErrorMessage: () => ({
getMessage: (_err: unknown) => ({ title: 'Error', body: 'error body' }),
}),
}))

vi.mock('@perawallet/wallet-core-assets', async importOriginal => {
const actual =
await importOriginal<typeof import('@perawallet/wallet-core-assets')>()
return {
...actual,
useAssetsQuery: vi.fn(() => ({ data: new Map() })),
useAssetPricesQuery: vi.fn(() => ({ data: new Map() })),
useAssetPreferencesStore: vi.fn(
(selector: (state: unknown) => unknown) =>
selector({
hideZeroBalance: false,
displayNfts: true,
displayOptedInNfts: true,
}),
),
isCollectible: vi.fn(() => false),
}
})

vi.mock('@perawallet/wallet-core-search', () => ({
useGlobalSearch: vi.fn(() => ({
value: '',
setValue: vi.fn(),
results: { assets: [] },
isLoading: false,
})),
}))

vi.mock('@constants/ui', () => ({
SEARCH_DEBOUNCE_TIME_SHORT: 150,
}))

describe('useAccountAssetList', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('does not show an error toast when user cancels the signing overlay', async () => {
mockOptOut.mockRejectedValueOnce(new UserRejectedSigningError())

const { result } = renderHook(() =>
useAccountAssetList({ account: mockAccount, t: mockT }),
)

// Set up an asset for opt-out and confirm
act(() => {
result.current.handleOptOut({
assetId: '123',
amount: new Decimal(0),
algoValue: new Decimal(0),
})
})

await act(async () => {
await result.current.handleConfirmOptOut()
})

expect(mockShowToast).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})

it('shows an error toast when opt-out fails with a non-cancel error', async () => {
mockOptOut.mockRejectedValueOnce(new Error('Network error'))

const { result } = renderHook(() =>
useAccountAssetList({ account: mockAccount, t: mockT }),
)

act(() => {
result.current.handleOptOut({
assetId: '123',
amount: new Decimal(0),
algoValue: new Decimal(0),
})
})

await act(async () => {
await result.current.handleConfirmOptOut()
})

expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type AssetSortMode,
} from '@perawallet/wallet-core-assets'
import { useGlobalSearch } from '@perawallet/wallet-core-search'
import { UserRejectedSigningError } from '@perawallet/wallet-core-signing'
import { useAssetOptOutMutation } from '@perawallet/wallet-core-transactions'
import { useAlgodErrorMessage } from '@hooks/useAlgodErrorMessage'
import { useModalState, ModalState } from '@hooks/useModalState'
Expand Down Expand Up @@ -206,6 +207,10 @@ export const useAccountAssetList = ({
type: 'success',
})
} catch (err) {
if (err instanceof UserRejectedSigningError) {
// User dismissed the LedgerSigningOverlay — overlay already went away; no toast.
return
}
showToast({
title: t('asset_opt_out.error'),
body: getMessage(err).body,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Decimal } from 'decimal.js'
import { useRemoveAssetsScreen } from '../useRemoveAssetsScreen'
import type { Nullable } from '@perawallet/wallet-core-shared'
import { UserRejectedSigningError } from '@perawallet/wallet-core-signing'

const mockAccount = { address: 'test-address', name: 'Test' }

Expand Down Expand Up @@ -282,4 +283,20 @@ describe('useRemoveAssetsScreen', () => {

expect(result.current.removeError?.message).toBe('Rate limited')
})

it('does not set removeError when user cancels the signing overlay', async () => {
mockOptOut.mockRejectedValueOnce(new UserRejectedSigningError())

const { result } = renderHook(() => useRemoveAssetsScreen())

act(() => {
result.current.handleToggleSelect('123')
})

await act(async () => {
await result.current.handleRemoveSelected()
})

expect(result.current.removeError).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
useAssetsQuery,
PeraAsset,
} from '@perawallet/wallet-core-assets'
import { UserRejectedSigningError } from '@perawallet/wallet-core-signing'
import { useAssetOptOutMutation } from '@perawallet/wallet-core-transactions'
import { useLanguage } from '@hooks/useLanguage'
import type { Nullable } from '@perawallet/wallet-core-shared'
Expand Down Expand Up @@ -145,6 +146,10 @@ export const useRemoveAssetsScreen = (): UseRemoveAssetsScreenResult => {
await optOut(optOutParams)
setSelectedAssetIds(new Set())
} catch (err) {
if (err instanceof UserRejectedSigningError) {
// User dismissed the LedgerSigningOverlay — overlay already went away; no toast.
return
}
setRemoveError(err instanceof Error ? err : new Error(String(err)))
}
}, [selectedAccount, selectedAssetIds, assets, optOut])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
Copyright 2022-2025 Pera Wallet, LDA
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License
*/

import { renderHook, act } from '@test-utils/render'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Decimal } from 'decimal.js'
import { useAddAssetScreen } from '../useAddAssetScreen'
import { UserRejectedSigningError } from '@perawallet/wallet-core-signing'

const mockAccount = { address: 'test-address', name: 'Test Account' }

const { mockGetSelectedAccount } = vi.hoisted(() => ({
mockGetSelectedAccount: vi.fn(() => mockAccount),
}))

vi.mock('@perawallet/wallet-core-accounts', async importOriginal => {
const actual =
await importOriginal<
typeof import('@perawallet/wallet-core-accounts')
>()
return {
...actual,
useAccountsStore: vi.fn((selector: (state: unknown) => unknown) =>
selector({ getSelectedAccount: mockGetSelectedAccount }),
),
useAccountBalancesQuery: vi.fn(() => ({
accountBalances: new Map([
[
'test-address',
{
assetBalances: [
{
assetId: '123',
amount: new Decimal(1),
algoValue: new Decimal(1),
},
],
},
],
]),
})),
}
})

const { mockOptIn } = vi.hoisted(() => ({
mockOptIn: vi.fn().mockResolvedValue({ txIds: ['tx1'] }),
}))

vi.mock('@perawallet/wallet-core-transactions', () => ({
useAssetOptInMutation: () => ({
optIn: mockOptIn,
isLoading: false,
}),
}))

const { mockShowToast } = vi.hoisted(() => ({
mockShowToast: vi.fn(),
}))

vi.mock('@hooks/useToast', () => ({
useToast: () => ({ showToast: mockShowToast }),
}))

vi.mock('@hooks/useAlgodErrorMessage', () => ({
useAlgodErrorMessage: () => ({
getMessage: (_err: unknown) => ({ title: 'Error', body: 'error body' }),
}),
}))

vi.mock('@hooks/useLanguage', () => ({
useLanguage: () => ({ t: (key: string) => key }),
}))

vi.mock('@perawallet/wallet-core-search', () => ({
useGlobalSearch: vi.fn(() => ({
value: '',
setValue: vi.fn(),
results: {
remoteAssets: [
{ assetId: '999', name: 'Test Asset', unitName: 'TST' },
],
},
isLoading: false,
hasNextRemotePage: false,
isFetchingNextRemotePage: false,
fetchNextRemotePage: vi.fn(),
})),
}))

vi.mock('@constants/ui', () => ({
SEARCH_DEBOUNCE_TIME: 300,
}))

describe('useAddAssetScreen', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSelectedAccount.mockReturnValue(mockAccount)
})

it('does not show an error toast when user cancels the signing overlay', async () => {
mockOptIn.mockRejectedValueOnce(new UserRejectedSigningError())

const { result } = renderHook(() => useAddAssetScreen())

// Request and confirm opt-in for an asset
act(() => {
result.current.handleRequestAdd('999')
})

await act(async () => {
await result.current.handleConfirmAdd()
})

expect(mockShowToast).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})

it('shows an error toast when opt-in fails with a non-cancel error', async () => {
mockOptIn.mockRejectedValueOnce(new Error('Network error'))

const { result } = renderHook(() => useAddAssetScreen())

act(() => {
result.current.handleRequestAdd('999')
})

await act(async () => {
await result.current.handleConfirmAdd()
})

expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
)
})
})
Loading
Loading