import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useRef } from "react"; import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { discardQueueItem, listQueue, summarizeQueue, type QueueItem } from "@/lib/offline/db"; import { resumeSync, syncPendingEntries } from "@/lib/offline/sync"; import { OfflineBand } from "./offline-band"; vi.mock("@/lib/offline/db", () => ({ summarizeQueue: vi.fn(), listQueue: vi.fn(), discardQueueItem: vi.fn(), })); vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn(), resumeSync: vi.fn() })); const NOTHING_SENT = { synced: 0, failed: 0, skippedForeign: 0 }; const REJECTED: QueueItem = { store: "pending-inspections", id: 7, label: "Durchsicht", queuedAt: "2026-10-13T08:15:00.000Z", lastError: { status: 400, message: "Volk V-03 ist beendet.", at: "2026-10-13T09:00:00.000Z" }, }; const WAITING: QueueItem = { store: "pending-feedings", id: 3, label: "Fütterung", queuedAt: "2026-10-13T08:20:00.000Z", }; function setOnLine(value: boolean) { vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(value); } /** The band as the layout places it: above the page content wrapper. */ function BandOnPage({ syncEnabled }: { syncEnabled?: boolean }) { const pageContentRef = useRef(null); return ( <>
Seiteninhalt
); } function renderBand(props: { syncEnabled?: boolean } = {}) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( , ); } /** Opens the sheet from the rejected entries and discards the rejected one. */ async function discardRejectedEntry(nextSummary: { own: number; failed: number; foreign: number }) { await userEvent.click( await screen.findByRole("button", { name: "1 Eintrag abgelehnt – ansehen" }), ); const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" }); vi.mocked(summarizeQueue).mockResolvedValue(nextSummary); vi.mocked(listQueue).mockResolvedValue(nextSummary.own > 0 ? [WAITING] : []); await userEvent.click( await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }), ); const confirm = await screen.findByRole("dialog", { name: /^Durchsicht vom .+ verwerfen\?$/ }); await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" })); await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED)); return sheet; } describe("OfflineBand", () => { beforeEach(() => { vi.mocked(summarizeQueue).mockReset(); vi.mocked(listQueue).mockReset().mockResolvedValue([]); vi.mocked(discardQueueItem).mockReset().mockResolvedValue(undefined); vi.mocked(syncPendingEntries).mockReset().mockResolvedValue(NOTHING_SENT); vi.mocked(resumeSync).mockReset(); }); afterEach(() => { vi.restoreAllMocks(); }); it("announces „Offline – 2 Einträge warten\" in a status region", async () => { setOnLine(false); vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 }); renderBand(); await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Offline – 2 Einträge warten"), ); expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite"); expect(summarizeQueue).toHaveBeenCalledWith("user-1"); expect(syncPendingEntries).not.toHaveBeenCalled(); }); it("says „1 Eintrag wartet\" for a single entry", async () => { setOnLine(false); vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 }); renderBand(); await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Offline – 1 Eintrag wartet"), ); }); it("tells that input is kept while offline with an empty queue", async () => { setOnLine(false); vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 0 }); renderBand(); expect(await screen.findByRole("status")).toHaveTextContent( "Offline – Eingaben werden gespeichert und später gesendet", ); }); it("keeps an empty status region online with an empty queue", async () => { setOnLine(true); vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 0, foreign: 3 }); renderBand(); await waitFor(() => expect(summarizeQueue).toHaveBeenCalled()); await act(async () => {}); // The live region must exist before its first message, or that message is not announced. const status = screen.getByRole("status"); expect(status).toHaveAttribute("aria-live", "polite"); expect(status).toBeEmptyDOMElement(); }); it("„Jetzt senden\" starts syncPendingEntries", async () => { setOnLine(true); vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 }); renderBand(); const button = await screen.findByRole("button", { name: "2 Einträge warten – Jetzt senden" }); // Online on mount the band already tries once by itself. await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(1)); await userEvent.click(button); await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledTimes(2)); expect(syncPendingEntries).toHaveBeenLastCalledWith("user-1"); }); it("its button is at least 44 px", async () => { setOnLine(true); vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 }); renderBand(); expect( await screen.findByRole("button", { name: "1 Eintrag wartet – Jetzt senden" }), ).toHaveClass("min-h-11"); expect(screen.getByRole("button", { name: "1 Eintrag abgelehnt – ansehen" })).toHaveClass( "min-h-11", ); }); it("opens the queue sheet from the rejected entries", async () => { setOnLine(true); vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 2, foreign: 0 }); renderBand(); await userEvent.click( await screen.findByRole("button", { name: "2 Einträge abgelehnt – ansehen" }), ); expect(await screen.findByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument(); expect(listQueue).toHaveBeenCalledWith("user-1"); }); it("closes the sheet once nothing is left and keeps focus in the band while it has content", async () => { setOnLine(false); vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 }); vi.mocked(listQueue).mockResolvedValue([REJECTED]); renderBand(); await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 }); await waitFor(() => expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(), ); // Offline, the band still says so - focus lands on that message. await waitFor(() => expect(screen.getByRole("status")).toHaveFocus()); }); it("moves focus to the page content when the band has nothing left to show", async () => { setOnLine(true); vi.mocked(summarizeQueue).mockResolvedValue({ own: 0, failed: 1, foreign: 0 }); vi.mocked(listQueue).mockResolvedValue([REJECTED]); renderBand(); await discardRejectedEntry({ own: 0, failed: 0, foreign: 0 }); await waitFor(() => expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(), ); await waitFor(() => expect(screen.getByTestId("page-content")).toHaveFocus()); expect(screen.getByRole("status")).toBeEmptyDOMElement(); }); it("keeps the sheet open with the waiting entries after the last rejected one is discarded", async () => { setOnLine(true); vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 1, foreign: 0 }); vi.mocked(listQueue).mockResolvedValue([REJECTED, WAITING]); renderBand(); const sheet = await discardRejectedEntry({ own: 1, failed: 0, foreign: 0 }); await waitFor(() => expect(screen.queryByRole("button", { name: /abgelehnt – ansehen/ })).not.toBeInTheDocument(), ); expect(within(sheet).getByText("Fütterung")).toBeInTheDocument(); expect(screen.getByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument(); await userEvent.click(within(sheet).getByRole("button", { name: "Schließen" })); await waitFor(() => expect(screen.queryByRole("dialog", { name: "Wartende Einträge" })).not.toBeInTheDocument(), ); // The trigger is gone; focus goes to the band, which still shows the // waiting entry (Base UI picks its first focusable element, the send button). await waitFor(() => expect(screen.getByRole("status")).toContainElement(document.activeElement as HTMLElement), ); expect(screen.getByRole("button", { name: "1 Eintrag wartet – Jetzt senden" })).toHaveFocus(); }); it("opens sync for the signed-in user before its first automatic sync", async () => { setOnLine(true); vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 }); renderBand(); await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1")); expect(resumeSync).toHaveBeenCalledTimes(1); expect(vi.mocked(resumeSync).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(syncPendingEntries).mock.invocationCallOrder[0], ); }); it("sends by itself as soon as the connection is back", async () => { setOnLine(false); vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 }); renderBand(); await screen.findByText("Offline – 1 Eintrag wartet"); expect(syncPendingEntries).not.toHaveBeenCalled(); setOnLine(true); act(() => { window.dispatchEvent(new Event("online")); }); await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1")); }); describe("in an offline session (no access token)", () => { it("neither opens the sync gate nor sends, even when the connection is back", async () => { setOnLine(true); vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 }); renderBand({ syncEnabled: false }); await screen.findByText("Offline – 2 Einträge warten"); act(() => { window.dispatchEvent(new Event("online")); }); expect(resumeSync).not.toHaveBeenCalled(); expect(syncPendingEntries).not.toHaveBeenCalled(); expect(screen.queryByRole("button", { name: /Jetzt senden/ })).not.toBeInTheDocument(); }); it("opens the gate and sends as soon as the session is online again", async () => { setOnLine(true); vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const view = (syncEnabled: boolean) => ( ); const { rerender } = render(view(false)); await screen.findByText("Offline – 1 Eintrag wartet"); rerender(view(true)); await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1")); expect(resumeSync).toHaveBeenCalled(); }); }); });