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();
});
});