Skip to content
327 changes: 281 additions & 46 deletions __tests__/components/TokensCollectiblesInline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react-native";
import { TokensCollectiblesInline } from "components/screens/SendScreen/components/TokensCollectiblesInline";
import { NETWORKS, TransactionContext } from "config/constants";
import { useCollectiblesStore } from "ducks/collectibles";
import { useBalancesList } from "hooks/useBalancesList";
import { useFilteredCollectibles } from "hooks/useFilteredCollectibles";
import React from "react";
import {
Expand Down Expand Up @@ -42,9 +43,10 @@ jest.mock("hooks/useAppTranslation", () => ({
t: (key: string) =>
({
"balancesList.title": "Tokens",
"balancesList.error": "Error loading balances",
"collectiblesGrid.title": "Collectibles",
"collectiblesGrid.error": "Error loading collectibles",
"collectiblesGrid.empty": "No collectibles",
"transactionTokenScreen.empty": "No tokens or collectibles to send",
})[key] || key,
}),
}));
Expand All @@ -65,82 +67,323 @@ jest.mock("ducks/collectibles", () => ({
useCollectiblesStore: jest.fn(),
}));

jest.mock("hooks/useBalancesList", () => ({
useBalancesList: jest.fn(),
}));

jest.mock("hooks/useFilteredCollectibles", () => ({
useFilteredCollectibles: jest.fn(),
}));

const mockUseCollectiblesStore =
useCollectiblesStore as unknown as jest.MockedFunction<any>;
const mockUseBalancesList =
useBalancesList as unknown as jest.MockedFunction<any>;
const mockUseFilteredCollectibles =
useFilteredCollectibles as unknown as jest.MockedFunction<any>;

const setupCollectibleState = ({
isLoading = false,
error = null,
const setupState = ({
// Tokens
tokensLoading = false,
tokensError = null,
noBalances = false,
// Collectibles
collectiblesLoading = false,
collectiblesError = null,
visibleCollectibles = [],
}: {
isLoading?: boolean;
error?: string | null;
tokensLoading?: boolean;
tokensError?: string | null;
noBalances?: boolean;
collectiblesLoading?: boolean;
collectiblesError?: string | null;
visibleCollectibles?: any[];
}) => {
mockUseBalancesList.mockReturnValue({
isLoading: tokensLoading,
error: tokensError,
noBalances,
});
mockUseCollectiblesStore.mockImplementation((selector: any) =>
selector({ isLoading, error }),
selector({ isLoading: collectiblesLoading, error: collectiblesError }),
);
mockUseFilteredCollectibles.mockReturnValue({ visibleCollectibles });
};

const renderComponent = (props = {}) =>
render(
<TokensCollectiblesInline
publicKey="G..."
network={NETWORKS.TESTNET}
feeContext={TransactionContext.Send}
{...props}
/>,
);

describe("TokensCollectiblesInline", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("shows loading spinner while collectibles are loading", () => {
setupCollectibleState({ isLoading: true, visibleCollectibles: [] });
it("shows a single spinner while tokens are still loading", () => {
setupState({ tokensLoading: true, noBalances: true });

render(
<TokensCollectiblesInline
publicKey="G..."
network={NETWORKS.TESTNET}
feeContext={TransactionContext.Send}
/>,
);
renderComponent();

expect(screen.getByTestId("collectibles-inline-spinner")).toBeTruthy();
expect(
screen.getByTestId("tokens-collectibles-inline-spinner"),
).toBeTruthy();
});

it("shows collectibles error state", () => {
setupCollectibleState({ error: "failed", visibleCollectibles: [] });
it("shows a single spinner while collectibles are still loading", () => {
setupState({ collectiblesLoading: true, visibleCollectibles: [] });

renderComponent();

render(
<TokensCollectiblesInline
publicKey="G..."
network={NETWORKS.TESTNET}
feeContext={TransactionContext.Send}
/>,
);
expect(
screen.getByTestId("tokens-collectibles-inline-spinner"),
).toBeTruthy();
});

it("does not blank the funded token list while tokens refresh in the background", () => {
// tokensLoading is true but balances are present (noBalances false), so the
// spinner must NOT show — matching BalancesList's own gate, the funded list
// stays visible during a background refresh.
setupState({
tokensLoading: true,
noBalances: false,
visibleCollectibles: [],
});

renderComponent();

expect(
screen.queryByTestId("tokens-collectibles-inline-spinner"),
).toBeNull();
expect(screen.getByTestId("balances-list-token-row")).toBeTruthy();
});

it("shows the collectibles error beneath its header when only collectibles fail", () => {
setupState({ collectiblesError: "failed", noBalances: true });

renderComponent();

expect(screen.getByText("Collectibles")).toBeTruthy();
expect(
screen.getByTestId("collectibles-inline-error"),
).toBeTruthy();
expect(screen.getByText("Error loading collectibles")).toBeTruthy();
});

it("shows collectibles empty state", () => {
setupCollectibleState({ visibleCollectibles: [] });
it("keeps the spinner up while one source loads even if the other has errored", () => {
// We wait for both sources before rendering content, so a settled token
// error stays hidden behind the spinner until collectibles finish loading.
setupState({
tokensError: "token-failed",
noBalances: true,
collectiblesLoading: true,
visibleCollectibles: [],
});

renderComponent();

expect(
screen.getByTestId("tokens-collectibles-inline-spinner"),
).toBeTruthy();
expect(screen.queryByTestId("tokens-inline-error")).toBeNull();
expect(screen.queryByText("Error loading balances")).toBeNull();
});

it("renders the tokens list alongside the collectibles error when only collectibles fail", () => {
setupState({ noBalances: false, collectiblesError: "failed" });

renderComponent();

expect(screen.getByText("Tokens")).toBeTruthy();
expect(screen.getByTestId("balances-list-token-row")).toBeTruthy();
expect(screen.getByText("Collectibles")).toBeTruthy();
expect(screen.getByTestId("collectibles-inline-error")).toBeTruthy();
expect(screen.getByText("Error loading collectibles")).toBeTruthy();
});

it("renders the collectibles alongside the tokens error when only tokens fail", () => {
setupState({
tokensError: "token-failed",
noBalances: true,
visibleCollectibles: [
{
collectionAddress: "CABC",
collectionName: "Cool Collection",
items: [
{
collectionAddress: "CABC",
tokenId: "42",
image: "https://example.com/item.png",
name: "Collectible #42",
},
],
},
],
});

renderComponent();

expect(screen.getByText("Tokens")).toBeTruthy();
expect(screen.getByTestId("tokens-inline-error")).toBeTruthy();
expect(screen.getByText("Error loading balances")).toBeTruthy();
expect(screen.getByText("Collectibles")).toBeTruthy();
expect(screen.getByText("Collectible #42")).toBeTruthy();
});

it("keeps the loaded collectibles visible while they reload in the background", () => {
// Once collectibles have loaded, a background refetch (which flips
// isLoading without clearing the data) must not blank the page back to a
// spinner. The network/account can't change while the send flow is
// mounted, so the loaded data is always current.
setupState({
noBalances: true,
collectiblesLoading: true,
visibleCollectibles: [
{
collectionAddress: "CABC",
collectionName: "Cool Collection",
items: [
{
collectionAddress: "CABC",
tokenId: "1",
image: "https://example.com/item.png",
name: "Collectible #1",
},
],
},
],
});

renderComponent();

expect(
screen.queryByTestId("tokens-collectibles-inline-spinner"),
).toBeNull();
expect(screen.getByText("Collectible #1")).toBeTruthy();
});

it("suppresses a stale collectibles error while a retry is loading", () => {
// The collectibles store keeps the old error set while re-fetching, so a
// retry-in-flight (loading + stale error) should show the spinner, not the
// stale error.
setupState({
noBalances: true,
collectiblesLoading: true,
collectiblesError: "stale-failure",
visibleCollectibles: [],
});

renderComponent();

expect(screen.queryByTestId("collectibles-inline-error")).toBeNull();
expect(
screen.getByTestId("tokens-collectibles-inline-spinner"),
).toBeTruthy();
});

it("shows both section errors when both sources fail", () => {
setupState({
tokensError: "token-failed",
noBalances: true,
collectiblesError: "collectibles-failed",
});

renderComponent();

expect(screen.getByTestId("tokens-inline-error")).toBeTruthy();
expect(screen.getByText("Error loading balances")).toBeTruthy();
expect(screen.getByTestId("collectibles-inline-error")).toBeTruthy();
expect(screen.getByText("Error loading collectibles")).toBeTruthy();
});

it("shows the combined empty fallback when there are no tokens or collectibles", () => {
setupState({ noBalances: true, visibleCollectibles: [] });

renderComponent();

expect(
screen.getByText("No tokens or collectibles to send"),
).toBeTruthy();
});

it("hides the collectibles section when there are no collectibles", () => {
setupState({ noBalances: false, visibleCollectibles: [] });

renderComponent();

expect(screen.getByText("Tokens")).toBeTruthy();
expect(screen.getByTestId("balances-list-token-row")).toBeTruthy();
expect(screen.queryByText("Collectibles")).toBeNull();
expect(
screen.queryByText("No tokens or collectibles to send"),
).toBeNull();
});

it("renders both the tokens and collectibles sections when both are present", () => {
setupState({
noBalances: false,
visibleCollectibles: [
{
collectionAddress: "CABC",
collectionName: "Cool Collection",
items: [
{
collectionAddress: "CABC",
tokenId: "42",
image: "https://example.com/item.png",
name: "Collectible #42",
},
],
},
],
});

renderComponent();

expect(screen.getByText("Tokens")).toBeTruthy();
expect(screen.getByTestId("balances-list-token-row")).toBeTruthy();
expect(screen.getByText("Collectibles")).toBeTruthy();
expect(screen.getByText("Collectible #42")).toBeTruthy();
expect(
screen.queryByText("No tokens or collectibles to send"),
).toBeNull();
});

it("hides the tokens section when there are no balances", () => {
setupState({
noBalances: true,
visibleCollectibles: [
{
collectionAddress: "CABC",
collectionName: "Cool Collection",
items: [
{
collectionAddress: "CABC",
tokenId: "42",
image: "https://example.com/item.png",
name: "Collectible #42",
},
],
},
],
});

render(
<TokensCollectiblesInline
publicKey="G..."
network={NETWORKS.TESTNET}
feeContext={TransactionContext.Send}
/>,
);
renderComponent();

expect(screen.getByText("No collectibles")).toBeTruthy();
expect(screen.queryByTestId("balances-list-token-row")).toBeNull();
expect(screen.getByText("Collectibles")).toBeTruthy();
});

it("forwards token and collectible press handlers", () => {
const onTokenPress = jest.fn();
const onCollectiblePress = jest.fn();

setupCollectibleState({
setupState({
visibleCollectibles: [
{
collectionAddress: "CABC",
Expand All @@ -157,15 +400,7 @@ describe("TokensCollectiblesInline", () => {
],
});

render(
<TokensCollectiblesInline
publicKey="G..."
network={NETWORKS.TESTNET}
onTokenPress={onTokenPress}
onCollectiblePress={onCollectiblePress}
feeContext={TransactionContext.Send}
/>,
);
renderComponent({ onTokenPress, onCollectiblePress });

fireEvent.press(screen.getByTestId("balances-list-token-row"));
expect(onTokenPress).toHaveBeenCalledWith("native");
Expand Down
Loading
Loading