feat(companies): UI fuer Firmen-Verwaltung mit Ansprechpartner-Links — Modul 12/16 des UI-Backlogs
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-09-15 22:56:38 +02:00
parent 71ffee021e
commit 06b72843da
8 changed files with 1099 additions and 4 deletions
@@ -0,0 +1,271 @@
/**
* Companies page tests — firmen management UI (UI-Backlog module 12/16).
*
* Covers: rendering, permission gating, list with cards, search/industry
* filters, create form validation + payload, edit prefill, delete flow,
* detail modal with contact persons link/unlink.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CompaniesPage } from '@/pages/Companies';
import type { Company } from '@/api/companies';
const { createMut, updateMut, deleteMut, linkMut, unlinkMut } = vi.hoisted(() => ({
createMut: vi.fn().mockResolvedValue({}),
updateMut: vi.fn().mockResolvedValue({}),
deleteMut: vi.fn().mockResolvedValue({}),
linkMut: vi.fn().mockResolvedValue({}),
unlinkMut: vi.fn().mockResolvedValue({}),
}));
const makeCompany = (overrides: Partial<Company> = {}): Company => ({
id: '11111111-1111-1111-1111-111111111111',
name: 'TechCorp GmbH',
displayname: 'TechCorp GmbH',
status: 'lead',
industry: 'IT',
description: null,
email_1: 'info@techcorp.de',
email_2: null,
phone_1: '+49 30 123456',
phone_2: null,
website: 'https://techcorp.de',
mailing_city: 'Berlin',
mailing_postalcode: null,
mailing_country: 'Deutschland',
tags: null,
custom: null,
...overrides,
});
let mockItems: Company[] = [];
let mockTotal = 0;
let mockDetail: Company | null = null;
let mockCanRead = true;
let mockCanWrite = true;
let mockCanDelete = true;
vi.mock('@/api/companies', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/companies')>();
return {
...actual,
useCompanies: () => ({
data: { items: mockItems, total: mockTotal, page: 1, page_size: 20 },
isLoading: false,
isError: false,
}),
useCompany: () => ({
data: mockDetail,
isLoading: false,
isError: false,
}),
useCompanyEmails: () => ({ data: [], isLoading: false }),
useCreateCompany: () => ({ mutate: createMut, isPending: false }),
useUpdateCompany: () => ({ mutate: updateMut, isPending: false }),
useDeleteCompany: () => ({ mutate: deleteMut, isPending: false }),
useLinkContact: () => ({ mutate: linkMut, isPending: false }),
useUnlinkContact: () => ({ mutate: unlinkMut, isPending: false }),
};
});
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
hasPermission: (perm: string) =>
(mockCanRead || perm !== 'contacts:read') &&
(mockCanWrite || perm !== 'contacts:write') &&
(mockCanDelete || perm !== 'contacts:delete'),
}),
}));
function renderPage() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<CompaniesPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
createMut.mockClear();
updateMut.mockClear();
deleteMut.mockClear();
linkMut.mockClear();
unlinkMut.mockClear();
mockItems = [];
mockTotal = 0;
mockDetail = null;
mockCanRead = true;
mockCanWrite = true;
mockCanDelete = true;
window.confirm = () => true;
});
describe('CompaniesPage', () => {
it('renders page title, export buttons and create button', () => {
renderPage();
expect(screen.getByTestId('companies-page')).toBeInTheDocument();
expect(screen.getByTestId('company-export-csv')).toBeInTheDocument();
expect(screen.getByTestId('company-export-xlsx')).toBeInTheDocument();
expect(screen.getByTestId('company-create-btn')).toBeInTheDocument();
});
it('shows empty state when no companies exist', async () => {
renderPage();
expect(await screen.findByTestId('companies-empty')).toBeInTheDocument();
});
it('shows no-permission card without contacts:read', () => {
mockCanRead = false;
renderPage();
expect(screen.getByTestId('companies-no-permission')).toBeInTheDocument();
});
it('renders company cards with name, status badge and industry', () => {
mockItems = [makeCompany()];
mockTotal = 1;
renderPage();
const card = screen.getByTestId('company-card-11111111-1111-1111-1111-111111111111');
expect(card).toBeInTheDocument();
expect(screen.getByText('TechCorp GmbH')).toBeInTheDocument();
expect(screen.getByTestId('company-status-11111111-1111-1111-1111-111111111111')).toHaveTextContent('lead');
expect(screen.getByText('IT')).toBeInTheDocument();
});
it('hides create/edit/delete buttons without write/delete permissions', () => {
mockItems = [makeCompany()];
mockTotal = 1;
mockCanWrite = false;
mockCanDelete = false;
renderPage();
expect(screen.queryByTestId('company-create-btn')).not.toBeInTheDocument();
expect(
screen.queryByTestId('company-edit-11111111-1111-1111-1111-111111111111'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('company-delete-11111111-1111-1111-1111-111111111111'),
).not.toBeInTheDocument();
});
it('opens the create form and submits a trimmed payload', async () => {
renderPage();
fireEvent.click(screen.getByTestId('company-create-btn'));
fireEvent.change(screen.getByTestId('company-form-name'), {
target: { value: ' Neue Firma AG ' },
});
fireEvent.change(screen.getByTestId('company-form-industry'), {
target: { value: 'Handel' },
});
fireEvent.change(screen.getByTestId('company-form-email'), {
target: { value: 'info@neuefirma.de' },
});
fireEvent.click(screen.getByTestId('company-form-submit'));
await waitFor(() => expect(createMut).toHaveBeenCalled());
const payload = createMut.mock.calls[0][0];
expect(payload.name).toBe('Neue Firma AG');
expect(payload.status).toBe('lead');
expect(payload.industry).toBe('Handel');
expect(payload.email_1).toBe('info@neuefirma.de');
});
it('create submit disabled without a name', () => {
renderPage();
fireEvent.click(screen.getByTestId('company-create-btn'));
expect(screen.getByTestId('company-form-submit')).toBeDisabled();
});
it('opens the edit form pre-filled and submits only changed fields', async () => {
mockItems = [makeCompany()];
mockTotal = 1;
renderPage();
fireEvent.click(screen.getByTestId('company-edit-11111111-1111-1111-1111-111111111111'));
await waitFor(() => {
expect(screen.getByTestId('company-form-name')).toHaveValue('TechCorp GmbH');
});
expect(screen.getByTestId('company-form-industry')).toHaveValue('IT');
fireEvent.change(screen.getByTestId('company-form-name'), {
target: { value: 'TechCorp AG' },
});
fireEvent.click(screen.getByTestId('company-form-submit'));
await waitFor(() => expect(updateMut).toHaveBeenCalled());
const call = updateMut.mock.calls[0][0];
expect(call.id).toBe('11111111-1111-1111-1111-111111111111');
expect(call.data.name).toBe('TechCorp AG');
// unchanged fields must not be part of the update payload
expect(call.data.industry).toBeUndefined();
expect(call.data.email_1).toBeUndefined();
});
it('deletes a company after confirm', async () => {
mockItems = [makeCompany()];
mockTotal = 1;
renderPage();
fireEvent.click(screen.getByTestId('company-delete-11111111-1111-1111-1111-111111111111'));
await waitFor(() => expect(deleteMut).toHaveBeenCalled());
expect(deleteMut.mock.calls[0][0].id).toBe('11111111-1111-1111-1111-111111111111');
});
it('opens the detail modal with contact persons and unlinks one', async () => {
mockItems = [makeCompany()];
mockTotal = 1;
mockDetail = makeCompany({
contacts: [
{ id: '22222222-2222-2222-2222-222222222222', firstname: 'Max', lastname: 'Mustermann', email: 'max@techcorp.de', phone: null },
],
});
renderPage();
fireEvent.click(screen.getByTestId('company-detail-trigger-11111111-1111-1111-1111-111111111111'));
await waitFor(() => {
expect(screen.getByTestId('company-person-22222222-2222-2222-2222-222222222222')).toBeInTheDocument();
});
expect(screen.getByText('Max Mustermann')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('company-unlink-22222222-2222-2222-2222-222222222222'));
await waitFor(() => expect(unlinkMut).toHaveBeenCalled());
expect(unlinkMut.mock.calls[0][0].companyId).toBe('11111111-1111-1111-1111-111111111111');
});
it('links a contact person via UUID input', async () => {
mockItems = [makeCompany()];
mockTotal = 1;
mockDetail = makeCompany({ contacts: [] });
renderPage();
fireEvent.click(screen.getByTestId('company-detail-trigger-11111111-1111-1111-1111-111111111111'));
await waitFor(() => {
expect(screen.getByTestId('company-link-contact-id')).toBeInTheDocument();
});
fireEvent.change(screen.getByTestId('company-link-contact-id'), {
target: { value: '33333333-3333-3333-3333-333333333333' },
});
fireEvent.click(screen.getByTestId('company-link-submit'));
await waitFor(() => expect(linkMut).toHaveBeenCalled());
expect(linkMut.mock.calls[0][0]).toEqual({
companyId: '11111111-1111-1111-1111-111111111111',
contactId: '33333333-3333-3333-3333-333333333333',
});
});
it('search input submits on Enter', async () => {
renderPage();
const input = screen.getByTestId('company-search-input');
fireEvent.change(input, { target: { value: 'Tech' } });
fireEvent.keyDown(input, { key: 'Enter' });
// no crash + the search button is still there
expect(screen.getByTestId('company-search-submit')).toBeInTheDocument();
});
});