diff --git a/frontend/src/components/layout/__tests__/ProtectedRoute.test.tsx b/frontend/src/components/layout/__tests__/ProtectedRoute.test.tsx new file mode 100644 index 0000000..f6f39ea --- /dev/null +++ b/frontend/src/components/layout/__tests__/ProtectedRoute.test.tsx @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { Routes, Route, MemoryRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { http, HttpResponse } from 'msw' +import { server } from '@/test/mocks/server' +import { mockUser, mockDakUser } from '@/test/mocks/data' +import { AuthProvider } from '@/context/AuthContext' +import { ProtectedRoute } from '@/components/layout/ProtectedRoute' + +/** + * Helper: renders a mini app with Routes so we can detect redirects. + * ProtectedRoute uses , so we set up + * a /login route that renders identifiable text. + */ +function renderWithRoutes( + options: { + initialRoute?: string + requireAdmin?: boolean + } = {}, +) { + const { initialRoute = '/protected', requireAdmin = false } = options + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }) + + return render( + + + + + +
Protected Content
+ + } + /> + Login Page} /> +
+
+
+
, + ) +} + +describe('ProtectedRoute', () => { + beforeEach(() => { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + }) + + it('redirects to /login when not authenticated (no token)', async () => { + // No token in localStorage -> AuthProvider sets isLoading=false, user=null immediately + // ProtectedRoute sees !user -> + server.use( + http.get('/api/auth/me', () => { + return new HttpResponse(null, { status: 401 }) + }), + ) + + renderWithRoutes() + + await waitFor(() => { + expect(screen.getByText('Login Page')).toBeInTheDocument() + }) + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + }) + + it('redirects to /login when token exists but /auth/me returns 401', async () => { + // Token is set but API rejects it (expired, invalid) + localStorage.setItem('access_token', 'expired-token') + + server.use( + http.get('/api/auth/me', () => { + return new HttpResponse(null, { status: 401 }) + }), + ) + + renderWithRoutes() + + await waitFor(() => { + expect(screen.getByText('Login Page')).toBeInTheDocument() + }) + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + }) + + it('shows children when authenticated as admin', async () => { + localStorage.setItem('access_token', 'valid-token') + + // Default handler returns mockUser (admin) — no override needed + + renderWithRoutes() + + await waitFor(() => { + expect(screen.getByText('Protected Content')).toBeInTheDocument() + }) + expect(screen.queryByText('Login Page')).not.toBeInTheDocument() + }) + + it('shows children when authenticated as dak_mitarbeiter (no requireAdmin)', async () => { + localStorage.setItem('access_token', 'valid-token') + + server.use( + http.get('/api/auth/me', () => { + return HttpResponse.json(mockDakUser) + }), + ) + + renderWithRoutes({ requireAdmin: false }) + + await waitFor(() => { + expect(screen.getByText('Protected Content')).toBeInTheDocument() + }) + expect(screen.queryByText('Login Page')).not.toBeInTheDocument() + }) + + it('blocks non-admin users when requireAdmin is true', async () => { + localStorage.setItem('access_token', 'valid-token') + + server.use( + http.get('/api/auth/me', () => { + return HttpResponse.json(mockDakUser) + }), + ) + + renderWithRoutes({ requireAdmin: true }) + + await waitFor(() => { + expect(screen.getByText('Zugriff verweigert')).toBeInTheDocument() + }) + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + expect(screen.queryByText('Login Page')).not.toBeInTheDocument() + }) + + it('allows admin users when requireAdmin is true', async () => { + localStorage.setItem('access_token', 'valid-token') + + // Default handler returns mockUser (role: 'admin') + + renderWithRoutes({ requireAdmin: true }) + + await waitFor(() => { + expect(screen.getByText('Protected Content')).toBeInTheDocument() + }) + expect(screen.queryByText('Zugriff verweigert')).not.toBeInTheDocument() + }) + + it('shows loading state while auth is checking', async () => { + localStorage.setItem('access_token', 'valid-token') + + // Delay the /auth/me response to observe the loading state + server.use( + http.get('/api/auth/me', async () => { + await new Promise((resolve) => setTimeout(resolve, 500)) + return HttpResponse.json(mockUser) + }), + ) + + renderWithRoutes() + + // ProtectedRoute shows "Laden..." while isLoading is true + expect(screen.getByText('Laden...')).toBeInTheDocument() + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument() + expect(screen.queryByText('Login Page')).not.toBeInTheDocument() + + // After the delayed response resolves, the protected content should appear + await waitFor(() => { + expect(screen.getByText('Protected Content')).toBeInTheDocument() + }) + expect(screen.queryByText('Laden...')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/__tests__/AccountPage.test.tsx b/frontend/src/pages/__tests__/AccountPage.test.tsx new file mode 100644 index 0000000..68eb442 --- /dev/null +++ b/frontend/src/pages/__tests__/AccountPage.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '@/test/utils' +import { AccountPage } from '@/pages/AccountPage' + +describe('AccountPage', () => { + beforeEach(() => { + localStorage.setItem('access_token', 'test-token') + }) + + it('renders the page title and description', async () => { + renderWithProviders(, { initialRoute: '/account' }) + + await waitFor(() => { + expect(screen.getByText('Kontoverwaltung')).toBeInTheDocument() + }) + + expect( + screen.getByText(/Verwalten Sie Ihr Profil, Ihre Sicherheitseinstellungen/), + ).toBeInTheDocument() + }) + + it('shows profile, security, and MFA tabs', async () => { + renderWithProviders(, { initialRoute: '/account' }) + + await waitFor(() => { + expect(screen.getByText('Profil')).toBeInTheDocument() + }) + + expect(screen.getByText('Sicherheit')).toBeInTheDocument() + expect(screen.getByText('Zwei-Faktor')).toBeInTheDocument() + }) + + it('shows profile form fields in the profile tab', async () => { + renderWithProviders(, { initialRoute: '/account' }) + + // Wait for auth to resolve and render the profile tab (default) + await waitFor(() => { + expect(screen.getByLabelText('Vorname')).toBeInTheDocument() + }) + + expect(screen.getByLabelText('Nachname')).toBeInTheDocument() + expect(screen.getByLabelText('Anzeigename')).toBeInTheDocument() + expect(screen.getByLabelText('Benutzername')).toBeInTheDocument() + expect(screen.getByLabelText('E-Mail')).toBeInTheDocument() + + // The "Speichern" button should be visible + expect(screen.getByRole('button', { name: /Speichern/ })).toBeInTheDocument() + + // The avatar upload button should be visible + expect(screen.getByText('Bild hochladen')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/__tests__/ImportPage.test.tsx b/frontend/src/pages/__tests__/ImportPage.test.tsx new file mode 100644 index 0000000..9d6f058 --- /dev/null +++ b/frontend/src/pages/__tests__/ImportPage.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '@/test/utils' +import { ImportPage } from '@/pages/ImportPage' + +describe('ImportPage', () => { + beforeEach(() => { + localStorage.setItem('access_token', 'test-token') + }) + + it('renders the page title', async () => { + renderWithProviders(, { initialRoute: '/import' }) + + await waitFor(() => { + expect(screen.getByText('Import')).toBeInTheDocument() + }) + }) + + it('shows CSV and ICD import tabs', async () => { + renderWithProviders(, { initialRoute: '/import' }) + + await waitFor(() => { + expect(screen.getByText('CSV-Import')).toBeInTheDocument() + }) + + expect(screen.getByText('ICD-Import')).toBeInTheDocument() + expect(screen.getByText('Import-Verlauf')).toBeInTheDocument() + }) + + it('shows the CSV upload drop zone by default', async () => { + renderWithProviders(, { initialRoute: '/import' }) + + await waitFor(() => { + expect(screen.getByText('CSV-Datei hierhin ziehen')).toBeInTheDocument() + }) + + expect(screen.getByText('oder klicken zum Auswählen')).toBeInTheDocument() + }) +})