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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { reportRequestOutcome } from "@/lib/offline/connectivity";
import { summarizeQueue } from "@/lib/offline/db";
import { syncPendingEntries } from "@/lib/offline/sync";
import { LogoutConfirmDialog } from "./logout-confirm-dialog";
const logoutMock = vi.fn();
let sessionMode: "online" | "offline" = "online";
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ logout: logoutMock, sessionMode }),
}));
vi.mock("@/lib/offline/db", () => ({ summarizeQueue: vi.fn() }));
vi.mock("@/lib/offline/sync", () => ({ syncPendingEntries: vi.fn() }));
const NOTHING_WAITS = { own: 0, failed: 0, foreign: 0 };
const onOpenChange = vi.fn();
const onLoggedOut = vi.fn();
function renderDialog() {
return render(
<LogoutConfirmDialog
open
onOpenChange={onOpenChange}
userId="user-1"
onLoggedOut={onLoggedOut}
/>,
);
}
describe("LogoutConfirmDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
logoutMock.mockResolvedValue(undefined);
vi.mocked(summarizeQueue).mockResolvedValue(NOTHING_WAITS);
vi.mocked(syncPendingEntries).mockResolvedValue({ synced: 0, failed: 0, skippedForeign: 0 });
reportRequestOutcome("reached-server");
sessionMode = "online";
});
afterEach(() => {
reportRequestOutcome("reached-server");
vi.restoreAllMocks();
});
it("logs out directly when nothing waits", async () => {
renderDialog();
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: false }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
});
it("asks before deleting unsent entries", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 1, foreign: 5 });
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
// Waiting and rejected entries of the person count; other accounts' do not.
expect(dialog).toHaveTextContent("3 Einträge sind noch nicht gesendet.");
expect(logoutMock).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Erst senden" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Abmelden und Einträge löschen" })).toBeVisible();
expect(screen.getByRole("button", { name: "Abbrechen" })).toBeVisible();
});
it("deletes the entries only after the destructive button was pressed", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
});
it("cancel leaves everything as it is", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abbrechen" }));
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(logoutMock).not.toHaveBeenCalled();
expect(syncPendingEntries).not.toHaveBeenCalled();
});
it("sends first and logs out once everything is gone", async () => {
vi.mocked(summarizeQueue)
.mockResolvedValueOnce({ own: 2, failed: 0, foreign: 0 })
.mockResolvedValue(NOTHING_WAITS);
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Erst senden" }));
await waitFor(() => expect(syncPendingEntries).toHaveBeenCalledWith("user-1"));
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: false }));
});
it("stays open and says how many still wait when sending was not enough", async () => {
vi.mocked(summarizeQueue)
.mockResolvedValueOnce({ own: 2, failed: 0, foreign: 0 })
.mockResolvedValue({ own: 0, failed: 1, foreign: 0 });
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Erst senden" }));
await waitFor(() =>
expect(screen.getByRole("dialog", { name: "Abmelden?" })).toHaveTextContent(
"1 Eintrag ist noch nicht gesendet.",
),
);
expect(logoutMock).not.toHaveBeenCalled();
});
it("offers no sending without network and warns that signing out deletes the entries", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 2, failed: 0, foreign: 0 });
act(() => reportRequestOutcome("network-error"));
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(screen.queryByRole("button", { name: "Erst senden" })).not.toBeInTheDocument();
expect(dialog).toHaveTextContent("Ohne Netz");
});
it("offline logout marks the logout as pending (through the auth context)", async () => {
// The pending mark is the auth context's job; the dialog only asks for the logout.
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
act(() => reportRequestOutcome("network-error"));
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
await waitFor(() => expect(logoutMock).toHaveBeenCalledWith({ discardQueue: true }));
});
it("shows an error and stays open when the logout fails", async () => {
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
logoutMock.mockRejectedValue(new Error("boom"));
renderDialog();
await userEvent.click(await screen.findByRole("button", { name: "Abmelden und Einträge löschen" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Abmelden hat nicht geklappt");
expect(onLoggedOut).not.toHaveBeenCalled();
});
it("does nothing while it is closed", () => {
render(
<LogoutConfirmDialog
open={false}
onOpenChange={onOpenChange}
userId="user-1"
onLoggedOut={onLoggedOut}
/>,
);
expect(summarizeQueue).not.toHaveBeenCalled();
expect(logoutMock).not.toHaveBeenCalled();
});
it("offers no sending in an offline session, even when the network looks fine", async () => {
sessionMode = "offline";
vi.mocked(summarizeQueue).mockResolvedValue({ own: 1, failed: 0, foreign: 0 });
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(screen.queryByRole("button", { name: "Erst senden" })).not.toBeInTheDocument();
expect(dialog).toHaveTextContent("Ohne Netz");
});
it("shows the error with a retry when the direct logout of an empty queue fails", async () => {
logoutMock.mockRejectedValueOnce(new Error("boom"));
renderDialog();
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
expect(await screen.findByRole("alert")).toHaveTextContent("Abmelden hat nicht geklappt");
expect(dialog).not.toHaveTextContent("noch nicht gesendet");
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(2));
expect(logoutMock).toHaveBeenLastCalledWith({ discardQueue: false });
await waitFor(() => expect(onLoggedOut).toHaveBeenCalled());
});
});