release / go (push) Successful in 2m17s
release / govulncheck (push) Successful in 26s
release / web (push) Successful in 2m4s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m39s
release / android (push) Canceled after 2m53s
release / Build signed APK (releases and dev) (push) Canceled after 2m24s
Speakers fetch their own audio, so the phone cannot level it. A cast
token minted with level=true (and the client's asAlbum, which only the
queue holder knows) now returns GET /api/tracks/{id}/leveled.flac: the
track rendered by ffmpeg at the user's gain (volume=XdB, plus
alimiter at -1 dBFS for a limiter-mode boost), metadata stripped, FLAC
at 16 or 24 bits and at most 48 kHz. The gain is computed server-side
from the user's preference and the stored loudness, carried as
?g=<centi-dB>&lim=0|1 and signed into the token, so an edited URL does
not verify. Unity gains get the plain stream.
Renders are written beside the cache file and renamed in, keyed by the
source's size and mtime, coalesced per file (singleflight, detached
from the requesting speaker so a retry finds the render running),
started at mint time so the fetch finds them ready, and evicted least
recently used past leveled_cache_mb, a new admin setting (migration
0068, Loudness analysis card).
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', () => ({
|
|
user: { get value() { return userState.current; } }
|
|
}));
|
|
|
|
vi.mock('$app/state', () => ({
|
|
page: { url: new URL('http://localhost/admin') }
|
|
}));
|
|
|
|
vi.mock('$lib/api/admin', 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';
|
|
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();
|
|
});
|
|
});
|