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, 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 // so assertions see this named spy rather than the setup's anonymous fn. const invalidateQueries = vi.fn(); vi.mock('@tanstack/svelte-query', async (orig) => { const actual = (await orig()) as Record; return { ...actual, useQueryClient: () => ({ invalidateQueries }) }; }); vi.mock('$lib/api/admin', () => ({ createLidarrConfigQuery: vi.fn(), createQualityProfilesQuery: vi.fn(), createMetadataProfilesQuery: vi.fn(), createRootFoldersQuery: vi.fn(), putLidarrConfig: vi.fn(), testLidarrConnection: vi.fn(), 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(), // AcoustIDCard loads on mount; a never-settling read leaves it on its own // "Loading AcoustID settings…", which the page's /loading…/i queries skip. getAcoustID: vi.fn(() => new Promise(() => {})), updateAcoustIDSettings: vi.fn(), runAcoustIDLookup: vi.fn(), listUnsettledAcoustID: vi.fn(), // NotificationEmailCard loads on mount; left pending like AcoustIDCard. getNotificationEmailSettings: vi.fn(() => new Promise(() => {})), updateNotificationEmailSettings: vi.fn() })); import IntegrationsPage from './+page.svelte'; import { createLidarrConfigQuery, createQualityProfilesQuery, createMetadataProfilesQuery, createRootFoldersQuery, putLidarrConfig, testLidarrConnection, createCoverProvidersQuery, updateCoverProvider, testCoverProvider, createTagProvidersQuery, updateTagProvider, testTagProvider, createSMTPConfigQuery, updateSMTPConfig, testSMTPConfig } from '$lib/api/admin'; const cfgConnected: LidarrConfig = { enabled: true, base_url: 'http://lidarr.local', api_key: '***', default_quality_profile_id: 1, default_metadata_profile_id: 1, default_root_folder_path: '/music' }; const cfgUnset: LidarrConfig = { enabled: false, base_url: '', api_key: '', default_quality_profile_id: 0, default_metadata_profile_id: 0, 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 }; // 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', quality_profiles: [ { id: 1, name: 'FLAC' }, { id: 2, name: 'MP3-320' } ], metadata_profiles: [{ id: 1, name: 'Standard' }], root_folders: [{ path: '/music', accessible: true, free_space: 12345 }] }; const smtpUnset: SMTPConfig = { enabled: false, host: '', port: 587, username: '', password: '', from_address: '', from_name: 'Minstrel', use_tls: true }; const smtpConfigured: SMTPConfig = { enabled: true, host: 'smtp.example.com', port: 587, username: 'user@example.com', password: '***', from_address: 'noreply@example.com', from_name: 'Minstrel', use_tls: true }; afterEach(() => { vi.clearAllMocks(); invalidateQueries.mockReset(); }); function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; tagProviders?: typeof defaultTagProviders | null; smtp?: SMTPConfig } = {}) { (createLidarrConfigQuery as ReturnType).mockReturnValue( mockQuery({ data: opts.config ?? cfgConnected }) ); (createQualityProfilesQuery as ReturnType).mockReturnValue( mockQuery({ data: [ { id: 1, name: 'Standard' }, { id: 2, name: 'Lossless' } ] }) ); (createMetadataProfilesQuery as ReturnType).mockReturnValue( mockQuery({ data: [ { id: 1, name: 'Standard' }, { id: 2, name: 'Singles only' } ] }) ); (createRootFoldersQuery as ReturnType).mockReturnValue( mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] }) ); const cpData = opts.coverProviders === null ? undefined : (opts.coverProviders ?? defaultCoverProviders); (createCoverProvidersQuery as ReturnType).mockReturnValue( mockQuery({ data: cpData, isPending: opts.coverProviders === null }) ); const tpData = opts.tagProviders === null ? undefined : (opts.tagProviders ?? defaultTagProviders); (createTagProvidersQuery as ReturnType).mockReturnValue( mockQuery({ data: tpData, isPending: opts.tagProviders === null }) ); (createSMTPConfigQuery as ReturnType).mockReturnValue( mockQuery({ data: opts.smtp ?? smtpUnset }) ); 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; } // Section-scoping helpers. Both Lidarr and the cover-art-providers panel render // "Save changes", "Test connection", and "API key" controls, so unscoped queries // match multiple elements. These helpers narrow every query to its own
. function lidarrSection(): HTMLElement { return screen.getByRole('heading', { name: /^lidarr$/i, level: 3 }).closest('section') as HTMLElement; } function coverProvidersSection(): HTMLElement { return screen.getByRole('heading', { name: /cover art providers/i, level: 3 }).closest('section') as HTMLElement; } // Each provider is rendered inside an
. Provider headings are h4. function providerCard(providerName: string): HTMLElement { return within(coverProvidersSection()) .getByRole('heading', { name: providerName, level: 4 }) .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(); // Save now runs Test first (commit bca8622); mock both endpoints. The // test response also has to match enough of the existing config that // the Save handler treats it as a valid passthrough. (testLidarrConnection as ReturnType).mockResolvedValueOnce(testResponseOk); (putLidarrConfig as ReturnType).mockResolvedValueOnce(cfgConnected); await fireEvent.click(within(lidarrSection()).getByRole('button', { name: /save changes/i })); await waitFor(() => { 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' } }); (testLidarrConnection as ReturnType).mockResolvedValueOnce(testResponseOk); (putLidarrConfig as ReturnType).mockResolvedValueOnce(cfgConnected); await fireEvent.click(within(lidarrSection()).getByRole('button', { name: /save changes/i })); await waitFor(() => { 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( within(lidarrSection()).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( within(lidarrSection()).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 / metadata / root folder dropdowns populate from API', () => { setup(); // 'Standard' appears in both quality + metadata fixtures, so use *AllBy*. expect(screen.getAllByText('Standard').length).toBeGreaterThanOrEqual(2); expect(screen.getByText('Lossless')).toBeInTheDocument(); expect(screen.getByText('Singles only')).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(); }); }); 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 cpSection = coverProvidersSection(); const saveButtons = within(cpSection).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 theAudioDbCard = providerCard('TheAudioDB'); const keyInput = within(theAudioDbCard).getByLabelText(/api key/i); await fireEvent.input(keyInput, { target: { value: 'myapikey' } }); // The TheAudioDB save button should now be enabled const theAudioDbSave = within(theAudioDbCard).getByRole('button', { name: /save changes/i }); 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).mockResolvedValueOnce({ ...providerTheAudioDB, version_bumped: false }); const theAudioDbCard = providerCard('TheAudioDB'); const keyInput = within(theAudioDbCard).getByLabelText(/api key/i); await fireEvent.input(keyInput, { target: { value: 'newkey123' } }); const btn = within(theAudioDbCard).getByRole('button', { name: /save changes/i }); 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).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).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: any[]) => 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).mockResolvedValueOnce({ ok: true, duration_ms: 120 }); const mbcaaCard = providerCard('MusicBrainz CAA'); await fireEvent.click(within(mbcaaCard).getByRole('button', { name: /test connection/i })); await waitFor(() => expect(screen.getByText(/ok.*120ms/i)).toBeInTheDocument() ); }); test('Test connection button shows failure message on error response', async () => { setup(); (testCoverProvider as ReturnType).mockResolvedValueOnce({ ok: false, error: 'mbcaa_unreachable' }); const mbcaaCard = providerCard('MusicBrainz CAA'); await fireEvent.click(within(mbcaaCard).getByRole('button', { name: /test connection/i })); 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(); }); }); function smtpSection(): HTMLElement { return screen.getByRole('heading', { name: /email \(smtp\)/i, level: 3 }).closest('section') as HTMLElement; } describe('/admin/integrations — SMTP card', () => { test('renders "Email (SMTP)" heading', () => { setup(); expect(screen.getByRole('heading', { name: /email \(smtp\)/i, level: 3 })).toBeInTheDocument(); }); test('Save calls updateSMTPConfig with form values; empty password preserves stored value', async () => { setup({ smtp: smtpConfigured }); (updateSMTPConfig as ReturnType).mockResolvedValue(undefined); // password input starts empty — should send '' which tells backend to preserve const section = smtpSection(); await fireEvent.click(within(section).getByRole('button', { name: /^save$/i })); await waitFor(() => expect(updateSMTPConfig).toHaveBeenCalledWith( expect.objectContaining({ password: '' }) ) ); }); test('Save with a typed password sends the typed value', async () => { setup({ smtp: smtpConfigured }); (updateSMTPConfig as ReturnType).mockResolvedValue(undefined); const section = smtpSection(); const pwInput = within(section).getByLabelText(/password/i); await fireEvent.input(pwInput, { target: { value: 'newsecret' } }); await fireEvent.click(within(section).getByRole('button', { name: /^save$/i })); await waitFor(() => expect(updateSMTPConfig).toHaveBeenCalledWith( expect.objectContaining({ password: 'newsecret' }) ) ); }); test('Test button calls testSMTPConfig', async () => { setup(); (testSMTPConfig as ReturnType).mockResolvedValue(undefined); const section = smtpSection(); await fireEvent.click(within(section).getByRole('button', { name: /send test email/i })); await waitFor(() => expect(testSMTPConfig).toHaveBeenCalled()); }); test('no_email_on_file error from testSMTPConfig surfaces actionable toast', async () => { setup(); (testSMTPConfig as ReturnType).mockRejectedValueOnce({ code: 'no_email_on_file' }); const section = smtpSection(); await fireEvent.click(within(section).getByRole('button', { name: /send test email/i })); await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/set your email in \/settings/i) ); }); test('password input placeholder shows hint when existing password is masked', async () => { setup({ smtp: smtpConfigured }); // smtpConfigured has password: '***' — placeholder should show "Leave blank to keep current" const section = smtpSection(); await waitFor(() => expect(within(section).getByPlaceholderText(/leave blank to keep current/i)).toBeInTheDocument() ); }); }); describe('Lidarr first-time setup (Test-then-Save)', () => { // Fresh-install variant of setup() — the three list queries return // empty arrays so we can verify the dropdowns get populated from the // test response, not from the (empty) cached query data. function setupFresh() { (createLidarrConfigQuery as ReturnType).mockReturnValue( mockQuery({ data: cfgUnset }) ); (createQualityProfilesQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); (createMetadataProfilesQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); (createRootFoldersQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); (createCoverProvidersQuery as ReturnType).mockReturnValue( mockQuery({ data: defaultCoverProviders }) ); (createSMTPConfigQuery as ReturnType).mockReturnValue( mockQuery({ data: smtpUnset }) ); return render(IntegrationsPage); } test('Save calls test first, fills defaults from response, then put-config', async () => { setupFresh(); (testLidarrConnection as ReturnType).mockResolvedValueOnce(testResponseOk); (putLidarrConfig as ReturnType).mockResolvedValueOnce(cfgConnected); const section = lidarrSection(); await fireEvent.input(within(section).getByLabelText(/Base URL/i), { target: { value: 'http://lidarr.local' } }); await fireEvent.input(within(section).getByLabelText(/API key/i), { target: { value: 'secret-key' } }); await fireEvent.click(within(section).getByRole('button', { name: /Save changes/i })); await waitFor(() => { expect(testLidarrConnection).toHaveBeenCalledWith({ base_url: 'http://lidarr.local', api_key: 'secret-key' }); }); await waitFor(() => { expect(putLidarrConfig).toHaveBeenCalledWith({ enabled: true, base_url: 'http://lidarr.local', api_key: 'secret-key', default_quality_profile_id: 1, default_metadata_profile_id: 1, default_root_folder_path: '/music' }); }); }); test('Save aborts when test fails and surfaces the error', async () => { setupFresh(); (testLidarrConnection as ReturnType).mockResolvedValueOnce({ ok: false, error: 'lidarr_unreachable' }); const section = lidarrSection(); await fireEvent.input(within(section).getByLabelText(/Base URL/i), { target: { value: 'http://wrong.local' } }); await fireEvent.input(within(section).getByLabelText(/API key/i), { target: { value: 'k' } }); await fireEvent.click(within(section).getByRole('button', { name: /Save changes/i })); await waitFor(() => { expect(testLidarrConnection).toHaveBeenCalled(); }); expect(putLidarrConfig).not.toHaveBeenCalled(); await waitFor(() => { expect(within(lidarrSection()).getByText(/save failed — lidarr_unreachable/i)).toBeInTheDocument(); }); }); test('Save preserves operator-picked defaults over auto-defaults', async () => { setupFresh(); (testLidarrConnection as ReturnType).mockResolvedValue(testResponseOk); (putLidarrConfig as ReturnType).mockResolvedValueOnce(cfgConnected); const section = lidarrSection(); await fireEvent.input(within(section).getByLabelText(/Base URL/i), { target: { value: 'http://lidarr.local' } }); await fireEvent.input(within(section).getByLabelText(/API key/i), { target: { value: 'secret-key' } }); // Click Test first to populate the dropdowns, then change the // quality profile selection before clicking Save. await fireEvent.click(within(section).getByRole('button', { name: /Test connection/i })); await waitFor(() => { expect(testLidarrConnection).toHaveBeenCalledTimes(1); }); const qualitySelect = within(section).getByLabelText(/Default quality profile/i); await fireEvent.change(qualitySelect, { target: { value: '2' } }); await fireEvent.click(within(section).getByRole('button', { name: /Save changes/i })); await waitFor(() => { expect(putLidarrConfig).toHaveBeenCalledWith( expect.objectContaining({ default_quality_profile_id: 2, default_metadata_profile_id: 1, default_root_folder_path: '/music' }) ); }); }); }); 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).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).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()); }); });