Skip to content
Open
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
213 changes: 213 additions & 0 deletions frontend/src/components/dashboard/dashboard-view.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="incoming-streams" />,
}));

vi.mock("./SSEStatusIndicator", () => ({
SSEStatusIndicator: () => <div data-testid="sse-status" />,
}));

vi.mock("../stream-creation/StreamCreationWizard", () => ({
StreamCreationWizard: () => <div data-testid="stream-wizard" />,
}));

vi.mock("../stream-creation/TopUpModal", () => ({
TopUpModal: () => <div data-testid="topup-modal" />,
}));

vi.mock("../stream-creation/CancelConfirmModal", () => ({
CancelConfirmModal: () => <div data-testid="cancel-modal" />,
}));

vi.mock("./StreamDetailsModal", () => ({
StreamDetailsModal: () => <div data-testid="details-modal" />,
}));

vi.mock("../ui/Button", () => ({
Button: ({
children,
onClick,
disabled,
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
glow?: boolean;
variant?: string;
size?: string;
children?: React.ReactNode;
}) => (
<button onClick={onClick} disabled={disabled} {...rest}>
{children}
</button>
),
}));

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(
<QueryClientProvider client={queryClient}>
<DashboardView session={session} onDisconnect={vi.fn()} />
</QueryClientProvider>,
);

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(
<QueryClientProvider client={queryClient}>
<DashboardView session={session} onDisconnect={vi.fn()} />
</QueryClientProvider>,
);

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(
<QueryClientProvider client={queryClient}>
<DashboardView session={session} onDisconnect={vi.fn()} />
</QueryClientProvider>,
);

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(
<QueryClientProvider client={queryClient}>
<DashboardView session={session} onDisconnect={vi.fn()} />
</QueryClientProvider>,
);

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);
});
128 changes: 48 additions & 80 deletions frontend/src/components/dashboard/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import toast from "react-hot-toast";

import {
getDashboardAnalytics,
fetchDashboardData,
useDashboard,
dashboardQueryKey,
type DashboardSnapshot,
type Stream,
Expand Down Expand Up @@ -511,11 +511,20 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
const [showWizard, setShowWizard] = React.useState(false);
const [modal, setModal] = React.useState<ModalState>(null);

const [snapshot, setSnapshot] = React.useState<DashboardSnapshot | null>(
null,
);
const [isSnapshotLoading, setIsSnapshotLoading] = React.useState(true);
const [snapshotError, setSnapshotError] = React.useState<string | null>(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,
Expand All @@ -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),
});
}
}
}
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<DashboardSnapshot | undefined>(
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) => {
Expand All @@ -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<DashboardSnapshot | undefined>(
dashboardQueryKey(session.publicKey),
(prev) => {
if (!prev) return prev;
return {
...prev,
outgoingStreams: [newStream, ...prev.outgoingStreams],
activeStreamsCount: prev.activeStreamsCount + 1,
};
},
);
};

// ── Contract handlers ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -958,7 +921,12 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {

// ── Error state ───────────────────────────────────────────────────────
if (snapshotError) {
return <ErrorState message={snapshotError} onRetry={loadSnapshot} />;
return (
<ErrorState
message={snapshotError}
onRetry={() => void refetchSnapshot()}
/>
);
}

// ── First-time / completely empty wallet ──────────────────────────────
Expand Down
Loading
Loading