Skip to content

Commit 3e75947

Browse files
authored
Merge pull request #421 from Deyanju23/fix/network-tooltip-export-feedback
2 parents c5bfcfc + 618f10b commit 3e75947

2 files changed

Lines changed: 409 additions & 0 deletions

File tree

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
/**
2+
* __tests__/TransactionsPage.test.tsx
3+
*
4+
* Verifies the offline UX for the Export CSV button on the Transactions page:
5+
* - Button is disabled and has aria-label when offline
6+
* - NetworkTooltip renders with the correct message when offline
7+
* - NetworkTooltip is NOT rendered when online
8+
* - Button is enabled and has no offline aria-label when online
9+
* - Export handler fires when online but is a no-op when offline
10+
*
11+
* Module structure under test:
12+
* app/(merchant)/transactions/page.tsx
13+
* └─ useOnlineStatus (mocked)
14+
* └─ NetworkTooltip (mocked — stub preserving data-testid)
15+
* └─ mockTransactions (mocked — empty array for render tests)
16+
*/
17+
18+
import React from "react";
19+
import { render, screen, fireEvent } from "@testing-library/react";
20+
21+
// ---------------------------------------------------------------------------
22+
// Module mocks — declared before imports that depend on them
23+
// ---------------------------------------------------------------------------
24+
25+
// ── useOnlineStatus ──────────────────────────────────────────────────────────
26+
const mockIsOnline = jest.fn<boolean, []>().mockReturnValue(true);
27+
jest.mock("@/lib/hooks/useOnlineStatus", () => ({
28+
useOnlineStatus: () => mockIsOnline(),
29+
}));
30+
31+
// ── NetworkTooltip ───────────────────────────────────────────────────────────
32+
// Stub that renders a visible tooltip-content div only when offline (isOnline=false).
33+
jest.mock("@/components/shared/NetworkTooltip", () => ({
34+
NetworkTooltip: ({
35+
isOnline,
36+
message = "Export unavailable while offline.",
37+
children,
38+
}: {
39+
isOnline: boolean;
40+
message?: string;
41+
children: React.ReactNode;
42+
}) => (
43+
<>
44+
{children}
45+
{!isOnline && (
46+
<div data-testid="network-tooltip-content" role="tooltip">
47+
{message}
48+
</div>
49+
)}
50+
</>
51+
),
52+
OFFLINE_MESSAGE: "Export unavailable while offline.",
53+
}));
54+
55+
// ── mockTransactions ─────────────────────────────────────────────────────────
56+
jest.mock("@/lib/mock/transactions", () => ({
57+
mockTransactions: [],
58+
}));
59+
60+
// ── next/navigation (not used in page but required by deps) ──────────────────
61+
jest.mock("next/navigation", () => ({
62+
usePathname: () => "/transactions",
63+
useRouter: () => ({ push: jest.fn() }),
64+
}));
65+
66+
// ── lucide-react icons ────────────────────────────────────────────────────────
67+
jest.mock("lucide-react", () => {
68+
const icon = (name: string) => {
69+
const I = ({ className }: { className?: string }) => (
70+
<svg data-testid={`icon-${name}`} className={className} />
71+
);
72+
I.displayName = name;
73+
return I;
74+
};
75+
return {
76+
Search: icon("Search"),
77+
Download: icon("Download"),
78+
Filter: icon("Filter"),
79+
SearchX: icon("SearchX"),
80+
};
81+
});
82+
83+
// ── @/lib/utils ───────────────────────────────────────────────────────────────
84+
jest.mock("@/lib/utils", () => ({
85+
cn: (...classes: (string | undefined | false | null)[]) =>
86+
classes.filter(Boolean).join(" "),
87+
}));
88+
89+
// ── @/lib/utils/format ────────────────────────────────────────────────────────
90+
jest.mock("@/lib/utils/format", () => ({
91+
formatDate: (d: string | Date) => String(d),
92+
truncateAddress: (a: string) => a,
93+
}));
94+
95+
// ── @/components/ui/* ─────────────────────────────────────────────────────────
96+
jest.mock("@/components/ui/button", () => ({
97+
Button: ({
98+
children,
99+
disabled,
100+
onClick,
101+
className,
102+
"aria-label": ariaLabel,
103+
"aria-disabled": ariaDisabled,
104+
id,
105+
...rest
106+
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
107+
"aria-label"?: string;
108+
"aria-disabled"?: boolean;
109+
}) => (
110+
<button
111+
id={id}
112+
disabled={disabled}
113+
onClick={onClick}
114+
className={className}
115+
aria-label={ariaLabel}
116+
aria-disabled={ariaDisabled}
117+
{...rest}
118+
>
119+
{children}
120+
</button>
121+
),
122+
}));
123+
124+
jest.mock("@/components/ui/input", () => ({
125+
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => (
126+
<input {...props} />
127+
),
128+
}));
129+
130+
jest.mock("@/components/ui/card", () => ({
131+
Card: ({ children, className }: { children: React.ReactNode; className?: string }) => (
132+
<div className={className}>{children}</div>
133+
),
134+
CardContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
135+
<div className={className}>{children}</div>
136+
),
137+
}));
138+
139+
jest.mock("@/components/ui/table", () => {
140+
const passthrough =
141+
(Tag: string) =>
142+
({ children, className, ...rest }: React.HTMLAttributes<HTMLElement>) =>
143+
React.createElement(Tag, { className, ...rest }, children);
144+
return {
145+
Table: passthrough("table"),
146+
TableHeader: passthrough("thead"),
147+
TableBody: passthrough("tbody"),
148+
TableRow: passthrough("tr"),
149+
TableHead: passthrough("th"),
150+
TableCell: passthrough("td"),
151+
};
152+
});
153+
154+
// ── shared components ─────────────────────────────────────────────────────────
155+
jest.mock("@/components/shared/StatusBadge", () => ({
156+
StatusBadge: ({ status }: { status: string }) => <span>{status}</span>,
157+
}));
158+
jest.mock("@/components/shared/CopyAddress", () => ({
159+
CopyAddress: ({ address }: { address: string }) => <span>{address}</span>,
160+
}));
161+
jest.mock("@/components/shared/CurrencyDisplay", () => ({
162+
CurrencyDisplay: ({ amount }: { amount: number }) => <span>{amount}</span>,
163+
}));
164+
jest.mock("@/components/shared/EmptyState", () => ({
165+
EmptyState: ({ title }: { title: string }) => <div>{title}</div>,
166+
}));
167+
jest.mock("@/components/transactions/TransactionDetail", () => ({
168+
TransactionDetail: () => null,
169+
}));
170+
171+
// ---------------------------------------------------------------------------
172+
// Component import — after all mocks
173+
// ---------------------------------------------------------------------------
174+
175+
import TransactionsPage from "@/app/(merchant)/transactions/page";
176+
177+
// ---------------------------------------------------------------------------
178+
// Helpers
179+
// ---------------------------------------------------------------------------
180+
181+
const renderPage = () => render(<TransactionsPage />);
182+
183+
// When offline, aria-label overrides the computed accessible name, so we
184+
// locate the button by its stable id rather than by accessible name.
185+
const getExportButton = () =>
186+
document.getElementById("export-csv-btn") as HTMLButtonElement;
187+
188+
// ---------------------------------------------------------------------------
189+
// Tests: Offline state
190+
// ---------------------------------------------------------------------------
191+
192+
describe("TransactionsPage — Export CSV (offline)", () => {
193+
beforeEach(() => {
194+
mockIsOnline.mockReturnValue(false);
195+
});
196+
197+
it("renders a disabled Export CSV button when offline", () => {
198+
renderPage();
199+
expect(getExportButton()).toBeDisabled();
200+
});
201+
202+
it("has aria-label 'Export unavailable while offline' on the button", () => {
203+
renderPage();
204+
expect(getExportButton()).toHaveAttribute(
205+
"aria-label",
206+
"Export unavailable while offline"
207+
);
208+
});
209+
210+
it("has aria-disabled=true on the button when offline", () => {
211+
renderPage();
212+
expect(getExportButton()).toHaveAttribute("aria-disabled", "true");
213+
});
214+
215+
it("renders NetworkTooltip with the offline message", () => {
216+
renderPage();
217+
const tooltip = screen.getByTestId("network-tooltip-content");
218+
expect(tooltip).toBeInTheDocument();
219+
expect(tooltip).toHaveTextContent("Export unavailable while offline.");
220+
});
221+
222+
it("tooltip has role=tooltip", () => {
223+
renderPage();
224+
expect(screen.getByRole("tooltip")).toBeInTheDocument();
225+
});
226+
227+
it("clicking the disabled button does NOT trigger the export", () => {
228+
// Button is disabled so onClick should not fire; verify no error thrown.
229+
renderPage();
230+
const btn = getExportButton();
231+
expect(() => fireEvent.click(btn)).not.toThrow();
232+
});
233+
});
234+
235+
// ---------------------------------------------------------------------------
236+
// Tests: Online state
237+
// ---------------------------------------------------------------------------
238+
239+
describe("TransactionsPage — Export CSV (online)", () => {
240+
beforeEach(() => {
241+
mockIsOnline.mockReturnValue(true);
242+
});
243+
244+
it("renders an enabled Export CSV button when online", () => {
245+
renderPage();
246+
expect(getExportButton()).not.toBeDisabled();
247+
});
248+
249+
it("does NOT have an offline aria-label when online", () => {
250+
renderPage();
251+
const btn = getExportButton();
252+
// When online, aria-label is not set — attribute value is null
253+
expect(btn.getAttribute("aria-label")).toBeNull();
254+
});
255+
256+
it("does NOT render the offline NetworkTooltip when online", () => {
257+
renderPage();
258+
expect(
259+
screen.queryByTestId("network-tooltip-content")
260+
).not.toBeInTheDocument();
261+
});
262+
263+
it("clicking the button when online does not throw", () => {
264+
renderPage();
265+
expect(() => fireEvent.click(getExportButton())).not.toThrow();
266+
});
267+
});
268+
269+
// ---------------------------------------------------------------------------
270+
// Tests: Connectivity transition (online → offline → online)
271+
// ---------------------------------------------------------------------------
272+
273+
describe("TransactionsPage — connectivity transitions", () => {
274+
it("button transitions from disabled (offline) to enabled (online) on re-render", () => {
275+
mockIsOnline.mockReturnValue(false);
276+
const { rerender } = render(<TransactionsPage />);
277+
expect(getExportButton()).toBeDisabled();
278+
279+
mockIsOnline.mockReturnValue(true);
280+
rerender(<TransactionsPage />);
281+
expect(getExportButton()).not.toBeDisabled();
282+
});
283+
284+
it("tooltip disappears when connectivity is restored", () => {
285+
mockIsOnline.mockReturnValue(false);
286+
const { rerender } = render(<TransactionsPage />);
287+
expect(screen.getByTestId("network-tooltip-content")).toBeInTheDocument();
288+
289+
mockIsOnline.mockReturnValue(true);
290+
rerender(<TransactionsPage />);
291+
expect(
292+
screen.queryByTestId("network-tooltip-content")
293+
).not.toBeInTheDocument();
294+
});
295+
});
296+
297+
// ---------------------------------------------------------------------------
298+
// Tests: Existing page functionality (regression guard)
299+
// ---------------------------------------------------------------------------
300+
301+
describe("TransactionsPage — existing functionality", () => {
302+
beforeEach(() => {
303+
mockIsOnline.mockReturnValue(true);
304+
});
305+
306+
it("renders the Transactions heading", () => {
307+
renderPage();
308+
expect(
309+
screen.getByRole("heading", { name: /transactions/i })
310+
).toBeInTheDocument();
311+
});
312+
313+
it("renders the search input", () => {
314+
renderPage();
315+
expect(
316+
screen.getByPlaceholderText(/search by hash or address/i)
317+
).toBeInTheDocument();
318+
});
319+
320+
it("renders the Filter button", () => {
321+
renderPage();
322+
expect(screen.getByRole("button", { name: /filter/i })).toBeInTheDocument();
323+
});
324+
325+
it("renders the empty state when there are no transactions", () => {
326+
renderPage();
327+
expect(screen.getByText(/no transactions found/i)).toBeInTheDocument();
328+
});
329+
});

0 commit comments

Comments
 (0)