feat(260929-dzu): Einstellungen > Eigene Module fuer jeden Benutzer, Verwaltung nur gemeinsam
- 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>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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<string, Record<string, unknown>> = {
|
||||
'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<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 }),
|
||||
}));
|
||||
|
||||
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(<CustomModulesSettingsPage />);
|
||||
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(<CustomModulesSettingsPage />);
|
||||
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(<CustomModulesSettingsPage />);
|
||||
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: 'infrastructure',
|
||||
});
|
||||
});
|
||||
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(<CustomModulesSettingsPage />);
|
||||
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(<CustomModulesSettingsPage />);
|
||||
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(<CustomModulesSettingsPage />);
|
||||
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(<CustomModulesSettingsPage />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user