diff --git a/frontend/src/__tests__/companies/CompaniesList.test.tsx b/frontend/src/__tests__/companies/CompaniesList.test.tsx index 4c1c550..3f91b23 100644 --- a/frontend/src/__tests__/companies/CompaniesList.test.tsx +++ b/frontend/src/__tests__/companies/CompaniesList.test.tsx @@ -1,45 +1,120 @@ import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { CompaniesListPage } from '@/pages/CompaniesList'; +const mockCompanies = [ + { id: '1', name: 'TestCorp GmbH', email: 'info@test.de', phone: '+49 30 123', industry: 'IT', account_number: 'K-001', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z' }, + { id: '2', name: 'Müller AG', email: 'kontakt@mueller.de', phone: '+49 89 987', industry: 'Logistik', account_number: 'K-002', created_at: '2025-01-02T00:00:00Z', updated_at: '2025-01-02T00:00:00Z' }, +]; + +let mockData: any = { items: mockCompanies, total: 2 }; +let mockIsLoading = false; + vi.mock('@/api/hooks', () => ({ - useCompanies: () => ({ - data: { - items: [ - { id: '1', name: 'TestCorp GmbH', email: 'info@test.de', phone: '+49 30 123', industry: 'IT', city: 'Berlin', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z' }, - ], - total: 1, - }, - isLoading: false, - }), - useCompanyExport: () => ({ mutateAsync: vi.fn(), isPending: false }), + useCompanies: () => ({ data: mockData, isLoading: mockIsLoading }), + useCompanyExport: () => ({ mutateAsync: vi.fn().mockResolvedValue(new Blob(['csv'], { type: 'text/csv' })), isPending: false }), useDeleteCompany: () => ({ mutateAsync: vi.fn(), isPending: false }), useCompanyImport: () => ({ mutateAsync: vi.fn(), isPending: false }), })); -vi.mock('@/store/uiStore', () => ({ - useUIStore: () => ({ addToast: vi.fn(), removeToast: vi.fn() }), -})); - vi.mock('@/components/ui/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }), })); +vi.mock('@/components/shared/UnsavedChangesGuard', () => ({ + UnsavedChangesGuard: () => null, +})); + +function renderWithRouter() { + return render(); +} + +beforeEach(() => { + mockData = { items: mockCompanies, total: 2 }; + mockIsLoading = false; +}); + +afterEach(() => { + mockData = { items: mockCompanies, total: 2 }; + mockIsLoading = false; +}); + describe('CompaniesListPage', () => { it('renders the page with title', () => { - render(); + renderWithRouter(); expect(screen.getByTestId('companies-list-page')).toBeInTheDocument(); }); - it('renders DataGrid for company list', () => { - render(); + it('renders DataGrid for company list (TanStack Table)', () => { + renderWithRouter(); expect(screen.getByTestId('companies-grid')).toBeInTheDocument(); }); it('renders company data in grid', () => { - render(); + renderWithRouter(); expect(screen.getByText('TestCorp GmbH')).toBeInTheDocument(); + expect(screen.getByText('Müller AG')).toBeInTheDocument(); + }); + + it('renders search input', () => { + renderWithRouter(); + expect(screen.getByLabelText(/Suchen/)).toBeInTheDocument(); + }); + + it('renders create button', () => { + renderWithRouter(); + expect(screen.getByText('Firma erstellen')).toBeInTheDocument(); + }); + + it('renders import button', () => { + renderWithRouter(); + expect(screen.getByText('CSV importieren')).toBeInTheDocument(); + }); + + it('renders export button', () => { + renderWithRouter(); + expect(screen.getByText('CSV exportieren')).toBeInTheDocument(); + }); + + it('renders table headers with sortable columns', () => { + renderWithRouter(); + expect(screen.getByText('Firmenname')).toBeInTheDocument(); + expect(screen.getByText('Branche')).toBeInTheDocument(); + }); + + it('shows empty state when no companies and no search', () => { + mockData = { items: [], total: 0 }; + renderWithRouter(); + expect(screen.getByText('Keine Firmen vorhanden')).toBeInTheDocument(); + expect(screen.getByText('Firma erstellen')).toBeInTheDocument(); + mockData = { items: mockCompanies, total: 2 }; + }); + + it('shows no results message when search yields nothing', async () => { + renderWithRouter(); + const searchInput = screen.getByLabelText(/Suchen/); + fireEvent.change(searchInput, { target: { value: 'xyz' } }); + mockData = { items: [], total: 0 }; + // With search text but no results, grid should show no results message + await waitFor(() => { + expect(screen.getByText('Keine Ergebnisse gefunden')).toBeInTheDocument(); + }); + }); + + it('clicking sort header toggles sort direction', () => { + renderWithRouter(); + const nameHeader = screen.getByText('Firmenname'); + const th = nameHeader.closest('th'); + expect(th).toHaveAttribute('aria-sort', 'none'); + fireEvent.click(nameHeader); + expect(th).toHaveAttribute('aria-sort', 'ascending'); + }); + + it('opens import dialog when import button clicked', () => { + renderWithRouter(); + fireEvent.click(screen.getByText('CSV importieren')); + expect(screen.getByTestId('csv-import-dialog')).toBeInTheDocument(); }); }); diff --git a/frontend/src/__tests__/companies/CompanyDetail.test.tsx b/frontend/src/__tests__/companies/CompanyDetail.test.tsx index ed3ef97..0c38650 100644 --- a/frontend/src/__tests__/companies/CompanyDetail.test.tsx +++ b/frontend/src/__tests__/companies/CompanyDetail.test.tsx @@ -1,21 +1,30 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { CompanyDetailPage } from '@/pages/CompanyDetail'; +const mockCompany = { + id: '1', + name: 'TestCorp GmbH', + email: 'info@testcorp.de', + phone: '+49 30 12345678', + website: 'https://testcorp.de', + industry: 'IT & Software', + account_number: 'K-00123', + description: 'Ein führendes IT-Unternehmen', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + contacts: [ + { id: 'c1', first_name: 'Max', last_name: 'Mustermann', email: 'max@testcorp.de', position: 'CEO' }, + { id: 'c2', first_name: 'Anna', last_name: 'Schmidt', email: 'anna@testcorp.de', position: 'CTO' }, + ], +}; + +let mockReturnData: any = { data: mockCompany, isLoading: false, isError: false }; + vi.mock('@/api/hooks', () => ({ - useCompany: () => ({ - data: { - id: '1', name: 'TestCorp GmbH', email: 'info@testcorp.de', - phone: '+49 30 12345678', website: 'https://testcorp.de', - industry: 'IT', address: 'Berlin', city: 'Berlin', - zip_code: '10115', country: 'Deutschland', - created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z', - contacts: [], - }, - isLoading: false, - }), + useCompany: () => mockReturnData, })); describe('CompanyDetailPage', () => { @@ -24,8 +33,56 @@ describe('CompanyDetailPage', () => { expect(screen.getByTestId('company-detail-page')).toBeInTheDocument(); }); + it('renders company name as heading', () => { + render(); + expect(screen.getByRole('heading', { level: 1, name: 'TestCorp GmbH' })).toBeInTheDocument(); + }); + it('renders tabs for detail sections', () => { render(); expect(screen.getByRole('tablist')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Übersicht/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Kontakte/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Dateien/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Aktivität/ })).toBeInTheDocument(); + }); + + it('renders overview tab content by default', () => { + render(); + expect(screen.getByText('K-00123')).toBeInTheDocument(); + expect(screen.getByText('IT & Software')).toBeInTheDocument(); + expect(screen.getByText('+49 30 12345678')).toBeInTheDocument(); + }); + + it('renders contacts tab with contact list and badge count', () => { + render(); + const contactsTab = screen.getByRole('tab', { name: /Kontakte/ }); + fireEvent.click(contactsTab); + expect(screen.getByText('Max Mustermann')).toBeInTheDocument(); + expect(screen.getByText('Anna Schmidt')).toBeInTheDocument(); + }); + + it('renders edit button', () => { + render(); + expect(screen.getByText('Bearbeiten')).toBeInTheDocument(); + }); + + it('renders back button', () => { + render(); + expect(screen.getByText(/Zurück/)).toBeInTheDocument(); + }); + + it('shows error state when company not found', () => { + mockReturnData = { data: undefined, isLoading: false, isError: true }; + render(); + expect(screen.getByText('Firma nicht gefunden.')).toBeInTheDocument(); + mockReturnData = { data: mockCompany, isLoading: false, isError: false }; + }); + + it('shows skeleton loading state', () => { + mockReturnData = { data: undefined, isLoading: true, isError: false }; + const { container } = render(); + expect(screen.getByTestId('company-detail-page')).toBeInTheDocument(); + mockReturnData = { data: mockCompany, isLoading: false, isError: false }; }); }); diff --git a/frontend/src/__tests__/companies/CompanyForm.test.tsx b/frontend/src/__tests__/companies/CompanyForm.test.tsx index 25d7219..d7d5d8a 100644 --- a/frontend/src/__tests__/companies/CompanyForm.test.tsx +++ b/frontend/src/__tests__/companies/CompanyForm.test.tsx @@ -1,12 +1,17 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { CompanyFormPage } from '@/pages/CompanyForm'; +const mockMutateAsync = vi.fn().mockResolvedValue({ id: 'new-1' }); + vi.mock('@/api/hooks', () => ({ useCompany: () => ({ data: undefined, isLoading: false }), - useCreateCompany: () => ({ mutateAsync: vi.fn(), isPending: false }), + useCreateCompany: () => ({ + mutateAsync: mockMutateAsync, + isPending: false, + }), useUpdateCompany: () => ({ mutateAsync: vi.fn(), isPending: false }), })); @@ -30,8 +35,59 @@ describe('CompanyFormPage', () => { expect(screen.getByLabelText(/Telefon/)).toBeInTheDocument(); }); + it('renders website and description fields', () => { + render(); + expect(screen.getByLabelText(/Website/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Beschreibung/)).toBeInTheDocument(); + }); + + it('renders account number and industry fields', () => { + render(); + expect(screen.getByLabelText(/Kontonummer/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Branche/)).toBeInTheDocument(); + }); + it('submit button is present', () => { render(); expect(screen.getByTestId('company-submit-btn')).toBeInTheDocument(); }); + + it('renders cancel button', () => { + render(); + expect(screen.getByText('Abbrechen')).toBeInTheDocument(); + }); + + it('renders form page with correct test id', () => { + render(); + expect(screen.getByTestId('company-form-page')).toBeInTheDocument(); + }); + + it('renders create title for new company', () => { + render(); + expect(screen.getByText('Firma erstellen')).toBeInTheDocument(); + }); + + it('name field is marked as required', () => { + render(); + const nameInput = screen.getByTestId('company-name-input'); + expect(nameInput).toHaveAttribute('required'); + }); + + it('shows inline validation error when submitting empty form', async () => { + render(); + const submitBtn = screen.getByTestId('company-submit-btn'); + fireEvent.click(submitBtn); + await waitFor(() => { + expect(screen.getByText(/Name ist erforderlich/)).toBeInTheDocument(); + }); + }); + + it('accepts valid form data and submits', async () => { + render(); + fireEvent.change(screen.getByTestId('company-name-input'), { target: { value: 'Acme Corp' } }); + fireEvent.click(screen.getByTestId('company-submit-btn')); + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith(expect.objectContaining({ name: 'Acme Corp' })); + }); + }); }); diff --git a/frontend/src/__tests__/contacts/ContactDetail.test.tsx b/frontend/src/__tests__/contacts/ContactDetail.test.tsx index 3a6271c..c4f6dc7 100644 --- a/frontend/src/__tests__/contacts/ContactDetail.test.tsx +++ b/frontend/src/__tests__/contacts/ContactDetail.test.tsx @@ -1,19 +1,29 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { ContactDetailPage } from '@/pages/ContactDetail'; +const mockContact = { + id: '1', + first_name: 'Max', + last_name: 'Mustermann', + email: 'max@example.com', + phone: '+49 30 12345678', + position: 'CEO', + company_ids: ['1', '2'], + companies: [ + { id: '1', name: 'TestCorp GmbH', industry: 'IT', email: 'info@testcorp.de' }, + { id: '2', name: 'Müller AG', industry: 'Logistik', email: 'kontakt@mueller.de' }, + ], + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', +}; + +let mockReturnData: any = { data: mockContact, isLoading: false, isError: false }; + vi.mock('@/api/hooks', () => ({ - useContact: () => ({ - data: { - id: '1', first_name: 'Max', last_name: 'Mustermann', - email: 'max@example.com', phone: '+49 30 12345678', - position: 'CEO', company_id: '1', company_name: 'TestCorp GmbH', - created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z', - }, - isLoading: false, - }), + useContact: () => mockReturnData, })); describe('ContactDetailPage', () => { @@ -22,8 +32,49 @@ describe('ContactDetailPage', () => { expect(screen.getByTestId('contact-detail-page')).toBeInTheDocument(); }); - it('renders tabs', () => { + it('renders contact name as heading', () => { + render(); + expect(screen.getByText('Max Mustermann')).toBeInTheDocument(); + }); + + it('renders tabs for detail sections', () => { render(); expect(screen.getByRole('tablist')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Übersicht/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Firmen/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Dateien/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Aktivität/ })).toBeInTheDocument(); + }); + + it('renders overview tab content by default', () => { + render(); + expect(screen.getByText('CEO')).toBeInTheDocument(); + expect(screen.getByText('max@example.com')).toBeInTheDocument(); + expect(screen.getByText('+49 30 12345678')).toBeInTheDocument(); + }); + + it('renders companies tab with assigned companies', () => { + render(); + const companiesTab = screen.getByRole('tab', { name: /Firmen/ }); + fireEvent.click(companiesTab); + expect(screen.getByText('TestCorp GmbH')).toBeInTheDocument(); + expect(screen.getByText('Müller AG')).toBeInTheDocument(); + }); + + it('renders edit button', () => { + render(); + expect(screen.getByText('Bearbeiten')).toBeInTheDocument(); + }); + + it('renders back button', () => { + render(); + expect(screen.getByText(/Zurück/)).toBeInTheDocument(); + }); + + it('shows error state when contact not found', () => { + mockReturnData = { data: undefined, isLoading: false, isError: true }; + render(); + expect(screen.getByText('Kontakt nicht gefunden.')).toBeInTheDocument(); + mockReturnData = { data: mockContact, isLoading: false, isError: false }; }); }); diff --git a/frontend/src/__tests__/contacts/ContactForm.test.tsx b/frontend/src/__tests__/contacts/ContactForm.test.tsx index f4b578c..afbdbe8 100644 --- a/frontend/src/__tests__/contacts/ContactForm.test.tsx +++ b/frontend/src/__tests__/contacts/ContactForm.test.tsx @@ -1,14 +1,27 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { ContactFormPage } from '@/pages/ContactForm'; vi.mock('@/api/hooks', () => ({ useContact: () => ({ data: undefined, isLoading: false }), - useCreateContact: () => ({ mutateAsync: vi.fn(), isPending: false }), + useCreateContact: () => ({ + mutateAsync: vi.fn().mockResolvedValue({ id: 'new-1' }), + isPending: false, + }), useUpdateContact: () => ({ mutateAsync: vi.fn(), isPending: false }), - useCompanies: () => ({ data: { items: [{ id: '1', name: 'TestCorp GmbH' }], total: 1 }, isLoading: false }), + useCompanies: () => ({ + data: { + items: [ + { id: '1', name: 'TestCorp GmbH', industry: 'IT' }, + { id: '2', name: 'Müller AG', industry: 'Logistik' }, + { id: '3', name: 'Schmidt GmbH', industry: 'Handel' }, + ], + total: 3, + }, + isLoading: false, + }), })); vi.mock('@/components/ui/Toast', () => ({ @@ -34,4 +47,70 @@ describe('ContactFormPage', () => { render(); expect(screen.getByLabelText(/E-Mail/)).toBeInTheDocument(); }); + + it('renders phone and position fields', () => { + render(); + expect(screen.getByLabelText(/Telefon/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Position/)).toBeInTheDocument(); + }); + + it('renders submit button', () => { + render(); + expect(screen.getByTestId('contact-submit-btn')).toBeInTheDocument(); + }); + + it('renders cancel button', () => { + render(); + expect(screen.getByText('Abbrechen')).toBeInTheDocument(); + }); + + it('renders form page with correct test id', () => { + render(); + expect(screen.getByTestId('contact-form-page')).toBeInTheDocument(); + }); + + it('renders create title for new contact', () => { + render(); + expect(screen.getByText('Kontakt erstellen')).toBeInTheDocument(); + }); + + it('first name field is marked as required', () => { + render(); + const firstNameInput = screen.getByTestId('contact-first-name-input'); + expect(firstNameInput).toHaveAttribute('required'); + }); + + it('last name field is marked as required', () => { + render(); + const lastNameInput = screen.getByTestId('contact-last-name-input'); + expect(lastNameInput).toHaveAttribute('required'); + }); + + it('renders company assignment section with available companies', () => { + render(); + expect(screen.getByTestId('company-assignment-list')).toBeInTheDocument(); + expect(screen.getByText('TestCorp GmbH')).toBeInTheDocument(); + expect(screen.getByText('Müller AG')).toBeInTheDocument(); + expect(screen.getByText('Schmidt GmbH')).toBeInTheDocument(); + }); + + it('can select multiple companies for assignment', () => { + render(); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(3); + fireEvent.click(checkboxes[0]); + fireEvent.click(checkboxes[1]); + expect(checkboxes[0]).toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + }); + + it('shows inline validation error when submitting empty form', async () => { + render(); + fireEvent.click(screen.getByTestId('contact-submit-btn')); + await waitFor(() => { + expect(screen.getByText(/Vorname ist erforderlich/)).toBeInTheDocument(); + expect(screen.getByText(/Nachname ist erforderlich/)).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/__tests__/contacts/ContactsList.test.tsx b/frontend/src/__tests__/contacts/ContactsList.test.tsx index 273eda0..c34819b 100644 --- a/frontend/src/__tests__/contacts/ContactsList.test.tsx +++ b/frontend/src/__tests__/contacts/ContactsList.test.tsx @@ -1,19 +1,19 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { ContactsListPage } from '@/pages/ContactsList'; +const mockContacts = [ + { id: '1', first_name: 'Max', last_name: 'Mustermann', email: 'max@test.de', phone: '+49 30 123', position: 'CEO', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z' }, + { id: '2', first_name: 'Anna', last_name: 'Schmidt', email: 'anna@test.de', phone: '+49 89 987', position: 'CTO', created_at: '2025-01-02T00:00:00Z', updated_at: '2025-01-02T00:00:00Z' }, +]; + +let mockData: any = { items: mockContacts, total: 2 }; +let mockIsLoading = false; + vi.mock('@/api/hooks', () => ({ - useContacts: () => ({ - data: { - items: [ - { id: '1', first_name: 'Max', last_name: 'Mustermann', email: 'max@test.de', phone: '+49 30 123', company_name: 'TestCorp GmbH', created_at: '2025-01-01T00:00:00Z', updated_at: '2025-01-01T00:00:00Z' }, - ], - total: 1, - }, - isLoading: false, - }), + useContacts: () => ({ data: mockData, isLoading: mockIsLoading }), useDeleteContact: () => ({ mutateAsync: vi.fn(), isPending: false }), })); @@ -21,19 +21,74 @@ vi.mock('@/components/ui/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }), })); +function renderWithRouter() { + return render(); +} + describe('ContactsListPage', () => { it('renders the page', () => { - render(); + renderWithRouter(); expect(screen.getByTestId('contacts-list-page')).toBeInTheDocument(); }); - it('renders DataGrid for contacts', () => { - render(); + it('renders DataGrid for contacts (TanStack Table)', () => { + renderWithRouter(); expect(screen.getByTestId('contacts-grid')).toBeInTheDocument(); }); it('renders contact data in grid', () => { - render(); + renderWithRouter(); expect(screen.getByText('Max Mustermann')).toBeInTheDocument(); + expect(screen.getByText('Anna Schmidt')).toBeInTheDocument(); + }); + + it('renders search input', () => { + renderWithRouter(); + expect(screen.getByLabelText(/Suchen/)).toBeInTheDocument(); + }); + + it('renders create button', () => { + renderWithRouter(); + expect(screen.getByText('Kontakt erstellen')).toBeInTheDocument(); + }); + + it('renders table headers', () => { + renderWithRouter(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('E-Mail')).toBeInTheDocument(); + expect(screen.getByText('Telefon')).toBeInTheDocument(); + expect(screen.getByText('Position')).toBeInTheDocument(); + }); + + it('renders email as mailto link', () => { + renderWithRouter(); + const emailLink = screen.getByText('max@test.de'); + expect(emailLink.closest('a')).toHaveAttribute('href', 'mailto:max@test.de'); + }); + + it('shows empty state when no contacts and no search', () => { + mockData = { items: [], total: 0 }; + renderWithRouter(); + expect(screen.getByText('Keine Kontakte vorhanden')).toBeInTheDocument(); + mockData = { items: mockContacts, total: 2 }; + }); + + it('clicking sort header toggles sort direction', () => { + renderWithRouter(); + const emailHeader = screen.getByText('E-Mail'); + const th = emailHeader.closest('th'); + expect(th).toHaveAttribute('aria-sort', 'none'); + fireEvent.click(emailHeader); + expect(th).toHaveAttribute('aria-sort', 'ascending'); + }); + + it('shows loading state in DataGrid when loading', () => { + mockIsLoading = true; + mockData = undefined; + renderWithRouter(); + expect(screen.getByTestId('contacts-grid')).toBeInTheDocument(); + expect(screen.getByText(/Wird geladen/)).toBeInTheDocument(); + mockIsLoading = false; + mockData = { items: mockContacts, total: 2 }; }); }); diff --git a/frontend/src/__tests__/dashboard/Dashboard.test.tsx b/frontend/src/__tests__/dashboard/Dashboard.test.tsx index 4233085..f8c50eb 100644 --- a/frontend/src/__tests__/dashboard/Dashboard.test.tsx +++ b/frontend/src/__tests__/dashboard/Dashboard.test.tsx @@ -11,8 +11,10 @@ vi.mock('@/api/hooks', () => ({ data: { items: [ { timestamp: '2025-06-29T10:00:00Z', user: 'anna.schmidt', action: 'create', entity: 'company', entity_id: '1', details: 'Firma erstellt' }, + { timestamp: '2025-06-28T14:00:00Z', user: 'max.mustermann', action: 'update', entity: 'contact', entity_id: '2', details: 'Kontakt aktualisiert' }, + { timestamp: '2025-06-27T09:00:00Z', user: 'admin', action: 'delete', entity: 'company', entity_id: '3', details: 'Firma gelöscht' }, ], - total: 1, + total: 3, }, isError: false, }), @@ -24,9 +26,58 @@ describe('DashboardPage', () => { expect(screen.getByTestId('dashboard-page')).toBeInTheDocument(); }); - it('renders stat cards', () => { + it('renders page title', () => { + render(); + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + }); + + it('renders stat card for companies', () => { render(); expect(screen.getByTestId('stat-companies')).toBeInTheDocument(); + }); + + it('renders stat card for contacts', () => { + render(); expect(screen.getByTestId('stat-contacts')).toBeInTheDocument(); }); + + it('renders stat card for active this week', () => { + render(); + expect(screen.getByTestId('stat-active-week')).toBeInTheDocument(); + }); + + it('renders stat card for new this month', () => { + render(); + expect(screen.getByTestId('stat-new-month')).toBeInTheDocument(); + }); + + it('renders correct company count in stat card', () => { + render(); + const statCompanies = screen.getByTestId('stat-companies'); + expect(statCompanies).toHaveTextContent('24'); + }); + + it('renders correct contact count in stat card', () => { + render(); + const statContacts = screen.getByTestId('stat-contacts'); + expect(statContacts).toHaveTextContent('156'); + }); + + it('renders activity feed', () => { + render(); + expect(screen.getByTestId('activity-feed')).toBeInTheDocument(); + }); + + it('renders activity feed with user names from audit log', () => { + render(); + expect(screen.getByText('anna.schmidt')).toBeInTheDocument(); + expect(screen.getByText('max.mustermann')).toBeInTheDocument(); + }); + + it('renders activity feed with action descriptions', () => { + render(); + expect(screen.getByText(/create/)).toBeInTheDocument(); + expect(screen.getByText(/update/)).toBeInTheDocument(); + expect(screen.getByText(/delete/)).toBeInTheDocument(); + }); }); diff --git a/frontend/src/__tests__/settings/SettingsProfile.test.tsx b/frontend/src/__tests__/settings/SettingsProfile.test.tsx index 5cfc7f2..55c7211 100644 --- a/frontend/src/__tests__/settings/SettingsProfile.test.tsx +++ b/frontend/src/__tests__/settings/SettingsProfile.test.tsx @@ -1,19 +1,22 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { SettingsProfilePage } from '@/pages/SettingsProfile'; vi.mock('@/store/authStore', () => ({ useAuthStore: () => ({ user: { - id: '1', email: 'test@test.de', first_name: 'Max', last_name: 'Mustermann', + id: '1', email: 'max@mustermann.de', first_name: 'Max', last_name: 'Mustermann', + avatar_url: '', }, }), })); +const mockUpdateUser = vi.fn().mockResolvedValue({}); + vi.mock('@/api/hooks', () => ({ - useUpdateUser: () => ({ mutateAsync: vi.fn(), isPending: false }), + useUpdateUser: () => ({ mutateAsync: mockUpdateUser, isPending: false }), })); vi.mock('@/components/ui/Toast', () => ({ @@ -31,8 +34,57 @@ describe('SettingsProfilePage', () => { expect(screen.getByLabelText(/Vorname/)).toBeInTheDocument(); }); + it('renders last name field', () => { + render(); + expect(screen.getByLabelText(/Nachname/)).toBeInTheDocument(); + }); + it('renders email field', () => { render(); expect(screen.getByLabelText(/E-Mail/)).toBeInTheDocument(); }); + + it('renders avatar upload section', () => { + render(); + expect(screen.getByTestId('avatar-upload')).toBeInTheDocument(); + }); + + it('renders password change section', () => { + render(); + expect(screen.getByTestId('current-password')).toBeInTheDocument(); + expect(screen.getByTestId('new-password')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-password')).toBeInTheDocument(); + }); + + it('renders save button for profile', () => { + render(); + expect(screen.getByTestId('profile-save-btn')).toBeInTheDocument(); + }); + + it('renders password change button', () => { + render(); + expect(screen.getByTestId('password-change-btn')).toBeInTheDocument(); + }); + + it('save button is disabled when profile is not dirty', () => { + render(); + expect(screen.getByTestId('profile-save-btn')).toBeDisabled(); + }); + + it('save button is enabled when profile fields change', () => { + render(); + const firstNameInput = screen.getByTestId('profile-first-name'); + fireEvent.change(firstNameInput, { target: { value: 'Maximilian' } }); + expect(screen.getByTestId('profile-save-btn')).not.toBeDisabled(); + }); + + it('pre-fills form with user data', () => { + render(); + const firstNameInput = screen.getByTestId('profile-first-name') as HTMLInputElement; + const lastNameInput = screen.getByTestId('profile-last-name') as HTMLInputElement; + const emailInput = screen.getByTestId('profile-email') as HTMLInputElement; + expect(firstNameInput.value).toBe('Max'); + expect(lastNameInput.value).toBe('Mustermann'); + expect(emailInput.value).toBe('max@mustermann.de'); + }); }); diff --git a/frontend/src/__tests__/settings/SettingsRoles.test.tsx b/frontend/src/__tests__/settings/SettingsRoles.test.tsx index 3f518c9..26f4ead 100644 --- a/frontend/src/__tests__/settings/SettingsRoles.test.tsx +++ b/frontend/src/__tests__/settings/SettingsRoles.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { SettingsRolesPage } from '@/pages/SettingsRoles'; @@ -18,4 +18,67 @@ describe('SettingsRolesPage', () => { render(); expect(screen.getByTestId('create-role-btn')).toBeInTheDocument(); }); + + it('renders existing roles with permissions', () => { + render(); + expect(screen.getByText('Administrator')).toBeInTheDocument(); + expect(screen.getByText('Mitarbeiter')).toBeInTheDocument(); + expect(screen.getByText('Gast')).toBeInTheDocument(); + }); + + it('renders edit buttons for each role', () => { + render(); + expect(screen.getByTestId('edit-role-1')).toBeInTheDocument(); + expect(screen.getByTestId('edit-role-2')).toBeInTheDocument(); + expect(screen.getByTestId('edit-role-3')).toBeInTheDocument(); + }); + + it('opens create role modal when create button clicked', () => { + render(); + fireEvent.click(screen.getByTestId('create-role-btn')); + expect(screen.getByTestId('create-role-form')).toBeInTheDocument(); + }); + + it('create role form has role name input', () => { + render(); + fireEvent.click(screen.getByTestId('create-role-btn')); + expect(screen.getByTestId('new-role-name')).toBeInTheDocument(); + }); + + it('create role form has permission checkboxes', () => { + render(); + fireEvent.click(screen.getByTestId('create-role-btn')); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBeGreaterThan(0); + }); + + it('can enter role name and save', () => { + render(); + fireEvent.click(screen.getByTestId('create-role-btn')); + const nameInput = screen.getByTestId('new-role-name'); + fireEvent.change(nameInput, { target: { value: 'Vertrieb' } }); + fireEvent.click(screen.getByTestId('save-role-btn')); + expect(screen.getByText('Vertrieb')).toBeInTheDocument(); + }); + + it('opens edit modal for existing role', () => { + render(); + fireEvent.click(screen.getByTestId('edit-role-1')); + expect(screen.getByTestId('edit-role-form')).toBeInTheDocument(); + }); + + it('can update role name via edit modal', () => { + render(); + fireEvent.click(screen.getByTestId('edit-role-1')); + const inputs = screen.getByTestId('edit-role-form').querySelectorAll('input:not([type="checkbox"])'); + fireEvent.change(inputs[0], { target: { value: 'Super Admin' } }); + fireEvent.click(screen.getByTestId('update-role-btn')); + expect(screen.getByText('Super Admin')).toBeInTheDocument(); + }); + + it('renders permission badges for roles', () => { + render(); + expect(screen.getAllByText(/Firmen lesen/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Kontakte lesen/).length).toBeGreaterThan(0); + }); }); diff --git a/frontend/src/__tests__/settings/SettingsUsers.test.tsx b/frontend/src/__tests__/settings/SettingsUsers.test.tsx index 5e34553..f3e4839 100644 --- a/frontend/src/__tests__/settings/SettingsUsers.test.tsx +++ b/frontend/src/__tests__/settings/SettingsUsers.test.tsx @@ -1,17 +1,18 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { SettingsUsersPage } from '@/pages/SettingsUsers'; +const mockUsers = [ + { id: '1', email: 'admin@test.de', first_name: 'Admin', last_name: 'User', role: 'admin', is_active: true }, + { id: '2', email: 'anna@test.de', first_name: 'Anna', last_name: 'Schmidt', role: 'user', is_active: true }, + { id: '3', email: 'gast@test.de', first_name: 'Gast', last_name: 'Benutzer', role: 'guest', is_active: false }, +]; + vi.mock('@/api/hooks', () => ({ useUsers: () => ({ - data: { - items: [ - { id: '1', email: 'admin@test.de', first_name: 'Admin', last_name: 'User', role: 'admin', is_active: true }, - ], - total: 1, - }, + data: { items: mockUsers, total: 3 }, isLoading: false, }), useCreateUser: () => ({ mutateAsync: vi.fn(), isPending: false }), @@ -33,4 +34,75 @@ describe('SettingsUsersPage', () => { render(); expect(screen.getAllByText(/Admin/).length).toBeGreaterThan(0); }); + + it('renders all mock users in the list', () => { + render(); + expect(screen.getByText('admin@test.de')).toBeInTheDocument(); + expect(screen.getByText('anna@test.de')).toBeInTheDocument(); + expect(screen.getByText('gast@test.de')).toBeInTheDocument(); + }); + + it('renders invite user button', () => { + render(); + expect(screen.getByTestId('invite-user-btn')).toBeInTheDocument(); + }); + + it('renders role select dropdowns for each user', () => { + render(); + const selects = screen.getAllByRole('combobox'); + expect(selects.length).toBeGreaterThanOrEqual(3); + }); + + it('renders activate/deactivate toggle buttons', () => { + render(); + expect(screen.getByTestId('toggle-active-1')).toBeInTheDocument(); + expect(screen.getByTestId('toggle-active-2')).toBeInTheDocument(); + expect(screen.getByTestId('toggle-active-3')).toBeInTheDocument(); + }); + + it('shows correct button text for active user (Deactivate)', () => { + render(); + expect(screen.getByTestId('toggle-active-1')).toHaveTextContent('Deaktivieren'); + }); + + it('shows correct button text for inactive user (Activate)', () => { + render(); + expect(screen.getByTestId('toggle-active-3')).toHaveTextContent('Aktivieren'); + }); + + it('shows active badge for active users', () => { + render(); + expect(screen.getAllByText('Aktiv').length).toBeGreaterThan(0); + }); + + it('shows inactive badge for inactive users', () => { + render(); + expect(screen.getAllByText('Inaktiv').length).toBeGreaterThan(0); + }); + + it('opens invite user modal when invite button clicked', () => { + render(); + fireEvent.click(screen.getByTestId('invite-user-btn')); + expect(screen.getByTestId('invite-user-form')).toBeInTheDocument(); + }); + + it('invite form has email input field', () => { + render(); + fireEvent.click(screen.getByTestId('invite-user-btn')); + expect(screen.getByTestId('invite-email')).toBeInTheDocument(); + }); + + it('invite form has role select', () => { + render(); + fireEvent.click(screen.getByTestId('invite-user-btn')); + const form = screen.getByTestId('invite-user-form'); + const selects = form.querySelectorAll('select'); + expect(selects.length).toBeGreaterThan(0); + }); + + it('invite form has send invite button', () => { + render(); + fireEvent.click(screen.getByTestId('invite-user-btn')); + expect(screen.getByTestId('send-invite-btn')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx index 84f79df..9adb0b9 100644 --- a/frontend/src/components/ui/Card.tsx +++ b/frontend/src/components/ui/Card.tsx @@ -8,15 +8,17 @@ export interface CardProps { footer?: React.ReactNode; className?: string; actions?: React.ReactNode; + 'data-testid'?: string; } -export function Card({ title, description, children, footer, className, actions }: CardProps) { +export function Card({ title, description, children, footer, className, actions, ...rest }: CardProps) { return (
{(title || actions) && (
diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx index 4e8abe1..43e25c1 100644 --- a/frontend/src/components/ui/Input.tsx +++ b/frontend/src/components/ui/Input.tsx @@ -38,6 +38,7 @@ export const Input = React.forwardRef( aria-invalid={!!error} aria-describedby={clsx(error && errorId, helperText && helperId) || undefined} aria-required={required} + required={required} {...props} /> {error && ( diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 78600f8..4859fc5 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -74,8 +74,14 @@ "inactive": "Inaktiv" }, "dashboard": { + "title": "Dashboard", "welcome": "Willkommen zurück", "recentActivity": "Letzte Aktivitäten", + "totalCompanies": "Firmen gesamt", + "totalContacts": "Kontakte gesamt", + "activeThisWeek": "Aktiv diese Woche", + "newThisMonth": "Neu diesen Monat", + "activityUnavailable": "Aktivitäten sind derzeit nicht verfügbar.", "statCompanies": "Firmen", "statContacts": "Kontakte", "statTasks": "Offene Aufgaben", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index aab905b..ca5ca1c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -74,8 +74,14 @@ "inactive": "Inactive" }, "dashboard": { + "title": "Dashboard", "welcome": "Welcome back", "recentActivity": "Recent Activity", + "totalCompanies": "Total Companies", + "totalContacts": "Total Contacts", + "activeThisWeek": "Active This Week", + "newThisMonth": "New This Month", + "activityUnavailable": "Activities are currently unavailable.", "statCompanies": "Companies", "statContacts": "Contacts", "statTasks": "Open Tasks", diff --git a/frontend/src/pages/CompanyForm.tsx b/frontend/src/pages/CompanyForm.tsx index 168937d..7d69dce 100644 --- a/frontend/src/pages/CompanyForm.tsx +++ b/frontend/src/pages/CompanyForm.tsx @@ -106,7 +106,7 @@ export function CompanyFormPage() {
-
+ - +