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 ); return render(MissingPage); } function want(extra: Partial = {}): 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); }); });