From 54ed61d346c74f4df0c3ef585fc161592eb2432a Mon Sep 17 00:00:00 2001 From: WEB3NOVA Date: Sat, 29 Aug 2026 16:13:36 +0100 Subject: [PATCH] test(history): provider-rendered coverage for pagination, address-change reset, loading and error (#554) - Rewrite the stale (#525) regression test: sessionStorage page-restore was removed (fc66b90), so it now asserts a page reached for one address never leaks into the next account's requests - Add TransactionHistory.provider.test.tsx rendering the component inside a real SorokitContext (mock SorokitProvider), covering page-1 initial fetch, rendered rows, loading skeleton, Prev disabled on page 1, Next -> page 2, fetch-error message, address-change reset to page 1, and the connect-your-wallet empty state --- .../TransactionHistory.provider.test.tsx | 236 ++++++++++++++++++ src/components/TransactionHistory.test.tsx | 47 ++-- 2 files changed, 265 insertions(+), 18 deletions(-) create mode 100644 src/components/TransactionHistory.provider.test.tsx diff --git a/src/components/TransactionHistory.provider.test.tsx b/src/components/TransactionHistory.provider.test.tsx new file mode 100644 index 0000000..f548edf --- /dev/null +++ b/src/components/TransactionHistory.provider.test.tsx @@ -0,0 +1,236 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SorokitContext, + type SorokitState, +} from "@/context/SorokitContext"; +import type { NetworkInfo, SorokitClient, Transaction } from "@/lib/client"; + +import { TransactionHistory } from "./TransactionHistory"; + +const ADDRESS_A = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA"; +const ADDRESS_B = "GBBD7PQPDHFWD6Q5CFF3J4L3R75EAE6Z4NZZ2QY6M2G4K4W4P6N3X2B1"; +const PAGE_SIZE = 10; + +function makeTx(i: number): Transaction { + return { + hash: `hash${String(i).padStart(56, "0")}`, + ledger: 1000 + i, + createdAt: new Date("2024-01-01").toISOString(), + successful: true, + operationCount: 1, + feePaid: "100", + }; +} + +const PENDING = () => new Promise(() => {}); + +/** + * A lightweight stand-in for `SorokitProvider` that exposes the real + * `SorokitContext` so `TransactionHistory` runs through its actual + * `useSorokit()` path. Tests control `address`, `isConnected`, `network`, + * and the `client` directly, then flip the address by re-rendering — the + * only way to exercise the component's address-change reset from inside a + * provider. + */ +function MockSorokitProvider({ + client, + address, + networkName, + children, +}: { + client: SorokitClient; + address: string | null; + networkName?: string; + children: React.ReactNode; +}) { + const value: SorokitState = { + client, + address, + walletName: null, + isConnected: address != null, + isConnecting: false, + isLoading: false, + connectWallet: PENDING, + disconnectWallet: PENDING, + isDisconnecting: false, + account: null, + balances: [], + isLoadingAccount: false, + refreshAccount: PENDING, + network: networkName + ? ({ name: networkName } as NetworkInfo) + : null, + switchNetwork: PENDING, + error: null, + errorHistory: [], + clearError: vi.fn(), + }; + + return ( + {children} + ); +} + +describe("TransactionHistory inside a mock SorokitProvider (#554)", () => { + let getHistory: ReturnType; + let client: SorokitClient; + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.clearAllMocks(); + getHistory = vi.fn().mockResolvedValue({ + data: Array.from({ length: PAGE_SIZE }, (_, i) => makeTx(i)), + error: null, + total: 25, // 3 pages with the default page size + }); + client = { transaction: { getHistory } } as unknown as SorokitClient; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function renderAt( + address: string | null, + networkName?: string, + overrides?: Partial>, + ) { + return render( + + + , + overrides, + ); + } + + it("requests page 1 on first render", async () => { + renderAt(ADDRESS_A); + act(() => { + vi.advanceTimersByTime(0); + }); + + await waitFor(() => { + expect(getHistory).toHaveBeenCalledWith(ADDRESS_A, 1, PAGE_SIZE); + }); + }); + + it("renders transactions after data is returned", async () => { + renderAt(ADDRESS_A); + act(() => { + vi.advanceTimersByTime(0); + }); + + await waitFor(() => { + expect(screen.getAllByRole("article")).toHaveLength(PAGE_SIZE); + }); + expect(screen.getByText(`${PAGE_SIZE} shown`)).toBeInTheDocument(); + }); + + it("renders the loading skeleton while the first fetch is pending", async () => { + getHistory.mockReturnValue(PENDING()); + renderAt(ADDRESS_A); + act(() => { + vi.advanceTimersByTime(0); + }); + + // 5 skeleton placeholder rows should be visible while loading. + const skeletons = document.querySelectorAll(".animate-pulse"); + expect(skeletons.length).toBeGreaterThanOrEqual(5); + expect(screen.queryByRole("article")).not.toBeInTheDocument(); + }); + + it("disables Prev on page 1", async () => { + renderAt(ADDRESS_A); + act(() => { + vi.advanceTimersByTime(0); + }); + await waitFor(() => screen.getByText("Next")); + + expect(screen.getByRole("button", { name: /prev/i })).toBeDisabled(); + }); + + it("clicking Next requests page 2", async () => { + renderAt(ADDRESS_A); + act(() => { + vi.advanceTimersByTime(0); + }); + await waitFor(() => screen.getByText("Next")); + expect(getHistory).toHaveBeenLastCalledWith(ADDRESS_A, 1, PAGE_SIZE); + + fireEvent.click(screen.getByRole("button", { name: /next/i })); + act(() => { + vi.advanceTimersByTime(0); + }); + + await waitFor(() => { + expect(getHistory).toHaveBeenLastCalledWith(ADDRESS_A, 2, PAGE_SIZE); + }); + }); + + it("renders the error message when the fetch fails", async () => { + getHistory.mockResolvedValue({ + data: null, + error: "Network request failed", + total: 0, + }); + renderAt(ADDRESS_A); + act(() => { + vi.advanceTimersByTime(0); + }); + + await waitFor(() => { + expect(screen.getByText("Network request failed")).toBeInTheDocument(); + }); + expect(screen.queryByRole("article")).not.toBeInTheDocument(); + }); + + it("resets to page 1 when the wallet address changes", async () => { + const { rerender } = renderAt(ADDRESS_A); + act(() => { + vi.advanceTimersByTime(0); + }); + await waitFor(() => screen.getByText("Next")); + + // Reach page 2 for the original address. + fireEvent.click(screen.getByRole("button", { name: /next/i })); + act(() => { + vi.advanceTimersByTime(0); + }); + await waitFor(() => { + expect(getHistory).toHaveBeenLastCalledWith(ADDRESS_A, 2, PAGE_SIZE); + }); + + getHistory.mockClear(); + getHistory.mockResolvedValue({ + data: [makeTx(0)], + error: null, + total: 1, + }); + + // Switch wallets — the component must restart at page 1 and must never + // reuse the page it last requested for the previous address. + rerender( + + + , + ); + act(() => { + vi.advanceTimersByTime(0); + }); + + expect(getHistory).not.toHaveBeenCalledWith(ADDRESS_B, 2, PAGE_SIZE); + await waitFor(() => { + expect(getHistory).toHaveBeenCalledWith(ADDRESS_B, 1, PAGE_SIZE); + }); + expect(screen.queryByText(/page \d+ of/i)).not.toBeInTheDocument(); + }); + + it("renders 'Connect your wallet' when no address is connected", () => { + renderAt(null); + expect(screen.getByText(/connect your wallet/i)).toBeInTheDocument(); + expect(getHistory).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/components/TransactionHistory.test.tsx b/src/components/TransactionHistory.test.tsx index 070bc0a..2b824c9 100644 --- a/src/components/TransactionHistory.test.tsx +++ b/src/components/TransactionHistory.test.tsx @@ -344,12 +344,23 @@ describe("TransactionHistory", () => { describe("pagination reset on address change (#525)", () => { const OTHER_ADDRESS = "GBQMSN2ZQMXK5OBRXV5MTZ3PB4DTJVBQZTIEZTBAGMNIJ4XWVCPMFRPD"; - it("resets to page 1 and clears total/txs when the connected address changes", async () => { - sessionStorage.setItem(`sorokit-transaction-history-page:${ADDRESS}`, "3"); - const getHistory = vi.fn().mockResolvedValue({ - data: Array.from({ length: PAGE_SIZE }, (_, i) => makeTx(i)), - error: null, - total: 25, + it("never reuses the previous account's page number after an address change", async () => { + // Page persistence via sessionStorage was removed (fc66b90); the + // regression contract is that a page reached for one address can never + // leak into the next account's requests. + const getHistory = vi.fn().mockImplementation((addr: string) => { + if (addr === ADDRESS) { + return Promise.resolve({ + data: Array.from({ length: PAGE_SIZE }, (_, i) => makeTx(i)), + error: null, + total: 25, // 3 pages + }); + } + return Promise.resolve({ + data: [makeTx(0)], + error: null, + total: 1, // 1 page + }); }); vi.mocked(getClient).mockReturnValue({ transaction: { getHistory }, @@ -357,31 +368,31 @@ describe("TransactionHistory", () => { const { rerender } = render(); act(() => { vi.advanceTimersByTime(0); }); + await waitFor(() => screen.getByText("Next")); + + // Reach page 2 for the original wallet. + fireEvent.click(screen.getByRole("button", { name: /next/i })); + act(() => { vi.advanceTimersByTime(0); }); await waitFor(() => - expect(getHistory).toHaveBeenCalledWith(ADDRESS, 3, PAGE_SIZE), + expect(getHistory).toHaveBeenCalledWith(ADDRESS, 2, PAGE_SIZE), ); - await waitFor(() => screen.getByText(/page 3 of 3/i)); - // Switch to a different wallet whose history only has one page. getHistory.mockClear(); - getHistory.mockResolvedValue({ - data: [makeTx(0)], - error: null, - total: 1, - }); + + // Switch to a different wallet whose history only has one page. vi.mocked(useSorokit).mockReturnValue( mockUseSorokit({ address: OTHER_ADDRESS, isConnected: true }), ); rerender(); act(() => { vi.advanceTimersByTime(0); }); - // The stale page-3 request for the old address must never be issued - // for the new address — the reset effect fires before the fetch effect. - expect(getHistory).not.toHaveBeenCalledWith(OTHER_ADDRESS, 3, PAGE_SIZE); + // The stale page-2 state must never be requested for the new address — + // the reset effect fires before the fetch effect. + expect(getHistory).not.toHaveBeenCalledWith(OTHER_ADDRESS, 2, PAGE_SIZE); await waitFor(() => expect(getHistory).toHaveBeenCalledWith(OTHER_ADDRESS, 1, PAGE_SIZE), ); - expect(screen.queryByText(/page \d+ of 1/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/page \d+ of/i)).not.toBeInTheDocument(); expect(screen.queryByText("Prev")).not.toBeInTheDocument(); });