feat(T04): contact management + USt-IdNr. validation + contact UI

- Contact model: company, role (kaeufer/verkaeufer/beide), soft-delete
- ContactPerson model: 1:N relation with CASCADE delete
- USt-IdNr. validation: DE + 10 EU countries
- Contact CRUD: 7 API endpoints with RBAC, search/filter/paginate
- Frontend: ContactList, ContactForm, ContactDetail with EU/Inland toggle
- 73 backend tests (91% coverage), 20 frontend tests
This commit is contained in:
2026-07-14 12:14:21 +02:00
parent 74b5e6afb8
commit 2cf433a01c
20 changed files with 3002 additions and 97 deletions
+243
View File
@@ -0,0 +1,243 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ContactList } from '@/components/contacts/ContactList';
import { ContactForm } from '@/components/contacts/ContactForm';
import { validateVatIdFormat } from '@/lib/contacts';
// Mock the contacts API module
vi.mock('@/lib/contacts', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/contacts')>();
return {
...actual,
listContacts: vi.fn(),
createContact: vi.fn(),
updateContact: vi.fn(),
};
});
import { listContacts, createContact } from '@/lib/contacts';
const mockContacts = {
items: [
{
id: 'test-id-1',
company_name: 'Müller Transport GmbH',
address_city: 'Berlin',
address_country: 'DE',
role: 'kaeufer',
vat_id: 'DE123456789',
vat_id_status: 'ungeprueft',
is_private: false,
contact_persons: [],
},
{
id: 'test-id-2',
company_name: 'Van der Berg B.V.',
address_city: 'Amsterdam',
address_country: 'NL',
role: 'verkaeufer',
vat_id: 'NL123456789B01',
vat_id_status: 'geprueft',
is_private: false,
contact_persons: [{ id: 'p1', contact_id: 'test-id-2', name: 'Jan' }],
},
],
total: 2,
page: 1,
page_size: 20,
};
describe('ContactList', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders contact list with search and filter controls', async () => {
vi.mocked(listContacts).mockResolvedValue(mockContacts);
render(<ContactList />);
await waitFor(() => {
expect(screen.getByTestId('contact-list')).toBeInTheDocument();
});
expect(screen.getByTestId('filter-search')).toBeInTheDocument();
expect(screen.getByTestId('filter-role')).toBeInTheDocument();
expect(screen.getByTestId('filter-eu')).toBeInTheDocument();
expect(screen.getByTestId('filter-sort')).toBeInTheDocument();
});
it('displays contacts in table after loading', async () => {
vi.mocked(listContacts).mockResolvedValue(mockContacts);
render(<ContactList />);
await waitFor(() => {
expect(screen.getByText('Müller Transport GmbH')).toBeInTheDocument();
});
expect(screen.getByText('Van der Berg B.V.')).toBeInTheDocument();
expect(screen.getByText('Berlin')).toBeInTheDocument();
expect(screen.getByText('Amsterdam')).toBeInTheDocument();
});
it('shows new contact button', async () => {
vi.mocked(listContacts).mockResolvedValue(mockContacts);
render(<ContactList />);
await waitFor(() => {
expect(screen.getByTestId('new-contact-btn')).toBeInTheDocument();
});
});
it('shows error message on API failure', async () => {
vi.mocked(listContacts).mockRejectedValue({
error: { message: 'Network error' },
});
render(<ContactList />);
await waitFor(() => {
expect(screen.getByTestId('contact-error')).toBeInTheDocument();
});
expect(screen.getByText('Network error')).toBeInTheDocument();
});
it('shows loading state initially', async () => {
vi.mocked(listContacts).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(mockContacts), 100))
);
render(<ContactList />);
expect(screen.getByTestId('contact-loading')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Müller Transport GmbH')).toBeInTheDocument();
});
});
});
describe('ContactForm', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders form with all required fields', () => {
render(<ContactForm mode="create" />);
expect(screen.getByTestId('contact-form')).toBeInTheDocument();
expect(screen.getByTestId('input-company_name')).toBeInTheDocument();
expect(screen.getByTestId('input-role')).toBeInTheDocument();
expect(screen.getByTestId('input-address_country')).toBeInTheDocument();
expect(screen.getByTestId('input-vat_id')).toBeInTheDocument();
expect(screen.getByTestId('input-phone')).toBeInTheDocument();
expect(screen.getByTestId('input-email')).toBeInTheDocument();
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
});
it('shows EU/Inland toggle', () => {
render(<ContactForm mode="create" />);
expect(screen.getByTestId('eu-inland-toggle')).toBeInTheDocument();
expect(screen.getByTestId('toggle-inland')).toBeInTheDocument();
expect(screen.getByTestId('toggle-eu')).toBeInTheDocument();
});
it('defaults to Inland (DE) and shows DE placeholder for VAT ID', () => {
render(<ContactForm mode="create" />);
const inlandRadio = screen.getByTestId('toggle-inland') as HTMLInputElement;
expect(inlandRadio.checked).toBe(true);
});
it('switches to EU mode and shows VAT ID hint when EU is selected', () => {
render(<ContactForm mode="create" />);
const euRadio = screen.getByTestId('toggle-eu');
fireEvent.click(euRadio);
expect(screen.getByTestId('vat-id-hint')).toBeInTheDocument();
});
it('validates VAT ID format and shows error for invalid DE VAT', () => {
render(<ContactForm mode="create" />);
const vatInput = screen.getByTestId('input-vat_id') as HTMLInputElement;
fireEvent.change(vatInput, { target: { value: 'DE123' } });
const submitBtn = screen.getByTestId('submit-button');
fireEvent.click(submitBtn);
expect(screen.getByText(/Invalid VAT ID format/)).toBeInTheDocument();
});
it('accepts valid DE VAT ID without error', async () => {
vi.mocked(createContact).mockResolvedValue({
id: 'new-id',
company_name: 'Test GmbH',
address_country: 'DE',
role: 'kaeufer',
vat_id_status: 'ungeprueft',
is_private: false,
contact_persons: [],
});
render(<ContactForm mode="create" />);
const companyInput = screen.getByTestId('input-company_name');
fireEvent.change(companyInput, { target: { value: 'Test GmbH' } });
const vatInput = screen.getByTestId('input-vat_id');
fireEvent.change(vatInput, { target: { value: 'DE123456789' } });
const submitBtn = screen.getByTestId('submit-button');
fireEvent.click(submitBtn);
await waitFor(() => {
expect(createContact).toHaveBeenCalled();
});
});
it('requires company name', () => {
render(<ContactForm mode="create" />);
const submitBtn = screen.getByTestId('submit-button');
fireEvent.click(submitBtn);
expect(screen.getByText('Company name is required')).toBeInTheDocument();
});
});
describe('validateVatIdFormat', () => {
it('returns null for empty VAT ID', () => {
expect(validateVatIdFormat('', 'DE')).toBeNull();
});
it('returns null for valid DE VAT ID', () => {
expect(validateVatIdFormat('DE123456789', 'DE')).toBeNull();
});
it('returns error for invalid DE VAT ID (too short)', () => {
const result = validateVatIdFormat('DE12345678', 'DE');
expect(result).toContain('Invalid');
});
it('returns null for valid AT VAT ID', () => {
expect(validateVatIdFormat('ATU12345678', 'AT')).toBeNull();
});
it('returns null for valid NL VAT ID', () => {
expect(validateVatIdFormat('NL123456789B01', 'NL')).toBeNull();
});
it('returns error for non-EU country', () => {
const result = validateVatIdFormat('US123456789', 'US');
expect(result).toContain('not supported');
});
it('handles lowercase input', () => {
expect(validateVatIdFormat('de123456789', 'DE')).toBeNull();
});
it('handles spaces in VAT ID', () => {
expect(validateVatIdFormat('DE 123 456 789', 'DE')).toBeNull();
});
});