Merge pull request #86 from Since-3/feature/unit-tests

added unit test framework
This commit is contained in:
Rajpreet0 2026-04-27 20:45:54 +02:00 • committed by GitHub
commit e05984c1c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2221 additions and 17 deletions

4
.husky/pre-push Normal file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run test:run

851
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,9 @@
"prepare": "husky",
"prettier:check": "prettier --check .",
"prettier:fix": "prettier --write .",
"test": "vitest"
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@prisma/client": "^6.19.0",
@ -49,6 +51,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
@ -57,11 +60,13 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "^4.0.6",
"eslint": "^9.39.0",
"eslint-config-next": "16.0.1",
"husky": "^9.1.7",
"jsdom": "^27.1.0",
"lint-staged": "^16.2.6",
"msw": "^2.13.6",
"prettier": "^3.6.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",

View file

@ -1,7 +0,0 @@
import { describe, it, expect } from "vitest";
describe("Placeholder CI Test", () => {
it("should always pass", () => {
expect(true).toBe(true);
});
});

24
tests/__mocks__/prisma.ts Normal file
View file

@ -0,0 +1,24 @@
import { vi } from "vitest";
export const mockPrisma = {
manager: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
user: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
employee: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
$disconnect: vi.fn(),
};

View file

@ -0,0 +1,28 @@
import { vi } from "vitest";
export const mockAuth = {
signInWithPassword: vi.fn(),
signUp: vi.fn(),
signOut: vi.fn(),
};
export const mockStorage = {
from: vi.fn().mockReturnValue({
upload: vi.fn(),
remove: vi.fn(),
getPublicUrl: vi
.fn()
.mockReturnValue({ data: { publicUrl: "https://example.com/avatar.jpg" } }),
}),
};
export const mockSupabaseClient = {
auth: mockAuth,
storage: mockStorage,
};
// Used by vi.mock('@/src/lib/supabase/server', ...)
export const createClient = vi.fn().mockResolvedValue(mockSupabaseClient);
// Used by vi.mock('@/src/lib/supabase/client', ...)
export const createClientClient = vi.fn().mockReturnValue(mockSupabaseClient);

View file

@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { POST } from "@/src/app/api/auth/login/route";
vi.mock("@/src/lib/supabase/server", () => ({
createClient: vi.fn(),
}));
import { createClient } from "@/src/lib/supabase/server";
const mockSignInWithPassword = vi.fn();
const mockSupabase = { auth: { signInWithPassword: mockSignInWithPassword } };
beforeEach(() => {
vi.mocked(createClient).mockResolvedValue(mockSupabase as never);
mockSignInWithPassword.mockReset();
});
function makeRequest(body: Record<string, unknown>) {
return new Request("http://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/auth/login", () => {
it("gibt 400 zurück wenn E-Mail fehlt", async () => {
const res = await POST(makeRequest({ password: "passwort123" }));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBeDefined();
});
it("gibt 400 zurück wenn Passwort fehlt", async () => {
const res = await POST(makeRequest({ email: "test@example.com" }));
expect(res.status).toBe(400);
});
it("gibt 401 zurück bei falschen Credentials", async () => {
mockSignInWithPassword.mockResolvedValue({
data: { user: null, session: null },
error: { message: "Invalid login credentials" },
});
const res = await POST(makeRequest({ email: "test@example.com", password: "falsch123" }));
expect(res.status).toBe(401);
const data = await res.json();
expect(data.error).toContain("falsch");
});
it("gibt 200 und User/Session zurück bei Erfolg", async () => {
const fakeUser = { id: "user-1", email: "test@example.com" };
const fakeSession = { access_token: "token-abc" };
mockSignInWithPassword.mockResolvedValue({
data: { user: fakeUser, session: fakeSession },
error: null,
});
const res = await POST(makeRequest({ email: "test@example.com", password: "Richtig!123" }));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.user.id).toBe("user-1");
expect(data.session.access_token).toBe("token-abc");
});
});

View file

@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { POST } from "@/src/app/api/auth/register/employee/route";
vi.mock("@/src/lib/supabase/server", () => ({
createClient: vi.fn(),
}));
vi.mock("@/src/lib/prisma", () => ({
prisma: {
employee: { findUnique: vi.fn() },
},
}));
import { createClient } from "@/src/lib/supabase/server";
import { prisma } from "@/src/lib/prisma";
const mockSignUp = vi.fn();
const mockSupabase = { auth: { signUp: mockSignUp } };
const validToken = "valid-token-abc";
const validEmployee = {
email: "employee@example.com",
isOnboarded: false,
onboardingTokenExpiry: new Date(Date.now() + 3600000),
};
beforeEach(() => {
vi.mocked(createClient).mockResolvedValue(mockSupabase as never);
vi.mocked(prisma.employee.findUnique).mockReset();
mockSignUp.mockReset();
});
const validBody = {
token: validToken,
email: "employee@example.com",
password: "Sicher!123",
firstName: "Anna",
lastName: "Schmidt",
};
let ipCounter = 0;
function makeRequest(body: Record<string, unknown>) {
// Each test gets a unique IP to avoid triggering the in-memory rate limiter
const ip = `10.0.0.${++ipCounter}`;
return new Request("http://localhost/api/auth/register/employee", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-forwarded-for": ip,
},
body: JSON.stringify(body),
});
}
describe("POST /api/auth/register/employee", () => {
it("gibt 400 zurück wenn Pflichtfelder fehlen", async () => {
const res = await POST(makeRequest({ token: validToken }));
expect(res.status).toBe(400);
});
it("gibt 401 zurück bei ungültigem Token", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue(null);
const res = await POST(makeRequest(validBody));
expect(res.status).toBe(401);
const data = await res.json();
expect(data.error).toContain("Ungültiger Token");
});
it("gibt 401 zurück bei bereits abgeschlossenem Onboarding", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue({
...validEmployee,
isOnboarded: true,
} as never);
const res = await POST(makeRequest(validBody));
expect(res.status).toBe(401);
});
it("gibt 401 zurück bei falschem E-Mail im Token", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue({
...validEmployee,
email: "andere@example.com",
} as never);
const res = await POST(makeRequest(validBody));
expect(res.status).toBe(401);
});
it("gibt 401 zurück bei abgelaufenem Token", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue({
...validEmployee,
onboardingTokenExpiry: new Date(Date.now() - 1000),
} as never);
const res = await POST(makeRequest(validBody));
expect(res.status).toBe(401);
const data = await res.json();
expect(data.error).toContain("abgelaufen");
});
it("gibt 400 zurück bei schwachem Passwort", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue(validEmployee as never);
const res = await POST(makeRequest({ ...validBody, password: "schwach" }));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("Passwort");
});
it("gibt 201 zurück bei erfolgreicher Registrierung", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue(validEmployee as never);
mockSignUp.mockResolvedValue({
data: { user: { id: "emp-1" } },
error: null,
});
const res = await POST(makeRequest(validBody));
expect(res.status).toBe(201);
const data = await res.json();
expect(data.success).toBe(true);
});
});

View file

@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { POST } from "@/src/app/api/auth/register/route";
vi.mock("@/src/lib/supabase/server", () => ({
createClient: vi.fn(),
}));
vi.mock("@/src/lib/prisma", () => ({
prisma: {
manager: { findUnique: vi.fn() },
user: { findUnique: vi.fn(), create: vi.fn() },
},
}));
import { createClient } from "@/src/lib/supabase/server";
import { prisma } from "@/src/lib/prisma";
const mockSignUp = vi.fn();
const mockSupabase = { auth: { signUp: mockSignUp } };
beforeEach(() => {
vi.mocked(createClient).mockResolvedValue(mockSupabase as never);
vi.mocked(prisma.manager.findUnique).mockReset();
vi.mocked(prisma.user.findUnique).mockReset();
vi.mocked(prisma.user.create).mockReset();
mockSignUp.mockReset();
});
const validBody = {
email: "neu@example.com",
password: "Sicher!123",
name: "Max Mustermann",
birthday: "1990-05-15",
plz: "10115",
city: "Berlin",
street: "Musterstraße 1",
houseNumber: "1",
gender: "Men",
};
function makeRequest(body: Record<string, unknown>) {
return new Request("http://localhost/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/auth/register", () => {
it("gibt 400 zurück wenn Pflichtfelder fehlen", async () => {
const res = await POST(makeRequest({ email: "test@example.com" }));
expect(res.status).toBe(400);
});
it("gibt 409 zurück wenn E-Mail bereits als Manager existiert", async () => {
vi.mocked(prisma.manager.findUnique).mockResolvedValue({
id: "m-1",
email: validBody.email,
} as never);
const res = await POST(makeRequest(validBody));
expect(res.status).toBe(409);
const data = await res.json();
expect(data.error).toContain("Manager");
});
it("gibt 409 zurück wenn User bereits existiert", async () => {
vi.mocked(prisma.manager.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "u-1",
email: validBody.email,
} as never);
const res = await POST(makeRequest(validBody));
expect(res.status).toBe(409);
const data = await res.json();
expect(data.error).toContain("existiert bereits");
});
it("gibt 400 zurück bei Supabase-Auth-Fehler", async () => {
vi.mocked(prisma.manager.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
mockSignUp.mockResolvedValue({
data: { user: null },
error: { message: "Email rate limit exceeded" },
});
const res = await POST(makeRequest(validBody));
expect(res.status).toBe(400);
});
it("gibt 201 und neuen User zurück bei Erfolg", async () => {
const fakeUser = { id: "new-1" };
const createdUser = { id: "new-1", email: validBody.email, name: validBody.name };
vi.mocked(prisma.manager.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
mockSignUp.mockResolvedValue({ data: { user: fakeUser }, error: null });
vi.mocked(prisma.user.create).mockResolvedValue(createdUser as never);
const res = await POST(makeRequest(validBody));
expect(res.status).toBe(201);
const data = await res.json();
expect(data.user.email).toBe(validBody.email);
});
});

View file

@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { POST } from "@/src/app/api/upload/avatar/route";
vi.mock("@/src/lib/supabase/admin", () => ({
createAdminClient: vi.fn(),
}));
vi.mock("@/src/lib/prisma", () => ({
prisma: {
employee: { findUnique: vi.fn() },
},
}));
import { createAdminClient } from "@/src/lib/supabase/admin";
import { prisma } from "@/src/lib/prisma";
const mockUpload = vi.fn();
const mockGetPublicUrl = vi.fn().mockReturnValue({
data: { publicUrl: "https://example.com/avatars/emp-1/photo.jpg" },
});
const mockStorageBucket = { upload: mockUpload, getPublicUrl: mockGetPublicUrl };
const mockAdminClient = { storage: { from: vi.fn().mockReturnValue(mockStorageBucket) } };
const validEmployee = {
id: "emp-1",
isOnboarded: false,
onboardingTokenExpiry: new Date(Date.now() + 3600000),
};
beforeEach(() => {
vi.mocked(createAdminClient).mockReturnValue(mockAdminClient as never);
vi.mocked(prisma.employee.findUnique).mockReset();
mockUpload.mockReset();
mockUpload.mockResolvedValue({ data: { path: "employees/emp-1/photo.jpg" }, error: null });
});
function makeImageFile(name = "photo.jpg", type = "image/jpeg", sizeBytes = 1024): File {
const content = new Uint8Array(sizeBytes);
const file = new File([content], name, { type });
// Ensure arrayBuffer() is available (jsdom File may lack it in Node.js test env)
if (typeof file.arrayBuffer !== "function") {
Object.defineProperty(file, "arrayBuffer", {
value: async () => content.buffer,
});
}
return file;
}
// jsdom's multipart body parser drops File.type, so we mock formData() directly
// to return a FormData containing the original File object unchanged.
function makeRequest(file: File | null, token: string | null): Request {
const req = new Request("http://localhost/api/upload/avatar", { method: "POST" });
req.formData = async () => {
const fd = {
get(key: string) {
if (key === "file") return file;
if (key === "token") return token;
return null;
},
} as unknown as FormData;
return fd;
};
return req;
}
describe("POST /api/upload/avatar", () => {
it("gibt 400 zurück wenn Datei fehlt", async () => {
const res = await POST(makeRequest(null, "valid-token"));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("Datei und Token erforderlich");
});
it("gibt 400 zurück wenn Token fehlt", async () => {
const res = await POST(makeRequest(makeImageFile(), null));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("Datei und Token erforderlich");
});
it("gibt 401 zurück bei ungültigem Token", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue(null);
const res = await POST(makeRequest(makeImageFile(), "invalid-token"));
expect(res.status).toBe(401);
});
it("gibt 401 zurück bei bereits abgeschlossenem Onboarding", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue({
...validEmployee,
isOnboarded: true,
} as never);
const res = await POST(makeRequest(makeImageFile(), "valid-token"));
expect(res.status).toBe(401);
});
it("gibt 401 zurück bei abgelaufenem Token", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue({
...validEmployee,
onboardingTokenExpiry: new Date(Date.now() - 1000),
} as never);
const res = await POST(makeRequest(makeImageFile(), "valid-token"));
expect(res.status).toBe(401);
});
it("gibt 400 zurück bei nicht-Bilddatei", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue(validEmployee as never);
const pdfFile = new File(["pdf content"], "doc.pdf", { type: "application/pdf" });
const res = await POST(makeRequest(pdfFile, "valid-token"));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("Bilddateien");
});
it("gibt 400 zurück bei zu großer Datei (>5MB)", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue(validEmployee as never);
const bigFile = makeImageFile("big.jpg", "image/jpeg", 6 * 1024 * 1024);
const res = await POST(makeRequest(bigFile, "valid-token"));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("5MB");
});
it("gibt 201 und publicUrl zurück bei Erfolg", async () => {
vi.mocked(prisma.employee.findUnique).mockResolvedValue(validEmployee as never);
const res = await POST(makeRequest(makeImageFile(), "valid-token"));
expect(res.status).toBe(201);
const data = await res.json();
expect(data.publicUrl).toContain("https://");
});
});

165
tests/hooks/useAuth.test.ts Normal file
View file

@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
const { mockRouterPush, mockSetUser, mockSupabaseSignIn } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockSetUser: vi.fn(),
mockSupabaseSignIn: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockRouterPush }),
}));
vi.mock("@/src/store/authStore", () => ({
useAuthStore: (selector: (s: { setUser: typeof mockSetUser }) => typeof mockSetUser) =>
selector({ setUser: mockSetUser }),
}));
vi.mock("@/src/lib/supabase/client", () => ({
createClient: () => ({ auth: { signInWithPassword: mockSupabaseSignIn } }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { useAuth } from "@/src/hooks/useAuth";
beforeEach(() => {
mockRouterPush.mockReset();
mockSetUser.mockReset();
mockSupabaseSignIn.mockReset();
vi.stubGlobal("fetch", vi.fn());
});
describe("useAuth", () => {
describe("loginUser", () => {
it("gibt Zod-Validierungsfehler bei ungültiger E-Mail zurück", async () => {
const { result } = renderHook(() => useAuth());
let success: boolean;
await act(async () => {
success = await result.current.loginUser("keine-email", "passwort123", false);
});
expect(success!).toBe(false);
expect(result.current.errors.email).toBeDefined();
expect(mockSupabaseSignIn).not.toHaveBeenCalled();
});
it("setzt Fehler bei falschen Credentials", async () => {
mockSupabaseSignIn.mockResolvedValue({
data: { user: null, session: null },
error: { message: "Invalid credentials" },
});
const { result } = renderHook(() => useAuth());
let success: boolean;
await act(async () => {
success = await result.current.loginUser("test@example.com", "Falsch!123", false);
});
expect(success!).toBe(false);
expect(result.current.errors.email).toBeDefined();
expect(mockRouterPush).not.toHaveBeenCalled();
});
it("ruft setUser und router.push bei erfolgreichem Login auf", async () => {
const fakeUser = { id: "user-1", email: "test@example.com" };
mockSupabaseSignIn.mockResolvedValue({
data: { user: fakeUser, session: {} },
error: null,
});
const { result } = renderHook(() => useAuth());
let success: boolean;
await act(async () => {
success = await result.current.loginUser("test@example.com", "Richtig!123", false);
});
expect(success!).toBe(true);
expect(mockSetUser).toHaveBeenCalledWith(fakeUser);
expect(mockRouterPush).toHaveBeenCalledWith("/dashboard");
});
it("setzt loading auf false nach Abschluss", async () => {
mockSupabaseSignIn.mockResolvedValue({
data: { user: { id: "1" }, session: {} },
error: null,
});
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.loginUser("test@example.com", "Richtig!123", false);
});
expect(result.current.loading).toBe(false);
});
});
describe("registerUser", () => {
const validFormData = {
fullName: "Max Mustermann",
email: "max@example.com",
password: "Sicher!123",
passwordConfirm: "Sicher!123",
birthday: "1990-05-15",
plz: "10115",
place: "Berlin",
street: "Musterstr. 1",
gender: "Men" as const,
};
it("gibt Zod-Fehler bei fehlenden Pflichtfeldern zurück", async () => {
const { result } = renderHook(() => useAuth());
let success: boolean;
await act(async () => {
success = await result.current.registerUser({
...validFormData,
email: "keine-email",
});
});
expect(success!).toBe(false);
expect(result.current.errors.email).toBeDefined();
});
it("gibt true bei erfolgreicher Registrierung zurück", async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ user: { id: "new-1" } }),
} as Response);
const { result } = renderHook(() => useAuth());
let success: boolean;
await act(async () => {
success = await result.current.registerUser(validFormData);
});
expect(success!).toBe(true);
});
it("setzt Fehler bei API-Fehlerantwort", async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
json: async () => ({ error: "Dieser Nutzer existiert bereits." }),
} as Response);
const { result } = renderHook(() => useAuth());
let success: boolean;
await act(async () => {
success = await result.current.registerUser(validFormData);
});
expect(success!).toBe(false);
expect(result.current.errors.email).toContain("existiert bereits");
});
});
});

View file

@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useImageUpload } from "@/src/hooks/useImageUpload";
vi.mock("@/src/lib/supabase/storage", () => ({
uploadFile: vi.fn(),
deleteFile: vi.fn(),
generateFilePath: vi.fn().mockReturnValue("users/user-1/1234567890.jpg"),
}));
import { uploadFile, deleteFile } from "@/src/lib/supabase/storage";
const defaultOptions = { bucket: "avatars" as const };
function makeFile(name = "photo.jpg", type = "image/jpeg", sizeBytes = 1024): File {
return new File([new Uint8Array(sizeBytes)], name, { type });
}
beforeEach(() => {
vi.mocked(uploadFile).mockReset();
vi.mocked(deleteFile).mockReset();
});
describe("useImageUpload", () => {
it("gibt initialen State zurück", () => {
const { result } = renderHook(() => useImageUpload(defaultOptions));
expect(result.current.isUploading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.progress).toBe(0);
});
it("lehnt ungültigen MIME-Type ab und setzt error", async () => {
const { result } = renderHook(() => useImageUpload(defaultOptions));
const pdfFile = new File(["pdf"], "doc.pdf", { type: "application/pdf" });
await act(async () => {
await expect(result.current.upload(pdfFile, "user-1")).rejects.toThrow();
});
expect(result.current.error).toContain("Ungültiger Dateityp");
expect(result.current.isUploading).toBe(false);
});
it("lehnt Datei über maximaler Größe ab und setzt error", async () => {
const { result } = renderHook(() => useImageUpload({ ...defaultOptions, maxSizeMB: 1 }));
const bigFile = makeFile("big.jpg", "image/jpeg", 2 * 1024 * 1024);
await act(async () => {
await expect(result.current.upload(bigFile, "user-1")).rejects.toThrow();
});
expect(result.current.error).toContain("groß");
expect(result.current.isUploading).toBe(false);
});
it("setzt isUploading auf false nach erfolgreichem Upload", async () => {
vi.mocked(uploadFile).mockResolvedValue({
path: "users/user-1/photo.jpg",
publicUrl: "https://example.com/photo.jpg",
});
const { result } = renderHook(() => useImageUpload(defaultOptions));
const file = makeFile();
let url: string;
await act(async () => {
url = await result.current.upload(file, "user-1");
});
expect(url!).toBe("https://example.com/photo.jpg");
expect(result.current.isUploading).toBe(false);
expect(result.current.error).toBeNull();
expect(result.current.progress).toBe(100);
});
it("setzt error bei fehlgeschlagenem Upload", async () => {
vi.mocked(uploadFile).mockRejectedValue(new Error("Netzwerkfehler"));
const { result } = renderHook(() => useImageUpload(defaultOptions));
await act(async () => {
await expect(result.current.upload(makeFile(), "user-1")).rejects.toThrow("Netzwerkfehler");
});
expect(result.current.error).toBe("Netzwerkfehler");
expect(result.current.isUploading).toBe(false);
});
it("ruft deleteFile mit korrektem Pfad auf", async () => {
vi.mocked(deleteFile).mockResolvedValue(undefined);
const { result } = renderHook(() => useImageUpload(defaultOptions));
await act(async () => {
await result.current.remove("users/user-1/old-photo.jpg");
});
expect(deleteFile).toHaveBeenCalledWith("avatars", "users/user-1/old-photo.jpg");
expect(result.current.error).toBeNull();
});
it("setzt error wenn delete fehlschlägt", async () => {
vi.mocked(deleteFile).mockRejectedValue(new Error("Löschen fehlgeschlagen"));
const { result } = renderHook(() => useImageUpload(defaultOptions));
await act(async () => {
await expect(result.current.remove("some/path.jpg")).rejects.toThrow();
});
expect(result.current.error).toBe("Löschen fehlgeschlagen");
});
it("akzeptiert konfigurierbare erlaubte MIME-Types", async () => {
const { result } = renderHook(() =>
useImageUpload({ ...defaultOptions, allowedTypes: ["image/png"] })
);
const jpegFile = makeFile("photo.jpg", "image/jpeg");
await act(async () => {
await expect(result.current.upload(jpegFile, "user-1")).rejects.toThrow();
});
expect(result.current.error).toContain("Ungültiger Dateityp");
});
});

View file

@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useIsMobile } from "@/src/hooks/use-mobile";
const mockAddEventListener = vi.fn();
const mockRemoveEventListener = vi.fn();
function setWindowWidth(width: number) {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: width,
});
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: (query: string) => ({
matches: width < 768,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener,
dispatchEvent: vi.fn(),
}),
});
}
beforeEach(() => {
mockAddEventListener.mockClear();
mockRemoveEventListener.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("useIsMobile", () => {
it("gibt true zurück bei schmaler Viewport-Breite (<768px)", () => {
setWindowWidth(500);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
it("gibt false zurück bei breiter Viewport-Breite (>=768px)", () => {
setWindowWidth(1024);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it("gibt false zurück genau bei 768px", () => {
setWindowWidth(768);
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it("registriert einen Event-Listener auf matchMedia", () => {
setWindowWidth(500);
renderHook(() => useIsMobile());
expect(mockAddEventListener).toHaveBeenCalledWith("change", expect.any(Function));
});
it("entfernt den Event-Listener beim Unmount", () => {
setWindowWidth(500);
const { unmount } = renderHook(() => useIsMobile());
unmount();
expect(mockRemoveEventListener).toHaveBeenCalledWith("change", expect.any(Function));
});
});

77
tests/msw/handlers.ts Normal file
View file

@ -0,0 +1,77 @@
import { http, HttpResponse } from "msw";
export const handlers = [
http.post("http://localhost/api/auth/login", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
if (!body.email || !body.password) {
return HttpResponse.json({ error: "E-Mail und Passwort sind erforderlich" }, { status: 400 });
}
if (body.email === "wrong@example.com") {
return HttpResponse.json({ error: "E-Mail oder Passwort ist falsch" }, { status: 401 });
}
return HttpResponse.json(
{
user: { id: "user-1", email: body.email },
session: { access_token: "token-123" },
},
{ status: 200 }
);
}),
http.post("http://localhost/api/auth/register", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
if (!body.email || !body.password || !body.name) {
return HttpResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 });
}
if (body.email === "existing@example.com") {
return HttpResponse.json({ error: "Dieser Nutzer existiert bereits." }, { status: 409 });
}
return HttpResponse.json(
{ user: { id: "new-user-1", email: body.email, name: body.name } },
{ status: 201 }
);
}),
http.post("http://localhost/api/auth/register/employee", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
if (!body.token || !body.email || !body.password || !body.firstName || !body.lastName) {
return HttpResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 });
}
if (body.token === "expired-token") {
return HttpResponse.json({ error: "Token abgelaufen" }, { status: 401 });
}
if (body.token === "invalid-token") {
return HttpResponse.json({ error: "Ungültiger Token" }, { status: 401 });
}
return HttpResponse.json({ success: true }, { status: 201 });
}),
http.post("http://localhost/api/upload/avatar", async ({ request }) => {
const formData = await request.formData();
const file = formData.get("file");
const token = formData.get("token");
if (!file || !token) {
return HttpResponse.json({ error: "Datei und Token erforderlich" }, { status: 400 });
}
if (token === "invalid-token") {
return HttpResponse.json({ error: "Ungültiger Token" }, { status: 401 });
}
return HttpResponse.json(
{ publicUrl: "https://example.com/avatars/employee-1/avatar.jpg" },
{ status: 201 }
);
}),
];

4
tests/msw/server.ts Normal file
View file

@ -0,0 +1,4 @@
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);

View file

@ -0,0 +1,155 @@
import { describe, it, expect } from "vitest";
import { courseSchema, publishedCourseSchema } from "@/src/modules/courses/schemas/course-schema";
describe("courseSchema (Draft)", () => {
it("akzeptiert komplett leere Daten (Entwurf)", () => {
const result = courseSchema.safeParse({ isStandingOrder: false });
expect(result.success).toBe(true);
});
it("akzeptiert leere String-Felder", () => {
const result = courseSchema.safeParse({
name: "",
date: "",
timeFrom: "",
timeTo: "",
isStandingOrder: false,
});
expect(result.success).toBe(true);
});
it("lehnt timeFrom >= timeTo ab", () => {
const result = courseSchema.safeParse({
isStandingOrder: false,
timeFrom: "14:00",
timeTo: "10:00",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("timeTo");
});
it("akzeptiert valide Zeitspanne", () => {
const result = courseSchema.safeParse({
isStandingOrder: false,
timeFrom: "09:00",
timeTo: "10:30",
});
expect(result.success).toBe(true);
});
});
const validPublishedCourse = {
name: "Yoga Grundlagen",
sport: ["Yoga"],
level: "Anfänger",
date: "2026-05-01",
timeFrom: "09:00",
timeTo: "10:30",
trainers: ["trainer-1"],
room: "raum-1",
description: "Einführungskurs",
maxParticipants: 15,
price: 19.99,
isStandingOrder: false,
};
describe("publishedCourseSchema", () => {
it("akzeptiert vollständig valide Kursdaten", () => {
const result = publishedCourseSchema.safeParse(validPublishedCourse);
expect(result.success).toBe(true);
});
it("lehnt fehlenden Kursnamen ab", () => {
const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, name: "" });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("name");
});
it("lehnt leeres Sport-Array ab", () => {
const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, sport: [] });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("sport");
});
it("lehnt leeres Trainer-Array ab", () => {
const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, trainers: [] });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("trainers");
});
it("lehnt maxParticipants über 100 ab", () => {
const result = publishedCourseSchema.safeParse({
...validPublishedCourse,
maxParticipants: 101,
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("maxParticipants");
});
it("lehnt maxParticipants unter 1 ab", () => {
const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, maxParticipants: 0 });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("maxParticipants");
});
it("lehnt Preis über 9999.99 ab", () => {
const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, price: 10000 });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("price");
});
it("lehnt negativen Preis ab", () => {
const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, price: -1 });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("price");
});
it("lehnt timeFrom >= timeTo ab", () => {
const result = publishedCourseSchema.safeParse({
...validPublishedCourse,
timeFrom: "11:00",
timeTo: "09:00",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("timeTo");
});
it("lehnt Dauerauftrag ohne Häufigkeit ab", () => {
const result = publishedCourseSchema.safeParse({
...validPublishedCourse,
isStandingOrder: true,
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("frequency");
});
it("akzeptiert Dauerauftrag mit Häufigkeit", () => {
const result = publishedCourseSchema.safeParse({
...validPublishedCourse,
isStandingOrder: true,
frequency: "weekly",
});
expect(result.success).toBe(true);
});
it("lehnt individuelle Häufigkeit ohne Wochentage ab", () => {
const result = publishedCourseSchema.safeParse({
...validPublishedCourse,
isStandingOrder: true,
frequency: "custom",
weekdays: [],
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("weekdays");
});
it("akzeptiert individuelle Häufigkeit mit Wochentagen", () => {
const result = publishedCourseSchema.safeParse({
...validPublishedCourse,
isStandingOrder: true,
frequency: "custom",
weekdays: ["Montag", "Mittwoch"],
});
expect(result.success).toBe(true);
});
});

View file

@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { employeeSchema } from "@/src/modules/employee/schemas/employee-schema";
const defaultPermissions = {
employees: { view: true, create: false, edit: false, delete: false },
courses: { view: true, create: false, edit: false, delete: false },
rooms: { view: true, create: false, edit: false, delete: false },
};
const validData = {
email: "mitarbeiter@example.com",
roles: ["Trainer"],
isMultipleLocation: false,
permissions: defaultPermissions,
};
describe("employeeSchema", () => {
it("akzeptiert valide Mitarbeiterdaten", () => {
const result = employeeSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it("lehnt ungültige E-Mail ab", () => {
const result = employeeSchema.safeParse({ ...validData, email: "keine-email" });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("email");
});
it("lehnt leeres Rollen-Array ab", () => {
const result = employeeSchema.safeParse({ ...validData, roles: [] });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("roles");
});
it("lehnt mehrere Standorte ohne Standort-Auswahl ab", () => {
const result = employeeSchema.safeParse({
...validData,
isMultipleLocation: true,
locations: [],
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("locations");
});
it("akzeptiert mehrere Standorte mit mindestens einem Standort", () => {
const result = employeeSchema.safeParse({
...validData,
isMultipleLocation: true,
locations: ["standort-1"],
});
expect(result.success).toBe(true);
});
it("akzeptiert mehrere Rollen", () => {
const result = employeeSchema.safeParse({
...validData,
roles: ["Trainer", "Admin", "Kassierer"],
});
expect(result.success).toBe(true);
});
it("akzeptiert alle Permissions als false", () => {
const noPermissions = {
employees: { view: false, create: false, edit: false, delete: false },
courses: { view: false, create: false, edit: false, delete: false },
rooms: { view: false, create: false, edit: false, delete: false },
};
const result = employeeSchema.safeParse({ ...validData, permissions: noPermissions });
expect(result.success).toBe(true);
});
});

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { userLoginSchema } from "@/src/modules/auth/validation/userLoginSchema";
describe("userLoginSchema", () => {
it("akzeptiert valide Anmeldedaten", () => {
const result = userLoginSchema.safeParse({
email: "test@example.com",
password: "passwort123",
});
expect(result.success).toBe(true);
});
it("lehnt ungültige E-Mail ab", () => {
const result = userLoginSchema.safeParse({
email: "kein-email",
password: "passwort123",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("email");
});
it("lehnt Passwort unter 8 Zeichen ab", () => {
const result = userLoginSchema.safeParse({
email: "test@example.com",
password: "kurz",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("password");
});
it("lehnt fehlende E-Mail ab", () => {
const result = userLoginSchema.safeParse({ password: "passwort123" });
expect(result.success).toBe(false);
});
it("lehnt fehlendes Passwort ab", () => {
const result = userLoginSchema.safeParse({ email: "test@example.com" });
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import { userRegisterSchema } from "@/src/modules/auth/validation/userRegisterSchema";
const validData = {
fullName: "Max Mustermann",
email: "max@example.com",
password: "Sicher!123",
passwordConfirm: "Sicher!123",
birthday: "1990-05-15",
plz: "10115",
place: "Berlin",
street: "Musterstraße 1",
gender: "Men" as const,
};
describe("userRegisterSchema", () => {
it("akzeptiert vollständig valide Daten", () => {
const result = userRegisterSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it("lehnt zu kurzen Namen ab", () => {
const result = userRegisterSchema.safeParse({ ...validData, fullName: "X" });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("fullName");
});
it("lehnt ungültige E-Mail ab", () => {
const result = userRegisterSchema.safeParse({ ...validData, email: "keine-email" });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("email");
});
it("lehnt Passwort ohne Großbuchstaben ab", () => {
const result = userRegisterSchema.safeParse({
...validData,
password: "sicher!123",
passwordConfirm: "sicher!123",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("password");
});
it("lehnt Passwort ohne Sonderzeichen ab", () => {
const result = userRegisterSchema.safeParse({
...validData,
password: "Sicher1234",
passwordConfirm: "Sicher1234",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("password");
});
it("lehnt Passwort unter 8 Zeichen ab", () => {
const result = userRegisterSchema.safeParse({
...validData,
password: "Ku!1",
passwordConfirm: "Ku!1",
});
expect(result.success).toBe(false);
});
it("lehnt nicht übereinstimmende Passwörter ab", () => {
const result = userRegisterSchema.safeParse({
...validData,
passwordConfirm: "AndersPasswort!1",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("passwordConfirm");
});
it("lehnt Geburtsdatum in der Zukunft ab", () => {
const result = userRegisterSchema.safeParse({ ...validData, birthday: "2099-01-01" });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("birthday");
});
it("lehnt ungültiges Datumsformat ab", () => {
const result = userRegisterSchema.safeParse({ ...validData, birthday: "15.05.1990" });
expect(result.success).toBe(false);
});
it("lehnt fehlendes Geschlecht ab", () => {
const result = userRegisterSchema.safeParse({ ...validData, gender: "" });
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe("gender");
});
it("lehnt ungültigen Gender-Wert ab", () => {
const result = userRegisterSchema.safeParse({ ...validData, gender: "Unknown" });
expect(result.success).toBe(false);
});
it("akzeptiert alle Gender-Werte", () => {
for (const gender of ["Men", "Woman", "Various"] as const) {
const result = userRegisterSchema.safeParse({ ...validData, gender });
expect(result.success).toBe(true);
}
});
});

37
tests/utils/utils.test.ts Normal file
View file

@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { cn } from "@/src/lib/utils";
describe("cn", () => {
it("gibt eine einzelne Klasse zurück", () => {
expect(cn("px-4")).toBe("px-4");
});
it("kombiniert mehrere Klassen", () => {
expect(cn("px-4", "py-2", "text-sm")).toBe("px-4 py-2 text-sm");
});
it("überschreibt konfliktive Tailwind-Klassen (twMerge)", () => {
expect(cn("px-2", "px-4")).toBe("px-4");
expect(cn("text-sm", "text-lg")).toBe("text-lg");
});
it("ignoriert falsy-Werte (clsx)", () => {
expect(cn("px-4", false && "text-red", undefined, null as never, "py-2")).toBe("px-4 py-2");
});
it("verarbeitet bedingte Objekte", () => {
expect(cn({ "px-4": true, "py-2": false, "text-sm": true })).toBe("px-4 text-sm");
});
it("verarbeitet Arrays", () => {
expect(cn(["px-4", "py-2"])).toBe("px-4 py-2");
});
it("gibt leeren String bei keinen Argumenten zurück", () => {
expect(cn()).toBe("");
});
it("gibt leeren String bei nur falsy-Werten zurück", () => {
expect(cn(false, undefined, null as never)).toBe("");
});
});

View file

@ -20,7 +20,8 @@
],
"paths": {
"@/*": ["./*"]
}
},
"types": ["vitest/globals"]
},
"include": [
"next-env.d.ts",
@ -30,5 +31,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "vitest.setup.ts", "vitest.config.ts"]
}

7
tsconfig.test.json Normal file
View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["vitest.setup.ts", "vitest.config.ts", "tests/**/*.ts", "tests/**/*.tsx"]
}

View file

@ -1,12 +1,30 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./"),
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: "./vitest.setup.ts",
include: ["**/*.test.{ts,tsx}"],
typecheck: { tsconfig: "./tsconfig.test.json" },
include: ["tests/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
include: [
"src/app/api/**",
"src/hooks/**",
"src/modules/**/schemas/**",
"src/modules/**/validation/**",
"src/lib/**",
],
},
},
});

View file

@ -1 +1,6 @@
import "@testing-library/jest-dom";
import { server } from "./tests/msw/server";
beforeAll(() => server.listen({ onUnhandledRequest: "warn" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());