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
@@ -1,15 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { StickySubmit } from "@/components/form/sticky-submit";
|
||||
import { purgeStaleDraftPhotos } from "@/lib/photos/store";
|
||||
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
|
||||
import AppLayout from "./layout";
|
||||
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
useRouter: () => ({ replace: vi.fn(), push: pushMock }),
|
||||
usePathname: () => "/inspections/new",
|
||||
}));
|
||||
|
||||
let sessionMode: "online" | "offline" = "online";
|
||||
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
@@ -23,20 +27,43 @@ vi.mock("@/lib/auth/auth-context", () => ({
|
||||
hasAvatar: false,
|
||||
},
|
||||
isLoading: false,
|
||||
sessionMode,
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/auth/logout-confirm-dialog", () => ({
|
||||
LogoutConfirmDialog: ({
|
||||
open,
|
||||
userId,
|
||||
onLoggedOut,
|
||||
}: {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
onLoggedOut: () => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label="Abmelden?">
|
||||
{userId}
|
||||
<button type="button" onClick={onLoggedOut}>
|
||||
Abgemeldet
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/notification-bell", () => ({ NotificationBell: () => null }));
|
||||
vi.mock("@/components/offline/offline-band", () => ({
|
||||
OfflineBand: ({
|
||||
userId,
|
||||
pageContentRef,
|
||||
syncEnabled,
|
||||
}: {
|
||||
userId: string;
|
||||
pageContentRef: { current: HTMLElement | null };
|
||||
syncEnabled?: boolean;
|
||||
}) => (
|
||||
<div data-testid="offline-band">
|
||||
<div data-testid="offline-band" data-sync-enabled={String(syncEnabled)}>
|
||||
{userId}
|
||||
<button type="button" onClick={() => pageContentRef.current?.focus()}>
|
||||
Fokus auf den Seiteninhalt
|
||||
@@ -45,7 +72,10 @@ vi.mock("@/components/offline/offline-band", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null }));
|
||||
vi.mock("@/lib/photos/store", () => ({ purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0) }));
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0),
|
||||
finishHandOvers: vi.fn().mockResolvedValue(0),
|
||||
}));
|
||||
|
||||
// A box with overflow auto/scroll/hidden is a scroll container. Any such
|
||||
// ancestor of the page content captures `position: sticky` (StickySubmit),
|
||||
@@ -59,6 +89,11 @@ function expectNoScrollContainerAbove(element: HTMLElement) {
|
||||
}
|
||||
|
||||
describe("AppLayout", () => {
|
||||
beforeEach(() => {
|
||||
sessionMode = "online";
|
||||
pushMock.mockClear();
|
||||
});
|
||||
|
||||
it("shows the offline band for the signed-in user right below the header", () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
@@ -71,6 +106,41 @@ describe("AppLayout", () => {
|
||||
expect(band.previousElementSibling?.tagName).toBe("HEADER");
|
||||
});
|
||||
|
||||
it("lets the band send only in an online session", () => {
|
||||
const { unmount } = render(
|
||||
<AppLayout>
|
||||
<p>Seiteninhalt</p>
|
||||
</AppLayout>,
|
||||
);
|
||||
expect(screen.getByTestId("offline-band")).toHaveAttribute("data-sync-enabled", "true");
|
||||
unmount();
|
||||
|
||||
sessionMode = "offline";
|
||||
render(
|
||||
<AppLayout>
|
||||
<p>Seiteninhalt</p>
|
||||
</AppLayout>,
|
||||
);
|
||||
expect(screen.getByTestId("offline-band")).toHaveAttribute("data-sync-enabled", "false");
|
||||
});
|
||||
|
||||
it("asks through the logout dialog and moves to the login page once signed out", async () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
<p>Seiteninhalt</p>
|
||||
</AppLayout>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog", { name: "Abmelden?" })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Konto-Menü" }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: "Abmelden" }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Abmelden?" });
|
||||
expect(dialog).toHaveTextContent("u1");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Abgemeldet" }));
|
||||
expect(pushMock).toHaveBeenCalledWith("/login");
|
||||
});
|
||||
|
||||
it("gives the band the page content wrapper as a stable focus target", () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
@@ -100,6 +170,19 @@ describe("AppLayout", () => {
|
||||
expect(now).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it("finishes the signed-in user's open photo hand-overs when the app starts", async () => {
|
||||
vi.mocked(finishHandOvers).mockClear();
|
||||
render(
|
||||
<AppLayout>
|
||||
<p>Seiteninhalt</p>
|
||||
</AppLayout>,
|
||||
);
|
||||
|
||||
// Even offline: the photos then wait as entries of their own.
|
||||
await waitFor(() => expect(finishHandOvers).toHaveBeenCalledTimes(1));
|
||||
expect(finishHandOvers).toHaveBeenCalledWith("u1");
|
||||
});
|
||||
|
||||
it("does not wrap the page content in its own scroll container", () => {
|
||||
render(
|
||||
<AppLayout>
|
||||
|
||||
Reference in new issue
Block a user