fix(custom-modules): null beim Aendern ablehnen, Ladefehler statt 404, Fehlertexte, Seitenleiste eingeklappt

- PATCH mit null fuer name/url/category ergibt 400 statt 500
- Modulansicht unterscheidet Ladefehler von "nicht gefunden"
- Formular/Loeschdialog nennen 403 und 400 eigens
- eingeklappte Seitenleiste folgt der Gruppenreihenfolge der ausgeklappten
- neue Eintraege sind mit "Eigene Module" vorbelegt
- Verwaltung zeigt bei Ladefehler nicht zusaetzlich "keine Eintraege"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent f7213f5e45
commit be1e0035e0
11 changed files with 401 additions and 104 deletions
@@ -28,6 +28,8 @@ const messages: Record<string, Record<string, unknown>> = {
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
saveError: 'Das Modul konnte nicht gespeichert werden.',
deleteError: 'Das Modul konnte nicht gelöscht werden.',
forbidden: 'Sie haben keine Berechtigung für diese Aktion.',
invalidInput: 'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
deleteConfirm: {
title: 'Eigenes Modul löschen',
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
@@ -58,8 +60,18 @@ vi.mock('next-intl', () => ({
}));
vi.mock('next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
<a href={href} className={className}>{children}</a>
default: ({
children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));
@@ -68,13 +80,7 @@ vi.mock('@/lib/use-category-label', () => ({
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
}));
const {
mockList,
mockCreate,
mockUpdate,
mockDelete,
mockBump,
} = vi.hoisted(() => ({
const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
mockList: vi.fn(),
mockCreate: vi.fn(),
mockUpdate: vi.fn(),
@@ -106,6 +112,7 @@ vi.mock('@/lib/stores/auth-store', () => ({
selector({ user: { role: mockRole } }),
}));
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
import AdminCustomModulesPage from './page';
const wiki = {
@@ -136,7 +143,9 @@ describe('AdminCustomModulesPage', () => {
render(<AdminCustomModulesPage />);
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length).toBeGreaterThan(0);
expect(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length,
).toBeGreaterThan(0);
});
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
@@ -154,13 +163,17 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.')).toBeInTheDocument();
expect(
await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'),
).toBeInTheDocument();
expect(mockCreate).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled();
});
@@ -168,7 +181,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
@@ -183,7 +198,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: leerer Name zeigt die Meldung', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), ' ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -196,7 +213,9 @@ describe('AdminCustomModulesPage', () => {
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
@@ -217,11 +236,62 @@ describe('AdminCustomModulesPage', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => {
it('Anlegen: die Kategorie ist vorbelegt mit „Eigene Module“', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
expect(screen.getByLabelText('Kategorie')).toHaveValue('infrastructure');
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
expect(screen.getByLabelText('Kategorie')).toHaveValue('custom-modules');
});
it('Anlegen ohne Kategoriewahl sendet die Kategorie „Eigene Module“', async () => {
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ category: 'custom-modules' }),
);
});
});
it('Speichern mit 403 zeigt „keine Berechtigung“, nicht den allgemeinen Fehler', async () => {
mockCreate.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Sie haben keine Berechtigung für diese Aktion.',
);
expect(screen.queryByText('Das Modul konnte nicht gespeichert werden.')).toBeNull();
});
it('Speichern mit 400 zeigt die Meldung des Servers', async () => {
mockList.mockResolvedValue([wiki]);
mockUpdate.mockRejectedValue(
new CustomModuleRequestError(400, 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.'),
);
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
const dialog = screen.getByRole('dialog');
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
);
});
it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
@@ -254,13 +324,17 @@ describe('AdminCustomModulesPage', () => {
mockCreate.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />);
await screen.findByText('Noch keine gemeinsamen Einträge');
await userEvent.click(screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0]);
await userEvent.click(
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
);
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Das Modul konnte nicht gespeichert werden.')).toBeInTheDocument();
expect(
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled();
});
@@ -271,7 +345,9 @@ describe('AdminCustomModulesPage', () => {
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
const dialog = screen.getByRole('dialog');
expect(within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.')).toBeInTheDocument();
expect(
within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.'),
).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
@@ -283,7 +359,9 @@ describe('AdminCustomModulesPage', () => {
mockList.mockResolvedValue([wiki]);
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }));
await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }),
);
expect(mockDelete).not.toHaveBeenCalled();
expect(mockBump).not.toHaveBeenCalled();
@@ -295,13 +373,38 @@ describe('AdminCustomModulesPage', () => {
mockDelete.mockRejectedValue(new Error('500'));
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }));
await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
);
expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(mockBump).not.toHaveBeenCalled();
});
it('Löschen mit 403 zeigt „keine Berechtigung“', async () => {
mockList.mockResolvedValue([wiki]);
mockDelete.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
render(<AdminCustomModulesPage />);
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
await userEvent.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Sie haben keine Berechtigung für diese Aktion.',
);
expect(screen.queryByText('Das Modul konnte nicht gelöscht werden.')).toBeNull();
});
it('Ladefehler zeigt nur die Fehlermeldung, nicht zusätzlich den Leer-Zustand', async () => {
mockList.mockRejectedValue(new CustomModuleRequestError(500, 'Internal server error'));
render(<AdminCustomModulesPage />);
expect(await screen.findByText('Laden fehlgeschlagen.')).toBeInTheDocument();
expect(screen.queryByText('Noch keine gemeinsamen Einträge')).toBeNull();
});
it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
mockList.mockResolvedValue([
wiki,