refetch()}
+ />
) : events.length === 0 ? (
No Soroban interactions found for this address
diff --git a/test/BackendStatusBanner.test.tsx b/test/BackendStatusBanner.test.tsx
new file mode 100644
index 0000000..ef5ff7e
--- /dev/null
+++ b/test/BackendStatusBanner.test.tsx
@@ -0,0 +1,142 @@
+/**
+ * Tests for BackendStatusBanner
+ *
+ * Verifies that:
+ * - The banner is shown when the health check fails (non-ok or network error)
+ * - The banner is hidden when the health check succeeds
+ * - The banner can be dismissed by the user
+ * - The configured indexer URL is shown in the banner text
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, waitFor, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import BackendStatusBanner from "../src/components/BackendStatusBanner";
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Make global.fetch resolve with a given status code. */
+function mockFetchOk() {
+ global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
+}
+
+function mockFetchFail() {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
+}
+
+function mockFetchNetworkError() {
+ global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
+}
+
+// ---------------------------------------------------------------------------
+// Setup / teardown
+// ---------------------------------------------------------------------------
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.spyOn(console, "error").mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("BackendStatusBanner", () => {
+ it("shows the banner when the health check fails with a non-ok response", async () => {
+ mockFetchFail();
+ render();
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("backend-status-banner"),
+ ).toBeInTheDocument();
+ });
+
+ expect(screen.getByText(/indexer unreachable/i)).toBeInTheDocument();
+ });
+
+ it("shows the banner when the health check throws a network error", async () => {
+ mockFetchNetworkError();
+ render();
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId("backend-status-banner"),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("displays the configured indexer URL in the banner", async () => {
+ mockFetchFail();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("backend-status-banner")).toBeInTheDocument();
+ });
+
+ // The banner must mention the URL so users know where to point their backend.
+ expect(screen.getByText(/localhost:3001/i)).toBeInTheDocument();
+ });
+
+ it("does NOT show the banner when the health check succeeds", async () => {
+ mockFetchOk();
+ render();
+
+ // Give the async check time to resolve.
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ expect(
+ screen.queryByTestId("backend-status-banner"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("hides the banner after the user dismisses it", async () => {
+ mockFetchFail();
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("backend-status-banner")).toBeInTheDocument();
+ });
+
+ const dismissBtn = screen.getByRole("button", {
+ name: /dismiss backend unreachable warning/i,
+ });
+ await user.click(dismissBtn);
+
+ expect(
+ screen.queryByTestId("backend-status-banner"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("calls /api/health endpoint", async () => {
+ mockFetchFail();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("backend-status-banner")).toBeInTheDocument();
+ });
+
+ const [url] = (global.fetch as ReturnType).mock.calls[0];
+ expect(url).toBe("/api/health");
+ });
+
+ it("has role=alert for assistive technology", async () => {
+ mockFetchFail();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/test/EventPage.test.tsx b/test/EventPage.test.tsx
new file mode 100644
index 0000000..cf3d0ac
--- /dev/null
+++ b/test/EventPage.test.tsx
@@ -0,0 +1,93 @@
+/**
+ * EventPage — empty / error state tests.
+ *
+ * Asserts:
+ * 1. "Event not found" empty state renders when the API resolves to null/undefined
+ * 2. Error state renders when the API call rejects (backend down / non-2xx)
+ * 3. Event data renders when the API resolves successfully
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+
+const mockEvent = vi.fn();
+
+vi.mock("../src/api", () => ({
+ api: {
+ event: mockEvent,
+ },
+}));
+
+// Suppress noisy React act() warnings from async query resolution in jsdom.
+beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+function renderEventPage(seq = "42") {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+
+ } />
+
+
+ ,
+ );
+}
+
+// Lazy-import so the vi.mock above is applied before the module resolves.
+function EventPageLazy() {
+ const [Comp, setComp] = React.useState(null);
+ React.useEffect(() => {
+ import("../src/pages/EventPage").then((m) => setComp(() => m.default));
+ }, []);
+ return Comp ? : null;
+}
+
+import React from "react";
+
+describe("EventPage — empty state (event not found)", () => {
+ it('shows "Event not found" when API resolves to undefined', async () => {
+ mockEvent.mockResolvedValue(undefined);
+ renderEventPage();
+ expect(await screen.findByText(/event not found/i)).toBeDefined();
+ });
+
+ it('renders a "Back to events" link in the not-found state', async () => {
+ mockEvent.mockResolvedValue(undefined);
+ renderEventPage();
+ await screen.findByText(/event not found/i);
+ expect(screen.getByText(/back to events/i)).toBeDefined();
+ });
+});
+
+describe("EventPage — error state (backend failure)", () => {
+ it("shows error-state when the API call rejects", async () => {
+ mockEvent.mockRejectedValue(new Error("API 503: /events/42"));
+ renderEventPage();
+ expect(await screen.findByTestId("error-state")).toBeDefined();
+ });
+
+ it("error message mentions the indexer backend", async () => {
+ mockEvent.mockRejectedValue(new Error("API 503: /events/42"));
+ renderEventPage();
+ await screen.findByTestId("error-state");
+ expect(
+ screen.getByText(/indexer backend/i),
+ ).toBeDefined();
+ });
+
+ it("shows a retry button in the error state", async () => {
+ mockEvent.mockRejectedValue(new Error("API 503: /events/42"));
+ renderEventPage();
+ await screen.findByTestId("error-state");
+ expect(screen.getByRole("button", { name: /retry/i })).toBeDefined();
+ });
+});
diff --git a/test/Home.test.tsx b/test/Home.test.tsx
index 97e974b..a645a92 100644
--- a/test/Home.test.tsx
+++ b/test/Home.test.tsx
@@ -126,3 +126,29 @@ describe("Home — empty events array", () => {
expect(screen.getByText(/Next →/)).toBeDefined();
});
});
+
+import { api } from "../src/api";
+
+describe("Home — API error", () => {
+ beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ vi.mocked(api.events).mockRejectedValue(
+ new Error("API 503: /events"),
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("shows the error state when the API call rejects", async () => {
+ renderHome();
+ expect(await screen.findByTestId("error-state")).toBeDefined();
+ });
+
+ it("shows a retry button in the error state", async () => {
+ renderHome();
+ await screen.findByTestId("error-state");
+ expect(screen.getByRole("button", { name: /retry/i })).toBeDefined();
+ });
+});
diff --git a/test/WalletPage.test.tsx b/test/WalletPage.test.tsx
index 1de309e..6739ff5 100644
--- a/test/WalletPage.test.tsx
+++ b/test/WalletPage.test.tsx
@@ -6,9 +6,11 @@ vi.mock("react-router-dom", () => ({
useParams: () => ({ address: "GA3X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X" }),
}));
+const mockWallet = vi.fn();
+
vi.mock("../src/api", () => ({
api: {
- wallet: vi.fn().mockResolvedValue([]),
+ wallet: mockWallet,
},
}));
@@ -27,6 +29,7 @@ describe("WalletPage", () => {
});
it("renders with empty-state message when no events", async () => {
+ mockWallet.mockResolvedValue([]);
const WalletPage = (await import("../src/pages/WalletPage")).default;
render(
@@ -39,6 +42,7 @@ describe("WalletPage", () => {
});
it("shows address in page heading even with no events", async () => {
+ mockWallet.mockResolvedValue([]);
const WalletPage = (await import("../src/pages/WalletPage")).default;
render(
@@ -49,4 +53,18 @@ describe("WalletPage", () => {
await screen.findByText("GA3X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X"),
).toBeDefined();
});
+
+ it("shows error state when the API request fails", async () => {
+ mockWallet.mockRejectedValue(new Error("API 503: /wallet/GA3X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X5X"));
+ const WalletPage = (await import("../src/pages/WalletPage")).default;
+ render(
+
+
+ ,
+ );
+ expect(await screen.findByTestId("error-state")).toBeDefined();
+ expect(
+ screen.getByText(/Could not reach the indexer backend/i),
+ ).toBeDefined();
+ });
});