Files
minstrel/web/src/routes/admin/integrations/integrations.test.ts
T
bvandeusenandClaude Opus 5.5 63709a433d
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(notifications): grouped email digest, new music as a daily summary (#5346)
Nothing is emailed per event. New music (request_completed) goes out at
most once a day, at the summary hour in each user's own timezone, grouped
by artist. Everything else is batched: one email a window after the first
un-emailed item, holding whatever accumulated.

- Migration 0074: notification_email_settings (summary hour, batch window,
  admin-configurable) and user_notification_email_state (batch start, last
  sent, failures and retry_after per user and group). Existing rows are
  stamped emailed so the upgrade sends no backlog.
- The Notifier stamps emailed_at at write time when the recipient's email
  channel is off, so turning email on later doesn't send old items.
- Read rows are never selected. A row is stamped only after the mailer
  accepts, in one transaction with the state, against the read's clock, so
  a coalesced row updated mid-send stays pending.
- A failed send backs off 5m doubling to 6h; SMTP not configured just waits.
- Links come from the public address; without one the email has none.
- The mailer now RFC 2047-encodes subjects and strips line breaks from them.
- Admin → Integrations gains a Notification emails card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:48:22 -04:00

746 lines
28 KiB
TypeScript

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<string, unknown>;
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<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.config ?? cfgConnected })
);
(createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({
data: [
{ id: 1, name: 'Standard' },
{ id: 2, name: 'Lossless' }
]
})
);
(createMetadataProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({
data: [
{ id: 1, name: 'Standard' },
{ id: 2, name: 'Singles only' }
]
})
);
(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 })
);
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 })
);
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 <section>.
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 <article>. 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<typeof vi.fn>).mockResolvedValueOnce(testResponseOk);
(putLidarrConfig as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce(testResponseOk);
(putLidarrConfig as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<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: 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(
mockQuery({ data: cfgUnset })
);
(createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
(createMetadataProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: defaultCoverProviders })
);
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: smtpUnset })
);
return render(IntegrationsPage);
}
test('Save calls test first, fills defaults from response, then put-config', async () => {
setupFresh();
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce(testResponseOk);
(putLidarrConfig as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(testResponseOk);
(putLidarrConfig as ReturnType<typeof vi.fn>).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<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());
});
});