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; }) => ( {children} ), })); vi.mock('next-intl', () => ({ useTranslations: (ns: string) => (key: string) => { const map: Record> = { 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; }, })); let mockCollapsed = false; function mockSidebarCollapsed(value: boolean) { mockCollapsed = value; } vi.mock('@/lib/stores/sidebar-store', () => ({ useSidebarStore: () => ({ isCollapsed: mockCollapsed, 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: () =>
, })); // 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: () =>
, })); vi.mock('@/components/layout/app-version-badge', () => ({ AppVersionBadge: () =>
, })); // 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 = []; mockCollapsed = false; }); 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(); 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(); 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(); await waitFor(() => { expect(screen.getByPlaceholderText('Module suchen...')).toBeInTheDocument(); }); }); it('shows noResults text when search matches no modules', async () => { const Sidebar = await importSidebar(); render(); // 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(); 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(); 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(); // Wait for category header to confirm initial fetch completed await waitFor(() => { expect(screen.getByText('Domain-Tools')).toBeInTheDocument(); }); const initialCallCount = (globalThis.fetch as ReturnType).mock.calls.length; mockRefreshKey = 1; rerender(); await waitFor(() => { const newCallCount = (globalThis.fetch as ReturnType).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(); await waitFor(() => { expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(1); }); // Erneutes Zeichnen ohne Bump des Auffrisch-Zaehlers loest KEINEN // weiteren Abruf aus. rerender(); await waitFor(() => { expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(1); }); // Ein Bump loest genau einen weiteren Abruf aus. mockRefreshKey = 1; rerender(); await waitFor(() => { expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(2); }); }); it('zeigt ein eigenes Modul unter seiner Kategorie als Link auf /modules/custom/', async () => { mockCustomModules = [customModule()]; const Sidebar = await importSidebar(); render(); 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(); 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('Kategorie „Eigene Module“ steht zuletzt und nur, wenn ein Eintrag darin liegt', async () => { mockCustomModules = [ customModule({ id: 'cm-1', category: 'custom-modules', name: 'Mein Wiki' }), customModule({ id: 'cm-2', category: 'fleet', name: 'Tankkarte' }), ]; const Sidebar = await importSidebar(); const { unmount } = render(); await waitFor(() => { expect(screen.getByText('Mein Wiki')).toBeInTheDocument(); }); const groups = Array.from( screen .getByRole('group', { name: 'Kategorien' }) .querySelectorAll(':scope > ul > li > button span.truncate'), ).map((n) => n.textContent); expect(groups.at(-1)).toBe('custom-modules'); expect(groups).toContain('fleet'); unmount(); mockCustomModules = [customModule({ category: 'fleet' })]; render(); await waitFor(() => { expect(screen.getByText('fleet')).toBeInTheDocument(); }); expect(screen.queryByText('custom-modules')).toBeNull(); }); it('eingeklappt stehen Eintraege aus „Eigene Module“ zuletzt', async () => { mockCustomModules = [ customModule({ id: 'cm-1', category: 'custom-modules', name: 'Mein Wiki' }), customModule({ id: 'cm-2', category: 'fleet', name: 'Tankkarte' }), ]; mockSidebarCollapsed(true); const Sidebar = await importSidebar(); render(); const hrefs = () => Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href'), ); await waitFor(() => { expect(hrefs()).toContain('/modules/custom/cm-1'); }); expect(hrefs().at(-1)).toBe('/modules/custom/cm-1'); expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan( hrefs().indexOf('/modules/custom/cm-1'), ); }); it('eingeklappt dieselbe Gruppenfolge wie ausgeklappt (eigenes Modul bei seiner Kategorie)', async () => { mockCustomModules = [ customModule({ id: 'cm-1', category: 'Domain-Tools', name: 'Mein Tool' }), customModule({ id: 'cm-2', category: 'custom-modules', name: 'Mein Wiki' }), ]; mockSidebarCollapsed(true); const Sidebar = await importSidebar(); render(); const hrefs = () => Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href'), ); await waitFor(() => { expect(hrefs()).toContain('/modules/custom/cm-2'); }); expect(hrefs()).toEqual([ '/modules/Domain-Tools/domaincheck', '/modules/custom/cm-1', '/modules/Utilities/converter', '/modules/custom/cm-2', ]); }); it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => { mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })]; const Sidebar = await importSidebar(); render(); 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/ 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(); 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(); 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(); await waitFor(() => { expect(screen.getByText('Dashboard')).toBeInTheDocument(); }); expect(screen.getByTestId('app-version-badge')).toBeInTheDocument(); }); });