feat(web): add /admin/integrations Lidarr connection panel

Connection management for Lidarr — base URL, API key, default quality
profile, default root folder. Empty api_key on save preserves the
masked-saved key per backend semantics. Disconnect requires typed
"DISCONNECT" confirmation. Includes a placeholder MusicBrainz overrides
section for future integrations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 23:06:19 -04:00
co-authored by Claude Opus 4.7
parent 041e63744d
commit c281c8b5dd
2 changed files with 494 additions and 0 deletions
@@ -0,0 +1,183 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrConfig } from '$lib/api/types';
// useQueryClient is wrapped so the page can call invalidateQueries() without
// a real QueryClient context. Everything else from svelte-query passes through.
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
});
vi.mock('$lib/api/admin', () => ({
createLidarrConfigQuery: vi.fn(),
createQualityProfilesQuery: vi.fn(),
createRootFoldersQuery: vi.fn(),
putLidarrConfig: vi.fn(),
testLidarrConnection: vi.fn()
}));
import IntegrationsPage from './+page.svelte';
import {
createLidarrConfigQuery,
createQualityProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection
} from '$lib/api/admin';
const cfgConnected: LidarrConfig = {
enabled: true,
base_url: 'http://lidarr.local',
api_key: '***',
default_quality_profile_id: 1,
default_root_folder_path: '/music'
};
const cfgUnset: LidarrConfig = {
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_root_folder_path: ''
};
afterEach(() => {
vi.clearAllMocks();
});
function setup(opts: { config?: LidarrConfig } = {}) {
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.config ?? cfgConnected })
);
(createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({
data: [
{ id: 1, name: 'Standard' },
{ id: 2, name: 'Lossless' }
]
})
);
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] })
);
return render(IntegrationsPage);
}
// Helper for finding the modal's Disconnect confirm button. Both the panel
// trigger and the modal confirm share the visible name "Disconnect"; the
// modal one is the descendant of role="dialog".
function modalConfirmButton(): HTMLButtonElement {
const matches = screen.getAllByRole('button', { name: /^disconnect$/i });
const inDialog = matches.find((b) => b.closest('[role="dialog"]') !== null);
if (!inDialog) throw new Error('modal Disconnect button not found');
return inDialog as HTMLButtonElement;
}
describe('/admin/integrations', () => {
test('Save calls putLidarrConfig with empty api_key when input is blank', async () => {
setup();
(putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgConnected);
await fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
expect(putLidarrConfig).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
base_url: 'http://lidarr.local',
api_key: '',
default_quality_profile_id: 1,
default_root_folder_path: '/music'
})
);
});
test('Save with new api_key sends the typed value', async () => {
setup();
const apiInput = screen.getByPlaceholderText(/saved — leave empty to keep/i);
await fireEvent.input(apiInput, { target: { value: 'newkey' } });
(putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgConnected);
await fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
expect(putLidarrConfig).toHaveBeenCalledWith(
expect.objectContaining({ api_key: 'newkey' })
);
});
test('Test connection renders Lidarr version on success', async () => {
setup();
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
version: '2.0.0'
});
await fireEvent.click(
screen.getByRole('button', { name: /test connection/i })
);
await waitFor(() =>
expect(screen.getByText(/lidarr 2\.0\.0/i)).toBeInTheDocument()
);
});
test('Test connection renders error code on failure', async () => {
setup();
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
error: 'lidarr_unreachable'
});
await fireEvent.click(
screen.getByRole('button', { name: /test connection/i })
);
await waitFor(() =>
expect(screen.getByText(/lidarr_unreachable/i)).toBeInTheDocument()
);
});
test('Disconnect requires typed confirm; cancelling does not call API', async () => {
setup();
// Open modal via the panel-level Disconnect button.
await fireEvent.click(
screen.getByRole('button', { name: /^disconnect$/i })
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Confirm button is disabled until "DISCONNECT" is typed.
expect(modalConfirmButton().disabled).toBe(true);
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(putLidarrConfig).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('Disconnect with typed confirmation calls API with cleared config', async () => {
setup();
await fireEvent.click(
screen.getByRole('button', { name: /^disconnect$/i })
);
const confirmInput = screen.getByPlaceholderText('DISCONNECT');
await fireEvent.input(confirmInput, { target: { value: 'DISCONNECT' } });
(putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgUnset);
await fireEvent.click(modalConfirmButton());
expect(putLidarrConfig).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_root_folder_path: ''
})
);
});
test('Quality profile and root folder dropdowns populate from API', () => {
setup();
expect(screen.getByText('Standard')).toBeInTheDocument();
expect(screen.getByText('Lossless')).toBeInTheDocument();
expect(screen.getByText('/music')).toBeInTheDocument();
});
test('header shows "Lidarr · connected" when configured', () => {
setup();
expect(screen.getByText(/lidarr · connected/i)).toBeInTheDocument();
});
test('header shows "Lidarr · unset" when not configured', () => {
setup({ config: cfgUnset });
expect(screen.getByText(/lidarr · unset/i)).toBeInTheDocument();
});
});