import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/') } })); vi.mock('$app/navigation', () => ({ goto: vi.fn() })); // Mutable handle so individual tests can flip the user between admin / // non-admin without re-importing the module. const userState = vi.hoisted(() => ({ current: { id: '1', username: 'alice', is_admin: false } as { id: string; username: string; is_admin: boolean; } | null })); vi.mock('$lib/auth/store.svelte', () => ({ user: { get value() { return userState.current; } }, logout: vi.fn().mockResolvedValue(undefined) })); // The bell's queries need a QueryClient the Shell tests do not provide; its own // behaviour is covered in NotificationBell.test.ts. vi.mock('$lib/api/notifications', async () => { const { mockQuery } = await import('../../test-utils/query'); return { createUnreadCountQuery: () => mockQuery({ data: 0 }), createNotificationsQuery: () => mockQuery({ data: { items: [], unread_count: 0 } }), markNotificationRead: vi.fn(), markAllNotificationsRead: vi.fn(), badgeLabel: () => '' }; }); import Shell from './Shell.svelte'; import { logout } from '$lib/auth/store.svelte'; import { goto } from '$app/navigation'; afterEach(() => { vi.clearAllMocks(); userState.current = { id: '1', username: 'alice', is_admin: false }; }); describe('Shell', () => { test('renders the username in the header', () => { render(Shell); expect(screen.getByText('alice')).toBeInTheDocument(); }); test('main nav renders the three primary surfaces in order', () => { render(Shell); // Library is a parent for Artists / Albums / Liked / History / // Playlists (tabs under /library); only the parent appears here. const labels = ['Home', 'Library', 'Discover']; for (const label of labels) { expect(screen.getByRole('link', { name: label })).toBeInTheDocument(); } expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/'); // Library jumps to the Artists tab directly so SPA navigation // bypasses the /library 308-redirect. expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/library/artists'); expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover'); }); test('main nav omits Artists/Albums/Liked/History/Playlists/Search/Settings/Admin', () => { userState.current = { id: '1', username: 'alice', is_admin: true }; render(Shell); // Library sub-pages reach their content via the /library tab bar // (in routes/library/+layout.svelte), not via the global Shell nav. // Search reaches /search via the global SearchInput on the right. // Settings + Admin live in the username dropdown — they're per- // operator chrome, not primary surfaces. const navLinks = screen .getAllByRole('link') .filter((el) => el.closest('nav[aria-label="Primary"]') !== null) .map((el) => el.textContent?.trim()); for (const label of [ 'Artists', 'Albums', 'Liked', 'History', 'Playlists', 'Search', 'Requests', 'Hidden', 'Settings', 'Admin' ]) { expect(navLinks).not.toContain(label); } }); test('user-menu shows Settings + Log out for non-admin users', async () => { userState.current = { id: '1', username: 'alice', is_admin: false }; render(Shell); await fireEvent.click(screen.getByRole('button', { name: /alice/i })); expect(screen.getByRole('menuitem', { name: 'Settings' })).toHaveAttribute('href', '/settings'); expect(screen.getByRole('menuitem', { name: /log out/i })).toBeInTheDocument(); expect(screen.queryByRole('menuitem', { name: 'Admin' })).not.toBeInTheDocument(); }); test('user-menu adds Admin between Settings and Log out for admin users', async () => { userState.current = { id: '1', username: 'alice', is_admin: true }; render(Shell); await fireEvent.click(screen.getByRole('button', { name: /alice/i })); const items = screen.getAllByRole('menuitem').map((el) => el.textContent?.trim()); expect(items).toEqual(['Settings', 'Admin', 'Log out']); expect(screen.getByRole('menuitem', { name: 'Admin' })).toHaveAttribute('href', '/admin'); }); test('user-menu "Log out" calls logout() and navigates to /login', async () => { render(Shell); await fireEvent.click(screen.getByRole('button', { name: /alice/i })); await fireEvent.click(screen.getByRole('menuitem', { name: /log out/i })); expect(logout).toHaveBeenCalledTimes(1); expect(goto).toHaveBeenCalledWith('/login', { replaceState: true }); }); });