b9d87be360
- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000 - API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten - Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“ - MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
let mockPathname = '/';
|
|
vi.mock('next/navigation', () => ({
|
|
usePathname: () => mockPathname,
|
|
}));
|
|
|
|
vi.mock('next/link', () => ({
|
|
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
|
<a href={href} className={className}>{children}</a>
|
|
),
|
|
}));
|
|
|
|
vi.mock('next-intl', () => ({
|
|
useTranslations: (ns: string) => (key: string) => {
|
|
const map: Record<string, Record<string, string>> = {
|
|
sidebar: {
|
|
dashboard: 'Dashboard',
|
|
marketplace: 'Marktplatz',
|
|
collapse: 'Einklappen',
|
|
expand: 'Ausklappen',
|
|
search: 'Module suchen...',
|
|
noResults: 'Keine Module gefunden',
|
|
'categories.label': 'Kategorien',
|
|
'categories.empty': 'Keine Module',
|
|
admin: 'Verwaltung',
|
|
users: 'Benutzer',
|
|
tenants: 'Mandanten',
|
|
modules: 'Module',
|
|
ldap: 'LDAP',
|
|
},
|
|
common: {
|
|
appName: 'Tessera',
|
|
'brand.logoAlt': 'Tessera Logo',
|
|
},
|
|
};
|
|
return map[ns]?.[key] ?? key;
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/stores/sidebar-store', () => ({
|
|
useSidebarStore: () => ({
|
|
isCollapsed: false,
|
|
isMobileOpen: false,
|
|
toggle: vi.fn(),
|
|
setMobileOpen: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
const mockAuthStore = vi.fn();
|
|
vi.mock('@/lib/stores/auth-store', () => ({
|
|
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
|
|
}));
|
|
|
|
let mockRefreshKey = 0;
|
|
vi.mock('@/lib/stores/marketplace-store', () => ({
|
|
useMarketplaceStore: (selector: (state: { sidebarRefreshKey: number }) => unknown) =>
|
|
selector({ sidebarRefreshKey: mockRefreshKey }),
|
|
}));
|
|
|
|
vi.mock('@/components/layout/sidebar-footer', () => ({
|
|
SidebarFooter: () => <div data-testid="sidebar-footer" />,
|
|
}));
|
|
|
|
// Das Abzeichen ruft `loadApiVersion()` und damit `fetch` — ohne Modul-Mock
|
|
// bekaeme es das Modul-Array aus `stubFetch()` (quick-260914-ku1).
|
|
vi.mock('@/components/dashboard/dashboard-greeting', () => ({
|
|
DashboardGreeting: () => <div data-testid="dashboard-greeting" />,
|
|
}));
|
|
|
|
vi.mock('@/components/layout/app-version-badge', () => ({
|
|
AppVersionBadge: () => <div data-testid="app-version-badge" />,
|
|
}));
|
|
|
|
// Eigene Module (quick-260929-9wc): eigener Client, Standard = leere Liste,
|
|
// damit die Abruf-Zaehler-Tests auf `fetch` unveraendert gelten.
|
|
let mockCustomModules: {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
category: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}[] = [];
|
|
vi.mock('@/lib/custom-modules-api', () => ({
|
|
listCustomModules: vi.fn(() => Promise.resolve(mockCustomModules)),
|
|
}));
|
|
|
|
const mockActiveModules = [
|
|
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
|
|
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
|
];
|
|
|
|
function stubFetch() {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(() =>
|
|
Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
|
|
),
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
mockPathname = '/';
|
|
mockRefreshKey = 0;
|
|
mockCustomModules = [];
|
|
});
|
|
|
|
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
|
|
return {
|
|
id: 'cm-1',
|
|
name: 'Wiki',
|
|
url: 'https://wiki.example.com',
|
|
category: 'infrastructure',
|
|
createdAt: '2026-09-29T00:00:00.000Z',
|
|
updatedAt: '2026-09-29T00:00:00.000Z',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function importSidebar() {
|
|
const mod = await import('./sidebar');
|
|
return mod.Sidebar;
|
|
}
|
|
|
|
describe('Sidebar', () => {
|
|
beforeEach(() => {
|
|
mockAuthStore.mockImplementation(
|
|
(selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
|
|
selector({ user: { id: 'u1', role: 'ADMIN', tenantId: 't1' } }),
|
|
);
|
|
stubFetch();
|
|
});
|
|
|
|
it('renders Dashboard and Marketplace as Link elements with correct hrefs', async () => {
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
|
});
|
|
|
|
const dashLink = screen.getByText('Dashboard').closest('a');
|
|
const marketLink = screen.getByText('Marktplatz').closest('a');
|
|
expect(dashLink).toHaveAttribute('href', '/');
|
|
expect(marketLink).toHaveAttribute('href', '/marketplace');
|
|
});
|
|
|
|
it('highlights Marketplace link when pathname is /marketplace', async () => {
|
|
mockPathname = '/marketplace';
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
|
});
|
|
|
|
const dashLink = screen.getByText('Dashboard').closest('a');
|
|
const marketLink = screen.getByText('Marktplatz').closest('a');
|
|
expect(marketLink?.className).toContain('bg-sidebar-accent');
|
|
expect(dashLink?.className).not.toContain('bg-sidebar-accent');
|
|
});
|
|
|
|
it('renders search input with correct placeholder', async () => {
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText('Module suchen...')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows noResults text when search matches no modules', async () => {
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
|
|
// Design „Mosaik“: Kategorien sind anfangs aufgeklappt.
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
|
});
|
|
|
|
const searchInput = screen.getByPlaceholderText('Module suchen...');
|
|
await userEvent.type(searchInput, 'zzz-nothing');
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Keine Module gefunden')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('Kategorien sind aufgeklappt, ein Klick auf die Kopfzeile klappt zu und wieder auf', async () => {
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
|
});
|
|
const header = screen.getByText('Domain-Tools').closest('button');
|
|
expect(header).toHaveAttribute('aria-expanded', 'true');
|
|
|
|
await userEvent.click(screen.getByText('Domain-Tools'));
|
|
expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
|
|
expect(header).toHaveAttribute('aria-expanded', 'false');
|
|
|
|
await userEvent.click(screen.getByText('Domain-Tools'));
|
|
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
|
});
|
|
|
|
it('Modul-Eintraege tragen die App-Kachel, die Auswahl die Akzent-Pille', async () => {
|
|
mockPathname = '/modules/Domain-Tools/domaincheck';
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
|
});
|
|
const link = screen.getByText('Domaincheck').closest('a');
|
|
expect(link?.querySelector('[data-module-tile="domaincheck"]')).not.toBeNull();
|
|
expect(link?.querySelector('[data-testid="nav-selection-pill"]')).not.toBeNull();
|
|
const dashLink = screen.getByText('Dashboard').closest('a');
|
|
expect(dashLink?.querySelector('[data-testid="nav-selection-pill"]')).toBeNull();
|
|
});
|
|
|
|
it('re-fetches modules when sidebarRefreshKey changes', async () => {
|
|
const Sidebar = await importSidebar();
|
|
const { rerender } = render(<Sidebar />);
|
|
|
|
// Wait for category header to confirm initial fetch completed
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
|
|
});
|
|
|
|
const initialCallCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
|
|
mockRefreshKey = 1;
|
|
rerender(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
const newCallCount = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
expect(newCallCount).toBeGreaterThan(initialCallCount);
|
|
});
|
|
});
|
|
|
|
it('quick-260921-gof (Befund 17): ein Bump von sidebarRefreshKey loest genau einen weiteren Abruf aus, erneutes Zeichnen ohne Bump keinen', async () => {
|
|
const Sidebar = await importSidebar();
|
|
const { rerender } = render(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
|
|
});
|
|
|
|
// Erneutes Zeichnen ohne Bump des Auffrisch-Zaehlers loest KEINEN
|
|
// weiteren Abruf aus.
|
|
rerender(<Sidebar />);
|
|
await waitFor(() => {
|
|
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
|
|
});
|
|
|
|
// Ein Bump loest genau einen weiteren Abruf aus.
|
|
mockRefreshKey = 1;
|
|
rerender(<Sidebar />);
|
|
await waitFor(() => {
|
|
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
it('zeigt ein eigenes Modul unter seiner Kategorie als Link auf /modules/custom/<id>', async () => {
|
|
mockCustomModules = [customModule()];
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
|
});
|
|
const link = screen.getByText('Wiki').closest('a');
|
|
expect(link).toHaveAttribute('href', '/modules/custom/cm-1');
|
|
expect(link?.querySelector('[data-module-tile="custom"]')).not.toBeNull();
|
|
// Kategorie-Kopf "infrastructure" (Test-Attrappe ohne Uebersetzung -> Kennung)
|
|
const header = screen.getByText('infrastructure').closest('li');
|
|
expect(header?.contains(link)).toBe(true);
|
|
});
|
|
|
|
it('zeigt eine Kategorie, die nur eigene Module hat', async () => {
|
|
mockCustomModules = [customModule({ category: 'fleet' })];
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('fleet')).toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
|
// Die eingebauten Kategorien bleiben daneben stehen.
|
|
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
|
|
});
|
|
|
|
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
|
|
mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })];
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Aaa Eigenes')).toBeInTheDocument();
|
|
});
|
|
const items = screen
|
|
.getByText('Domain-Tools')
|
|
.closest('li')
|
|
?.querySelectorAll('a span.truncate');
|
|
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']);
|
|
});
|
|
|
|
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
|
|
mockCustomModules = [customModule(), customModule({ id: 'cm-2', name: 'Anderes' })];
|
|
mockPathname = '/modules/custom/cm-1';
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
|
});
|
|
expect(
|
|
screen.getByText('Wiki').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
|
).not.toBeNull();
|
|
expect(
|
|
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
|
).toBeNull();
|
|
expect(
|
|
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('die Suche findet ein eigenes Modul', async () => {
|
|
mockCustomModules = [customModule()];
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
|
});
|
|
|
|
await userEvent.type(screen.getByPlaceholderText('Module suchen...'), 'wik');
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Domaincheck')).not.toBeInTheDocument();
|
|
});
|
|
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders the version badge below the navigation', async () => {
|
|
const Sidebar = await importSidebar();
|
|
render(<Sidebar />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Dashboard')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByTestId('app-version-badge')).toBeInTheDocument();
|
|
});
|
|
});
|