ee97b4ed9f
- gemeinsame Oberflaeche (CustomModuleManager, Formular, Loeschdialog) fuer Verwaltung und Einstellungen - Einstellungen: persoenliche Eintraege, Verwaltung sendet shared: true und zeigt nur gemeinsame - Navigation, Texte de/en, Komponententests Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// Namensraumfaehiger next-intl-Mock: Punktschluessel werden gegen den
|
|
// Namensraum aufgeloest, {param} wird eingesetzt (Muster groups-page.test.tsx).
|
|
const messages: Record<string, Record<string, unknown>> = {
|
|
'admin.customModules': {
|
|
title: 'Eigene Module',
|
|
description: 'Erklärung gemeinsam',
|
|
create: 'Gemeinsamen Eintrag anlegen',
|
|
noModules: 'Noch keine gemeinsamen Einträge',
|
|
noModulesBody: 'Legen Sie Ihren ersten gemeinsamen Eintrag an.',
|
|
},
|
|
'customModules.manage': {
|
|
actions: 'Aktionen',
|
|
loadError: 'Laden fehlgeschlagen.',
|
|
},
|
|
'customModules.form': {
|
|
create: 'Eigenes Modul anlegen',
|
|
edit: 'Eigenes Modul bearbeiten',
|
|
name: 'Name',
|
|
url: 'Adresse',
|
|
urlHint: 'Hinweis https',
|
|
category: 'Kategorie',
|
|
nameRequired: 'Bitte geben Sie einen Namen ein.',
|
|
urlNotHttps: 'Bitte geben Sie eine Adresse ein, die mit https:// beginnt.',
|
|
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.',
|
|
deleteConfirm: {
|
|
title: 'Eigenes Modul löschen',
|
|
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
|
|
bodyPersonal: 'Möchten Sie „{name}“ wirklich löschen? Nur bei Ihnen.',
|
|
},
|
|
},
|
|
common: {
|
|
loading: 'Laden...',
|
|
cancel: 'Abbrechen',
|
|
save: 'Speichern',
|
|
delete: 'Löschen',
|
|
edit: 'Bearbeiten',
|
|
accessDenied: 'Zugriff verweigert',
|
|
},
|
|
};
|
|
|
|
vi.mock('next-intl', () => ({
|
|
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
|
|
let val: unknown = messages[ns];
|
|
for (const part of key.split('.')) {
|
|
val = (val as Record<string, unknown> | undefined)?.[part];
|
|
}
|
|
if (typeof val !== 'string') return key;
|
|
return params
|
|
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
|
|
: val;
|
|
},
|
|
}));
|
|
|
|
vi.mock('next/link', () => ({
|
|
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
|
<a href={href} className={className}>{children}</a>
|
|
),
|
|
}));
|
|
|
|
vi.mock('@/lib/use-category-label', () => ({
|
|
useCategoryLabel: () => (slug: string) =>
|
|
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
|
|
}));
|
|
|
|
const {
|
|
mockList,
|
|
mockCreate,
|
|
mockUpdate,
|
|
mockDelete,
|
|
mockBump,
|
|
} = vi.hoisted(() => ({
|
|
mockList: vi.fn(),
|
|
mockCreate: vi.fn(),
|
|
mockUpdate: vi.fn(),
|
|
mockDelete: vi.fn(),
|
|
mockBump: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/custom-modules-api', async () => {
|
|
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
|
'@/lib/custom-modules-api',
|
|
);
|
|
return {
|
|
...actual,
|
|
listCustomModules: mockList,
|
|
createCustomModule: mockCreate,
|
|
updateCustomModule: mockUpdate,
|
|
deleteCustomModule: mockDelete,
|
|
};
|
|
});
|
|
|
|
vi.mock('@/lib/stores/marketplace-store', () => ({
|
|
useMarketplaceStore: (selector: (s: { bumpSidebarRefresh: () => void }) => unknown) =>
|
|
selector({ bumpSidebarRefresh: mockBump }),
|
|
}));
|
|
|
|
let mockRole = 'ADMIN';
|
|
vi.mock('@/lib/stores/auth-store', () => ({
|
|
useAuthStore: (selector: (s: { user: { role: string } }) => unknown) =>
|
|
selector({ user: { role: mockRole } }),
|
|
}));
|
|
|
|
import AdminCustomModulesPage from './page';
|
|
|
|
const wiki = {
|
|
id: 'cm-1',
|
|
name: 'Wiki',
|
|
url: 'https://wiki.example.com',
|
|
category: 'infrastructure',
|
|
personal: false,
|
|
createdAt: '2026-09-29T00:00:00.000Z',
|
|
updatedAt: '2026-09-29T00:00:00.000Z',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockRole = 'ADMIN';
|
|
mockList.mockReset().mockResolvedValue([]);
|
|
mockCreate.mockReset().mockResolvedValue(wiki);
|
|
mockUpdate.mockReset().mockResolvedValue(wiki);
|
|
mockDelete.mockReset().mockResolvedValue(undefined);
|
|
mockBump.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
describe('AdminCustomModulesPage', () => {
|
|
it('zeigt ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => {
|
|
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);
|
|
});
|
|
|
|
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
|
|
mockList.mockResolvedValue([wiki]);
|
|
render(<AdminCustomModulesPage />);
|
|
|
|
const link = await screen.findByRole('link', { name: 'Wiki' });
|
|
expect(link).toHaveAttribute('href', '/modules/custom/cm-1');
|
|
expect(screen.getByText('https://wiki.example.com')).toBeInTheDocument();
|
|
expect(screen.getByText('Infrastruktur')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
|
|
});
|
|
|
|
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.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(mockCreate).not.toHaveBeenCalled();
|
|
expect(mockBump).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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.type(screen.getByLabelText('Name'), 'Beispielseite');
|
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
|
|
|
expect(
|
|
await screen.findByText('Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.'),
|
|
).toBeInTheDocument();
|
|
expect(mockCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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.type(screen.getByLabelText('Name'), ' ');
|
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
|
|
|
expect(await screen.findByText('Bitte geben Sie einen Namen ein.')).toBeInTheDocument();
|
|
expect(mockCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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.type(screen.getByLabelText('Name'), ' Beispielseite ');
|
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
|
await userEvent.selectOptions(screen.getByLabelText('Kategorie'), 'security-tools');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockCreate).toHaveBeenCalledWith({
|
|
name: 'Beispielseite',
|
|
url: 'https://example.com',
|
|
category: 'security-tools',
|
|
shared: true,
|
|
});
|
|
});
|
|
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
|
// Anfangsladen + Neuladen nach dem Speichern
|
|
expect(mockList).toHaveBeenCalledTimes(2);
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', 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');
|
|
});
|
|
|
|
it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
|
|
mockList.mockResolvedValue([wiki]);
|
|
render(<AdminCustomModulesPage />);
|
|
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
|
|
|
|
const dialog = screen.getByRole('dialog');
|
|
expect(within(dialog).getByLabelText('Name')).toHaveValue('Wiki');
|
|
expect(within(dialog).getByLabelText('Adresse')).toHaveValue('https://wiki.example.com');
|
|
expect(within(dialog).getByLabelText('Kategorie')).toHaveValue('infrastructure');
|
|
|
|
const nameInput = within(dialog).getByLabelText('Name');
|
|
await userEvent.clear(nameInput);
|
|
await userEvent.type(nameInput, 'Wiki neu');
|
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdate).toHaveBeenCalledWith('cm-1', {
|
|
name: 'Wiki neu',
|
|
url: 'https://wiki.example.com',
|
|
category: 'infrastructure',
|
|
});
|
|
});
|
|
expect(mockCreate).not.toHaveBeenCalled();
|
|
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it('Serverfehler beim Speichern bleibt im Dialog sichtbar, der Dialog bleibt offen', async () => {
|
|
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.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(screen.getByRole('dialog')).toBeInTheDocument();
|
|
expect(mockBump).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('Löschen: Rückfrage nennt den Namen, Bestätigen ruft delete, lädt neu und aktualisiert die Seitenleiste', async () => {
|
|
mockList.mockResolvedValue([wiki]);
|
|
render(<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();
|
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
|
|
|
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
|
|
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
|
expect(mockList).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('Löschen: Abbrechen ruft nichts auf', async () => {
|
|
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' }));
|
|
|
|
expect(mockDelete).not.toHaveBeenCalled();
|
|
expect(mockBump).not.toHaveBeenCalled();
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('Löschfehler bleibt im Dialog sichtbar', async () => {
|
|
mockList.mockResolvedValue([wiki]);
|
|
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' }));
|
|
|
|
expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
|
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
expect(mockBump).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
|
|
mockList.mockResolvedValue([
|
|
wiki,
|
|
{ ...wiki, id: 'cm-2', name: 'Mein Privates', personal: true },
|
|
]);
|
|
render(<AdminCustomModulesPage />);
|
|
expect(await screen.findByRole('link', { name: 'Wiki' })).toBeInTheDocument();
|
|
expect(screen.queryByText('Mein Privates')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('nur persönliche Einträge vorhanden: Leer-Zustand der Verwaltung', async () => {
|
|
mockList.mockResolvedValue([{ ...wiki, personal: true }]);
|
|
render(<AdminCustomModulesPage />);
|
|
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
|
|
});
|
|
|
|
it('Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text', async () => {
|
|
mockRole = 'USER';
|
|
render(<AdminCustomModulesPage />);
|
|
expect(await screen.findByText('Zugriff verweigert')).toBeInTheDocument();
|
|
expect(mockList).not.toHaveBeenCalled();
|
|
});
|
|
});
|