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:
47
frontend/src/__tests__/CaseOverviewGrid.test.tsx
Normal file
47
frontend/src/__tests__/CaseOverviewGrid.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
67
frontend/src/__tests__/DeadlineTrafficLights.test.tsx
Normal file
67
frontend/src/__tests__/DeadlineTrafficLights.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
143
frontend/src/__tests__/LoginPage.test.tsx
Normal file
143
frontend/src/__tests__/LoginPage.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
182
frontend/src/__tests__/api.test.ts
Normal file
182
frontend/src/__tests__/api.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
7
frontend/src/__tests__/setup.ts
Normal file
7
frontend/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Reference in New Issue
Block a user