- 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]>
282 lines
8.6 KiB
TypeScript
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);
|
|
});
|
|
});
|