diff --git a/frontend/src/components/dashboard/dashboard-view.test.tsx b/frontend/src/components/dashboard/dashboard-view.test.tsx new file mode 100644 index 00000000..fe9503e1 --- /dev/null +++ b/frontend/src/components/dashboard/dashboard-view.test.tsx @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; + +// ─── Mocks ────────────────────────────────────────────────────────────────── +// +// dashboard-view.tsx pulls in a lot of feature components (the stream +// creation wizard, modals, SSE status indicator, etc.) that aren't relevant +// to the "does switching tabs/routes re-trigger a network fetch within +// staleTime" question this file is about. They're stubbed out below so the +// test can focus on the useDashboard/query-cache wiring. + +vi.mock("@/hooks/useStreamEvents", () => ({ + useStreamEvents: () => ({ + events: [], + connected: true, + reconnecting: false, + error: null, + }), +})); + +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(() => "toast-id"), + }, +})); + +vi.mock("@/lib/soroban", () => ({ + createStream: vi.fn(), + topUpStream: vi.fn(), + cancelStream: vi.fn(), + withdrawFromStream: vi.fn(), + toBaseUnits: vi.fn((v: string) => BigInt(v)), + toDurationSeconds: vi.fn(() => 0), + getTokenAddress: vi.fn((symbol: string) => symbol), + toSorobanErrorMessage: vi.fn((e) => String(e)), +})); + +vi.mock("@/lib/stellar", () => ({ + isValidStellarPublicKey: vi.fn(() => true), +})); + +vi.mock("../IncomingStreams", () => ({ + default: () =>
, +})); + +vi.mock("./SSEStatusIndicator", () => ({ + SSEStatusIndicator: () =>
, +})); + +vi.mock("../stream-creation/StreamCreationWizard", () => ({ + StreamCreationWizard: () =>
, +})); + +vi.mock("../stream-creation/TopUpModal", () => ({ + TopUpModal: () =>
, +})); + +vi.mock("../stream-creation/CancelConfirmModal", () => ({ + CancelConfirmModal: () =>
, +})); + +vi.mock("./StreamDetailsModal", () => ({ + StreamDetailsModal: () =>
, +})); + +vi.mock("../ui/Button", () => ({ + Button: ({ + children, + onClick, + disabled, + ...rest + }: React.ButtonHTMLAttributes & { + glow?: boolean; + variant?: string; + size?: string; + children?: React.ReactNode; + }) => ( + + ), +})); + +import { DashboardView } from "./dashboard-view"; +import type { WalletSession } from "@/lib/wallet"; + +const PUBLIC_KEY = "GABCDEFPUBLICKEY000000000000000000000000000000000000000"; + +const session: WalletSession = { + walletId: "freighter", + walletName: "Freighter", + publicKey: PUBLIC_KEY, + connectedAt: new Date().toISOString(), + network: "TESTNET", + mocked: false, +}; + +function jsonResponse(body: unknown) { + return { + ok: true, + status: 200, + json: async () => body, + } as Response; +} + +describe("DashboardView + useDashboard cache integration", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse([]))); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not issue a duplicate network fetch when the dashboard is unmounted and remounted within staleTime (e.g. switching tabs away and back)", async () => { + // Mirror the app's real QueryClient defaults (see + // frontend/src/components/providers/query-provider.tsx) so this proves + // the behavior users actually get: staleTime 10s means a remount within + // that window should be served from cache, not refetched. + const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 10_000, refetchOnWindowFocus: false, retry: 1 }, + }, + }); + + const renderDashboard = () => + render( + + + , + ); + + const first = renderDashboard(); + await waitFor(() => + expect(screen.getByText(/start your first stream/i)).toBeInTheDocument(), + ); + // One request each for outgoing ("sender") and incoming ("recipient") streams. + expect(fetch).toHaveBeenCalledTimes(2); + + // Simulate navigating/switching away from the dashboard and back within + // the 10s staleTime window. + first.unmount(); + + render( + + + , + ); + + await waitFor(() => + expect(screen.getByText(/start your first stream/i)).toBeInTheDocument(), + ); + + // Still just the 2 calls from the first mount — the remounted dashboard + // was served from the React Query cache instead of refetching. + expect(fetch).toHaveBeenCalledTimes(2); + }, 20000); + + it("shows the loading skeleton, then renders content once useDashboard resolves", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + , + ); + + expect(screen.getByLabelText(/loading dashboard/i)).toBeInTheDocument(); + + await waitFor(() => + expect(screen.getByText(/start your first stream/i)).toBeInTheDocument(), + ); + }, 20000); + + it("shows the error state with a retry button when the fetch fails, and retrying refetches", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("network down")), + ); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + , + ); + + await waitFor(() => + expect(screen.getByText(/failed to load streams/i)).toBeInTheDocument(), + ); + + const callsBeforeRetry = vi.mocked(fetch).mock.calls.length; + + vi.mocked(fetch).mockResolvedValue(jsonResponse([])); + screen.getByRole("button", { name: /retry/i }).click(); + + await waitFor(() => + expect(screen.getByText(/start your first stream/i)).toBeInTheDocument(), + ); + + expect(vi.mocked(fetch).mock.calls.length).toBeGreaterThan( + callsBeforeRetry, + ); + }, 20000); +}); diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index 6a0c252c..6e6c000c 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -17,7 +17,7 @@ import toast from "react-hot-toast"; import { getDashboardAnalytics, - fetchDashboardData, + useDashboard, dashboardQueryKey, type DashboardSnapshot, type Stream, @@ -511,11 +511,20 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { const [showWizard, setShowWizard] = React.useState(false); const [modal, setModal] = React.useState(null); - const [snapshot, setSnapshot] = React.useState( - null, - ); - const [isSnapshotLoading, setIsSnapshotLoading] = React.useState(true); - const [snapshotError, setSnapshotError] = React.useState(null); + const { + data: snapshotData, + isLoading: isSnapshotLoading, + isError: isSnapshotError, + error: snapshotErrorObj, + refetch: refetchSnapshot, + } = useDashboard(session.publicKey); + + const snapshot: DashboardSnapshot | null = snapshotData ?? null; + const snapshotError = isSnapshotError + ? snapshotErrorObj instanceof Error + ? snapshotErrorObj.message + : "Failed to fetch dashboard data." + : null; const { events: streamEvents, @@ -541,15 +550,9 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { "resumed", ]; if (relevantTypes.includes(latestEvent.type)) { - fetchDashboardData(session.publicKey) - .then(setSnapshot) - .catch((err) => { - setSnapshotError( - err instanceof Error - ? err.message - : "Failed to refresh dashboard", - ); - }); + void queryClient.invalidateQueries({ + queryKey: dashboardQueryKey(session.publicKey), + }); } } } @@ -619,51 +622,6 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { persistTemplates(templates); }, [templates, templatesHydrated]); - // ── Load dashboard snapshot ─────────────────────────────────────────────── - - const loadSnapshot = React.useCallback(async () => { - setIsSnapshotLoading(true); - setSnapshotError(null); - try { - const next = await fetchDashboardData(session.publicKey); - setSnapshot(next); - } catch (err) { - setSnapshot(null); - setSnapshotError( - err instanceof Error ? err.message : "Failed to fetch dashboard data.", - ); - } finally { - setIsSnapshotLoading(false); - } - }, [session.publicKey, setIsSnapshotLoading, setSnapshotError, setSnapshot]); - - React.useEffect(() => { - let cancelled = false; - const run = async () => { - setIsSnapshotLoading(true); - setSnapshotError(null); - try { - const next = await fetchDashboardData(session.publicKey); - if (!cancelled) setSnapshot(next); - } catch (err) { - if (!cancelled) { - setSnapshot(null); - setSnapshotError( - err instanceof Error - ? err.message - : "Failed to fetch dashboard data.", - ); - } - } finally { - if (!cancelled) setIsSnapshotLoading(false); - } - }; - void run(); - return () => { - cancelled = true; - }; - }, [session.publicKey]); - // ── Template handlers ───────────────────────────────────────────────────── const updateStreamForm = (field: keyof StreamFormValues, value: string) => { @@ -779,15 +737,18 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { }; const topUpStreamLocally = (streamId: string, amount: number) => { - setSnapshot((prev) => { - if (!prev) return prev; - return { - ...prev, - outgoingStreams: prev.outgoingStreams.map((s) => - s.id === streamId ? { ...s, deposited: s.deposited + amount } : s, - ), - }; - }); + queryClient.setQueryData( + dashboardQueryKey(session.publicKey), + (prev) => { + if (!prev) return prev; + return { + ...prev, + outgoingStreams: prev.outgoingStreams.map((s) => + s.id === streamId ? { ...s, deposited: s.deposited + amount } : s, + ), + }; + }, + ); }; const addStreamLocally = (data: StreamFormData) => { @@ -804,14 +765,17 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { lastUpdateTime: Math.floor(Date.now() / 1000), isActive: true, }; - setSnapshot((prev) => { - if (!prev) return prev; - return { - ...prev, - outgoingStreams: [newStream, ...prev.outgoingStreams], - activeStreamsCount: prev.activeStreamsCount + 1, - }; - }); + queryClient.setQueryData( + dashboardQueryKey(session.publicKey), + (prev) => { + if (!prev) return prev; + return { + ...prev, + outgoingStreams: [newStream, ...prev.outgoingStreams], + activeStreamsCount: prev.activeStreamsCount + 1, + }; + }, + ); }; // ── Contract handlers ───────────────────────────────────────────────────── @@ -872,8 +836,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { await sorobanWithdraw(session, { streamId: BigInt(stream.id.replace(/\D/g, "") || "0"), }); - const refreshed = await fetchDashboardData(session.publicKey); - setSnapshot(refreshed); + await refetchSnapshot(); toast.success("Withdrawal successful!", { id: toastId }); } catch (err) { toast.error(toSorobanErrorMessage(err), { id: toastId }); @@ -958,7 +921,12 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { // ── Error state ─────────────────────────────────────────────────────── if (snapshotError) { - return ; + return ( + void refetchSnapshot()} + /> + ); } // ── First-time / completely empty wallet ────────────────────────────── diff --git a/frontend/src/lib/dashboard.test.ts b/frontend/src/lib/dashboard.test.ts index bb1cf02e..93832bee 100644 --- a/frontend/src/lib/dashboard.test.ts +++ b/frontend/src/lib/dashboard.test.ts @@ -1,8 +1,12 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; import { mapBackendStreamToFrontend, getDashboardAnalytics, dashboardQueryKey, + useDashboard, type DashboardSnapshot, } from "./dashboard"; import type { BackendStream } from "./api-types"; @@ -181,3 +185,103 @@ describe("getDashboardAnalytics", () => { expect(volume30d.value).toBe(50); // only the recent activity }); }); + +// ── useDashboard ───────────────────────────────────────────────────────────── + +describe("useDashboard", () => { + const PUBLIC_KEY = "GABCDEFPUBLICKEY000000000000000000000000000000000000000"; + + function jsonResponse(body: unknown) { + return { + ok: true, + status: 200, + json: async () => body, + } as Response; + } + + function makeWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement( + QueryClientProvider, + { client: queryClient }, + children, + ); + }; + } + + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not fetch when publicKey is empty (disabled query)", () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const { result } = renderHook(() => useDashboard(""), { + wrapper: makeWrapper(queryClient), + }); + + expect(result.current.fetchStatus).toBe("idle"); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("fetches and returns a mapped dashboard snapshot for a given publicKey", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse([])); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const { result } = renderHook(() => useDashboard(PUBLIC_KEY), { + wrapper: makeWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual({ + totalSent: 0, + totalReceived: 0, + totalValueLocked: 0, + activeStreamsCount: 0, + recentActivity: [], + outgoingStreams: [], + incomingStreams: [], + }); + // one request for outgoing ("sender") streams, one for incoming ("recipient") + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("does not refetch within staleTime when remounted with the same QueryClient (no duplicate fetch on tab switch)", async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse([])); + // Mirror the app's real QueryClient defaults (frontend/src/components/providers/query-provider.tsx) + // so this test proves the behavior users actually get. + const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 10_000, refetchOnWindowFocus: false, retry: 1 }, + }, + }); + const wrapper = makeWrapper(queryClient); + + const first = renderHook(() => useDashboard(PUBLIC_KEY), { wrapper }); + await waitFor(() => expect(first.result.current.isSuccess).toBe(true)); + expect(fetch).toHaveBeenCalledTimes(2); + + // Simulate navigating away (unmount) and back (remount) within the + // 10s staleTime window, sharing the same QueryClient instance the way + // the app's QueryProvider keeps one alive across route/tab changes. + first.unmount(); + + const second = renderHook(() => useDashboard(PUBLIC_KEY), { wrapper }); + await waitFor(() => + expect(second.result.current.data).toBeDefined(), + ); + + // Cache hit: still just the 2 calls from the initial mount — no + // duplicate network fetch for switching away and back within staleTime. + expect(fetch).toHaveBeenCalledTimes(2); + expect(second.result.current.isFetching).toBe(false); + }); +});