Files
ApiCore/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:36:38 +02:00

299 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLDivElement>(null);
return (
<>
<OfflineBand userId="user-1" pageContentRef={pageContentRef} syncEnabled={syncEnabled} />
<div ref={pageContentRef} tabIndex={-1} data-testid="page-content">
Seiteninhalt
</div>
</>
);
}
function renderBand(props: { syncEnabled?: boolean } = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<BandOnPage {...props} />
</QueryClientProvider>,
);
}
/** 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) => (
<QueryClientProvider client={client}>
<BandOnPage syncEnabled={syncEnabled} />
</QueryClientProvider>
);
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();
});
});
});