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:
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
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
|
||||
// a real QueryClient context. Per-file mock OVERRIDES the vitest.setup default
|
||||
@@ -23,6 +23,9 @@ vi.mock('$lib/api/admin', () => ({
|
||||
createCoverProvidersQuery: vi.fn(),
|
||||
updateCoverProvider: vi.fn(),
|
||||
testCoverProvider: vi.fn(),
|
||||
createTagProvidersQuery: vi.fn(),
|
||||
updateTagProvider: vi.fn(),
|
||||
testTagProvider: vi.fn(),
|
||||
createSMTPConfigQuery: vi.fn(),
|
||||
updateSMTPConfig: vi.fn(),
|
||||
testSMTPConfig: vi.fn()
|
||||
@@ -39,6 +42,9 @@ import {
|
||||
createCoverProvidersQuery,
|
||||
updateCoverProvider,
|
||||
testCoverProvider,
|
||||
createTagProvidersQuery,
|
||||
updateTagProvider,
|
||||
testTagProvider,
|
||||
createSMTPConfigQuery,
|
||||
updateSMTPConfig,
|
||||
testSMTPConfig
|
||||
@@ -95,6 +101,34 @@ const defaultCoverProviders = {
|
||||
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 = {
|
||||
ok: true as const,
|
||||
version: '2.0.5',
|
||||
@@ -133,7 +167,7 @@ afterEach(() => {
|
||||
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(
|
||||
mockQuery({ data: opts.config ?? cfgConnected })
|
||||
);
|
||||
@@ -160,6 +194,10 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov
|
||||
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
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(
|
||||
mockQuery({ data: opts.smtp ?? smtpUnset })
|
||||
);
|
||||
@@ -195,6 +233,16 @@ function providerCard(providerName: string): 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', () => {
|
||||
test('Save calls putLidarrConfig with empty api_key when input is blank', async () => {
|
||||
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