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 (Muster admin/custom-modules-page.test.tsx). const messages: Record> = { 'settings.customModules': { title: 'Eigene Module', description: 'Diese Einträge sehen nur Sie.', create: 'Eigenes Modul anlegen', noModules: 'Noch keine eigenen Module', noModulesBody: 'Legen Sie Ihr erstes Modul 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', }, }; 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 }), })); import CustomModulesSettingsPage from './page'; const mine = { id: 'cm-1', name: 'Mein Wiki', url: 'https://wiki.example.com', category: 'infrastructure', personal: true, createdAt: '2026-09-29T00:00:00.000Z', updatedAt: '2026-09-29T00:00:00.000Z', }; const shared = { ...mine, id: 'cm-2', name: 'Firmenseite', personal: false }; beforeEach(() => { mockList.mockReset().mockResolvedValue([]); mockCreate.mockReset().mockResolvedValue(mine); mockUpdate.mockReset().mockResolvedValue(mine); mockDelete.mockReset().mockResolvedValue(undefined); mockBump.mockReset(); }); afterEach(() => { cleanup(); }); describe('CustomModulesSettingsPage', () => { it('zeigt Einleitung und ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => { render(); expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument(); expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument(); expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan( 0, ); }); it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => { mockList.mockResolvedValue([shared, mine]); render(); expect(await screen.findByRole('link', { name: 'Mein Wiki' })).toHaveAttribute( 'href', '/modules/custom/cm-1', ); expect(screen.queryByText('Firmenseite')).not.toBeInTheDocument(); expect(screen.getAllByRole('button', { name: 'Bearbeiten' })).toHaveLength(1); }); it('Anlegen sendet KEIN shared (persönlicher Eintrag), lädt neu und aktualisiert die Seitenleiste', async () => { render(); await screen.findByText('Noch keine eigenen Module'); await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]); await userEvent.type(screen.getByLabelText('Name'), ' Mein Wiki '); await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com'); await userEvent.click(screen.getByRole('button', { name: 'Speichern' })); await waitFor(() => { expect(mockCreate).toHaveBeenCalledWith({ name: 'Mein Wiki', url: 'https://example.com', category: 'custom-modules', }); }); expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared'); await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1)); expect(mockList).toHaveBeenCalledTimes(2); }); it('Anlegen: http-Adresse wird vor dem Senden abgelehnt', async () => { render(); await screen.findByText('Noch keine eigenen Module'); await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]); await userEvent.type(screen.getByLabelText('Name'), 'X'); 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(); }); it('Bearbeiten: ruft update ohne shared und aktualisiert die Seitenleiste', async () => { mockList.mockResolvedValue([mine]); render(); await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' })); const dialog = screen.getByRole('dialog'); const nameInput = within(dialog).getByLabelText('Name'); expect(nameInput).toHaveValue('Mein Wiki'); 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('Löschen: Rückfrage nennt den persönlichen Text, Bestätigen ruft delete und aktualisiert die Seitenleiste', async () => { mockList.mockResolvedValue([mine]); render(); await userEvent.click(await screen.findByRole('button', { name: 'Löschen' })); const dialog = screen.getByRole('dialog'); expect( within(dialog).getByText('Möchten Sie „Mein Wiki“ wirklich löschen? Nur bei Ihnen.'), ).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('Serverfehler beim Speichern bleibt im Dialog sichtbar', async () => { mockCreate.mockRejectedValue(new Error('500')); render(); await screen.findByText('Noch keine eigenen Module'); await userEvent.click(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' })[0]); await userEvent.type(screen.getByLabelText('Name'), 'X'); 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(); }); });