diff --git a/app/components/StreamViewer/ConnectionStatus.test.tsx b/app/components/StreamViewer/ConnectionStatus.test.tsx new file mode 100644 index 0000000..6a592de --- /dev/null +++ b/app/components/StreamViewer/ConnectionStatus.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react" +import { ConnectionStatus } from "./ConnectionStatus" + +describe("ConnectionStatus", () => { + it("renders the connecting status correctly", () => { + const { container } = render() + expect(screen.getByText("connecting")).toBeInTheDocument() + const dot = container.querySelector(".rounded-full") + expect(dot).toHaveClass("bg-yellow-500") + }) + + it("renders the connected status correctly", () => { + const { container } = render() + expect(screen.getByText("connected")).toBeInTheDocument() + const dot = container.querySelector(".rounded-full") + expect(dot).toHaveClass("bg-green-500") + }) + + it("renders the disconnected status correctly", () => { + const { container } = render() + expect(screen.getByText("disconnected")).toBeInTheDocument() + const dot = container.querySelector(".rounded-full") + expect(dot).toHaveClass("bg-gray-500") + }) + + it("renders the error status correctly (error state test)", () => { + const { container } = render() + expect(screen.getByText("error")).toBeInTheDocument() + const dot = container.querySelector(".rounded-full") + expect(dot).toHaveClass("bg-red-500") + }) +}) diff --git a/app/components/StreamViewer/StreamFeed.test.tsx b/app/components/StreamViewer/StreamFeed.test.tsx new file mode 100644 index 0000000..c7bec75 --- /dev/null +++ b/app/components/StreamViewer/StreamFeed.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react" +import { StreamFeed } from "./StreamFeed" + +describe("StreamFeed", () => { + it("renders empty state when no events are provided (empty/error state test)", () => { + render() + expect(screen.getByText("No events received yet.")).toBeInTheDocument() + }) + + it("renders a list of stream events correctly (rendering test)", () => { + const events = [ + { + id: "1", + type: "info", + timestamp: "2026-06-18T12:00:00.000Z", + message: "Stream started", + }, + { + id: "2", + type: "warning", + timestamp: "2026-06-18T12:05:00.000Z", + message: "High latency detected", + }, + ] + + render() + + expect(screen.getByText("info")).toBeInTheDocument() + expect(screen.getByText("Stream started")).toBeInTheDocument() + + expect(screen.getByText("warning")).toBeInTheDocument() + expect(screen.getByText("High latency detected")).toBeInTheDocument() + + // Verify times are rendered + const time1 = new Date("2026-06-18T12:00:00.000Z").toLocaleTimeString() + const time2 = new Date("2026-06-18T12:05:00.000Z").toLocaleTimeString() + expect(screen.getByText(time1)).toBeInTheDocument() + expect(screen.getByText(time2)).toBeInTheDocument() + }) +}) diff --git a/app/components/StreamViewer/StreamViewer.test.tsx b/app/components/StreamViewer/StreamViewer.test.tsx new file mode 100644 index 0000000..56baa92 --- /dev/null +++ b/app/components/StreamViewer/StreamViewer.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react" +import { StreamViewer } from "./StreamViewer" +import { useStreamSocket } from "../../hooks/useStreamSocket" + +// Mock the useStreamSocket hook +jest.mock("../../hooks/useStreamSocket") + +const mockUseStreamSocket = useStreamSocket as jest.MockedFunction< + typeof useStreamSocket +> + +describe("StreamViewer", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders connecting state and empty feed (rendering test)", () => { + mockUseStreamSocket.mockReturnValue({ + status: "connecting", + events: [], + }) + + render() + + expect(screen.getByText("Live Stream Feed")).toBeInTheDocument() + expect(screen.getByText("connecting")).toBeInTheDocument() + expect(screen.getByText("No events received yet.")).toBeInTheDocument() + expect(mockUseStreamSocket).toHaveBeenCalledWith("ws://localhost:3001") + }) + + it("renders connected state with events (interaction / data rendering test)", () => { + const mockEvents = [ + { + id: "event-1", + type: "click", + timestamp: "2026-06-18T12:00:00.000Z", + message: "User clicked submit", + }, + ] + mockUseStreamSocket.mockReturnValue({ + status: "connected", + events: mockEvents, + }) + + render() + + expect(screen.getByText("connected")).toBeInTheDocument() + expect(screen.getByText("click")).toBeInTheDocument() + expect(screen.getByText("User clicked submit")).toBeInTheDocument() + }) + + it("renders error state (error state test)", () => { + mockUseStreamSocket.mockReturnValue({ + status: "error", + events: [], + }) + + render() + + expect(screen.getByText("error")).toBeInTheDocument() + expect(screen.getByText("No events received yet.")).toBeInTheDocument() + }) +}) diff --git a/app/components/streams/embed-snippet.test.tsx b/app/components/streams/embed-snippet.test.tsx new file mode 100644 index 0000000..9969093 --- /dev/null +++ b/app/components/streams/embed-snippet.test.tsx @@ -0,0 +1,184 @@ +import { render, screen, act, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { EmbedSnippet } from "./embed-snippet" + +type MutableNavigator = Omit & { clipboard?: unknown } + +describe("EmbedSnippet", () => { + const mockWriteText = jest.fn() + let originalClipboardDescriptor: PropertyDescriptor | undefined + + beforeAll(() => { + originalClipboardDescriptor = Object.getOwnPropertyDescriptor( + Navigator.prototype, + "clipboard", + ) + + const mockClipboard = { + writeText: mockWriteText, + } + + // Define on prototype + Object.defineProperty(Navigator.prototype, "clipboard", { + value: mockClipboard, + configurable: true, + writable: true, + }) + + // Define on window.navigator + try { + Object.defineProperty(window.navigator, "clipboard", { + value: mockClipboard, + configurable: true, + writable: true, + }) + } catch (e) {} + + // Define on global.navigator + try { + Object.defineProperty(navigator, "clipboard", { + value: mockClipboard, + configurable: true, + writable: true, + }) + } catch (e) {} + }) + + afterAll(() => { + if (originalClipboardDescriptor) { + Object.defineProperty( + Navigator.prototype, + "clipboard", + originalClipboardDescriptor, + ) + } else { + delete (Navigator.prototype as unknown as MutableNavigator).clipboard + } + + try { + delete (window.navigator as unknown as MutableNavigator).clipboard + } catch (e) {} + + try { + delete (navigator as unknown as MutableNavigator).clipboard + } catch (e) {} + }) + + + + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + mockWriteText.mockResolvedValue(undefined) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it("renders the iframe embed code with publicId (rendering test)", () => { + render() + + expect(screen.getByText("Embed snippet")).toBeInTheDocument() + const codeContainer = screen.getByLabelText("iframe embed code") + expect(codeContainer).toHaveTextContent( + '' + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith(expectedSnippet) + expect( + screen.getByRole("button", { name: "Copied" }), + ).toBeInTheDocument() + }) + + // Fast-forward 1800ms + act(() => { + jest.advanceTimersByTime(1800) + }) + + expect( + screen.getByRole("button", { name: "Copy embed snippet" }), + ).toBeInTheDocument() + }) + + it("throws error in dev if publicId is missing (validation test)", () => { + // Suppress console.error output to avoid test pollution + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}) + + expect(() => { + render() + }).toThrow("EmbedSnippet: publicId is required") + + consoleSpy.mockRestore() + }) + + it("throws error in dev if publicId looks like a secret (validation / security test)", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}) + + // Containing dot (like JWT or token) + expect(() => { + render() + }).toThrow("EmbedSnippet: publicId looks like a secret token") + + // Too long (length > 64) + const longId = "a".repeat(65) + expect(() => { + render() + }).toThrow("EmbedSnippet: publicId looks like a secret token") + + consoleSpy.mockRestore() + }) +}) diff --git a/app/components/streams/stream-tag-chips.test.tsx b/app/components/streams/stream-tag-chips.test.tsx new file mode 100644 index 0000000..ff82a22 --- /dev/null +++ b/app/components/streams/stream-tag-chips.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { StreamTagChips } from "./stream-tag-chips" +import { Tag } from "@/lib/api/tags" + +describe("StreamTagChips", () => { + const mockTags: Tag[] = [ + { id: 1, name: "Gaming", slug: "gaming", createdAt: "2026-06-18" }, + { id: 2, name: "Music", slug: "music", createdAt: "2026-06-18" }, + ] + + it("renders empty label when tags list is empty (empty state test)", () => { + render() + expect(screen.getByText("No tags attached.")).toBeInTheDocument() + }) + + it("renders custom empty label when provided", () => { + render() + expect(screen.getByText("Empty list")).toBeInTheDocument() + }) + + it("renders tags badges in read-only mode (rendering test)", () => { + render() + expect(screen.getByText("Gaming")).toBeInTheDocument() + expect(screen.getByText("Music")).toBeInTheDocument() + // No remove buttons should be present + expect(screen.queryByRole("button")).not.toBeInTheDocument() + }) + + it("renders tags with remove buttons and triggers onRemove on click (interaction test)", async () => { + const user = userEvent.setup() + const handleRemove = jest.fn() + + render() + + expect(screen.getByText("Gaming")).toBeInTheDocument() + expect(screen.getByText("Music")).toBeInTheDocument() + + const removeButtons = screen.getAllByRole("button") + expect(removeButtons).toHaveLength(2) + + expect( + screen.getByRole("button", { name: "Remove tag Gaming" }), + ).toBeInTheDocument() + expect( + screen.getByRole("button", { name: "Remove tag Music" }), + ).toBeInTheDocument() + + await user.click(removeButtons[0]) + expect(handleRemove).toHaveBeenCalledTimes(1) + expect(handleRemove).toHaveBeenCalledWith(mockTags[0]) + }) +}) diff --git a/app/components/streams/stream-tag-editor.test.tsx b/app/components/streams/stream-tag-editor.test.tsx new file mode 100644 index 0000000..3a2a5ad --- /dev/null +++ b/app/components/streams/stream-tag-editor.test.tsx @@ -0,0 +1,209 @@ +import { render, screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { StreamTagEditor } from "@/src/app/dashboard/streams/stream-tag-editor" +import { + attachTagToStream, + detachTagFromStream, + listTags, + Tag, + TagsApiError, +} from "@/lib/api/tags" +import { toast } from "sonner" + +jest.mock("@/lib/api/tags", () => { + const actual = jest.requireActual("@/lib/api/tags") + return { + ...actual, + attachTagToStream: jest.fn(), + detachTagFromStream: jest.fn(), + listTags: jest.fn().mockResolvedValue({ items: [], page: 1, limit: 100, total: 0, hasMore: false }), + } +}) +jest.mock("sonner", () => ({ + toast: { + error: jest.fn(), + }, +})) + +const mockAttach = attachTagToStream as jest.MockedFunction< + typeof attachTagToStream +> +const mockDetach = detachTagFromStream as jest.MockedFunction< + typeof detachTagFromStream +> +const mockListTags = listTags as jest.MockedFunction +const mockToastError = toast.error as jest.MockedFunction + + +describe("StreamTagEditor", () => { + const initialTags: Tag[] = [ + { id: 1, name: "Gaming", slug: "gaming", createdAt: "2026-06-18" }, + { id: 2, name: "Music", slug: "music", createdAt: "2026-06-18" }, + ] + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders with initial tags (rendering test)", () => { + render( + , + ) + + expect(screen.getByText("Tags")).toBeInTheDocument() + const selectedList = screen.getByLabelText("selected tags") + expect(within(selectedList).getByText("Gaming")).toBeInTheDocument() + expect(within(selectedList).getByText("Music")).toBeInTheDocument() + }) + + it("performs optimistic update and calls detachTagFromStream on tag removal (interaction test)", async () => { + const user = userEvent.setup() + mockDetach.mockResolvedValueOnce() + + render( + , + ) + + const removeGamingBtns = screen.getAllByRole("button", { + name: "Remove tag Gaming", + }) + + // Click to remove tag "Gaming" + await user.click(removeGamingBtns[0]) + + // Check optimistic update: Gaming should be gone immediately from lists + const selectedList = screen.getByLabelText("selected tags") + expect(within(selectedList).queryByText("Gaming")).not.toBeInTheDocument() + + const attachedList = screen.getByLabelText("stream tags") + expect(within(attachedList).queryByText("Gaming")).not.toBeInTheDocument() + + // Check that detach API was called + expect(mockDetach).toHaveBeenCalledWith(123, 1, { userId: "user-1" }) + }) + + it("rolls back state and displays error toast on detachment failure (error state test)", async () => { + const user = userEvent.setup() + mockDetach.mockRejectedValueOnce(new TagsApiError(500, "Database down")) + + render( + , + ) + + const removeGamingBtns = screen.getAllByRole("button", { + name: "Remove tag Gaming", + }) + + await user.click(removeGamingBtns[0]) + + // Check rollback: Gaming tag should be restored in the UI + await waitFor(() => { + const selectedList = screen.getByLabelText("selected tags") + expect(within(selectedList).getByText("Gaming")).toBeInTheDocument() + }) + + // Verify toast notification + expect(mockToastError).toHaveBeenCalledWith( + "Failed to update tags: Database down", + ) + }) + + it("attaches tag to stream when select option changes (interaction test)", async () => { + const user = userEvent.setup() + mockAttach.mockResolvedValueOnce({ + id: 3, + name: "Coding", + slug: "coding", + createdAt: "2026-06-18", + }) + + // TagCombobox needs to be able to resolve listTags when opened + mockListTags.mockResolvedValueOnce({ + items: [ + { id: 1, name: "Gaming", slug: "gaming", createdAt: "2026-06-18" }, + { id: 2, name: "Music", slug: "music", createdAt: "2026-06-18" }, + { id: 3, name: "Coding", slug: "coding", createdAt: "2026-06-18" }, + ], + page: 1, + limit: 100, + total: 3, + hasMore: false, + }) + + render( + , + ) + + // Open combobox and select "Coding" + await user.click(screen.getByRole("combobox")) + await waitFor(() => { + expect(screen.getByText("Coding")).toBeInTheDocument() + }) + + await user.click(screen.getByText("Coding")) + + // Verify attach was called + expect(mockAttach).toHaveBeenCalledWith(123, "Coding", { userId: "user-1" }) + }) + + it("rolls back state on attachment failure (error state test)", async () => { + const user = userEvent.setup() + mockAttach.mockRejectedValueOnce(new TagsApiError(400, "Tag limit reached")) + + mockListTags.mockResolvedValueOnce({ + items: [ + { id: 1, name: "Gaming", slug: "gaming", createdAt: "2026-06-18" }, + { id: 2, name: "Music", slug: "music", createdAt: "2026-06-18" }, + { id: 3, name: "Coding", slug: "coding", createdAt: "2026-06-18" }, + ], + page: 1, + limit: 100, + total: 3, + hasMore: false, + }) + + render( + , + ) + + await user.click(screen.getByRole("combobox")) + await waitFor(() => { + expect(screen.getByText("Coding")).toBeInTheDocument() + }) + + await user.click(screen.getByText("Coding")) + + // The tag is optimistically added and then rolled back from lists + await waitFor(() => { + const selectedList = screen.getByLabelText("selected tags") + expect(within(selectedList).queryByText("Coding")).not.toBeInTheDocument() + + const attachedList = screen.getByLabelText("stream tags") + expect(within(attachedList).queryByText("Coding")).not.toBeInTheDocument() + }) + + expect(mockToastError).toHaveBeenCalledWith( + "Failed to update tags: Tag limit reached", + ) + }) +}) diff --git a/app/components/streams/tag-combobox.test.tsx b/app/components/streams/tag-combobox.test.tsx new file mode 100644 index 0000000..dcd161d --- /dev/null +++ b/app/components/streams/tag-combobox.test.tsx @@ -0,0 +1,156 @@ +import { render, screen, waitFor, act } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { TagCombobox } from "./tag-combobox" +import { listTags, Tag, PagedTags } from "@/lib/api/tags" + +jest.mock("@/lib/api/tags") + +const mockListTags = listTags as jest.MockedFunction + +describe("TagCombobox", () => { + const selectedTags: Tag[] = [ + { id: 1, name: "Gaming", slug: "gaming", createdAt: "2026-06-18" }, + ] + const availableTags: Tag[] = [ + { id: 1, name: "Gaming", slug: "gaming", createdAt: "2026-06-18" }, + { id: 2, name: "Music", slug: "music", createdAt: "2026-06-18" }, + { id: 3, name: "Coding", slug: "coding", createdAt: "2026-06-18" }, + ] + + beforeEach(() => { + jest.clearAllMocks() + mockListTags.mockResolvedValue({ + items: availableTags, + page: 1, + limit: 100, + total: 3, + hasMore: false, + }) + }) + + it("renders selected tags and placeholder (rendering test)", () => { + render() + + expect(screen.getByText("Gaming")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveTextContent("Add tags…") + }) + + it("renders empty state when no tags are selected", () => { + render() + expect(screen.getByText("No tags yet.")).toBeInTheDocument() + }) + + it("opens the popover and loads available tags (interaction / network test)", async () => { + const user = userEvent.setup() + let resolveListTags: () => void + const listTagsPromise = new Promise((resolve) => { + resolveListTags = () => + resolve({ + items: availableTags, + page: 1, + limit: 100, + total: 3, + hasMore: false, + }) + }) + mockListTags.mockReturnValueOnce(listTagsPromise) + + render() + + const trigger = screen.getByRole("combobox") + await user.click(trigger) + + expect(screen.getByText("Loading…")).toBeInTheDocument() + expect(mockListTags).toHaveBeenCalled() + + await act(async () => { + resolveListTags() + await listTagsPromise + }) + + await waitFor(() => { + expect(screen.queryByText("Loading…")).not.toBeInTheDocument() + }) + + expect(screen.getByText("Existing")).toBeInTheDocument() + expect(screen.getByText("Music")).toBeInTheDocument() + expect(screen.getByText("Coding")).toBeInTheDocument() + }) + + it("calls onChange with updated list when removing a selected tag badge (interaction test)", async () => { + const user = userEvent.setup() + const handleChange = jest.fn() + render() + + const removeBtn = screen.getByRole("button", { name: "Remove tag Gaming" }) + await user.click(removeBtn) + + expect(handleChange).toHaveBeenCalledWith([]) + }) + + it("calls onChange with updated list when selecting an existing tag in dropdown (interaction test)", async () => { + const user = userEvent.setup() + const handleChange = jest.fn() + render() + + await user.click(screen.getByRole("combobox")) + await waitFor(() => { + expect(screen.getByText("Music")).toBeInTheDocument() + }) + + await user.click(screen.getByText("Music")) + expect(handleChange).toHaveBeenCalledWith([...selectedTags, availableTags[1]]) + }) + + it("handles loading error gracefully (error state test)", async () => { + const user = userEvent.setup() + mockListTags.mockRejectedValueOnce(new Error("API offline")) + + render() + await user.click(screen.getByRole("combobox")) + + await waitFor(() => { + expect(screen.getByText("API offline")).toBeInTheDocument() + }) + }) + + it("shows create new button and calls onCreate when creating a tag (interaction test)", async () => { + const user = userEvent.setup() + const handleChange = jest.fn() + const handleCreate = jest.fn().mockResolvedValue({ + id: 4, + name: "React", + slug: "react", + createdAt: "2026-06-18", + }) + + render( + , + ) + + await user.click(screen.getByRole("combobox")) + await waitFor(() => { + expect(screen.getByPlaceholderText("Search or create…")).toBeInTheDocument() + }) + + const input = screen.getByPlaceholderText("Search or create…") + await user.type(input, "React") + + const createBtn = screen.getByText(/Create.*React/) + expect(createBtn).toBeInTheDocument() + + await user.click(createBtn) + + expect(handleCreate).toHaveBeenCalledWith("React") + await waitFor(() => { + expect(handleChange).toHaveBeenCalledWith([ + ...selectedTags, + { id: 4, name: "React", slug: "react", createdAt: "2026-06-18" }, + ]) + }) + }) +}) diff --git a/app/jest.setup.ts b/app/jest.setup.ts index df6631e..7a7c7dd 100644 --- a/app/jest.setup.ts +++ b/app/jest.setup.ts @@ -1 +1,14 @@ import "@testing-library/jest-dom" + +class MockResizeObserver { + observe = jest.fn() + unobserve = jest.fn() + disconnect = jest.fn() +} + +global.ResizeObserver = MockResizeObserver + +if (!window.HTMLElement.prototype.scrollIntoView) { + window.HTMLElement.prototype.scrollIntoView = jest.fn() +} + diff --git a/app/src/app/admin/admin-dashboard.test.tsx b/app/src/app/admin/admin-dashboard.test.tsx new file mode 100644 index 0000000..181c505 --- /dev/null +++ b/app/src/app/admin/admin-dashboard.test.tsx @@ -0,0 +1,127 @@ +import { render, screen, act, waitFor } from "@testing-library/react" +import { AdminDashboard } from "./admin-dashboard" +import { fetchAdminStats, AdminStats } from "@/lib/api/admin-stats" + +jest.mock("@/lib/api/admin-stats") + +const mockFetchAdminStats = fetchAdminStats as jest.MockedFunction< + typeof fetchAdminStats +> + +describe("AdminDashboard", () => { + const mockStats: AdminStats = { + totalUsers: 150, + totalStreams: 45, + activeStreams: 12, + eventsLast24h: 9800, + generatedAt: "2026-06-18T12:00:00.000Z", + } + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it("renders loading state initially (rendering test)", () => { + // Keep fetch pending + mockFetchAdminStats.mockReturnValue(new Promise(() => {})) + + const { container } = render() + + expect(screen.getByText("Admin Dashboard")).toBeInTheDocument() + expect(screen.getByText("loading…")).toBeInTheDocument() + + // Skeletons should be displayed for each stat card + const skeletons = container.querySelectorAll(".animate-pulse") + expect(skeletons.length).toBeGreaterThan(0) + }) + + it("renders ready state with stats on successful fetch (interaction/rendering test)", async () => { + mockFetchAdminStats.mockResolvedValue(mockStats) + + render() + + // Wait for mock fetch to resolve + await waitFor(() => { + expect(screen.getByText("150")).toBeInTheDocument() + }) + + expect(screen.getByText("45")).toBeInTheDocument() + expect(screen.getByText("12")).toBeInTheDocument() + expect(screen.getByText("9,800")).toBeInTheDocument() + + expect(screen.getByText(/updated/)).toBeInTheDocument() + expect(screen.queryByRole("alert")).not.toBeInTheDocument() + }) + + it("renders error state when fetch fails initially (error state test)", async () => { + mockFetchAdminStats.mockRejectedValue(new Error("API Error")) + + render() + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument() + }) + + expect(screen.getByText("Failed to refresh stats")).toBeInTheDocument() + expect(screen.getByText("API Error")).toBeInTheDocument() + expect(screen.getByText("stale")).toBeInTheDocument() + }) + + it("retains and renders last successful snapshot when a refresh fails (stale cache test)", async () => { + mockFetchAdminStats + .mockResolvedValueOnce(mockStats) // First call succeeds + .mockRejectedValueOnce(new Error("Network Timeout")) // Refresh fails + + render() + + // First load is successful + await waitFor(() => { + expect(screen.getByText("150")).toBeInTheDocument() + }) + + // Advance timers to trigger refresh interval (60 seconds) + await act(async () => { + jest.advanceTimersByTime(60000) + }) + + // Now showing error alert + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument() + }) + + expect(screen.getByText("Network Timeout")).toBeInTheDocument() + expect(screen.getByText("stale")).toBeInTheDocument() + + // Still displays the previous counts (cached/stale snapshot) + expect(screen.getByText("150")).toBeInTheDocument() + expect(screen.getByText("45")).toBeInTheDocument() + expect(screen.getByText(/Showing last successful snapshot/)).toBeInTheDocument() + }) + + it("auto-refreshes data every 60 seconds", async () => { + mockFetchAdminStats.mockResolvedValue(mockStats) + + render() + + await waitFor(() => { + expect(mockFetchAdminStats).toHaveBeenCalledTimes(1) + }) + + // Advance timers by 60 seconds + await act(async () => { + jest.advanceTimersByTime(60000) + }) + expect(mockFetchAdminStats).toHaveBeenCalledTimes(2) + + // Advance another 60 seconds + await act(async () => { + jest.advanceTimersByTime(60000) + }) + expect(mockFetchAdminStats).toHaveBeenCalledTimes(3) + }) +})