fix(admin): re-acquisition settings take effect without a restart, and say why a save was refused (#3936, #3937)
test-web / test (push) Successful in 1m9s
test-go / test (push) Successful in 1m28s
test-go / integration (push) Successful in 3m57s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped

#3936: Router() built a reacquisition.SettingsService of its own, so a save
from the admin card refreshed that instance's cache while the sweeper in
main.go kept serving what it loaded at boot. The card showed the new
policy, the feature ran the old one, and only a restart reconciled them.
main.go now hands its instance to the server (srv.ReacqSettings), as it
already did for RecSettings, TagSettings and FingerprintSettings, and
Router() constructs one only when that field is nil. The regression test
saves through the router and reads the sweeper's instance.

#3937: the card's catch tested `e instanceof Error`, but api.put throws a
plain {code, message, status} object, so every reason the server gave was
discarded in favour of "Couldn't save settings." It now uses errMessage,
which appends the server's message for invalid_setting. Its test rejected
with an Error no code path produces, so it passed throughout; it now
rejects with what the client actually throws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
2026-09-11 20:15:35 -04:00
co-authored by Claude Opus 5
parent 37d4906033
commit 516413f4ca
5 changed files with 144 additions and 13 deletions
@@ -6,6 +6,7 @@
updateReacquisitionSettings,
type ReacquisitionSettings
} from '$lib/api/admin';
import { errMessage } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
// Policy for turning a missing file back into a Lidarr request
@@ -60,8 +61,10 @@
} catch (e) {
// The server validates the same ranges the database CHECKs enforce and
// names the offending field, so surface its message rather than a
// generic failure.
pushToast(e instanceof Error ? e.message : "Couldn't save settings.", 'error');
// generic failure. errMessage, not `e.message`: the API client throws a
// plain {code, message} object, never an Error, so an instanceof check
// here silently discarded every reason the server gave (#3937).
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { ReacquisitionSettings } from '$lib/api/admin';
import { ERROR_COPY } from '$lib/api/error-copy';
vi.mock('$lib/api/admin', () => ({
getReacquisitionSettings: vi.fn(),
@@ -80,10 +81,18 @@ describe('ReacquisitionSettingsCard', () => {
// 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 () => {
vi.mocked(updateReacquisitionSettings).mockRejectedValue(
new Error('grace_hours must be 1-720')
);
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 the first attempt/i });
@@ -91,7 +100,7 @@ describe('ReacquisitionSettingsCard', () => {
await fireEvent.click(await screen.findByRole('button', { name: /save/i }));
await waitFor(() =>
expect(pushToast).toHaveBeenCalledWith('grace_hours must be 1-720', 'error')
expect(pushToast).toHaveBeenCalledWith(`${ERROR_COPY.invalid_setting} ${message}`, 'error')
);
});