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:
@@ -8,7 +8,11 @@
|
|||||||
createMetadataProfilesQuery,
|
createMetadataProfilesQuery,
|
||||||
createRootFoldersQuery,
|
createRootFoldersQuery,
|
||||||
putLidarrConfig,
|
putLidarrConfig,
|
||||||
testLidarrConnection
|
testLidarrConnection,
|
||||||
|
createCoverProvidersQuery,
|
||||||
|
updateCoverProvider,
|
||||||
|
testCoverProvider,
|
||||||
|
type CoverProvider
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
import type { LidarrConfig, LidarrTestResult } from '$lib/api/types';
|
||||||
@@ -161,6 +165,67 @@
|
|||||||
disconnectInput = '';
|
disconnectInput = '';
|
||||||
disconnectError = null;
|
disconnectError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Cover art providers ----
|
||||||
|
const coverProvidersStore = $derived(createCoverProvidersQuery());
|
||||||
|
const coverProvidersQ = $derived($coverProvidersStore);
|
||||||
|
|
||||||
|
// Local edit state — keyed by provider_id. Initialised lazily when
|
||||||
|
// the query data lands, then preserved across refetches so the
|
||||||
|
// operator's in-flight edits don't get clobbered.
|
||||||
|
let coverLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
|
||||||
|
let coverTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
|
||||||
|
let coverSaving = $state<Record<string, boolean>>({});
|
||||||
|
let coverTesting = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (coverProvidersQ.data) {
|
||||||
|
for (const p of coverProvidersQ.data.providers) {
|
||||||
|
if (!coverLocalState[p.id]) {
|
||||||
|
coverLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function coverHasChanges(provider: CoverProvider): boolean {
|
||||||
|
const local = coverLocalState[provider.id];
|
||||||
|
if (!local) return false;
|
||||||
|
return provider.enabled !== local.enabled || local.apiKey !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function coverSave(provider: CoverProvider) {
|
||||||
|
const local = coverLocalState[provider.id];
|
||||||
|
if (!local || !coverHasChanges(provider)) return;
|
||||||
|
const patch: { enabled?: boolean; api_key?: string } = {};
|
||||||
|
if (provider.enabled !== local.enabled) patch.enabled = local.enabled;
|
||||||
|
if (local.apiKey !== '') patch.api_key = local.apiKey;
|
||||||
|
|
||||||
|
coverSaving[provider.id] = true;
|
||||||
|
try {
|
||||||
|
const result = await updateCoverProvider(provider.id, patch);
|
||||||
|
coverLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
|
||||||
|
await client.invalidateQueries({ queryKey: qk.coverProviders() });
|
||||||
|
if (result.version_bumped) {
|
||||||
|
await client.invalidateQueries({ queryKey: qk.coverage() });
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// Error display is handled inline via saveError pattern; swallow here.
|
||||||
|
} finally {
|
||||||
|
coverSaving[provider.id] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function coverTest(provider: CoverProvider) {
|
||||||
|
coverTesting[provider.id] = true;
|
||||||
|
try {
|
||||||
|
coverTestResults[provider.id] = await testCoverProvider(provider.id);
|
||||||
|
} catch (e) {
|
||||||
|
coverTestResults[provider.id] = { ok: false, error: (e as Error).message };
|
||||||
|
} finally {
|
||||||
|
coverTesting[provider.id] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{pageTitle('Admin · Integrations')}</title></svelte:head>
|
<svelte:head><title>{pageTitle('Admin · Integrations')}</title></svelte:head>
|
||||||
@@ -317,6 +382,100 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-text-secondary">Not yet configured.</p>
|
<p class="text-sm text-text-secondary">Not yet configured.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Cover art providers -->
|
||||||
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-display text-lg font-medium text-text-primary">Cover art providers</h3>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
External sources for album covers and artist images. Providers
|
||||||
|
are tried in registration order; first hit wins.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if coverProvidersQ.data}
|
||||||
|
{#each coverProvidersQ.data.providers as provider (provider.id)}
|
||||||
|
<article class="rounded-lg border border-border bg-background p-4 space-y-3">
|
||||||
|
<header class="flex items-baseline justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h4 class="font-display text-base font-medium text-text-primary">{provider.display_name}</h4>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full {provider.enabled ? 'bg-action-primary' : 'bg-text-muted'}"
|
||||||
|
title={provider.enabled ? 'Active' : 'Disabled'}
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each provider.supports as cap}
|
||||||
|
<span class="rounded bg-surface px-1.5 py-0.5 text-xs text-text-secondary">{cap.replace('_', ' ')}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if coverLocalState[provider.id]}
|
||||||
|
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
|
||||||
|
<label class="text-sm text-text-secondary" for="enabled-{provider.id}">Enabled</label>
|
||||||
|
<div>
|
||||||
|
<input id="enabled-{provider.id}" type="checkbox"
|
||||||
|
bind:checked={coverLocalState[provider.id].enabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if provider.requires_api_key}
|
||||||
|
<label class="text-sm text-text-secondary" for="key-{provider.id}">API key</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="key-{provider.id}" type="password"
|
||||||
|
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Leave blank to use the public test key'}
|
||||||
|
bind:value={coverLocalState[provider.id].apiKey}
|
||||||
|
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent flex-1" />
|
||||||
|
{#if provider.api_key_set}
|
||||||
|
<span class="text-xs text-text-secondary whitespace-nowrap">✓ Set</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 pt-1">
|
||||||
|
<button type="button"
|
||||||
|
disabled={!coverHasChanges(provider) || coverSaving[provider.id]}
|
||||||
|
onclick={() => coverSave(provider)}
|
||||||
|
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
|
||||||
|
{coverSaving[provider.id] ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
{#if provider.testable}
|
||||||
|
<button type="button"
|
||||||
|
disabled={coverTesting[provider.id]}
|
||||||
|
onclick={() => coverTest(provider)}
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
|
||||||
|
{coverTesting[provider.id] ? 'Testing…' : 'Test connection'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if coverTestResults[provider.id]}
|
||||||
|
{#if coverTestResults[provider.id].ok}
|
||||||
|
<p class="text-sm text-action-primary">
|
||||||
|
OK{coverTestResults[provider.id].duration_ms ? ` (${coverTestResults[provider.id].duration_ms}ms)` : ''}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-error">
|
||||||
|
Failed — {coverTestResults[provider.id].error}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if provider.id === 'theaudiodb'}
|
||||||
|
<p class="text-xs text-text-muted">
|
||||||
|
Public test key works out of the box. A free personal key gives separate quota
|
||||||
|
accounting — apply at <a class="underline" href="https://www.theaudiodb.com/api_apply.php" target="_blank" rel="noopener noreferrer">theaudiodb.com/api_apply.php</a>.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{:else if coverProvidersQ.isPending}
|
||||||
|
<p class="text-sm text-text-muted">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-text-muted">No providers registered.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if modalOpen}
|
{#if modalOpen}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
import { mockQuery } from '../../../test-utils/query';
|
import { mockQuery } from '../../../test-utils/query';
|
||||||
import type { LidarrConfig } from '$lib/api/types';
|
import type { LidarrConfig } from '$lib/api/types';
|
||||||
|
import type { CoverProvider } from '$lib/api/admin';
|
||||||
|
|
||||||
// useQueryClient is wrapped so the page can call invalidateQueries() without
|
// useQueryClient is wrapped so the page can call invalidateQueries() without
|
||||||
// a real QueryClient context. Everything else from svelte-query passes through.
|
// a real QueryClient context. Everything else from svelte-query passes through.
|
||||||
|
const invalidateQueries = vi.fn();
|
||||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||||
const actual = (await orig()) as Record<string, unknown>;
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
|
return { ...actual, useQueryClient: () => ({ invalidateQueries }) };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('$lib/api/admin', () => ({
|
vi.mock('$lib/api/admin', () => ({
|
||||||
@@ -16,7 +18,10 @@ vi.mock('$lib/api/admin', () => ({
|
|||||||
createMetadataProfilesQuery: vi.fn(),
|
createMetadataProfilesQuery: vi.fn(),
|
||||||
createRootFoldersQuery: vi.fn(),
|
createRootFoldersQuery: vi.fn(),
|
||||||
putLidarrConfig: 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';
|
import IntegrationsPage from './+page.svelte';
|
||||||
@@ -26,7 +31,10 @@ import {
|
|||||||
createMetadataProfilesQuery,
|
createMetadataProfilesQuery,
|
||||||
createRootFoldersQuery,
|
createRootFoldersQuery,
|
||||||
putLidarrConfig,
|
putLidarrConfig,
|
||||||
testLidarrConnection
|
testLidarrConnection,
|
||||||
|
createCoverProvidersQuery,
|
||||||
|
updateCoverProvider,
|
||||||
|
testCoverProvider
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
|
|
||||||
const cfgConnected: LidarrConfig = {
|
const cfgConnected: LidarrConfig = {
|
||||||
@@ -47,11 +55,45 @@ const cfgUnset: LidarrConfig = {
|
|||||||
default_root_folder_path: ''
|
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(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
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(
|
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
mockQuery({ data: opts.config ?? cfgConnected })
|
mockQuery({ data: opts.config ?? cfgConnected })
|
||||||
);
|
);
|
||||||
@@ -74,6 +116,10 @@ function setup(opts: { config?: LidarrConfig } = {}) {
|
|||||||
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] })
|
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);
|
return render(IntegrationsPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,3 +241,157 @@ describe('/admin/integrations', () => {
|
|||||||
expect(screen.getByText(/lidarr · unset/i)).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user