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 = {}) { 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(); }); });