feat(domains): Kunden, Kontakte aus AutoDNS mit Zuordnung und Domainliste

- Kunden mit Markierung eigene Firma, Kontakte live aus AutoDNS (seitenweise, 60 s Zwischenspeicher), Zuordnung je System
- Kontakt anlegen (Person/Organisation) und einem oder mehreren Kunden zuordnen, nur mit Verwalten
- Domainliste mit Kunde (ueber den Inhaber-Kontakt), Inhaber, Ablaufdatum, Status; Filter und Gruppierung nach Kunde
- Mandantenschutz-Inventar um die beiden neuen Zugriffspaare ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:50:06 +02:00
parent 1f1c984184
commit 450218fe77
26 changed files with 3943 additions and 36 deletions
@@ -9,7 +9,13 @@ import {
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DomainsSettings, DomainsStatus } from '@/lib/domains-api';
import type {
DomainsContactList,
DomainsCustomer,
DomainsDomainList,
DomainsSettings,
DomainsStatus,
} from '@/lib/domains-api';
import de from '@/messages/de.json';
import DomainsPage from './page';
@@ -25,6 +31,12 @@ const mockGetStatus = vi.fn();
const mockGetSettings = vi.fn();
const mockSaveSettings = vi.fn();
const mockTestConnection = vi.fn();
const mockListCustomers = vi.fn();
const mockListContacts = vi.fn();
const mockListDomains = vi.fn();
const mockAssignContacts = vi.fn();
const mockCreateCustomer = vi.fn();
const mockDeleteCustomer = vi.fn();
vi.mock('@/lib/domains-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
@@ -34,6 +46,12 @@ vi.mock('@/lib/domains-api', async (importOriginal) => {
getDomainsSettings: (...args: unknown[]) => mockGetSettings(...args),
saveDomainsSettings: (...args: unknown[]) => mockSaveSettings(...args),
testDomainsConnection: (...args: unknown[]) => mockTestConnection(...args),
listCustomers: (...args: unknown[]) => mockListCustomers(...args),
listContacts: (...args: unknown[]) => mockListContacts(...args),
listDomains: (...args: unknown[]) => mockListDomains(...args),
assignContacts: (...args: unknown[]) => mockAssignContacts(...args),
createCustomer: (...args: unknown[]) => mockCreateCustomer(...args),
deleteCustomer: (...args: unknown[]) => mockDeleteCustomer(...args),
};
});
@@ -64,8 +82,109 @@ function settings(over: Partial<DomainsSettings> = {}): DomainsSettings {
};
}
const CUSTOMERS: DomainsCustomer[] = [
{ id: 'c-own', name: 'Unsere Firma', isOwnCompany: true, contactCount: 1 },
{ id: 'c-a', name: 'Alpha GmbH', isOwnCompany: false, contactCount: 2 },
];
function domainList(): DomainsDomainList {
return {
environment: 'DEMO',
truncated: false,
fetchedAt: '2026-10-08T10:00:00.000Z',
domains: [
{
name: 'frei.de',
expire: '2027-03-01T12:00:00.000Z',
status: 'ACTIVE',
cancelationPending: false,
ownerContactId: 5,
ownerName: 'Niemand',
customerId: null,
customerName: null,
},
{
name: 'alpha.de',
expire: '2027-07-15T12:00:00.000Z',
status: 'LOCK',
cancelationPending: true,
ownerContactId: 6,
ownerName: 'Alpha GmbH',
customerId: 'c-a',
customerName: 'Alpha GmbH',
},
{
name: 'eigen.de',
expire: null,
status: 'SONDERWERT',
cancelationPending: false,
ownerContactId: 7,
ownerName: 'Unsere Firma',
customerId: 'c-own',
customerName: 'Unsere Firma',
},
],
};
}
function contactList(): DomainsContactList {
return {
environment: 'DEMO',
truncated: false,
fetchedAt: '2026-10-08T10:00:00.000Z',
contacts: [
{
id: 6,
type: 'ORG',
displayName: 'Alpha GmbH',
fname: 'Anna',
lname: 'Alpha',
organization: 'Alpha GmbH',
address: ['Weg 1'],
pcode: '10115',
city: 'Berlin',
country: 'DE',
email: 'anna@alpha.example',
phone: '+49 30 1',
alias: null,
customerId: 'c-a',
customerName: 'Alpha GmbH',
},
{
id: 5,
type: 'PERSON',
displayName: 'Bert Beta',
fname: 'Bert',
lname: 'Beta',
organization: null,
address: [],
pcode: null,
city: 'Hamburg',
country: 'DE',
email: 'bert@beta.example',
phone: null,
alias: null,
customerId: null,
customerName: null,
},
],
};
}
/** Oeffnet den Reiter Einstellungen (Standardreiter ist Domains). */
async function renderSettings() {
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' }));
}
beforeEach(() => {
mockCanManage = true;
mockListCustomers.mockReset().mockResolvedValue(CUSTOMERS);
mockListDomains.mockReset().mockResolvedValue(domainList());
mockListContacts.mockReset().mockResolvedValue(contactList());
mockAssignContacts.mockReset().mockResolvedValue({ assigned: 1, removed: 0 });
mockCreateCustomer.mockReset().mockResolvedValue(CUSTOMERS[1]);
mockDeleteCustomer.mockReset().mockResolvedValue({ deleted: true });
mockGetStatus.mockReset().mockResolvedValue(status());
mockGetSettings.mockReset().mockResolvedValue(settings());
mockSaveSettings.mockReset().mockImplementation(async () => settings());
@@ -82,6 +201,9 @@ describe('Domains-Seite — Kopf und Reiter', () => {
it('Verwalter sieht den Reiter Einstellungen und die Demo-Kennzeichnung', async () => {
render(<DomainsPage />);
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Domains' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Kontakte' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Kunden' })).toBeTruthy();
expect((await screen.findByTestId('domains-environment-badge')).textContent).toBe(
'Demo-System (Testbetrieb)',
);
@@ -129,7 +251,7 @@ describe('Domains-Seite — Kopf und Reiter', () => {
describe('Einstellungen', () => {
it('Passwortfelder beginnen leer, mit Hinweis auf das gespeicherte Passwort', async () => {
render(<DomainsPage />);
await renderSettings();
const demoPassword = (await screen.findByLabelText('Passwort', {
selector: '#domains-demo-password',
})) as HTMLInputElement;
@@ -140,7 +262,7 @@ describe('Einstellungen', () => {
});
it('der Live-Kontext ist mit 4 vorbelegt, der Demo-Kontext zeigt den gespeicherten Wert', async () => {
render(<DomainsPage />);
await renderSettings();
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
expect((document.getElementById('domains-live-context') as HTMLInputElement).value).toBe('4');
expect((document.getElementById('domains-demo-context') as HTMLInputElement).value).toBe('1');
@@ -148,7 +270,7 @@ describe('Einstellungen', () => {
it('Wechsel auf Live verlangt Bestaetigung; erst die bestaetigte Speicherung sendet confirmLive', async () => {
mockSaveSettings.mockResolvedValue(settings({ environment: 'LIVE' }));
render(<DomainsPage />);
await renderSettings();
const liveRadio = await screen.findByRole('radio', { name: /Live-System \(kostenpflichtig\)/ });
fireEvent.click(liveRadio);
@@ -176,7 +298,7 @@ describe('Einstellungen', () => {
});
it('Speichern des Zugangs sendet nur die eigenen Felder; ohne Eingabe kein Passwort', async () => {
render(<DomainsPage />);
await renderSettings();
const user = (await screen.findByLabelText('Benutzername', {
selector: '#domains-demo-user',
})) as HTMLInputElement;
@@ -195,7 +317,7 @@ describe('Einstellungen', () => {
resolveTest = resolve;
}),
);
render(<DomainsPage />);
await renderSettings();
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
const demoCard = document.getElementById('domains-demo') as HTMLElement;
const button = within(demoCard).getByRole('button', { name: 'Verbindung testen' });
@@ -213,7 +335,7 @@ describe('Einstellungen', () => {
});
it('der Verbindungstest ist bei ungespeicherten Aenderungen gesperrt', async () => {
render(<DomainsPage />);
await renderSettings();
const password = (await screen.findByLabelText('Passwort', {
selector: '#domains-demo-password',
})) as HTMLInputElement;
@@ -227,7 +349,7 @@ describe('Einstellungen', () => {
});
it('Nameserver: speichert die bereinigte Liste', async () => {
render(<DomainsPage />);
await renderSettings();
const first = (await screen.findByLabelText('Nameserver 1')) as HTMLInputElement;
fireEvent.change(first, { target: { value: ' NS1.Example.com ' } });
fireEvent.change(screen.getByLabelText('Nameserver 2'), {
@@ -241,3 +363,157 @@ describe('Einstellungen', () => {
});
});
});
describe('Reiter Domains, Kontakte und Kunden', () => {
it('Benutzen-Benutzer sieht Domains, Kontakte, Kunden, aber weder Einstellungen noch Schreibknoepfe', async () => {
mockCanManage = false;
render(<DomainsPage />);
expect(await screen.findByRole('button', { name: 'Domains' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Kontakte' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Kunden' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Kontakte' }));
await screen.findByText('Bert Beta');
expect(screen.queryByRole('button', { name: 'Neuer Kontakt' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Zuordnen' })).toBeNull();
expect(screen.queryByRole('checkbox', { name: /auswählen/ })).toBeNull();
// Lesen darf jeder: neu einlesen steht allen zur Verfuegung.
expect(screen.getByRole('button', { name: 'Aus AutoDNS neu einlesen' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Kunden' }));
await screen.findByText('Alpha GmbH');
expect(screen.queryByRole('button', { name: 'Kunde anlegen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Bearbeiten' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Löschen' })).toBeNull();
});
it('Verwalter sieht die Schreibknoepfe', async () => {
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' }));
expect(await screen.findByRole('button', { name: 'Neuer Kontakt' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Zuordnen' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Kunden' }));
expect(await screen.findByRole('button', { name: 'Kunde anlegen' })).toBeTruthy();
expect(screen.getAllByRole('button', { name: 'Bearbeiten' })).toHaveLength(2);
});
it('Domainliste: Gruppen je Kunde, "Nicht zugeordnet" zuletzt, Datum und Statusbeschriftung', async () => {
render(<DomainsPage />);
await screen.findByText('alpha.de');
const titles = Array.from(document.querySelectorAll('.cc-section-title')).map(
(h) => h.textContent,
);
expect(titles).toEqual(['Unsere Firma', 'Alpha GmbH', 'Nicht zugeordnet']);
const row = screen.getByText('alpha.de').closest('tr') as HTMLElement;
expect(within(row).getByText('15.07.2027')).toBeTruthy();
expect(within(row).getByText(/Gesperrt/)).toBeTruthy();
expect(within(row).getByText('Kündigung vorgemerkt')).toBeTruthy();
const frei = screen.getByText('frei.de').closest('tr') as HTMLElement;
expect(within(frei).getByText('01.03.2027')).toBeTruthy();
expect(within(frei).getByText('Aktiv')).toBeTruthy();
// Unbekannter Status: Rohwert als Rueckfall, fehlendes Datum als Strich.
const eigen = screen.getByText('eigen.de').closest('tr') as HTMLElement;
expect(within(eigen).getByText('SONDERWERT')).toBeTruthy();
});
it('Kundenfilter blendet die anderen Gruppen aus', async () => {
render(<DomainsPage />);
await screen.findByText('alpha.de');
fireEvent.change(document.getElementById('domains-filter') as HTMLSelectElement, {
target: { value: 'c-a' },
});
expect(screen.getByText('alpha.de')).toBeTruthy();
expect(screen.queryByText('frei.de')).toBeNull();
expect(screen.queryByText('eigen.de')).toBeNull();
fireEvent.change(document.getElementById('domains-filter') as HTMLSelectElement, {
target: { value: 'unassigned' },
});
expect(screen.getByText('frei.de')).toBeTruthy();
expect(screen.queryByText('alpha.de')).toBeNull();
});
it('Textsuche und "Aus AutoDNS neu laden"', async () => {
render(<DomainsPage />);
await screen.findByText('alpha.de');
fireEvent.change(document.getElementById('domains-search') as HTMLInputElement, {
target: { value: 'FREI' },
});
expect(screen.queryByText('alpha.de')).toBeNull();
expect(screen.getByText('frei.de')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Aus AutoDNS neu laden' }));
await waitFor(() => expect(mockListDomains).toHaveBeenLastCalledWith({ refresh: true }));
});
it('zeigt den Hinweis bei abgeschnittener Liste und die Fehlermeldung der API', async () => {
mockListDomains.mockResolvedValueOnce({ ...domainList(), truncated: true });
render(<DomainsPage />);
expect(await screen.findByText('Es werden die ersten 2000 Einträge angezeigt.')).toBeTruthy();
});
it('Kontakte zuordnen: ausgewaehlte Kontakte gehen an den gewaehlten Kunden', async () => {
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' }));
await screen.findByText('Bert Beta');
fireEvent.click(screen.getByRole('checkbox', { name: 'Kontakt Bert Beta auswählen' }));
fireEvent.change(document.getElementById('contacts-assign-target') as HTMLSelectElement, {
target: { value: 'c-a' },
});
fireEvent.click(screen.getByRole('button', { name: 'Zuordnen' }));
await waitFor(() => expect(mockAssignContacts).toHaveBeenCalledTimes(1));
expect(mockAssignContacts).toHaveBeenCalledWith([5], 'c-a');
});
it('Zuordnung entfernen sendet null', async () => {
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' }));
await screen.findByText('Bert Beta');
fireEvent.click(screen.getByRole('checkbox', { name: 'Kontakt Alpha GmbH auswählen' }));
fireEvent.change(document.getElementById('contacts-assign-target') as HTMLSelectElement, {
target: { value: '__remove__' },
});
fireEvent.click(screen.getByRole('button', { name: 'Zuordnen' }));
await waitFor(() => expect(mockAssignContacts).toHaveBeenCalledWith([6], null));
});
it('Kunden: Loeschen verlangt eine Bestaetigung und zeigt den 409-Text der API', async () => {
const { DomainsRequestError } = await import('@/lib/domains-api');
mockDeleteCustomer.mockRejectedValueOnce(
new DomainsRequestError(409, 'customerInUse', 'Diesem Kunden sind noch Kontakte zugeordnet.'),
);
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Kunden' }));
const buttons = await screen.findAllByRole('button', { name: 'Löschen' });
fireEvent.click(buttons[1]);
const dialog = await screen.findByRole('alertdialog');
expect(mockDeleteCustomer).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: 'Endgültig löschen' }));
await waitFor(() => expect(mockDeleteCustomer).toHaveBeenCalledWith('c-a'));
expect(await screen.findByText('Diesem Kunden sind noch Kontakte zugeordnet.')).toBeTruthy();
});
it('Kunden: neuer Kunde mit Markierung eigene Firma', async () => {
render(<DomainsPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Kunden' }));
fireEvent.change(await screen.findByLabelText('Name', { selector: '#customer-new-name' }), {
target: { value: ' Neu AG ' },
});
fireEvent.click(screen.getByRole('checkbox', { name: 'Das ist unsere eigene Firma' }));
fireEvent.click(screen.getByRole('button', { name: 'Kunde anlegen' }));
await waitFor(() => expect(mockCreateCustomer).toHaveBeenCalledTimes(1));
expect(mockCreateCustomer).toHaveBeenCalledWith({ name: 'Neu AG', isOwnCompany: true });
});
it('nicht eingerichtet: keine Liste, nur der Hinweis', async () => {
mockGetStatus.mockResolvedValue(status({ configured: false, demoConfigured: false }));
render(<DomainsPage />);
await screen.findByText('AutoDNS ist noch nicht eingerichtet.');
expect(mockListDomains).not.toHaveBeenCalled();
});
});