import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/svelte'; import { mockQuery } from '../../test-utils/query'; // Mutable mock-user handle so individual tests can flip is_admin / null // without re-importing the module under test. const userState = vi.hoisted(() => ({ current: null as { id: string; username: string; is_admin: boolean } | null })); vi.mock('$lib/auth/store.svelte', () => ({ user: { get value() { return userState.current; } } })); vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/admin') } })); vi.mock('$lib/api/admin', () => ({ createAdminRequestsQuery: vi.fn(), createLidarrConfigQuery: vi.fn(), createAdminQuarantineQuery: vi.fn(), approveRequest: vi.fn().mockResolvedValue({}), rejectRequest: vi.fn().mockResolvedValue({}), resolveQuarantine: vi.fn().mockResolvedValue({}), deleteQuarantineFile: vi.fn().mockResolvedValue({}), deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}) })); vi.mock('@tanstack/svelte-query', async (orig) => { const actual = (await orig()) as Record; return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; }); afterEach(() => { cleanup(); vi.clearAllMocks(); userState.current = null; }); describe('admin/+layout.ts load gate', () => { test('non-admin user throws redirect to /', async () => { userState.current = { id: 'u1', username: 'alice', is_admin: false }; const { load } = await import('./+layout'); await expect(async () => load({} as Parameters[0]) ).rejects.toMatchObject({ status: 302, location: '/' }); }); test('unauthenticated (null user) throws redirect to /', async () => { userState.current = null; const { load } = await import('./+layout'); await expect(async () => load({} as Parameters[0]) ).rejects.toMatchObject({ status: 302, location: '/' }); }); test('admin user passes the gate', async () => { userState.current = { id: 'u1', username: 'root', is_admin: true }; const { load } = await import('./+layout'); expect(load({} as Parameters[0])).toEqual({}); }); }); // Top-level imports — Svelte/Vite caches the module graph across tests, // and dynamic re-imports inside each test produced "stale-mock" symptoms // where the page picked up empty data despite `mockReturnValue` being set. // Importing at module load means the mocks attach to the same handles the // page binds against. import { createAdminRequestsQuery, createLidarrConfigQuery, createAdminQuarantineQuery, approveRequest, rejectRequest, resolveQuarantine, deleteQuarantineFile, deleteQuarantineViaLidarr } from '$lib/api/admin'; import OverviewPage from './+page.svelte'; function setupOverview(opts: { requests?: unknown[]; quarantine?: unknown[]; lidarr?: { enabled: boolean }; } = {}) { (createAdminRequestsQuery as ReturnType).mockReturnValue( mockQuery({ data: opts.requests ?? [] }) ); (createAdminQuarantineQuery as ReturnType).mockReturnValue( mockQuery({ data: opts.quarantine ?? [] }) ); (createLidarrConfigQuery as ReturnType).mockReturnValue( mockQuery({ data: opts.lidarr ?? { enabled: false } }) ); } describe('admin Overview page', () => { test('renders top stat cards (requests, quarantine, lidarr)', async () => { setupOverview({ requests: [{ id: '1' }, { id: '2' }], quarantine: [{ track_id: 't1', report_count: 2, reports: [] }], lidarr: { enabled: true } }); render(OverviewPage); // "Pending requests" appears as both stat-card label and section // header — assert via the link role to disambiguate to the stat card. const reqLink = screen.getByRole('link', { name: /pending requests/i }); expect(reqLink).toHaveTextContent('2'); const quarLink = screen.getByRole('link', { name: /quarantined tracks/i }); expect(quarLink).toHaveTextContent('1'); expect(screen.getByText(/connected/i)).toBeInTheDocument(); }); test('shows empty-state copy when both queues are empty', async () => { setupOverview({ requests: [], quarantine: [], lidarr: { enabled: false } }); render(OverviewPage); expect(screen.getByText(/no pending requests/i)).toBeInTheDocument(); expect(screen.getByText(/no quarantined tracks/i)).toBeInTheDocument(); expect(screen.getByText(/unset/i)).toBeInTheDocument(); }); test('top stat cards link to their sub-pages', async () => { setupOverview(); render(OverviewPage); expect(screen.getByRole('link', { name: /pending requests/i })) .toHaveAttribute('href', '/admin/requests'); expect(screen.getByRole('link', { name: /quarantined tracks/i })) .toHaveAttribute('href', '/admin/quarantine'); expect(screen.getByRole('link', { name: /^lidarr/i })) .toHaveAttribute('href', '/admin/integrations'); }); test('clicking Approve on a request row calls approveRequest', async () => { setupOverview({ requests: [ { id: 'r1', kind: 'artist', artist_name: 'Boards of Canada', status: 'pending' } ] }); render(OverviewPage); await fireEvent.click(screen.getByRole('button', { name: /approve boards of canada/i })); expect(approveRequest).toHaveBeenCalledWith('r1'); }); test('clicking Reject on a request row calls rejectRequest', async () => { setupOverview({ requests: [ { id: 'r1', kind: 'artist', artist_name: 'Boards of Canada', status: 'pending' } ] }); render(OverviewPage); await fireEvent.click(screen.getByRole('button', { name: /reject boards of canada/i })); expect(rejectRequest).toHaveBeenCalledWith('r1'); }); test('Resolve fires immediately on a quarantine row', async () => { setupOverview({ quarantine: [ { track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC', album_title: 'Geogaddi', report_count: 1, reports: [] } ] }); render(OverviewPage); await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i })); expect(resolveQuarantine).toHaveBeenCalledWith('t1'); }); test('Delete file requires two clicks (inline confirm)', async () => { setupOverview({ quarantine: [ { track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC', album_title: 'Geogaddi', report_count: 1, reports: [] } ] }); render(OverviewPage); // First click: arms the confirm; no API call yet. await fireEvent.click(screen.getByRole('button', { name: /^delete file roygbiv$/i })); expect(deleteQuarantineFile).not.toHaveBeenCalled(); // Second click on the now-armed button fires the action. await fireEvent.click( screen.getByRole('button', { name: /confirm delete file roygbiv/i }) ); expect(deleteQuarantineFile).toHaveBeenCalledWith('t1'); }); test('Delete via Lidarr requires two clicks (inline confirm)', async () => { setupOverview({ quarantine: [ { track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC', album_title: 'Geogaddi', report_count: 1, reports: [] } ] }); render(OverviewPage); await fireEvent.click( screen.getByRole('button', { name: /^delete via lidarr roygbiv$/i }) ); expect(deleteQuarantineViaLidarr).not.toHaveBeenCalled(); await fireEvent.click( screen.getByRole('button', { name: /confirm delete via lidarr roygbiv/i }) ); expect(deleteQuarantineViaLidarr).toHaveBeenCalledWith('t1'); }); test('"View all" link surfaces only when count exceeds the preview limit', async () => { // 6 requests > limit of 5; quarantine empty. const six = Array.from({ length: 6 }, (_, i) => ({ id: `r${i}`, kind: 'artist' as const, artist_name: `Artist ${i}`, status: 'pending' as const })); setupOverview({ requests: six }); render(OverviewPage); expect(screen.getByRole('link', { name: /view all 6/i })).toHaveAttribute( 'href', '/admin/requests' ); }); });