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:
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());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user