Files
minstrel/web/src/routes/admin/reports/reports.test.ts
T
bvandeusenandClaude Opus 5.5 89a2653995
release / govulncheck (push) Successful in 16s
release / web (push) Failing after 1m18s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: Admin › Reports is one queue of broken tracks, whether a listener flagged it or it failed to play, one row per track with one set of actions (M503 #5606)
- /admin/reports folds the quarantine queue and the unresolved playback
  errors into one row per track, newest first: who flagged it and why,
  what failed and for whom, the file, with Resolve (clears the reports and
  marks the errors fixed), Play, Copy details, Delete file and, for an album
  Lidarr holds, Delete via Lidarr.
- One delete path: Delete file removes the track, and its reports and
  errors go with the row.
- A Handled tab shows what was done: reports cleared, files deleted,
  errors marked fixed or ignored.
- The summary counts reports by track across both sources, so the rail,
  the Overview and the page agree.
- /admin/quarantine and /admin/playback-errors redirect to Reports: the
  notifications and the Android app link to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:53:58 -04:00

129 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { AdminPlaybackError, AdminQuarantineRow } from '#lib/api/types.js';
vi.mock('#lib/api/admin.js', () => ({
createAdminQuarantineQuery: vi.fn(),
createAdminPlaybackErrorsQuery: vi.fn(),
createQuarantineActionsQuery: vi.fn(),
resolveQuarantine: vi.fn().mockResolvedValue({}),
resolvePlaybackError: vi.fn().mockResolvedValue({}),
deleteQuarantineFile: vi.fn().mockResolvedValue({}),
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({})
}));
vi.mock('#lib/player/store.svelte.js', () => ({ playRadio: vi.fn() }));
import Page from './+page.svelte';
import {
createAdminPlaybackErrorsQuery,
createAdminQuarantineQuery,
createQuarantineActionsQuery,
deleteQuarantineFile,
resolvePlaybackError,
resolveQuarantine
} from '#lib/api/admin.js';
const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString();
const flagged: AdminQuarantineRow = {
track_id: 't1',
track_title: 'Roygbiv',
artist_name: 'Boards of Canada',
album_title: 'Geogaddi',
album_id: 'al1',
lidarr_album_mbid: 'rg1',
report_count: 1,
latest_at: hoursAgo(5),
reason_counts: { bad_rip: 1 },
reports: [{ user_id: 'u1', username: 'alice', reason: 'bad_rip', notes: 'crackles', created_at: hoursAgo(5) }]
};
const failure = (id: string, track: string, title: string, h: number): AdminPlaybackError => ({
id,
track_id: track,
user_id: 'u2',
username: 'bob',
client_id: 'android',
kind: 'load_failed',
occurred_at: hoursAgo(h),
track_title: title,
track_file_path: `/music/${title}.flac`,
artist_name: 'Annadale',
album_title: 'Dial Tone',
album_id: 'al2'
});
afterEach(() => vi.clearAllMocks());
function setup(q: AdminQuarantineRow[], e: AdminPlaybackError[], opts: { error?: boolean } = {}) {
vi.mocked(createAdminQuarantineQuery).mockReturnValue(
(opts.error ? mockQuery({ isError: true, error: new Error('x') }) : mockQuery({ data: q })) as never
);
vi.mocked(createAdminPlaybackErrorsQuery).mockReturnValue(mockQuery({ data: e }) as never);
vi.mocked(createQuarantineActionsQuery).mockReturnValue(mockQuery({ data: [] }) as never);
return render(Page);
}
describe('/admin/reports', () => {
test('a flagged track and a track that failed to play are one list, one row per track, newest first', () => {
setup([flagged], [failure('e1', 't1', 'Roygbiv', 1), failure('e2', 't2', 'Intro', 9)]);
const rows = screen.getAllByTestId('report-row');
expect(rows).toHaveLength(2);
expect(screen.getByTestId('reports-verdict').textContent).toMatch(/2 tracks need a look/);
const first = within(rows[0]).getByTestId('report-kinds').textContent?.replace(/\s+/g, ' ');
expect(first).toMatch(/1× Bad rip/);
expect(first).toMatch(/1× Wouldn't load/);
expect(rows[1].textContent).toMatch(/Intro/);
});
test('Resolve clears the listeners\' reports and marks the playback errors fixed', async () => {
setup([flagged], [failure('e1', 't1', 'Roygbiv', 1)]);
await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i }));
await waitFor(() => expect(resolveQuarantine).toHaveBeenCalledWith('t1'));
await waitFor(() => expect(resolvePlaybackError).toHaveBeenCalledWith('e1', 'fixed'));
});
test('a track only failing to play is resolved without touching listener reports', async () => {
setup([], [failure('e2', 't2', 'Intro', 2)]);
await fireEvent.click(screen.getByRole('button', { name: /resolve intro/i }));
await waitFor(() => expect(resolvePlaybackError).toHaveBeenCalledWith('e2', 'fixed'));
expect(resolveQuarantine).not.toHaveBeenCalled();
});
test('Delete file is one path, whatever the report came from', async () => {
setup([], [failure('e2', 't2', 'Intro', 2)]);
await fireEvent.click(screen.getByRole('button', { name: /delete file for intro/i }));
await fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: /confirm delete file/i }));
await waitFor(() => expect(deleteQuarantineFile).toHaveBeenCalledWith('t2'));
expect(resolvePlaybackError).not.toHaveBeenCalled();
});
test('Delete via Lidarr is offered only for an album Lidarr holds, and on a phone it is in the row menu', async () => {
setup([flagged], [failure('e2', 't2', 'Intro', 2)]);
expect(screen.getByRole('button', { name: /delete via lidarr for roygbiv/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /delete via lidarr for intro/i })).toBeNull();
await fireEvent.click(screen.getAllByRole('button', { name: /more actions/i })[0]);
expect(within(screen.getByRole('menu')).getByText('Delete via Lidarr')).toBeInTheDocument();
});
test('details show who flagged it, who it failed for, and the file', async () => {
setup([flagged], [failure('e1', 't1', 'Roygbiv', 1)]);
await fireEvent.click(screen.getByRole('button', { name: /^details$/i }));
const details = screen.getByTestId('report-details').textContent?.replace(/\s+/g, ' ');
expect(details).toMatch(/alice flagged it · Bad rip · crackles/);
expect(details).toMatch(/Failed to play for bob · Wouldn't load/);
expect(details).toMatch(/\/music\/Roygbiv\.flac/);
});
test('a load failure says so with Try again, and an empty queue says nothing waits', () => {
setup([], [], { error: true });
expect(screen.getByTestId('reports-error').textContent).toMatch(/couldn't load the reports/i);
});
test('with nothing reported it says so', () => {
setup([], []);
expect(screen.getByTestId('reports-empty').textContent).toMatch(/no reports/i);
});
});