feat(04-03): migrate sidebar to Link/usePathname, add search and refresh signal
Replace all raw <a> with Next.js Link. Add usePathname-based active highlighting, SidebarSearch with category/module filtering, and sidebarRefreshKey subscription for live activation updates. 26 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
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" />,
|
||||
}));
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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 />);
|
||||
|
||||
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('re-fetches modules when sidebarRefreshKey changes', async () => {
|
||||
const Sidebar = await importSidebar();
|
||||
const { rerender } = render(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Domaincheck')).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user