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>
247 lines
9.4 KiB
TypeScript
247 lines
9.4 KiB
TypeScript
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);
|
||
});
|
||
});
|