Files
minstrel/web/src/routes/collection/missing/missing.test.ts
T
bvandeusenandClaude Opus 5.5 ead0abf0cf
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m23s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 6m2s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: one Missing files page under Collection: folders gone, each with its album's want, last search or download, and Search, Releases and Remove; an album held before its files went is no longer shown as held again (M504 #5626)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:44:33 -04:00

247 lines
9.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 { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { AdminMissingResponse, Want } from '#lib/api/types.js';
// The page reads the re-acquisition policy to say what happens to these;
// the settings themselves are on Admin › Acquisition.
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
})
}));
// A folder's want is acted on here as on the wanted list (M504).
vi.mock('#lib/api/wanted.js', () => ({
removeWant: vi.fn().mockResolvedValue(undefined),
searchWant: vi.fn().mockResolvedValue({
outcome: 'nothing', considered: 0, best_release: '', best_verdict: '', narrowed: []
}),
createCandidatesQuery: vi.fn(),
grabCandidate: vi.fn(),
blockCandidate: vi.fn(),
unblockRelease: vi.fn()
}));
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
import MissingPage from './+page.svelte';
import { createMissingFilesQuery } from '#lib/api/admin.js';
import { removeWant, searchWant } from '#lib/api/wanted.js';
import { pushToast } from '#lib/stores/toast.svelte.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(MissingPage);
}
function want(extra: Partial<Want> = {}): Want {
return {
id: 'w-1',
release_group_mbid: 'rg-1',
title: 'Minutes to Midnight',
artist_mbid: 'lp',
artist_name: 'Linkin Park',
primary_type: 'Album',
secondary_types: [],
year: '2007',
reason: 'missing',
wanted_at: new Date(Date.now() - 2 * DAY).toISOString(),
held_at: null,
image_url: '',
search: {
searched_at: new Date(Date.now() - 3_600_000).toISOString(),
outcome: 'nothing',
considered: 0,
best_release: '',
best_verdict: '',
error: ''
},
...extra
};
}
function wantedBack(w: Want | null = want()): AdminMissingResponse {
const withState = structuredClone(response);
withState.groups[0].reacquisition = { wanted_at: new Date(Date.now() - 2 * DAY).toISOString(), held_at: null, want: w };
return withState;
}
describe('/collection/missing', () => {
test('says what happens to missing files, and links to where it is set', async () => {
renderWith(response);
const line = screen.getByTestId('reacquisition-policy');
await screen.findByText(/missing for 24 hours go back on the wanted list/i);
expect(line.querySelector('a')?.getAttribute('href')).toBe('/admin/acquisition#reacquisition');
});
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/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('an album wanted back says since when, and what its last search found', () => {
renderWith(wantedBack());
expect(text('reacquisition-state')).toMatch(/wanted back since 2d ago/i);
expect(text('missing-want-status')).toBe('Searched 1h ago: nothing found');
});
test('its download, when one is running, is the line', () => {
renderWith(
wantedBack(
want({
grab: {
id: 'g', name: 'LP - MtM [FLAC]', grabbed_at: new Date().toISOString(), origin: 'schedule', quality: 'FLAC',
live: true, end_reason: '', progress: 0.42, import_state: 'downloading', import_reason: '', end_detail: '',
imported_at: null, imported_files: 0
}
})
)
);
expect(text('missing-want-status')).toBe('Downloading 42% · LP - MtM [FLAC]');
expect(screen.getByRole('button', { name: /search for minutes to midnight now/i })).toHaveProperty('disabled', true);
});
test('Search runs one now and says what it found', async () => {
renderWith(wantedBack());
await fireEvent.click(screen.getByRole('button', { name: /search for minutes to midnight now/i }));
await waitFor(() => expect(searchWant).toHaveBeenCalledWith('w-1'));
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing found for Minutes to Midnight.', 'info'));
});
test('Remove takes it off the wanted list', async () => {
renderWith(wantedBack());
await fireEvent.click(screen.getByRole('button', { name: /stop wanting minutes to midnight/i }));
await waitFor(() => expect(removeWant).toHaveBeenCalledWith('w-1'));
expect(pushToast).toHaveBeenCalledWith("Minutes to Midnight is off the wanted list, and won't be fetched back.");
});
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(),
want: want({ held_at: new Date(Date.now() - DAY).toISOString() })
};
renderWith(withState);
expect(text('reacquisition-state')).toMatch(/held again 1d ago/i);
// Nothing left to search for.
expect(screen.queryByTestId('missing-want-status')).toBeNull();
expect(screen.queryByRole('button', { name: /search for/i })).toBeNull();
});
// 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);
});
});