feat(web/m7-cover-sources): admin /integrations cover-provider cards

Adds a "Cover art providers" section to the existing /admin/integrations
page with one card per registered provider (currently MBCAA + TheAudioDB).

Each card has:
- Provider name + capability badges (album cover · artist thumb · artist fanart)
- Status dot (green if enabled; muted if disabled)
- Enabled toggle + API key text field (only when requires_api_key)
- Save button (explicit save matches existing Lidarr card pattern)
- Test connection button (when testable) with inline ok/fail indicator
- Footer help line for TheAudioDB linking to the free key application

Save invalidates qk.coverProviders() always and qk.coverage() when
the PATCH response indicates version_bumped: true (so the coverage
gauge re-queries to show settled→with_art transitions on the next
scan).

API key field uses type=password with a placeholder that clarifies
the test-key fallback behaviour — never echoes back the stored key
(api_key_set bool from the GET response controls the "✓ Set"
indicator).

Local edit state is keyed by provider_id and survives query refetches
so in-flight edits aren't clobbered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 13:15:10 -04:00
co-authored by Claude Sonnet 4.6
parent 39a07a280c
commit 8307cbdbf9
2 changed files with 364 additions and 5 deletions
@@ -2,12 +2,14 @@ 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';
import type { CoverProvider } from '$lib/api/admin';
// useQueryClient is wrapped so the page can call invalidateQueries() without
// a real QueryClient context. Everything else from svelte-query passes through.
const invalidateQueries = vi.fn();
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
return { ...actual, useQueryClient: () => ({ invalidateQueries }) };
});
vi.mock('$lib/api/admin', () => ({
@@ -16,7 +18,10 @@ vi.mock('$lib/api/admin', () => ({
createMetadataProfilesQuery: vi.fn(),
createRootFoldersQuery: vi.fn(),
putLidarrConfig: vi.fn(),
testLidarrConnection: vi.fn()
testLidarrConnection: vi.fn(),
createCoverProvidersQuery: vi.fn(),
updateCoverProvider: vi.fn(),
testCoverProvider: vi.fn()
}));
import IntegrationsPage from './+page.svelte';
@@ -26,7 +31,10 @@ import {
createMetadataProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection
testLidarrConnection,
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider
} from '$lib/api/admin';
const cfgConnected: LidarrConfig = {
@@ -47,11 +55,45 @@ const cfgUnset: LidarrConfig = {
default_root_folder_path: ''
};
// Fixture cover providers
const providerMbcaa: CoverProvider = {
id: 'mbcaa',
display_name: 'MusicBrainz CAA',
requires_api_key: false,
supports: ['album_cover'],
enabled: true,
api_key_set: false,
display_order: 0,
testable: true
};
const providerTheAudioDB: CoverProvider = {
id: 'theaudiodb',
display_name: 'TheAudioDB',
requires_api_key: true,
supports: ['album_cover', 'artist_thumb', 'artist_fanart'],
enabled: true,
api_key_set: false,
display_order: 1,
testable: true
};
const providerTheAudioDBKeySet: CoverProvider = {
...providerTheAudioDB,
api_key_set: true
};
const defaultCoverProviders = {
providers: [providerMbcaa, providerTheAudioDB],
sources_version: 1
};
afterEach(() => {
vi.clearAllMocks();
invalidateQueries.mockReset();
});
function setup(opts: { config?: LidarrConfig } = {}) {
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null } = {}) {
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.config ?? cfgConnected })
);
@@ -74,6 +116,10 @@ function setup(opts: { config?: LidarrConfig } = {}) {
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] })
);
const cpData = opts.coverProviders === null ? undefined : (opts.coverProviders ?? defaultCoverProviders);
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
);
return render(IntegrationsPage);
}
@@ -195,3 +241,157 @@ describe('/admin/integrations', () => {
expect(screen.getByText(/lidarr · unset/i)).toBeInTheDocument();
});
});
describe('/admin/integrations — cover art providers', () => {
test('renders one card per provider in the response', () => {
setup();
expect(screen.getByText('MusicBrainz CAA')).toBeInTheDocument();
expect(screen.getByText('TheAudioDB')).toBeInTheDocument();
});
test('capability badges render for each capability in supports[]', () => {
setup();
// TheAudioDB has album_cover, artist_thumb, artist_fanart
expect(screen.getAllByText('album cover').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('artist thumb')).toBeInTheDocument();
expect(screen.getByText('artist fanart')).toBeInTheDocument();
});
test('"✓ Set" indicator renders when api_key_set is true', () => {
setup({
coverProviders: {
providers: [providerTheAudioDBKeySet],
sources_version: 1
}
});
expect(screen.getByText(/✓ set/i)).toBeInTheDocument();
});
test('"✓ Set" indicator does not render when api_key_set is false', () => {
setup();
expect(screen.queryByText(/✓ set/i)).not.toBeInTheDocument();
});
test('Save button is disabled initially (no changes)', () => {
setup();
const saveButtons = screen.getAllByRole('button', { name: /save changes/i });
// Both provider cards render Save buttons, both should be disabled initially
for (const btn of saveButtons) {
expect((btn as HTMLButtonElement).disabled).toBe(true);
}
});
test('Save button enables after typing in API key field', async () => {
setup();
const keyInput = screen.getByLabelText(/api key/i);
await fireEvent.input(keyInput, { target: { value: 'myapikey' } });
// The TheAudioDB save button should now be enabled
const saveButtons = screen.getAllByRole('button', { name: /save changes/i });
const theAudioDbSave = saveButtons.find((b) => !b.closest('article')?.textContent?.includes('MusicBrainz'));
expect(theAudioDbSave).toBeDefined();
expect((theAudioDbSave as HTMLButtonElement).disabled).toBe(false);
});
test('clicking Save calls updateCoverProvider with api_key patch body', async () => {
setup();
(updateCoverProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...providerTheAudioDB,
version_bumped: false
});
const keyInput = screen.getByLabelText(/api key/i);
await fireEvent.input(keyInput, { target: { value: 'newkey123' } });
const saveButtons = screen.getAllByRole('button', { name: /save changes/i });
const theAudioDbCard = screen.getByText('TheAudioDB').closest('article');
const theAudioDbSave = theAudioDbCard?.querySelector('button[type="button"]');
// Find the save button for TheAudioDB specifically
const btn = saveButtons.find((b) => b.closest('article')?.contains(keyInput));
await fireEvent.click(btn!);
expect(updateCoverProvider).toHaveBeenCalledWith(
'theaudiodb',
expect.objectContaining({ api_key: 'newkey123' })
);
});
test('when version_bumped: true, invalidateQueries is called with qk.coverage()', async () => {
setup();
(updateCoverProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...providerMbcaa,
enabled: false,
version_bumped: true
});
// Toggle enabled on MusicBrainz CAA card
const mbcaaCard = screen.getByText('MusicBrainz CAA').closest('article')!;
const enabledCheckbox = mbcaaCard.querySelector('input[type="checkbox"]') as HTMLInputElement;
await fireEvent.click(enabledCheckbox);
const saveButtons = screen.getAllByRole('button', { name: /save changes/i });
const mbcaaSave = saveButtons.find((b) => b.closest('article')?.contains(enabledCheckbox));
await fireEvent.click(mbcaaSave!);
await waitFor(() => {
expect(invalidateQueries).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['coverage'] })
);
});
});
test('when version_bumped: false, invalidateQueries is NOT called with qk.coverage()', async () => {
setup();
(updateCoverProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...providerMbcaa,
enabled: false,
version_bumped: false
});
const mbcaaCard = screen.getByText('MusicBrainz CAA').closest('article')!;
const enabledCheckbox = mbcaaCard.querySelector('input[type="checkbox"]') as HTMLInputElement;
await fireEvent.click(enabledCheckbox);
const saveButtons = screen.getAllByRole('button', { name: /save changes/i });
const mbcaaSave = saveButtons.find((b) => b.closest('article')?.contains(enabledCheckbox));
await fireEvent.click(mbcaaSave!);
await waitFor(() => {
expect(invalidateQueries).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['coverProviders'] })
);
});
// coverage invalidation should NOT have been called
const coverageCalls = invalidateQueries.mock.calls.filter(
(call: [{ queryKey: unknown[] }]) => JSON.stringify(call[0]?.queryKey) === JSON.stringify(['coverage'])
);
expect(coverageCalls.length).toBe(0);
});
test('Test connection button calls testCoverProvider and shows OK result', async () => {
setup();
(testCoverProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
duration_ms: 120
});
const testButtons = screen.getAllByRole('button', { name: /test connection/i });
await fireEvent.click(testButtons[0]);
await waitFor(() =>
expect(screen.getByText(/ok.*120ms/i)).toBeInTheDocument()
);
});
test('Test connection button shows failure message on error response', async () => {
setup();
(testCoverProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
error: 'mbcaa_unreachable'
});
const testButtons = screen.getAllByRole('button', { name: /test connection/i });
await fireEvent.click(testButtons[0]);
await waitFor(() =>
expect(screen.getByText(/mbcaa_unreachable/i)).toBeInTheDocument()
);
});
test('TheAudioDB help text renders with link', () => {
setup();
expect(screen.getByText(/public test key works out of the box/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /theaudiodb\.com\/api_apply\.php/i })).toBeInTheDocument();
});
test('shows loading state when cover providers query is pending', () => {
setup({ coverProviders: null });
expect(screen.getByText(/loading…/i)).toBeInTheDocument();
});
});