Files
minstrel/web/src/routes/admin/missing-files/missing-files.test.ts
T
bvandeusenandClaude Opus 5.5 ebbc49b902
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
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: import with verification, and a Wanted view of its own (M502 #5522)
A finished download is checked against MusicBrainz before anything is
filed: the files are paired with an edition's tracks by position or path,
and AcoustID, tag ids, lengths and titles say whether it is the album.
What is the album is copied into the library as
Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's
MusicBrainz ids, and scanned; the download keeps seeding. What is not is
refused: set aside, removed from the client, and the album searched for
again. A download the check cannot settle waits for a person, who imports
it anyway or refuses it. Tracks the library already holds are skipped.

The wanted list leaves Admin for a top-level Wanted view, admin-only and
web-only, with tabs for Wanted, Activity (downloads and their imports,
the ones needing a person first), Missing files, Held, Following (with the
release-type profiles, moved off Integrations) and Blocked. The search
settings gain "File new music into" when the library has several folders.

The search pass logs why each failed search failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:36:21 -04:00

172 lines
6.4 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 { render, screen, within } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { AdminMissingResponse } from '#lib/api/types.js';
// The page now embeds ReacquisitionSettingsCard, which loads its own settings
// from this same module on mount. Stubbing both keeps these tests about the
// missing-files list — the card has its own suite.
vi.mock('#lib/api/admin.js', () => ({
createMissingFilesQuery: vi.fn(),
getReacquisitionSettings: vi.fn().mockResolvedValue({
enabled: true,
grace_hours: 24,
max_per_pass: 20,
unnameable_albums: 0
}),
updateReacquisitionSettings: vi.fn()
}));
import AdminMissingFilesPage from './+page.svelte';
import { createMissingFilesQuery } from '#lib/api/admin.js';
const DAY = 24 * 3_600_000;
function track(title: string, opts: { lastPlayed?: string | null } = {}) {
return {
track_id: `t-${title}`,
title,
artist_id: 'ar-1',
artist_name: 'Linkin Park',
album_id: 'al-1',
album_title: 'Minutes to Midnight',
file_path: `/music/Linkin Park/Minutes to Midnight/${title}.flac`,
duration_sec: 185,
missing_since: new Date(Date.now() - 3 * DAY).toISOString(),
last_played_at: opts.lastPlayed === undefined ? null : opts.lastPlayed
};
}
const response: AdminMissingResponse = {
total: 3,
limit: 50,
offset: 0,
groups: [
{
directory: '/music/Linkin Park/Minutes to Midnight',
missing_since: new Date(Date.now() - 3 * DAY).toISOString(),
reacquisition: null,
tracks: [
track('Given Up', { lastPlayed: new Date(Date.now() - 2 * DAY).toISOString() }),
track('Bleed It Out')
]
},
{
directory: '/music/Boards of Canada/Geogaddi',
missing_since: new Date(Date.now() - 9 * DAY).toISOString(),
reacquisition: null,
tracks: [track('1969')]
}
]
};
afterEach(() => vi.clearAllMocks());
// Whitespace-normalised text of a testid'd element. Svelte templates wrap
// mid-sentence, so raw textContent has newlines where the rendered page has
// single spaces — asserting on it directly makes tests fail on reflow.
function text(testId: string): string {
return (screen.getByTestId(testId).textContent ?? '').replace(/\s+/g, ' ').trim();
}
function renderWith(data: AdminMissingResponse | undefined, extra = {}) {
vi.mocked(createMissingFilesQuery).mockReturnValue(
mockQuery({ data, ...extra }) as ReturnType<typeof createMissingFilesQuery>
);
return render(AdminMissingFilesPage);
}
describe('admin missing files', () => {
test('groups rows under their directory', () => {
renderWith(response);
expect(screen.getByText('/music/Linkin Park/Minutes to Midnight')).toBeTruthy();
expect(screen.getByText('/music/Boards of Canada/Geogaddi')).toBeTruthy();
expect(screen.getAllByTestId('missing-track-row')).toHaveLength(3);
});
test('the count pill reports the server total, not the page size', () => {
renderWith(response);
expect(screen.getByTestId('missing-count-pill').textContent?.trim()).toBe('3');
});
test('a group states how many tracks and how long gone', () => {
renderWith(response);
expect(screen.getByText(/2 tracks · gone 3d ago/)).toBeTruthy();
expect(screen.getByText(/1 track · gone 9d ago/)).toBeTruthy();
});
// The deciding fact for whether a missing file is worth chasing.
test('distinguishes a played track from one never played', () => {
renderWith(response);
expect(screen.getByText(/last played 2d ago/)).toBeTruthy();
expect(screen.getAllByText('never played')).toHaveLength(2);
});
// An operator who has never seen this page should not have to guess what
// would put a row here.
test('the empty state explains what missing means', () => {
renderWith({ total: 0, limit: 50, offset: 0, groups: [] });
expect(screen.getByText(/every track's file is where it should be/i)).toBeTruthy();
expect(screen.getByText(/can't find its file/i)).toBeTruthy();
expect(screen.queryByTestId('missing-count-pill')).toBeNull();
});
test('shows a loading line while pending', () => {
renderWith(undefined, { isPending: true });
expect(screen.getByText(/checking what's missing/i)).toBeTruthy();
});
test('shows an error line when the query fails', () => {
renderWith(undefined, { isError: true });
expect(screen.getByText(/couldn't load the missing-files list/i)).toBeTruthy();
});
// Not wanted yet is the common case for a folder that just went missing;
// a line saying so would be noise on every row.
test('no re-acquisition line before the album is wanted', () => {
renderWith(response);
expect(screen.queryByTestId('reacquisition-state')).toBeNull();
});
test('a wanted album says since when, and links the wanted list', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
wanted_at: new Date(Date.now() - 2 * DAY).toISOString(),
held_at: null
};
renderWith(withState);
const line = text('reacquisition-state');
expect(line).toMatch(/on the wanted list since 2d ago/i);
expect(
within(screen.getByTestId('reacquisition-state')).getByRole('link', { name: /wanted list/i }).getAttribute('href')
).toBe(
'/wanted/missing'
);
});
test('an album held again says so', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
wanted_at: new Date(Date.now() - 5 * DAY).toISOString(),
held_at: new Date(Date.now() - DAY).toISOString()
};
renderWith(withState);
expect(text('reacquisition-state')).toMatch(/held again 1d ago/i);
});
// Paging only appears when it can do something: a single page of results
// should not render dead Previous/Next buttons.
test('no pager when everything fits on one page', () => {
renderWith(response);
expect(screen.queryByLabelText('Missing files pages')).toBeNull();
});
test('pager appears when the server reports more than this page holds', () => {
renderWith({ ...response, total: 120 });
expect(screen.getByLabelText('Missing files pages')).toBeTruthy();
expect(screen.getByText('1–3 of 120')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Previous' })).toHaveProperty('disabled', true);
expect(screen.getByRole('button', { name: 'Next' })).toHaveProperty('disabled', false);
});
});