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
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>
746 lines
28 KiB
TypeScript
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());
|
|
});
|
|
});
|