release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
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
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz release group. A want comes from an approved request, a new release by a followed artist, or an album whose files went missing, and is held once the library has a full edition of it. - Migration 0081: release_type_profiles (Standard seeded: Album, EP, Single, studio only), follows, follow_seen_groups and wants. lidarr_requests becomes requests with plain *_mbid columns; approved album and track requests move onto the wanted list, approved artist requests become follows, and the library's album artists start out followed. missing_reacquisitions and the retry settings go. - internal/wanted: follows are checked against MusicBrainz discographies daily. The first check sets a baseline, and only releases after it are wanted unless the follow asked for what already exists. Profiles filter by primary and secondary type, so live recordings stay out unless a profile ticks Live. - internal/requests (was lidarrrequests): approving puts the request on the wanted list, and an artist request can be approved on a chosen profile. A request completes when its want is held. - The re-acquisition sweeper wants a missing album back instead of asking Lidarr; searching for it is the search schedule's (#5521). - Web: an admin Wanted page (Wanted, Missing files, Held, Following), a follow control on artist pages, a release-type profiles card on Integrations, "Approve on a profile" on Requests, and want state on Missing files. The artist detail API returns the artist's MBID. - Android: the request body sends artist_mbid, album_mbid and track_mbid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
6.3 KiB
TypeScript
170 lines
6.3 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||
import { render, screen } 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(screen.getByRole('link', { name: /wanted list/i }).getAttribute('href')).toBe(
|
||
'/admin/wanted?tab=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);
|
||
});
|
||
});
|