feat(web): tag enrichment sources admin card (#1490 Step 4 web)
Add a "Tag enrichment sources" card to the admin integrations page, mirroring the cover-art providers card: per-provider enable toggle + API-key field + Save + Test connection, over /api/admin/tag-sources. Enabling/keying a source (e.g. pasting a Last.fm key) re-opens settled tracks for re-enrichment via the version bump. - admin.ts: TagProvider types + get/update/test functions + createTagProvidersQuery; qk.tagProviders key. - integrations/+page.svelte: the card + local edit state, with MusicBrainz (keyless baseline) and Last.fm (needs a free key) notes. - Tests: admin.tag-sources API test + integrations component tests (render / enable+key save / test connection), plus the mock plumbing the existing suite needs for the new query. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
getTagProviders,
|
||||||
|
updateTagProvider,
|
||||||
|
testTagProvider,
|
||||||
|
type TagProvider,
|
||||||
|
type TagProvidersResponse
|
||||||
|
} from './admin';
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
describe('admin tag-sources API', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('getTagProviders GETs the correct path', async () => {
|
||||||
|
const sample: TagProvidersResponse = {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: 'musicbrainz',
|
||||||
|
display_name: 'MusicBrainz',
|
||||||
|
requires_api_key: false,
|
||||||
|
supports: ['track_tags'],
|
||||||
|
enabled: true,
|
||||||
|
api_key_set: false,
|
||||||
|
display_order: 0,
|
||||||
|
testable: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sources_version: 1
|
||||||
|
};
|
||||||
|
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||||
|
const got = await getTagProviders();
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/admin/tag-sources');
|
||||||
|
expect(got.providers[0].id).toBe('musicbrainz');
|
||||||
|
expect(got.sources_version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateTagProvider PATCHes the right path with patch body', async () => {
|
||||||
|
const patched: TagProvider & { version_bumped: boolean } = {
|
||||||
|
id: 'lastfm',
|
||||||
|
display_name: 'Last.fm',
|
||||||
|
requires_api_key: true,
|
||||||
|
supports: ['track_tags'],
|
||||||
|
enabled: true,
|
||||||
|
api_key_set: true,
|
||||||
|
display_order: 1,
|
||||||
|
testable: true,
|
||||||
|
version_bumped: true
|
||||||
|
};
|
||||||
|
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(patched);
|
||||||
|
const got = await updateTagProvider('lastfm', { enabled: true, api_key: 'mykey' });
|
||||||
|
expect(api.patch).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/tag-sources/lastfm',
|
||||||
|
{ enabled: true, api_key: 'mykey' }
|
||||||
|
);
|
||||||
|
expect(got.api_key_set).toBe(true);
|
||||||
|
expect(got.version_bumped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testTagProvider POSTs to /test endpoint with empty body', async () => {
|
||||||
|
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
duration_ms: 55
|
||||||
|
});
|
||||||
|
const got = await testTagProvider('musicbrainz');
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/api/admin/tag-sources/musicbrainz/test', {});
|
||||||
|
expect(got.ok).toBe(true);
|
||||||
|
expect(got.duration_ms).toBe(55);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testTagProvider returns ok=false with error string on failure', async () => {
|
||||||
|
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
error: 'lastfm api key not set'
|
||||||
|
});
|
||||||
|
const got = await testTagProvider('lastfm');
|
||||||
|
expect(got.ok).toBe(false);
|
||||||
|
expect(got.error).toBe('lastfm api key not set');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -383,6 +383,67 @@ export function createCoverProvidersQuery() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag-enrichment providers -------------------------------------------------
|
||||||
|
// Parallel to the cover-art providers surface, over /api/admin/tag-sources.
|
||||||
|
// Independent settings so a new folksonomy source is added without touching
|
||||||
|
// art config (#1490).
|
||||||
|
|
||||||
|
export type TagProviderCapability = 'track_tags';
|
||||||
|
|
||||||
|
export type TagProvider = {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
requires_api_key: boolean;
|
||||||
|
supports: TagProviderCapability[];
|
||||||
|
enabled: boolean;
|
||||||
|
api_key_set: boolean;
|
||||||
|
display_order: number;
|
||||||
|
testable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TagProvidersResponse = {
|
||||||
|
providers: TagProvider[];
|
||||||
|
sources_version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getTagProviders(): Promise<TagProvidersResponse> {
|
||||||
|
return api.get<TagProvidersResponse>('/api/admin/tag-sources');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateTagProviderPatch = {
|
||||||
|
enabled?: boolean;
|
||||||
|
api_key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateTagProviderResponse = TagProvider & {
|
||||||
|
version_bumped: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function updateTagProvider(
|
||||||
|
id: string,
|
||||||
|
patch: UpdateTagProviderPatch
|
||||||
|
): Promise<UpdateTagProviderResponse> {
|
||||||
|
return api.patch<UpdateTagProviderResponse>(`/api/admin/tag-sources/${id}`, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TestTagProviderResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
duration_ms?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function testTagProvider(id: string): Promise<TestTagProviderResponse> {
|
||||||
|
return api.post<TestTagProviderResponse>(`/api/admin/tag-sources/${id}/test`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTagProvidersQuery() {
|
||||||
|
return createQuery({
|
||||||
|
queryKey: qk.tagProviders(),
|
||||||
|
queryFn: getTagProviders,
|
||||||
|
staleTime: 60_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Admin user-management ------------------------------------------------------
|
// Admin user-management ------------------------------------------------------
|
||||||
|
|
||||||
export type AdminUser = {
|
export type AdminUser = {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const qk = {
|
|||||||
scanStatus: () => ['scanStatus'] as const,
|
scanStatus: () => ['scanStatus'] as const,
|
||||||
coverage: () => ['coverage'] as const,
|
coverage: () => ['coverage'] as const,
|
||||||
coverProviders: () => ['coverProviders'] as const,
|
coverProviders: () => ['coverProviders'] as const,
|
||||||
|
tagProviders: () => ['tagProviders'] as const,
|
||||||
adminUsers: () => ['adminUsers'] as const,
|
adminUsers: () => ['adminUsers'] as const,
|
||||||
adminInvites: () => ['adminInvites'] as const,
|
adminInvites: () => ['adminInvites'] as const,
|
||||||
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
|
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
|
||||||
|
|||||||
@@ -13,10 +13,14 @@
|
|||||||
createCoverProvidersQuery,
|
createCoverProvidersQuery,
|
||||||
updateCoverProvider,
|
updateCoverProvider,
|
||||||
testCoverProvider,
|
testCoverProvider,
|
||||||
|
createTagProvidersQuery,
|
||||||
|
updateTagProvider,
|
||||||
|
testTagProvider,
|
||||||
updateSMTPConfig,
|
updateSMTPConfig,
|
||||||
testSMTPConfig,
|
testSMTPConfig,
|
||||||
createSMTPConfigQuery,
|
createSMTPConfigQuery,
|
||||||
type CoverProvider,
|
type CoverProvider,
|
||||||
|
type TagProvider,
|
||||||
type SMTPConfig
|
type SMTPConfig
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
@@ -280,6 +284,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Tag enrichment providers ----
|
||||||
|
// Mirrors the cover-art providers panel over /api/admin/tag-sources (#1490).
|
||||||
|
const tagProvidersStore = $derived(createTagProvidersQuery());
|
||||||
|
const tagProvidersQ = $derived($tagProvidersStore);
|
||||||
|
|
||||||
|
let tagLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
|
||||||
|
let tagTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
|
||||||
|
let tagSaving = $state<Record<string, boolean>>({});
|
||||||
|
let tagTesting = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (tagProvidersQ.data) {
|
||||||
|
for (const p of tagProvidersQ.data.providers) {
|
||||||
|
if (!tagLocalState[p.id]) {
|
||||||
|
tagLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function tagHasChanges(provider: TagProvider): boolean {
|
||||||
|
const local = tagLocalState[provider.id];
|
||||||
|
if (!local) return false;
|
||||||
|
return provider.enabled !== local.enabled || local.apiKey !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tagSave(provider: TagProvider) {
|
||||||
|
const local = tagLocalState[provider.id];
|
||||||
|
if (!local || !tagHasChanges(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;
|
||||||
|
|
||||||
|
tagSaving[provider.id] = true;
|
||||||
|
try {
|
||||||
|
const result = await updateTagProvider(provider.id, patch);
|
||||||
|
tagLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
|
||||||
|
await client.invalidateQueries({ queryKey: qk.tagProviders() });
|
||||||
|
} catch (_e) {
|
||||||
|
// Inline error surfacing handled by the mutation toast layer; swallow here.
|
||||||
|
} finally {
|
||||||
|
tagSaving[provider.id] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tagTest(provider: TagProvider) {
|
||||||
|
tagTesting[provider.id] = true;
|
||||||
|
try {
|
||||||
|
tagTestResults[provider.id] = await testTagProvider(provider.id);
|
||||||
|
} catch (e) {
|
||||||
|
tagTestResults[provider.id] = { ok: false, error: (e as Error).message };
|
||||||
|
} finally {
|
||||||
|
tagTesting[provider.id] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SMTP config -------------------------------------------------------------
|
// SMTP config -------------------------------------------------------------
|
||||||
|
|
||||||
const smtpStore = $derived(createSMTPConfigQuery());
|
const smtpStore = $derived(createSMTPConfigQuery());
|
||||||
@@ -597,6 +657,108 @@
|
|||||||
<p class="text-sm text-text-muted">No providers registered.</p>
|
<p class="text-sm text-text-muted">No providers registered.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Tag enrichment 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">Tag enrichment sources</h3>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
Folksonomy style/mood tags fetched per track and folded into each
|
||||||
|
listener's taste profile alongside the raw file genre — so "Rock"
|
||||||
|
gains "post-punk / shoegaze / melancholic". All enabled sources are
|
||||||
|
merged. Enabling or disabling a source re-opens previously-processed
|
||||||
|
tracks for a fresh pass.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tagProvidersQ.data}
|
||||||
|
{#each tagProvidersQ.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 tagLocalState[provider.id]}
|
||||||
|
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
|
||||||
|
<label class="text-sm text-text-secondary" for="tag-enabled-{provider.id}">Enabled</label>
|
||||||
|
<div>
|
||||||
|
<input id="tag-enabled-{provider.id}" type="checkbox"
|
||||||
|
bind:checked={tagLocalState[provider.id].enabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if provider.requires_api_key}
|
||||||
|
<label class="text-sm text-text-secondary" for="tag-key-{provider.id}">API key</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input id="tag-key-{provider.id}" type="password"
|
||||||
|
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Paste your API key to enable this source'}
|
||||||
|
bind:value={tagLocalState[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={!tagHasChanges(provider) || tagSaving[provider.id]}
|
||||||
|
onclick={() => tagSave(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">
|
||||||
|
{tagSaving[provider.id] ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
{#if provider.testable}
|
||||||
|
<button type="button"
|
||||||
|
disabled={tagTesting[provider.id]}
|
||||||
|
onclick={() => tagTest(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">
|
||||||
|
{tagTesting[provider.id] ? 'Testing…' : 'Test connection'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if tagTestResults[provider.id]}
|
||||||
|
{#if tagTestResults[provider.id].ok}
|
||||||
|
<p class="text-sm text-action-primary">
|
||||||
|
OK{tagTestResults[provider.id].duration_ms ? ` (${tagTestResults[provider.id].duration_ms}ms)` : ''}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-error">
|
||||||
|
Failed — {tagTestResults[provider.id].error}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if provider.id === 'musicbrainz'}
|
||||||
|
<p class="text-xs text-text-muted">
|
||||||
|
Keyless and on by default — the always-available baseline. Matches tags by
|
||||||
|
recording MBID, so coverage tracks how well your library is MusicBrainz-tagged.
|
||||||
|
</p>
|
||||||
|
{:else if provider.id === 'lastfm'}
|
||||||
|
<p class="text-xs text-text-muted">
|
||||||
|
Name-based (artist + title), so it covers tracks without an MBID. Requires a free
|
||||||
|
API key — get one at <a class="underline" href="https://www.last.fm/api/account/create" target="_blank" rel="noopener noreferrer">last.fm/api</a>.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{:else if tagProvidersQ.isPending}
|
||||||
|
<p class="text-sm text-text-muted">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-text-muted">No providers registered.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
<!-- SMTP / email config -->
|
<!-- SMTP / email config -->
|
||||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor, within } 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, SMTPConfig } from '$lib/api/admin';
|
import type { CoverProvider, TagProvider, SMTPConfig } 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. Per-file mock OVERRIDES the vitest.setup default
|
// a real QueryClient context. Per-file mock OVERRIDES the vitest.setup default
|
||||||
@@ -23,6 +23,9 @@ vi.mock('$lib/api/admin', () => ({
|
|||||||
createCoverProvidersQuery: vi.fn(),
|
createCoverProvidersQuery: vi.fn(),
|
||||||
updateCoverProvider: vi.fn(),
|
updateCoverProvider: vi.fn(),
|
||||||
testCoverProvider: vi.fn(),
|
testCoverProvider: vi.fn(),
|
||||||
|
createTagProvidersQuery: vi.fn(),
|
||||||
|
updateTagProvider: vi.fn(),
|
||||||
|
testTagProvider: vi.fn(),
|
||||||
createSMTPConfigQuery: vi.fn(),
|
createSMTPConfigQuery: vi.fn(),
|
||||||
updateSMTPConfig: vi.fn(),
|
updateSMTPConfig: vi.fn(),
|
||||||
testSMTPConfig: vi.fn()
|
testSMTPConfig: vi.fn()
|
||||||
@@ -39,6 +42,9 @@ import {
|
|||||||
createCoverProvidersQuery,
|
createCoverProvidersQuery,
|
||||||
updateCoverProvider,
|
updateCoverProvider,
|
||||||
testCoverProvider,
|
testCoverProvider,
|
||||||
|
createTagProvidersQuery,
|
||||||
|
updateTagProvider,
|
||||||
|
testTagProvider,
|
||||||
createSMTPConfigQuery,
|
createSMTPConfigQuery,
|
||||||
updateSMTPConfig,
|
updateSMTPConfig,
|
||||||
testSMTPConfig
|
testSMTPConfig
|
||||||
@@ -95,6 +101,34 @@ const defaultCoverProviders = {
|
|||||||
sources_version: 1
|
sources_version: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fixture tag providers
|
||||||
|
const providerMusicbrainz: TagProvider = {
|
||||||
|
id: 'musicbrainz',
|
||||||
|
display_name: 'MusicBrainz',
|
||||||
|
requires_api_key: false,
|
||||||
|
supports: ['track_tags'],
|
||||||
|
enabled: true,
|
||||||
|
api_key_set: false,
|
||||||
|
display_order: 0,
|
||||||
|
testable: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerLastfm: TagProvider = {
|
||||||
|
id: 'lastfm',
|
||||||
|
display_name: 'Last.fm',
|
||||||
|
requires_api_key: true,
|
||||||
|
supports: ['track_tags'],
|
||||||
|
enabled: false,
|
||||||
|
api_key_set: false,
|
||||||
|
display_order: 1,
|
||||||
|
testable: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultTagProviders = {
|
||||||
|
providers: [providerMusicbrainz, providerLastfm],
|
||||||
|
sources_version: 1
|
||||||
|
};
|
||||||
|
|
||||||
const testResponseOk = {
|
const testResponseOk = {
|
||||||
ok: true as const,
|
ok: true as const,
|
||||||
version: '2.0.5',
|
version: '2.0.5',
|
||||||
@@ -133,7 +167,7 @@ afterEach(() => {
|
|||||||
invalidateQueries.mockReset();
|
invalidateQueries.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; smtp?: SMTPConfig } = {}) {
|
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; tagProviders?: typeof defaultTagProviders | null; smtp?: SMTPConfig } = {}) {
|
||||||
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
mockQuery({ data: opts.config ?? cfgConnected })
|
mockQuery({ data: opts.config ?? cfgConnected })
|
||||||
);
|
);
|
||||||
@@ -160,6 +194,10 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov
|
|||||||
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
|
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
|
||||||
);
|
);
|
||||||
|
const tpData = opts.tagProviders === null ? undefined : (opts.tagProviders ?? defaultTagProviders);
|
||||||
|
(createTagProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
mockQuery({ data: tpData, isPending: opts.tagProviders === null })
|
||||||
|
);
|
||||||
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
mockQuery({ data: opts.smtp ?? smtpUnset })
|
mockQuery({ data: opts.smtp ?? smtpUnset })
|
||||||
);
|
);
|
||||||
@@ -195,6 +233,16 @@ function providerCard(providerName: string): HTMLElement {
|
|||||||
.closest('article') as HTMLElement;
|
.closest('article') as HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tagProvidersSection(): HTMLElement {
|
||||||
|
return screen.getByRole('heading', { name: /tag enrichment sources/i, level: 3 }).closest('section') as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagProviderCard(providerName: string): HTMLElement {
|
||||||
|
return within(tagProvidersSection())
|
||||||
|
.getByRole('heading', { name: providerName, level: 4 })
|
||||||
|
.closest('article') as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
describe('/admin/integrations', () => {
|
describe('/admin/integrations', () => {
|
||||||
test('Save calls putLidarrConfig with empty api_key when input is blank', async () => {
|
test('Save calls putLidarrConfig with empty api_key when input is blank', async () => {
|
||||||
setup();
|
setup();
|
||||||
@@ -641,3 +689,48 @@ describe('Lidarr first-time setup (Test-then-Save)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('/admin/integrations tag sources', () => {
|
||||||
|
test('renders tag enrichment providers', () => {
|
||||||
|
setup();
|
||||||
|
const section = tagProvidersSection();
|
||||||
|
expect(within(section).getByRole('heading', { name: 'MusicBrainz', level: 4 })).toBeInTheDocument();
|
||||||
|
expect(within(section).getByRole('heading', { name: 'Last.fm', level: 4 })).toBeInTheDocument();
|
||||||
|
expect(within(section).getAllByText('track tags').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabling Last.fm and typing a key calls updateTagProvider', async () => {
|
||||||
|
setup();
|
||||||
|
(updateTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
...providerLastfm,
|
||||||
|
enabled: true,
|
||||||
|
api_key_set: true,
|
||||||
|
version_bumped: true
|
||||||
|
});
|
||||||
|
const card = tagProviderCard('Last.fm');
|
||||||
|
const enabledCheckbox = card.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||||
|
await fireEvent.click(enabledCheckbox);
|
||||||
|
const keyInput = within(card).getByLabelText(/api key/i);
|
||||||
|
await fireEvent.input(keyInput, { target: { value: 'lfmkey' } });
|
||||||
|
await fireEvent.click(within(card).getByRole('button', { name: /save changes/i }));
|
||||||
|
|
||||||
|
expect(updateTagProvider).toHaveBeenCalledWith(
|
||||||
|
'lastfm',
|
||||||
|
expect.objectContaining({ enabled: true, api_key: 'lfmkey' })
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ queryKey: ['tagProviders'] })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Test connection on a tag provider calls testTagProvider and shows OK', async () => {
|
||||||
|
setup();
|
||||||
|
(testTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, duration_ms: 42 });
|
||||||
|
const card = tagProviderCard('MusicBrainz');
|
||||||
|
await fireEvent.click(within(card).getByRole('button', { name: /test connection/i }));
|
||||||
|
await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz'));
|
||||||
|
await waitFor(() => expect(within(card).getByText(/ok.*42ms/i)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user