From c281c8b5ddc0a669de232537ddf4f16836b45be3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 23:06:19 -0400 Subject: [PATCH] feat(web): add /admin/integrations Lidarr connection panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../routes/admin/integrations/+page.svelte | 311 ++++++++++++++++++ .../admin/integrations/integrations.test.ts | 183 +++++++++++ 2 files changed, 494 insertions(+) create mode 100644 web/src/routes/admin/integrations/+page.svelte create mode 100644 web/src/routes/admin/integrations/integrations.test.ts diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte new file mode 100644 index 00000000..3e3a914c --- /dev/null +++ b/web/src/routes/admin/integrations/+page.svelte @@ -0,0 +1,311 @@ + + +
+
+

Integrations

+ {#if config.data?.enabled} + + + Lidarr · connected + + {:else} + + + Lidarr · unset + + {/if} +
+ + +
+
+

Lidarr

+

+ Search Lidarr from /discover and + route approved requests to it. +

+
+ + + + + + + + + + {#if testResult} + {#if testResult.ok} +

+ Connected — Lidarr {testResult.version} +

+ {:else} +

Connection failed — {testResult.error}

+ {/if} + {/if} + {#if saveError} +

Save failed — {saveError}

+ {/if} + +
+ + + +
+
+ + +
+
+

+ MusicBrainz overrides +

+ + + unset + +
+

Not yet configured.

+
+
+ +{#if modalOpen} + + +
+ +
+{/if} diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts new file mode 100644 index 00000000..120c6acc --- /dev/null +++ b/web/src/routes/admin/integrations/integrations.test.ts @@ -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; + 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).mockReturnValue( + mockQuery({ data: opts.config ?? cfgConnected }) + ); + (createQualityProfilesQuery as ReturnType).mockReturnValue( + mockQuery({ + data: [ + { id: 1, name: 'Standard' }, + { id: 2, name: 'Lossless' } + ] + }) + ); + (createRootFoldersQuery as ReturnType).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).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).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).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).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).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(); + }); +});