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>
119 lines
4.8 KiB
TypeScript
119 lines
4.8 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
import type { ReacquisitionSettings } from '#lib/api/admin.js';
|
|
import { ERROR_COPY } from '#lib/api/error-copy.js';
|
|
|
|
vi.mock('#lib/api/admin.js', () => ({
|
|
getReacquisitionSettings: vi.fn(),
|
|
updateReacquisitionSettings: vi.fn()
|
|
}));
|
|
|
|
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
|
|
|
import ReacquisitionSettingsCard from './ReacquisitionSettingsCard.svelte';
|
|
import { getReacquisitionSettings, updateReacquisitionSettings } from '#lib/api/admin.js';
|
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
|
|
|
const base: ReacquisitionSettings = {
|
|
enabled: true,
|
|
grace_hours: 24,
|
|
max_per_pass: 20,
|
|
unnameable_albums: 0
|
|
};
|
|
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
async function renderCard(over: Partial<ReacquisitionSettings> = {}) {
|
|
vi.mocked(getReacquisitionSettings).mockResolvedValue({ ...base, ...over });
|
|
const r = render(ReacquisitionSettingsCard);
|
|
await waitFor(() => expect(getReacquisitionSettings).toHaveBeenCalled());
|
|
return r;
|
|
}
|
|
|
|
describe('ReacquisitionSettingsCard', () => {
|
|
// Searching and retrying belong to the wanted list's search schedule, so
|
|
// the card holds no retry policy of its own.
|
|
test('has no retry or approval settings', async () => {
|
|
await renderCard();
|
|
await screen.findByRole('spinbutton', { name: /wait before wanting it back/i });
|
|
expect(screen.queryByText(/attempts/i)).toBeNull();
|
|
expect(screen.queryByText(/retry/i)).toBeNull();
|
|
expect(screen.queryByText(/without approval/i)).toBeNull();
|
|
});
|
|
|
|
// Saving an unchanged form would be a pointless round-trip, and a live Save
|
|
// button invites the operator to wonder whether anything happened.
|
|
test('save is disabled until something changes', async () => {
|
|
await renderCard();
|
|
const save = await screen.findByRole('button', { name: /save/i });
|
|
expect(save).toHaveProperty('disabled', true);
|
|
|
|
const grace = screen.getByRole('spinbutton', { name: /wait before wanting it back/i });
|
|
await fireEvent.input(grace, { target: { value: '48' } });
|
|
await waitFor(() => expect(save).toHaveProperty('disabled', false));
|
|
});
|
|
|
|
test('saving sends the edited values', async () => {
|
|
vi.mocked(updateReacquisitionSettings).mockResolvedValue({ ...base, grace_hours: 48 });
|
|
await renderCard();
|
|
|
|
const grace = screen.getByRole('spinbutton', { name: /wait before wanting it back/i });
|
|
await fireEvent.input(grace, { target: { value: '48' } });
|
|
await fireEvent.click(await screen.findByRole('button', { name: /save/i }));
|
|
|
|
await waitFor(() =>
|
|
expect(updateReacquisitionSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ grace_hours: 48 })
|
|
)
|
|
);
|
|
});
|
|
|
|
// The server names the offending field ("grace_hours must be 1-720"); a
|
|
// generic "couldn't save" would throw that away.
|
|
//
|
|
// Rejects with what api.put actually throws — a plain {code, message, status}
|
|
// object, not an Error. The old version of this test rejected with an Error,
|
|
// which no code path produces, and so passed while the card was discarding
|
|
// every server message it was handed (#3937).
|
|
test('a rejected save surfaces the server message', async () => {
|
|
const message = 'grace_hours must be 1-720';
|
|
vi.mocked(updateReacquisitionSettings).mockRejectedValue({
|
|
code: 'invalid_setting',
|
|
message,
|
|
status: 400
|
|
});
|
|
await renderCard();
|
|
|
|
const grace = screen.getByRole('spinbutton', { name: /wait before wanting it back/i });
|
|
await fireEvent.input(grace, { target: { value: '900' } });
|
|
await fireEvent.click(await screen.findByRole('button', { name: /save/i }));
|
|
|
|
await waitFor(() =>
|
|
expect(pushToast).toHaveBeenCalledWith(`${ERROR_COPY.invalid_setting} ${message}`, 'error')
|
|
);
|
|
});
|
|
|
|
// Without this the operator watches those albums never get wanted and
|
|
// reasonably concludes the feature is broken.
|
|
test('unnameable albums are called out with the reason', async () => {
|
|
await renderCard({ unnameable_albums: 3 });
|
|
await waitFor(() => expect(screen.getByText(/no\s+MusicBrainz ID/i)).toBeTruthy());
|
|
expect(screen.getByText(/3\s+albums have/i)).toBeTruthy();
|
|
});
|
|
|
|
test('no warning when every missing album is identifiable', async () => {
|
|
await renderCard({ unnameable_albums: 0 });
|
|
await screen.findByRole('spinbutton', { name: /wait before wanting it back/i });
|
|
expect(screen.queryByText(/MusicBrainz ID/i)).toBeNull();
|
|
});
|
|
|
|
test('a failed load offers a retry rather than an empty card', async () => {
|
|
vi.mocked(getReacquisitionSettings).mockRejectedValue(new Error('nope'));
|
|
render(ReacquisitionSettingsCard);
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/couldn't load re-acquisition settings/i)).toBeTruthy()
|
|
);
|
|
expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy();
|
|
});
|
|
});
|