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]>
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
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();
|
||
});
|
||
});
|
||
});
|