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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user