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)
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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 14:44:33 -04:00
co-authored by Claude Opus 5.5
parent 38b2325d2d
commit ead0abf0cf
20 changed files with 702 additions and 390 deletions
@@ -0,0 +1,246 @@
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);
});
});