Merges Renovate's kit 3 (PR #149) and adapter-static 4 (PR #148) bumps, plus the migration they need. The mechanical part is `sv migrate sveltekit-3`, run one task at a time and reviewed: - svelte.config.js is gone. Its options move into sveltekit() in vite.config.ts, exported as kitOptions so vitest.config.ts runs the same kit setup, including the $test-utils alias the tests import. - $lib becomes #lib through package.json "imports". There is no src/lib/index, so only the "#lib/*" entry is kept. - tsconfig extends $app/tsconfig. - Peer floors raised to kit 3's requirements: svelte ^5.57.1, vite ^8.0.12, svelte-check ^4.7.5. By hand, from the codemod's list of non-automated tasks: - goto's replaceState option is now replace; keepFocus becomes reset: false. For the search typeahead, reset: false also stops the scroll-to-top, which is wanted while typing. - The test setup mocks drop pushState/replaceState and $app/paths base/assets, which kit 3 removed, and mock refreshAll in place of invalidateAll. - The other flagged files only read page.url or goto internal routes, so they needed no change. TypeScript goes to ^6, not the ^7 Renovate offers: kit 3 declares typescript ^6 as a peer and svelte-check 4.7 accepts ^5 || ^6. Move to 7 once both accept it. With Tailwind 4 and kit 3 in, `npm audit` on the whole tree reports 0, so the web lane now audits every dependency rather than only what ships to browsers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
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.js', () => ({
|
|
user: { get value() { return userState.current; } }
|
|
}));
|
|
|
|
vi.mock('$app/state', () => ({
|
|
page: { url: new URL('http://localhost/admin') }
|
|
}));
|
|
|
|
vi.mock('#lib/api/admin.js', async () => {
|
|
const { readable } = await import('svelte/store');
|
|
return {
|
|
createAdminRequestsQuery: vi.fn(),
|
|
createLidarrConfigQuery: vi.fn(),
|
|
createAdminQuarantineQuery: vi.fn(),
|
|
createScanStatusQuery: vi.fn(() =>
|
|
readable({
|
|
data: { id: '', started_at: '', finished_at: null, in_flight: false },
|
|
isPending: false,
|
|
isError: false
|
|
})
|
|
),
|
|
createCoverageQuery: () =>
|
|
readable({
|
|
data: undefined,
|
|
isPending: false,
|
|
isError: false
|
|
}),
|
|
createFingerprintCoverageQuery: () =>
|
|
readable({
|
|
data: undefined,
|
|
isPending: false,
|
|
isError: false
|
|
}),
|
|
approveRequest: vi.fn().mockResolvedValue({}),
|
|
rejectRequest: vi.fn().mockResolvedValue({}),
|
|
resolveQuarantine: vi.fn().mockResolvedValue({}),
|
|
deleteQuarantineFile: vi.fn().mockResolvedValue({}),
|
|
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
|
|
triggerScan: vi.fn().mockResolvedValue({}),
|
|
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
|
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
|
// LoudnessSettingsCard, rendered by the page and tested on its own.
|
|
getLoudnessSettings: vi
|
|
.fn()
|
|
.mockResolvedValue({ enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 }),
|
|
updateLoudnessSettings: vi.fn(),
|
|
getLoudnessCoverage: vi.fn().mockResolvedValue({
|
|
total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true
|
|
})
|
|
};
|
|
});
|
|
|
|
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<typeof load>[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<typeof load>[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<typeof load>[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,
|
|
createScanStatusQuery,
|
|
approveRequest,
|
|
rejectRequest,
|
|
resolveQuarantine,
|
|
deleteQuarantineFile,
|
|
deleteQuarantineViaLidarr,
|
|
researchMissingArt
|
|
} from '#lib/api/admin.js';
|
|
import OverviewPage from './+page.svelte';
|
|
|
|
function setupOverview(opts: {
|
|
requests?: unknown[];
|
|
quarantine?: unknown[];
|
|
lidarr?: { enabled: boolean };
|
|
} = {}) {
|
|
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockQuery({ data: opts.requests ?? [] })
|
|
);
|
|
(createAdminQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
mockQuery({ data: opts.quarantine ?? [] })
|
|
);
|
|
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).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'
|
|
);
|
|
});
|
|
|
|
test('renders Re-search missing art button and invokes on click', async () => {
|
|
setupOverview();
|
|
render(OverviewPage);
|
|
const btn = screen.getByRole('button', { name: /re-search missing art/i });
|
|
expect(btn).toBeInTheDocument();
|
|
await fireEvent.click(btn);
|
|
expect(researchMissingArt).toHaveBeenCalled();
|
|
});
|
|
|
|
test('Library scan card shows Processing badge when library tally is populated and in-flight', async () => {
|
|
const { readable } = await import('svelte/store');
|
|
(createScanStatusQuery as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
|
readable({
|
|
data: {
|
|
id: 'in-flight',
|
|
started_at: '2026-05-06T11:00:00Z',
|
|
finished_at: null,
|
|
in_flight: true,
|
|
library: { scanned: 100, added: 1, updated: 0, skipped: 99, errored: 0 }
|
|
},
|
|
isPending: false,
|
|
isError: false
|
|
})
|
|
);
|
|
setupOverview();
|
|
userState.current = { id: 'admin', username: 'admin', is_admin: true };
|
|
render(OverviewPage);
|
|
expect(await screen.findByText('Processing')).toBeInTheDocument();
|
|
});
|
|
});
|