feat: Integrations splits into Library, Acquisition, Metadata sources and Email & network, each a page on the admin rail; the Overview keeps only what needs you (M503 #5607)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 16s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 16s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- Library: the folders, Run scan and a row per scan job, cover art, fingerprints and loudness (off the Overview and Duplicates) - Acquisition: indexers and clients, searching, re-acquisition (off Missing files), Lidarr - Metadata sources: AcoustID, and one provider block for cover art and tags in place of the pasted pair - Email & network: outgoing email, the notification schedule, public address, proxies - Lidarr, outgoing email and the provider block are rebuilt as SettingsGroup rows; failures read as sentences, not codes - Duplicates and Missing files say what their setting is and link to where it is set Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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 { CoverProvider, TagProvider } from '#lib/api/admin.js';
|
||||
|
||||
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.js', () => ({
|
||||
createCoverProvidersQuery: vi.fn(),
|
||||
updateCoverProvider: vi.fn(),
|
||||
testCoverProvider: vi.fn(),
|
||||
createTagProvidersQuery: vi.fn(),
|
||||
updateTagProvider: vi.fn(),
|
||||
testTagProvider: vi.fn(),
|
||||
// AcoustIDCard loads on mount and has its own suite; left pending.
|
||||
getAcoustID: vi.fn(() => new Promise(() => {})),
|
||||
updateAcoustIDSettings: vi.fn(),
|
||||
runAcoustIDLookup: vi.fn(),
|
||||
listUnsettledAcoustID: vi.fn()
|
||||
}));
|
||||
|
||||
import MetadataPage from './+page.svelte';
|
||||
import {
|
||||
createCoverProvidersQuery,
|
||||
updateCoverProvider,
|
||||
testCoverProvider,
|
||||
createTagProvidersQuery,
|
||||
updateTagProvider,
|
||||
testTagProvider
|
||||
} from '#lib/api/admin.js';
|
||||
|
||||
const caa: 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 audiodb: 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 mb: 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 lastfm: 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
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
invalidateQueries.mockReset();
|
||||
});
|
||||
|
||||
function setup(opts: { cover?: ReturnType<typeof mockQuery>; covers?: CoverProvider[] } = {}) {
|
||||
vi.mocked(createCoverProvidersQuery).mockReturnValue(
|
||||
(opts.cover ?? mockQuery({ data: { providers: opts.covers ?? [caa, audiodb], sources_version: 1 } })) as never
|
||||
);
|
||||
vi.mocked(createTagProvidersQuery).mockReturnValue(
|
||||
mockQuery({ data: { providers: [mb, lastfm], sources_version: 1 } }) as never
|
||||
);
|
||||
return render(MetadataPage);
|
||||
}
|
||||
|
||||
const source = (id: string) => screen.getAllByTestId('provider').find((el) => el.dataset.provider === id)!;
|
||||
const group = (title: string) => screen.getByRole('heading', { name: title, level: 2 }).closest('section') as HTMLElement;
|
||||
|
||||
describe('Admin › Metadata sources', () => {
|
||||
test('each source is a row saying what it supplies in words, not its codes', () => {
|
||||
setup();
|
||||
const row = source('theaudiodb');
|
||||
expect(row.textContent).toMatch(/Album covers, artist photos, artist backdrops/);
|
||||
expect(row.textContent).not.toMatch(/artist_thumb|album_cover/);
|
||||
expect(within(group('Tags')).getAllByTestId('provider')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a saved key says so, and an unsaved one invites a key', () => {
|
||||
setup({ covers: [{ ...audiodb, api_key_set: true }] });
|
||||
expect(within(source('theaudiodb')).getByPlaceholderText(/key saved/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Save is off until something changes, then sends only the change', async () => {
|
||||
setup();
|
||||
vi.mocked(updateCoverProvider).mockResolvedValueOnce({ ...audiodb, version_bumped: false });
|
||||
const row = source('theaudiodb');
|
||||
const save = within(row).getByRole('button', { name: /^save$/i }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(true);
|
||||
await fireEvent.input(within(row).getByLabelText(/theaudiodb api key/i), { target: { value: 'newkey123' } });
|
||||
expect(save.disabled).toBe(false);
|
||||
await fireEvent.click(save);
|
||||
expect(updateCoverProvider).toHaveBeenCalledWith('theaudiodb', { api_key: 'newkey123' });
|
||||
});
|
||||
|
||||
test('switching a cover source off reopens covers no source had', async () => {
|
||||
setup();
|
||||
vi.mocked(updateCoverProvider).mockResolvedValueOnce({ ...caa, enabled: false, version_bumped: true });
|
||||
const row = source('mbcaa');
|
||||
await fireEvent.click(within(row).getByRole('switch'));
|
||||
await fireEvent.click(within(row).getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['coverage'] })));
|
||||
});
|
||||
|
||||
test('a save that fails says so', async () => {
|
||||
setup();
|
||||
vi.mocked(updateCoverProvider).mockRejectedValueOnce({ code: 'server_error' });
|
||||
const row = source('theaudiodb');
|
||||
await fireEvent.input(within(row).getByLabelText(/api key/i), { target: { value: 'k' } });
|
||||
await fireEvent.click(within(row).getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/couldn't save theaudiodb/i));
|
||||
});
|
||||
|
||||
test('Test says whether the source answered', async () => {
|
||||
setup();
|
||||
vi.mocked(testCoverProvider).mockResolvedValueOnce({ ok: true, duration_ms: 120 });
|
||||
await fireEvent.click(within(source('mbcaa')).getByRole('button', { name: /^test$/i }));
|
||||
await waitFor(() => expect(within(source('mbcaa')).getByTestId('provider-test').textContent).toMatch(/works · answered in 120 ms/i));
|
||||
});
|
||||
|
||||
test('sources that fail to load say so, with Try again', () => {
|
||||
setup({ cover: mockQuery({ isError: true, error: { code: 'server_error' } }) });
|
||||
expect(within(group('Cover art')).getByTestId('providers-error').textContent).toMatch(/couldn't load the cover art sources/i);
|
||||
});
|
||||
|
||||
test('a tag source is switched on with its key in one save', async () => {
|
||||
setup();
|
||||
vi.mocked(updateTagProvider).mockResolvedValueOnce({ ...lastfm, enabled: true, api_key_set: true });
|
||||
const row = source('lastfm');
|
||||
await fireEvent.click(within(row).getByRole('switch'));
|
||||
await fireEvent.input(within(row).getByLabelText(/api key/i), { target: { value: 'lfmkey' } });
|
||||
await fireEvent.click(within(row).getByRole('button', { name: /^save$/i }));
|
||||
expect(updateTagProvider).toHaveBeenCalledWith('lastfm', { enabled: true, api_key: 'lfmkey' });
|
||||
await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['tagProviders'] })));
|
||||
});
|
||||
|
||||
test('Test on a tag source asks that source', async () => {
|
||||
setup();
|
||||
vi.mocked(testTagProvider).mockResolvedValueOnce({ ok: true, duration_ms: 42 });
|
||||
await fireEvent.click(within(source('musicbrainz')).getByRole('button', { name: /^test$/i }));
|
||||
await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz'));
|
||||
});
|
||||
|
||||
test('where to get a key is linked from the source that needs one', () => {
|
||||
setup();
|
||||
expect(within(source('lastfm')).getByRole('link', { name: /get a free key/i })).toBeInTheDocument();
|
||||
expect(within(source('theaudiodb')).getByRole('link', { name: /a free key of your own/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user