Files
ApiCore/ApiCore-Frontend/src/app/(app)/layout.spec.tsx
T
Nick SzaunigandClaude Opus 5.5 e712c5d4d1
Backend Image / build-and-push (push) Successful in 6m39s
Frontend Image / build-and-push (push) Successful in 14m30s
fix: T6 browser-check findings (compliance, treatments, labels)
- api-client accepts 2xx answers without body (revoke showed an error)
- authority notices: sentOn in the future is rejected (Berlin day) on
  create, mark-sent and PATCH; dialog defaults to the Berlin day
- service worker never caches /api/v1/site-settings
- treatments/new no longer overflows at 390 px (min-w-0 on form
  wrappers); dose with more than 3 decimals is an error, never rounded
- medicines: expired lots flagged, disposal over the rest warns
- label designer: side panel at lg, sample values instead of
  placeholders, compliance panel reopens on lg, labelled zoom buttons
- skip link to #main-content, headings on no-access pages

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 18:34:52 +02:00

282 lines
8.6 KiB
TypeScript

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 { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import AppLayout from "./layout";
const pushMock = vi.fn();
let pathname = "/inspections/new";
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: pushMock }),
usePathname: () => pathname,
}));
let sessionMode: "online" | "offline" = "online";
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({
user: {
id: "u1",
email: "[email protected]",
name: "Test Imker",
roleKey: "admin",
roleName: "Admin",
permissions,
mfaEnabled: false,
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" data-sync-enabled={String(syncEnabled)}>
{userId}
<button type="button" onClick={() => pageContentRef.current?.focus()}>
Fokus auf den Seiteninhalt
</button>
</div>
),
}));
vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null }));
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),
// and since the page itself scrolls on the window, the bar would never stick.
const SCROLL_CONTAINER_CLASS = /(^|\s)([a-z]+:)*overflow-(x-|y-)?(auto|scroll|hidden)(\s|$)/;
function expectNoScrollContainerAbove(element: HTMLElement) {
for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
expect(ancestor.className).not.toMatch(SCROLL_CONTAINER_CLASS);
}
}
describe("AppLayout", () => {
beforeEach(() => {
sessionMode = "online";
pathname = "/inspections/new";
permissions = [];
pushMock.mockClear();
});
it("starts with a skip link that moves the focus to the page content", async () => {
render(
<AppLayout>
<main>
<p>Seiteninhalt</p>
</main>
</AppLayout>,
);
const link = screen.getByRole("link", { name: "Zum Inhalt springen" });
// First in the tab order: before the sidebar and every other link.
await userEvent.tab();
expect(link).toHaveFocus();
expect(link).toHaveAttribute("href", "#main-content");
await userEvent.keyboard("{Enter}");
const content = screen.getByText("Seiteninhalt").closest("#main-content");
expect(content).not.toBeNull();
expect(content).toHaveFocus();
});
it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => {
pathname = "/suggestions";
permissions = ["inspections:create"];
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
const content = screen.getByText("Seiteninhalt").closest("[tabindex='-1']") as HTMLElement;
// FAB top edge = nav + 1rem gap + 3rem button = nav + 4rem; padding nav + 4.5rem leaves 0.5rem free
// below the last button of the page, so the FAB never covers it at the end of the scroll.
expect(content.className).toContain("pb-[calc(var(--bottom-nav-height)+--spacing(18))]");
expect(18 * 0.25).toBeGreaterThan(1 + 3);
});
it("adds no extra room on a form page (the quick-add button is hidden there) or without the button", () => {
permissions = ["inspections:create"];
const { unmount } = render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.getByText("Seiteninhalt").closest("[tabindex='-1']")?.className).not.toContain(
"--spacing(18)",
);
unmount();
pathname = "/suggestions";
permissions = [];
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
expect(screen.getByText("Seiteninhalt").closest("[tabindex='-1']")?.className).not.toContain(
"--spacing(18)",
);
});
it("shows the offline band for the signed-in user right below the header", () => {
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
const band = screen.getByTestId("offline-band");
expect(band).toHaveTextContent("u1");
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>
<p>Seiteninhalt</p>
</AppLayout>,
);
fireEvent.click(screen.getByRole("button", { name: "Fokus auf den Seiteninhalt" }));
const wrapper = screen.getByText("Seiteninhalt").closest("[tabindex='-1']");
expect(wrapper).not.toBeNull();
expect(wrapper).toHaveFocus();
expect(wrapper?.previousElementSibling).toBe(screen.getByTestId("offline-band"));
});
it("deletes photos of form drafts older than 3 days when the app starts", async () => {
vi.mocked(purgeStaleDraftPhotos).mockClear();
const before = Date.now();
render(
<AppLayout>
<p>Seiteninhalt</p>
</AppLayout>,
);
await waitFor(() => expect(purgeStaleDraftPhotos).toHaveBeenCalledTimes(1));
const [now] = vi.mocked(purgeStaleDraftPhotos).mock.calls[0];
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>
<p>Seiteninhalt</p>
</AppLayout>,
);
expectNoScrollContainerAbove(screen.getByText("Seiteninhalt"));
});
it("keeps every ancestor of a form's save bar, including the Card, free of scroll containers", () => {
render(
<AppLayout>
<main>
<Card className="max-w-lg">
<CardContent>
<form>
<StickySubmit>
<button type="submit">Speichern</button>
</StickySubmit>
</form>
</CardContent>
</Card>
</main>
</AppLayout>,
);
const saveBar = screen.getByRole("button", { name: "Speichern" }).parentElement!;
expectNoScrollContainerAbove(saveBar);
});
});