test: comprehensive E2E and API test suite for full KanzlAI stack

Backend (Go):
- Expanded integration_test.go: health, auth middleware (expired/invalid/wrong-secret JWT),
  tenant CRUD, case CRUD (create/list/get/update/delete + filters + validation),
  deadline CRUD (create/list/update/complete/delete), appointment CRUD,
  dashboard (verifies all sections), deadline calculator (valid/invalid/unknown type),
  proceeding types & rules, document endpoints, AI extraction (no-key path),
  and full critical path E2E (auth -> case -> deadline -> appointment -> dashboard -> complete)
- New handler unit tests: case (10), appointment (11), dashboard (1), calculate (5),
  document (10), AI (4) — all testing validation, auth guards, and error paths without DB
- Total: ~80 backend tests (unit + integration)

Frontend (TypeScript/Vitest):
- Installed vitest 2.x, @testing-library/react, @testing-library/jest-dom, jsdom 24, msw
- vitest.config.ts with jsdom env, esbuild JSX automatic, path aliases
- API client tests (13): URL construction, no double /api/, auth header, tenant header,
  POST/PUT/PATCH/DELETE methods, error handling, 204 responses
- DeadlineTrafficLights tests (5): renders cards, correct counts, zero state, onFilter callback
- CaseOverviewGrid tests (4): renders categories, counts, header, zero state
- LoginPage tests (8): form rendering, mode toggle, password login, redirect, error display,
  magic link, registration link
- Total: 30 frontend tests

Makefile: test-frontend target now runs vitest instead of placeholder echo.
This commit is contained in:
m
2026-03-25 16:19:00 +01:00
parent 19bea8d058
commit 325fbeb5de
16 changed files with 2492 additions and 30 deletions

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { CaseOverviewGrid } from "@/components/dashboard/CaseOverviewGrid";
import type { CaseSummary } from "@/lib/types";
describe("CaseOverviewGrid", () => {
const defaultData: CaseSummary = {
active_count: 15,
new_this_month: 4,
closed_count: 8,
};
it("renders all three case categories", () => {
render(<CaseOverviewGrid data={defaultData} />);
expect(screen.getByText("Aktive Akten")).toBeInTheDocument();
expect(screen.getByText("Neu (Monat)")).toBeInTheDocument();
expect(screen.getByText("Abgeschlossen")).toBeInTheDocument();
});
it("displays correct counts", () => {
render(<CaseOverviewGrid data={defaultData} />);
expect(screen.getByText("15")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("8")).toBeInTheDocument();
});
it("renders the section header", () => {
render(<CaseOverviewGrid data={defaultData} />);
expect(screen.getByText("Aktenübersicht")).toBeInTheDocument();
});
it("handles zero counts", () => {
const zeroData: CaseSummary = {
active_count: 0,
new_this_month: 0,
closed_count: 0,
};
render(<CaseOverviewGrid data={zeroData} />);
const zeros = screen.getAllByText("0");
expect(zeros).toHaveLength(3);
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { DeadlineTrafficLights } from "@/components/dashboard/DeadlineTrafficLights";
import type { DeadlineSummary } from "@/lib/types";
describe("DeadlineTrafficLights", () => {
const defaultData: DeadlineSummary = {
overdue_count: 3,
due_this_week: 5,
due_next_week: 2,
ok_count: 10,
};
it("renders all three traffic light cards", () => {
render(<DeadlineTrafficLights data={defaultData} />);
expect(screen.getByText("Überfällig")).toBeInTheDocument();
expect(screen.getByText("Diese Woche")).toBeInTheDocument();
expect(screen.getByText("Im Zeitplan")).toBeInTheDocument();
});
it("displays correct counts", () => {
render(<DeadlineTrafficLights data={defaultData} />);
// Overdue: 3
expect(screen.getByText("3")).toBeInTheDocument();
// This week: 5
expect(screen.getByText("5")).toBeInTheDocument();
// OK: ok_count + due_next_week = 10 + 2 = 12
expect(screen.getByText("12")).toBeInTheDocument();
});
it("displays zero counts correctly", () => {
const zeroData: DeadlineSummary = {
overdue_count: 0,
due_this_week: 0,
due_next_week: 0,
ok_count: 0,
};
render(<DeadlineTrafficLights data={zeroData} />);
const zeros = screen.getAllByText("0");
expect(zeros).toHaveLength(3);
});
it("calls onFilter with correct key when clicked", () => {
const onFilter = vi.fn();
render(<DeadlineTrafficLights data={defaultData} onFilter={onFilter} />);
fireEvent.click(screen.getByText("Überfällig"));
expect(onFilter).toHaveBeenCalledWith("overdue");
fireEvent.click(screen.getByText("Diese Woche"));
expect(onFilter).toHaveBeenCalledWith("this_week");
fireEvent.click(screen.getByText("Im Zeitplan"));
expect(onFilter).toHaveBeenCalledWith("ok");
});
it("renders without onFilter prop (no crash)", () => {
expect(() => {
render(<DeadlineTrafficLights data={defaultData} />);
fireEvent.click(screen.getByText("Überfällig"));
}).not.toThrow();
});
});

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
// Mock next/navigation
const mockPush = vi.fn();
const mockRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
}));
// Mock Supabase
const mockSignInWithPassword = vi.fn();
const mockSignInWithOtp = vi.fn();
vi.mock("@/lib/supabase/client", () => ({
createClient: () => ({
auth: {
signInWithPassword: mockSignInWithPassword,
signInWithOtp: mockSignInWithOtp,
},
}),
}));
// Import after mocks
const { default: LoginPage } = await import(
"@/app/(auth)/login/page"
);
describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders login form with email and password fields", () => {
render(<LoginPage />);
expect(screen.getByText("KanzlAI")).toBeInTheDocument();
expect(screen.getByText("Melden Sie sich an")).toBeInTheDocument();
expect(screen.getByLabelText("E-Mail")).toBeInTheDocument();
expect(screen.getByLabelText("Passwort")).toBeInTheDocument();
expect(screen.getByText("Anmelden")).toBeInTheDocument();
});
it("renders mode toggle between Passwort and Magic Link", () => {
render(<LoginPage />);
// "Passwort" appears twice (toggle button + label), so use getAllByText
const passwortElements = screen.getAllByText("Passwort");
expect(passwortElements.length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Magic Link")).toBeInTheDocument();
});
it("switches to magic link mode and hides password field", () => {
render(<LoginPage />);
fireEvent.click(screen.getByText("Magic Link"));
expect(screen.queryByLabelText("Passwort")).not.toBeInTheDocument();
expect(screen.getByText("Link senden")).toBeInTheDocument();
});
it("submits password login to Supabase", async () => {
mockSignInWithPassword.mockResolvedValue({ error: null });
render(<LoginPage />);
fireEvent.change(screen.getByLabelText("E-Mail"), {
target: { value: "test@kanzlei.de" },
});
fireEvent.change(screen.getByLabelText("Passwort"), {
target: { value: "geheim123" },
});
fireEvent.click(screen.getByText("Anmelden"));
await waitFor(() => {
expect(mockSignInWithPassword).toHaveBeenCalledWith({
email: "test@kanzlei.de",
password: "geheim123",
});
});
});
it("redirects to / on successful login", async () => {
mockSignInWithPassword.mockResolvedValue({ error: null });
render(<LoginPage />);
fireEvent.change(screen.getByLabelText("E-Mail"), {
target: { value: "test@kanzlei.de" },
});
fireEvent.change(screen.getByLabelText("Passwort"), {
target: { value: "geheim123" },
});
fireEvent.click(screen.getByText("Anmelden"));
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/");
expect(mockRefresh).toHaveBeenCalled();
});
});
it("displays error on failed login", async () => {
mockSignInWithPassword.mockResolvedValue({
error: { message: "Ungültige Anmeldedaten" },
});
render(<LoginPage />);
fireEvent.change(screen.getByLabelText("E-Mail"), {
target: { value: "bad@email.de" },
});
fireEvent.change(screen.getByLabelText("Passwort"), {
target: { value: "wrong" },
});
fireEvent.click(screen.getByText("Anmelden"));
await waitFor(() => {
expect(screen.getByText("Ungültige Anmeldedaten")).toBeInTheDocument();
});
});
it("shows magic link sent confirmation", async () => {
mockSignInWithOtp.mockResolvedValue({ error: null });
render(<LoginPage />);
// Switch to magic link mode
fireEvent.click(screen.getByText("Magic Link"));
fireEvent.change(screen.getByLabelText("E-Mail"), {
target: { value: "test@kanzlei.de" },
});
fireEvent.click(screen.getByText("Link senden"));
await waitFor(() => {
expect(screen.getByText("Link gesendet")).toBeInTheDocument();
expect(screen.getByText("Zurueck zum Login")).toBeInTheDocument();
});
});
it("has link to registration page", () => {
render(<LoginPage />);
const registerLink = screen.getByText("Registrieren");
expect(registerLink).toBeInTheDocument();
expect(registerLink.closest("a")).toHaveAttribute("href", "/register");
});
});

View File

@@ -0,0 +1,182 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock Supabase client
const mockGetSession = vi.fn();
vi.mock("@/lib/supabase/client", () => ({
createClient: () => ({
auth: {
getSession: mockGetSession,
},
}),
}));
// Must import after mock setup
const { api } = await import("@/lib/api");
describe("ApiClient", () => {
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
mockGetSession.mockResolvedValue({
data: { session: { access_token: "test-token-123" } },
});
});
it("constructs correct URL with /api base", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ cases: [], total: 0 }), { status: 200 }),
);
await api.get("/cases");
expect(fetchSpy).toHaveBeenCalledWith(
"/api/cases",
expect.objectContaining({ method: "GET" }),
);
});
it("does not double-prefix /api/", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await api.get("/deadlines");
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toBe("/api/deadlines");
expect(url).not.toContain("/api/api/");
});
it("sets Authorization header from Supabase session", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await api.get("/cases");
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
const headers = requestInit.headers as Record<string, string>;
expect(headers["Authorization"]).toBe("Bearer test-token-123");
});
it("sets X-Tenant-ID header from localStorage", async () => {
localStorage.setItem("kanzlai_tenant_id", "tenant-uuid-123");
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await api.get("/cases");
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
const headers = requestInit.headers as Record<string, string>;
expect(headers["X-Tenant-ID"]).toBe("tenant-uuid-123");
});
it("omits X-Tenant-ID when not in localStorage", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await api.get("/cases");
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
const headers = requestInit.headers as Record<string, string>;
expect(headers["X-Tenant-ID"]).toBeUndefined();
});
it("omits Authorization when no session", async () => {
mockGetSession.mockResolvedValue({
data: { session: null },
});
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await api.get("/cases");
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
const headers = requestInit.headers as Record<string, string>;
expect(headers["Authorization"]).toBeUndefined();
});
it("sends POST with JSON body", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ id: "new-id" }), { status: 201 }),
);
const body = { case_number: "TEST/001", title: "Test Case" };
await api.post("/cases", body);
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
expect(requestInit.method).toBe("POST");
expect(requestInit.body).toBe(JSON.stringify(body));
const headers = requestInit.headers as Record<string, string>;
expect(headers["Content-Type"]).toBe("application/json");
});
it("sends PUT with JSON body", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await api.put("/cases/123", { title: "Updated" });
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
expect(requestInit.method).toBe("PUT");
});
it("sends PATCH with JSON body", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await api.patch("/deadlines/123/complete", {});
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
expect(requestInit.method).toBe("PATCH");
});
it("sends DELETE", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await api.delete("/cases/123");
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
expect(requestInit.method).toBe("DELETE");
});
it("throws ApiError on non-ok response", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: "not found" }), { status: 404 }),
);
await expect(api.get("/cases/nonexistent")).rejects.toEqual({
error: "not found",
status: 404,
});
});
it("handles 204 No Content response", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(null, { status: 204 }),
);
const result = await api.delete("/appointments/123");
expect(result).toBeUndefined();
});
it("handles error response without JSON body", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("Internal Server Error", {
status: 500,
statusText: "Internal Server Error",
}),
);
await expect(api.get("/broken")).rejects.toEqual({
error: "Internal Server Error",
status: 500,
});
});
});

View File

@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});