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> = { '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) => { let val: unknown = messages[ns]; for (const part of key.split('.')) { val = (val as Record | 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 }) => ( {children} ), })); 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( '@/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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument(); }); it('Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text', async () => { mockRole = 'USER'; render(); expect(await screen.findByText('Zugriff verweigert')).toBeInTheDocument(); expect(mockList).not.toHaveBeenCalled(); }); });