T07a: frontend core SPA — shell + auth + routing + i18n + UI library + a11y

- React 18 + Vite + TypeScript + Tailwind CSS setup
- AppShell with Sidebar (plugin menu) + TopBar (tenant switcher, search, notifications, user menu)
- Auth pages: Login, PasswordResetRequest, PasswordResetConfirm
- Protected routes with auth guard
- API client (axios with interceptors: session cookie, 401 redirect, 422 validation)
- TanStack Query hooks for auth, users, companies, contacts, notifications
- Zustand stores: authStore, uiStore
- i18n setup (de/en locales) with react-i18next
- UI component library: Button, Input, Select, Modal, Toast, Table, Card, Badge, Avatar, Pagination, EmptyState, Skeleton, ConfirmDialog
- Accessibility: ARIA labels, 44px touch targets, keyboard nav, reduced-motion, sr-only
- Design tokens from prototype as CSS custom properties
- 111 tests passing across 20 test files
- tsc --noEmit: 0 errors
- npm run build: success (471KB JS, 24KB CSS)
This commit is contained in:
leocrm-bot
2026-06-29 07:55:47 +02:00
parent f8193a6ab5
commit 22976abe92
66 changed files with 8598 additions and 0 deletions
@@ -0,0 +1,70 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { LoginPage } from '@/pages/Login';
vi.mock('@/api/hooks', () => ({
useLogin: () => ({
mutateAsync: vi.fn().mockResolvedValue({ user: { id: '1', email: 'test@test.de' } }),
isPending: false,
}),
}));
vi.mock('@/store/uiStore', () => ({
useUIStore: () => ({
addToast: vi.fn(),
removeToast: vi.fn(),
}),
}));
function renderLogin() {
return render(
<MemoryRouter initialEntries={['/login']}>
<LoginPage />
</MemoryRouter>
);
}
describe('LoginPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders email and password form', () => {
renderLogin();
expect(screen.getByLabelText(/E-Mail-Adresse/)).toBeInTheDocument();
expect(screen.getByLabelText(/Passwort/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Anmelden/ })).toBeInTheDocument();
});
it('has forgot password link', () => {
renderLogin();
expect(screen.getByText(/Passwort vergessen/)).toBeInTheDocument();
});
it('form has noValidate attribute', () => {
renderLogin();
const form = screen.getByRole('form');
expect(form).toHaveAttribute('noValidate');
});
it('email input has type=email', () => {
renderLogin();
const emailInput = screen.getByLabelText(/E-Mail-Adresse/);
expect(emailInput).toHaveAttribute('type', 'email');
});
it('password input has type=password', () => {
renderLogin();
const passwordInput = screen.getByLabelText(/Passwort/);
expect(passwordInput).toHaveAttribute('type', 'password');
});
it('submit button has min-h-touch for 44px target', () => {
renderLogin();
const btn = screen.getByRole('button', { name: /Anmelden/ });
expect(btn.className).toContain('min-h-touch');
});
});
@@ -0,0 +1,79 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { PasswordResetRequestPage } from '@/pages/PasswordResetRequest';
import { PasswordResetConfirmPage } from '@/pages/PasswordResetConfirm';
vi.mock('@/api/hooks', () => ({
usePasswordResetRequest: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
}),
usePasswordResetConfirm: () => ({
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
}),
}));
describe('PasswordResetRequestPage', () => {
it('renders form with email input and submit button', () => {
render(
<MemoryRouter initialEntries={['/password-reset']}>
<PasswordResetRequestPage />
</MemoryRouter>
);
expect(screen.getByLabelText(/E-Mail-Adresse/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Link senden/ })).toBeInTheDocument();
});
it('renders description text', () => {
render(
<MemoryRouter initialEntries={['/password-reset']}>
<PasswordResetRequestPage />
</MemoryRouter>
);
expect(screen.getByText(/Geben Sie Ihre E-Mail-Adresse ein/)).toBeInTheDocument();
});
it('has back to login link', () => {
render(
<MemoryRouter initialEntries={['/password-reset']}>
<PasswordResetRequestPage />
</MemoryRouter>
);
expect(screen.getByText(/Zurück zur Anmeldung/)).toBeInTheDocument();
});
});
describe('PasswordResetConfirmPage', () => {
it('renders new password and confirm password inputs', () => {
render(
<MemoryRouter initialEntries={['/password-reset/confirm?token=abc123']}>
<PasswordResetConfirmPage />
</MemoryRouter>
);
expect(screen.getByLabelText(/Neues Passwort/)).toBeInTheDocument();
expect(screen.getByLabelText(/Passwort bestätigen/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Passwort ändern/ })).toBeInTheDocument();
});
it('renders description text', () => {
render(
<MemoryRouter initialEntries={['/password-reset/confirm?token=abc123']}>
<PasswordResetConfirmPage />
</MemoryRouter>
);
expect(screen.getByText(/Geben Sie Ihr neues Passwort ein/)).toBeInTheDocument();
});
it('reads token from URL search params', () => {
render(
<MemoryRouter initialEntries={['/password-reset/confirm?token=test-token-123']}>
<PasswordResetConfirmPage />
</MemoryRouter>
);
// Token is used internally for the API call
expect(screen.getByTestId('reset-confirm-page')).toBeInTheDocument();
});
});
@@ -0,0 +1,53 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { AppShell } from '@/components/layout/AppShell';
vi.mock('@/api/hooks', () => ({
useLogout: () => ({ mutateAsync: vi.fn(), isPending: false }),
useSwitchTenant: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
function renderWithRouter(initialPath = '/dashboard') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="*" element={<AppShell />} />
</Routes>
</MemoryRouter>
);
}
describe('AppShell', () => {
it('renders sidebar, topbar, and content area', () => {
renderWithRouter();
expect(screen.getByTestId('app-shell')).toBeInTheDocument();
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
expect(screen.getByTestId('topbar')).toBeInTheDocument();
expect(screen.getByTestId('content-area')).toBeInTheDocument();
});
it('sidebar contains navigation items', () => {
renderWithRouter();
const sidebar = screen.getByTestId('sidebar');
expect(within(sidebar).getByText('Dashboard')).toBeInTheDocument();
expect(within(sidebar).getByText('Firmen')).toBeInTheDocument();
expect(within(sidebar).getByText('Kontakte')).toBeInTheDocument();
expect(within(sidebar).getByText('Einstellungen')).toBeInTheDocument();
});
it('topbar contains tenant switcher, search, notifications, and user menu', () => {
renderWithRouter();
const topbar = screen.getByTestId('topbar');
expect(within(topbar).getByLabelText('Mandant wechseln')).toBeInTheDocument();
expect(within(topbar).getByLabelText('Suchen...')).toBeInTheDocument();
expect(within(topbar).getByLabelText('Benachrichtigungen')).toBeInTheDocument();
expect(within(topbar).getByLabelText('Benutzermenü')).toBeInTheDocument();
});
it('content area has main role for screen readers', () => {
renderWithRouter();
expect(screen.getByRole('main')).toBeInTheDocument();
});
});
@@ -0,0 +1,42 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { MemoryRouter, Route, Routes, Navigate } from 'react-router-dom';
import { ProtectedRoute } from '@/routes/ProtectedRoute';
import { useAuthStore } from '@/store/authStore';
function ProtectedTest() {
return (
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route path="/login" element={<div data-testid="login-page">Login</div>} />
<Route path="/dashboard" element={
<ProtectedRoute>
<div data-testid="protected-content">Protected</div>
</ProtectedRoute>
} />
</Routes>
</MemoryRouter>
);
}
describe('Router & ProtectedRoute', () => {
it('redirects to /login when not authenticated', () => {
useAuthStore.setState({ isAuthenticated: false, user: null });
render(<ProtectedTest />);
expect(screen.getByTestId('login-page')).toBeInTheDocument();
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
});
it('shows protected content when authenticated', () => {
useAuthStore.setState({
isAuthenticated: true,
user: { id: '1', email: 'test@test.de', first_name: 'Test', last_name: 'User', role: 'admin', avatar_url: null, tenants: [{ id: 't1', name: 'Test Tenant', slug: 'test' }] },
currentTenant: { id: 't1', name: 'Test Tenant', slug: 'test' },
});
render(<ProtectedTest />);
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
expect(screen.queryByTestId('login-page')).not.toBeInTheDocument();
useAuthStore.setState({ isAuthenticated: false, user: null });
});
});
@@ -0,0 +1,40 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { Sidebar } from '@/components/layout/Sidebar';
describe('Sidebar', () => {
it('renders navigation links with ARIA labels', () => {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<Sidebar />
</MemoryRouter>
);
expect(screen.getByLabelText('Dashboard')).toBeInTheDocument();
expect(screen.getByLabelText('Firmen')).toBeInTheDocument();
expect(screen.getByLabelText('Kontakte')).toBeInTheDocument();
expect(screen.getByLabelText('Einstellungen')).toBeInTheDocument();
});
it('has navigation landmark with aria-label', () => {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<Sidebar />
</MemoryRouter>
);
expect(screen.getByLabelText('Seitenleiste Navigation')).toBeInTheDocument();
});
it('all nav links have min 44px touch targets', () => {
render(
<MemoryRouter initialEntries={['/dashboard']}>
<Sidebar />
</MemoryRouter>
);
const navLinks = screen.getAllByRole('link');
navLinks.forEach((link) => {
expect(link.className).toContain('min-h-touch');
});
});
});
@@ -0,0 +1,68 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { TopBar } from '@/components/layout/TopBar';
import { useAuthStore } from '@/store/authStore';
vi.mock('@/api/hooks', () => ({
useLogout: () => ({ mutateAsync: vi.fn(), isPending: false }),
useSwitchTenant: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
function renderTopBar() {
return render(
<MemoryRouter initialEntries={['/dashboard']}>
<TopBar />
</MemoryRouter>
);
}
describe('TopBar', () => {
beforeEach(() => {
useAuthStore.setState({
isAuthenticated: true,
user: {
id: '1',
email: 'max@test.de',
first_name: 'Max',
last_name: 'Mustermann',
role: 'admin',
avatar_url: null,
tenants: [
{ id: 't1', name: 'Firma Alpha', slug: 'alpha' },
{ id: 't2', name: 'Firma Beta', slug: 'beta' },
],
},
currentTenant: { id: 't1', name: 'Firma Alpha', slug: 'alpha' },
});
});
it('renders tenant switcher with current tenant name', () => {
renderTopBar();
expect(screen.getByText('Firma Alpha')).toBeInTheDocument();
});
it('renders search input with aria-label', () => {
renderTopBar();
expect(screen.getByLabelText('Suchen...')).toBeInTheDocument();
});
it('renders notifications button with aria-label', () => {
renderTopBar();
expect(screen.getByLabelText('Benachrichtigungen')).toBeInTheDocument();
});
it('renders user menu button with aria-label', () => {
renderTopBar();
expect(screen.getByLabelText('Benutzermenü')).toBeInTheDocument();
});
it('all interactive elements have min-h-touch for 44px targets', () => {
renderTopBar();
const buttons = screen.getAllByRole('button');
buttons.forEach((btn) => {
expect(btn.className).toContain('min-h-touch');
});
});
});
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Avatar } from '@/components/ui/Avatar';
describe('Avatar', () => {
it('renders initials when no src provided', () => {
render(<Avatar name="Max Mustermann" />);
expect(screen.getByText('MM')).toBeInTheDocument();
});
it('renders single initial for single name', () => {
render(<Avatar name="Anna" />);
expect(screen.getByText('A')).toBeInTheDocument();
});
it('has role=img and aria-label', () => {
render(<Avatar name="Max Mustermann" />);
expect(screen.getByRole('img')).toHaveAttribute('aria-label', 'Avatar von Max Mustermann');
});
it('renders image when src provided', () => {
render(<Avatar name="Test" src="https://example.com/avatar.png" />);
const img = screen.getByRole('img').querySelector('img')!;
expect(img).not.toBeNull();
});
it('falls back to initials on image error', () => {
render(<Avatar name="Max Mustermann" src="https://example.com/broken.png" />);
const img = screen.getByRole('img').querySelector('img')!;
fireEvent.error(img);
expect(screen.getByText('MM')).toBeInTheDocument();
});
});
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Badge } from '@/components/ui/Badge';
describe('Badge', () => {
it('renders children text', () => {
render(<Badge>aktiv</Badge>);
expect(screen.getByText('aktiv')).toBeInTheDocument();
});
it('renders with default variant', () => {
render(<Badge>Standard</Badge>);
const badge = screen.getByText('Standard');
expect(badge.className).toContain('bg-secondary-100');
});
it('renders with success variant', () => {
render(<Badge variant="success">OK</Badge>);
expect(screen.getByText('OK').className).toContain('bg-success-100');
});
it('renders with danger variant', () => {
render(<Badge variant="danger">Fehler</Badge>);
expect(screen.getByText('Fehler').className).toContain('bg-danger-100');
});
it('renders dot indicator when dot=true', () => {
const { container } = render(<Badge dot>aktiv</Badge>);
const dot = container.querySelector('span > span');
expect(dot).toBeInTheDocument();
});
});
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '@/components/ui/Button';
describe('Button', () => {
it('renders primary variant by default', () => {
render(<Button>Klick mich</Button>);
const btn = screen.getByRole('button', { name: 'Klick mich' });
expect(btn).toBeInTheDocument();
expect(btn.className).toContain('bg-primary-600');
});
it('renders secondary variant', () => {
render(<Button variant="secondary">Sekundär</Button>);
const btn = screen.getByRole('button', { name: 'Sekundär' });
expect(btn.className).toContain('bg-secondary-200');
});
it('renders danger variant', () => {
render(<Button variant="danger">Löschen</Button>);
const btn = screen.getByRole('button', { name: 'Löschen' });
expect(btn.className).toContain('bg-danger-600');
});
it('renders ghost variant', () => {
render(<Button variant="ghost">Geist</Button>);
const btn = screen.getByRole('button', { name: 'Geist' });
expect(btn.className).toContain('bg-transparent');
});
it('calls onClick when clicked', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Klick</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('shows loading spinner when isLoading', () => {
render(<Button isLoading>Laden</Button>);
const btn = screen.getByRole('button');
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute('aria-busy', 'true');
});
it('has min-h-touch for 44px touch target', () => {
render(<Button>Test</Button>);
expect(screen.getByRole('button').className).toContain('min-h-touch');
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('renders with full width when fullWidth', () => {
render(<Button fullWidth>Voll</Button>);
expect(screen.getByRole('button').className).toContain('w-full');
});
});
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Card } from '@/components/ui/Card';
describe('Card', () => {
it('renders children content', () => {
render(<Card>Inhalt der Karte</Card>);
expect(screen.getByText('Inhalt der Karte')).toBeInTheDocument();
});
it('renders title when provided', () => {
render(<Card title="Firmendetails">Inhalt</Card>);
expect(screen.getByText('Firmendetails')).toBeInTheDocument();
});
it('renders description when provided', () => {
render(<Card title="Test" description="Eine Beschreibung">Inhalt</Card>);
expect(screen.getByText('Eine Beschreibung')).toBeInTheDocument();
});
it('renders footer when provided', () => {
render(<Card footer={<button>Aktion</button>}>Inhalt</Card>);
expect(screen.getByRole('button', { name: 'Aktion' })).toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
describe('ConfirmDialog', () => {
it('renders nothing when open=false', () => {
render(<ConfirmDialog open={false} message="Wirklich löschen?" onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.queryByText('Wirklich löschen?')).not.toBeInTheDocument();
});
it('renders message when open=true', () => {
render(<ConfirmDialog open={true} message="Wirklich löschen?" onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.getByText('Wirklich löschen?')).toBeInTheDocument();
});
it('calls onConfirm when confirm button clicked', () => {
const onConfirm = vi.fn();
render(<ConfirmDialog open={true} message="Löschen?" onConfirm={onConfirm} onCancel={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Bestätigen' }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('calls onCancel when cancel button clicked', () => {
const onCancel = vi.fn();
render(<ConfirmDialog open={true} message="Löschen?" onConfirm={() => {}} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('renders default title', () => {
render(<ConfirmDialog open={true} message="Test" onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.getByText('Bestätigung erforderlich')).toBeInTheDocument();
});
it('renders custom title', () => {
render(<ConfirmDialog open={true} title="Löschen bestätigen" message="Test" onConfirm={() => {}} onCancel={() => {}} />);
expect(screen.getByText('Löschen bestätigen')).toBeInTheDocument();
});
it('uses danger variant button when variant=danger', () => {
render(<ConfirmDialog open={true} variant="danger" message="Test" onConfirm={() => {}} onCancel={() => {}} />);
const confirmBtn = screen.getByRole('button', { name: 'Bestätigen' });
expect(confirmBtn.className).toContain('bg-danger-600');
});
});
@@ -0,0 +1,28 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EmptyState } from '@/components/ui/EmptyState';
describe('EmptyState', () => {
it('renders default title and description', () => {
render(<EmptyState />);
expect(screen.getByText('Nichts gefunden')).toBeInTheDocument();
expect(screen.getByText('Es sind noch keine Daten vorhanden.')).toBeInTheDocument();
});
it('renders custom title and description', () => {
render(<EmptyState title="Keine Firmen" description="Erstellen Sie Ihre erste Firma." />);
expect(screen.getByText('Keine Firmen')).toBeInTheDocument();
expect(screen.getByText('Erstellen Sie Ihre erste Firma.')).toBeInTheDocument();
});
it('renders action when provided', () => {
render(<EmptyState action={<button>Firma erstellen</button>} />);
expect(screen.getByRole('button', { name: 'Firma erstellen' })).toBeInTheDocument();
});
it('has role=status', () => {
render(<EmptyState />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Input } from '@/components/ui/Input';
describe('Input', () => {
it('renders with label', () => {
render(<Input label="Name" />);
expect(screen.getByText('Name')).toBeInTheDocument();
});
it('renders error message with role=alert', () => {
render(<Input label="Email" error="Ungültige E-Mail" />);
expect(screen.getByRole('alert')).toHaveTextContent('Ungültige E-Mail');
});
it('renders helper text', () => {
render(<Input label="Passwort" helperText="Mindestens 8 Zeichen" />);
expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument();
});
it('does not render helper text when error is present', () => {
render(<Input label="Passwort" error="Fehler" helperText="Hilfetext" />);
expect(screen.queryByText('Hilfetext')).not.toBeInTheDocument();
});
it('marks input as required with aria-required', () => {
render(<Input label="Name" required />);
expect(screen.getByLabelText(/Name/)).toHaveAttribute('aria-required', 'true');
});
it('sets aria-invalid when error present', () => {
render(<Input label="Email" error="Fehler" />);
expect(screen.getByLabelText(/Email/)).toHaveAttribute('aria-invalid', 'true');
});
it('has min-h-touch for 44px target', () => {
render(<Input label="Test" />);
expect(screen.getByLabelText(/Test/).className).toContain('min-h-touch');
});
it('links error via aria-describedby', () => {
render(<Input label="Email" error="Fehler" />);
const input = screen.getByLabelText(/Email/);
const errorId = input.getAttribute('aria-describedby');
expect(errorId).toBeTruthy();
expect(document.getElementById(errorId!)).toHaveTextContent('Fehler');
});
});
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Modal } from '@/components/ui/Modal';
describe('Modal', () => {
it('renders nothing when open=false', () => {
render(<Modal open={false} onClose={() => {}}>Content</Modal>);
expect(screen.queryByText('Content')).not.toBeInTheDocument();
});
it('renders content when open=true', () => {
render(<Modal open={true} onClose={() => {}}>Modal Content</Modal>);
expect(screen.getByText('Modal Content')).toBeInTheDocument();
});
it('has role=dialog and aria-modal=true', () => {
render(<Modal open={true} onClose={() => {}}>Content</Modal>);
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
});
it('calls onClose when backdrop clicked', () => {
const onClose = vi.fn();
render(<Modal open={true} onClose={onClose}>Content</Modal>);
fireEvent.click(screen.getByTestId('modal-backdrop'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when ESC pressed', () => {
const onClose = vi.fn();
render(<Modal open={true} onClose={onClose}>Content</Modal>);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does not call onClose on backdrop when closeOnBackdrop=false', () => {
const onClose = vi.fn();
render(<Modal open={true} onClose={onClose} closeOnBackdrop={false}>Content</Modal>);
fireEvent.click(screen.getByTestId('modal-backdrop'));
expect(onClose).not.toHaveBeenCalled();
});
it('renders title with aria-labelledby', () => {
render(<Modal open={true} onClose={() => {}} title="Test Title">Content</Modal>);
const dialog = screen.getByRole('dialog');
expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title');
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
it('renders close button with aria-label', () => {
render(<Modal open={true} onClose={() => {}}>Content</Modal>);
expect(screen.getByLabelText('Close dialog')).toBeInTheDocument();
});
});
@@ -0,0 +1,61 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Pagination } from '@/components/ui/Pagination';
describe('Pagination', () => {
it('renders page info and navigation', () => {
render(
<Pagination currentPage={2} totalPages={5} total={100} pageSize={20} onPageChange={() => {}} />
);
expect(screen.getByText('2140')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
});
it('calls onPageChange with next page', () => {
const onPageChange = vi.fn();
render(
<Pagination currentPage={1} totalPages={3} total={60} pageSize={20} onPageChange={onPageChange} />
);
fireEvent.click(screen.getByLabelText('Weiter'));
expect(onPageChange).toHaveBeenCalledWith(2);
});
it('calls onPageChange with previous page', () => {
const onPageChange = vi.fn();
render(
<Pagination currentPage={2} totalPages={3} total={60} pageSize={20} onPageChange={onPageChange} />
);
fireEvent.click(screen.getByLabelText('Zurück'));
expect(onPageChange).toHaveBeenCalledWith(1);
});
it('disables previous on first page', () => {
render(
<Pagination currentPage={1} totalPages={3} total={60} pageSize={20} onPageChange={() => {}} />
);
expect(screen.getByLabelText('Zurück')).toBeDisabled();
});
it('disables next on last page', () => {
render(
<Pagination currentPage={3} totalPages={3} total={60} pageSize={20} onPageChange={() => {}} />
);
expect(screen.getByLabelText('Weiter')).toBeDisabled();
});
it('returns null when totalPages <= 1', () => {
const { container } = render(
<Pagination currentPage={1} totalPages={1} total={5} pageSize={20} onPageChange={() => {}} />
);
expect(container.firstChild).toBeNull();
});
it('has aria-current on current page button', () => {
render(
<Pagination currentPage={2} totalPages={5} total={100} pageSize={20} onPageChange={() => {}} />
);
const currentBtn = screen.getByLabelText('Seite 2');
expect(currentBtn).toHaveAttribute('aria-current', 'page');
});
});
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Select } from '@/components/ui/Select';
const options = [
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
];
describe('Select', () => {
it('renders label and options', () => {
render(<Select label="Sprache" options={options} />);
expect(screen.getByText('Sprache')).toBeInTheDocument();
expect(screen.getByText('Deutsch')).toBeInTheDocument();
expect(screen.getByText('English')).toBeInTheDocument();
});
it('renders error with role=alert', () => {
render(<Select label="Sprache" options={options} error="Bitte auswählen" />);
expect(screen.getByRole('alert')).toHaveTextContent('Bitte auswählen');
});
it('renders helper text', () => {
render(<Select label="Sprache" options={options} helperText="Wählen Sie eine Sprache" />);
expect(screen.getByText('Wählen Sie eine Sprache')).toBeInTheDocument();
});
it('calls onChange when value changes', () => {
const onChange = vi.fn();
render(<Select label="Sprache" options={options} onChange={onChange} />);
fireEvent.change(screen.getByLabelText(/Sprache/), { target: { value: 'en' } });
expect(onChange).toHaveBeenCalled();
});
it('has min-h-touch for 44px target', () => {
render(<Select label="Sprache" options={options} />);
expect(screen.getByLabelText(/Sprache/).className).toContain('min-h-touch');
});
it('sets aria-required when required', () => {
render(<Select label="Sprache" options={options} required />);
expect(screen.getByLabelText(/Sprache/)).toHaveAttribute('aria-required', 'true');
});
});
@@ -0,0 +1,41 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Skeleton, SkeletonText } from '@/components/ui/Skeleton';
describe('Skeleton', () => {
it('renders with role=status', () => {
render(<Skeleton />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('renders with aria-label', () => {
render(<Skeleton />);
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Wird geladen');
});
it('applies variant classes correctly', () => {
const { container } = render(<Skeleton variant="circle" />);
expect(container.firstChild).toHaveClass('rounded-full');
});
it('applies width and height styles', () => {
const { container } = render(<Skeleton width="200px" height="50px" />);
const el = container.firstChild as HTMLElement;
expect(el.style.width).toBe('200px');
expect(el.style.height).toBe('50px');
});
});
describe('SkeletonText', () => {
it('renders specified number of lines', () => {
const { container } = render(<SkeletonText lines={3} />);
const skeletons = container.querySelectorAll('[role="status"]');
expect(skeletons.length).toBeGreaterThanOrEqual(1);
});
it('has role=status for accessibility', () => {
render(<SkeletonText lines={2} />);
expect(screen.getAllByRole('status').length).toBeGreaterThan(0);
});
});
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Table, TableColumn } from '@/components/ui/Table';
interface TestRow {
id: string;
name: string;
email: string;
}
const columns: TableColumn<TestRow>[] = [
{ key: 'name', header: 'Name', sortable: true },
{ key: 'email', header: 'E-Mail', sortable: true },
{ key: 'id', header: 'ID', sortable: false },
];
const data: TestRow[] = [
{ id: '1', name: 'Anna Schmidt', email: 'anna@test.de' },
{ id: '2', name: 'Max Müller', email: 'max@test.de' },
{ id: '3', name: 'Lisa Weber', email: 'lisa@test.de' },
];
describe('Table', () => {
it('renders all rows and headers', () => {
render(<Table columns={columns} data={data} rowKey={(r) => r.id} />);
expect(screen.getByText('Anna Schmidt')).toBeInTheDocument();
expect(screen.getByText('Max Müller')).toBeInTheDocument();
expect(screen.getByText('Lisa Weber')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('E-Mail')).toBeInTheDocument();
});
it('sortable headers have role=button and aria-label', () => {
render(<Table columns={columns} data={data} rowKey={(r) => r.id} />);
const nameHeader = screen.getByRole('button', { name: /Name/ });
expect(nameHeader).toBeInTheDocument();
});
it('clicking sortable header sorts ascending then descending', () => {
render(<Table columns={columns} data={data} rowKey={(r) => r.id} />);
const nameHeader = screen.getByRole('button', { name: /Name/ });
fireEvent.click(nameHeader);
// ascending: Anna, Lisa, Max
const rows = screen.getAllByRole('row');
expect(rows[1]).toHaveTextContent('Anna Schmidt');
expect(rows[3]).toHaveTextContent('Max Müller');
fireEvent.click(nameHeader);
// descending: Max, Lisa, Anna
const rowsDesc = screen.getAllByRole('row');
expect(rowsDesc[1]).toHaveTextContent('Max Müller');
expect(rowsDesc[3]).toHaveTextContent('Anna Schmidt');
});
it('aria-sort updates on sort', () => {
render(<Table columns={columns} data={data} rowKey={(r) => r.id} />);
const nameHeader = screen.getByRole('button', { name: /Name/ });
expect(nameHeader.closest('th')).toHaveAttribute('aria-sort', 'none');
fireEvent.click(nameHeader);
expect(nameHeader.closest('th')).toHaveAttribute('aria-sort', 'ascending');
});
it('shows empty message when no data', () => {
render(<Table columns={columns} data={[]} rowKey={(r) => r.id} emptyMessage="Keine Daten" />);
expect(screen.getByText('Keine Daten')).toBeInTheDocument();
});
it('shows loading state', () => {
render(<Table columns={columns} data={[]} rowKey={(r) => r.id} loading={true} />);
expect(screen.getByText('Wird geladen...')).toBeInTheDocument();
});
});
+56
View File
@@ -0,0 +1,56 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act, fireEvent } from '@testing-library/react';
import { ToastContainer, useToast } from '@/components/ui/Toast';
import { useUIStore } from '@/store/uiStore';
describe('Toast', () => {
beforeEach(() => {
useUIStore.setState({ toasts: [] });
});
it('renders toast container', () => {
render(<ToastContainer />);
expect(screen.getByTestId('toast-container')).toBeInTheDocument();
});
it('displays toast when added to store', () => {
useUIStore.setState({
toasts: [{ id: '1', type: 'success', message: 'Erfolgreich gespeichert' }],
});
render(<ToastContainer />);
expect(screen.getByText('Erfolgreich gespeichert')).toBeInTheDocument();
});
it('toast has role=alert for accessibility', () => {
useUIStore.setState({
toasts: [{ id: '1', type: 'error', message: 'Fehler aufgetreten' }],
});
render(<ToastContainer />);
expect(screen.getByRole('alert')).toHaveTextContent('Fehler aufgetreten');
});
it('auto-dismisses after duration', () => {
vi.useFakeTimers();
useUIStore.setState({
toasts: [{ id: '1', type: 'info', message: 'Info', duration: 1000 }],
});
render(<ToastContainer />);
expect(screen.getByText('Info')).toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(1000);
});
expect(screen.queryByText('Info')).not.toBeInTheDocument();
vi.useRealTimers();
});
it('can be manually dismissed', () => {
useUIStore.setState({
toasts: [{ id: '1', type: 'warning', message: 'Warnung' }],
});
render(<ToastContainer />);
const closeBtn = screen.getByLabelText('Close notification');
fireEvent.click(closeBtn);
expect(screen.queryByText('Warnung')).not.toBeInTheDocument();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect, beforeAll } from 'vitest';
import i18n from '@/i18n';
describe('i18n', () => {
beforeAll(async () => {
await i18n.changeLanguage('de');
});
it('loads German locale by default', () => {
expect(i18n.language).toBe('de');
});
it('translates key in German', () => {
expect(i18n.t('nav.dashboard')).toBe('Dashboard');
expect(i18n.t('nav.companies')).toBe('Firmen');
expect(i18n.t('auth.login')).toBe('Anmelden');
});
it('can switch to English', async () => {
await i18n.changeLanguage('en');
expect(i18n.language).toBe('en');
expect(i18n.t('auth.login')).toBe('Sign In');
expect(i18n.t('nav.companies')).toBe('Companies');
});
it('falls back to German for missing English keys', async () => {
await i18n.changeLanguage('en');
expect(i18n.t('app.name')).toBe('leocrm');
});
it('switches back to German', async () => {
await i18n.changeLanguage('de');
expect(i18n.t('auth.login')).toBe('Anmelden');
});
});