feat(web/m7-cover-sources): cover-providers API client + query

Adds CoverProvider type + capability union, getCoverProviders /
updateCoverProvider / testCoverProvider against the three new admin
endpoints, plus createCoverProvidersQuery (60s staleTime; refetches
on focus + after mutations rather than polling — settings rarely
change in operator time).

New api.patch helper added to client.ts (the existing helpers were
get/post/put/del; PATCH was missing).

New qk.coverProviders() key follows the existing tuple pattern.
api_key_set: bool replaces echoing the credential. UpdateCoverProviderPatch
uses optional fields so the operator can change just enabled, just
key, or both atomically.

Vitest covers GET path, PATCH path with body, version_bumped
response, POST /test path with empty body, ok=false error response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 13:11:08 -04:00
parent b6632136b7
commit 39a07a280c
4 changed files with 166 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getCoverProviders,
updateCoverProvider,
testCoverProvider,
type CoverProvider,
type CoverProvidersResponse
} from './admin';
vi.mock('./client', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
}));
import { api } from './client';
describe('admin cover-sources API', () => {
beforeEach(() => vi.clearAllMocks());
it('getCoverProviders GETs the correct path', async () => {
const sample: CoverProvidersResponse = {
providers: [
{
id: 'mbcaa',
display_name: 'Cover Art Archive',
requires_api_key: false,
supports: ['album_cover'],
enabled: true,
api_key_set: false,
display_order: 0,
testable: true
}
],
sources_version: 1
};
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await getCoverProviders();
expect(api.get).toHaveBeenCalledWith('/api/admin/cover-sources');
expect(got.providers[0].id).toBe('mbcaa');
expect(got.sources_version).toBe(1);
});
it('updateCoverProvider PATCHes the right path with patch body', async () => {
const patched: CoverProvider & { version_bumped: boolean } = {
id: 'theaudiodb',
display_name: 'TheAudioDB',
requires_api_key: true,
supports: ['album_cover', 'artist_thumb', 'artist_fanart'],
enabled: true,
api_key_set: true,
display_order: 1,
testable: true,
version_bumped: false
};
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(patched);
const got = await updateCoverProvider('theaudiodb', { api_key: 'mykey' });
expect(api.patch).toHaveBeenCalledWith(
'/api/admin/cover-sources/theaudiodb',
{ api_key: 'mykey' }
);
expect(got.api_key_set).toBe(true);
expect(got.version_bumped).toBe(false);
});
it('updateCoverProvider with enabled toggle returns version_bumped: true', async () => {
const patched: CoverProvider & { version_bumped: boolean } = {
id: 'theaudiodb',
display_name: 'TheAudioDB',
requires_api_key: true,
supports: ['album_cover', 'artist_thumb', 'artist_fanart'],
enabled: false,
api_key_set: false,
display_order: 1,
testable: true,
version_bumped: true
};
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(patched);
const got = await updateCoverProvider('theaudiodb', { enabled: false });
expect(got.version_bumped).toBe(true);
expect(got.enabled).toBe(false);
});
it('testCoverProvider POSTs to /test endpoint with empty body', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
duration_ms: 234
});
const got = await testCoverProvider('theaudiodb');
expect(api.post).toHaveBeenCalledWith('/api/admin/cover-sources/theaudiodb/test', {});
expect(got.ok).toBe(true);
expect(got.duration_ms).toBe(234);
});
it('testCoverProvider returns ok=false with error string on failure', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
duration_ms: 12,
error: 'connection refused'
});
const got = await testCoverProvider('theaudiodb');
expect(got.ok).toBe(false);
expect(got.error).toBe('connection refused');
});
});
+60
View File
@@ -269,3 +269,63 @@ export function createCoverageQuery() {
refetchInterval: 3_000
});
}
// Cover-art providers ------------------------------------------------------
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
export type CoverProvider = {
id: string;
display_name: string;
requires_api_key: boolean;
supports: CoverProviderCapability[];
enabled: boolean;
api_key_set: boolean;
display_order: number;
testable: boolean;
};
export type CoverProvidersResponse = {
providers: CoverProvider[];
sources_version: number;
};
export async function getCoverProviders(): Promise<CoverProvidersResponse> {
return api.get<CoverProvidersResponse>('/api/admin/cover-sources');
}
export type UpdateCoverProviderPatch = {
enabled?: boolean;
api_key?: string;
};
export type UpdateCoverProviderResponse = CoverProvider & {
version_bumped: boolean;
};
export async function updateCoverProvider(
id: string,
patch: UpdateCoverProviderPatch
): Promise<UpdateCoverProviderResponse> {
return api.patch<UpdateCoverProviderResponse>(`/api/admin/cover-sources/${id}`, patch);
}
export type TestCoverProviderResponse = {
ok: boolean;
duration_ms?: number;
error?: string;
};
export async function testCoverProvider(id: string): Promise<TestCoverProviderResponse> {
return api.post<TestCoverProviderResponse>(`/api/admin/cover-sources/${id}/test`, {});
}
// Settings rarely change in operator time. 60s staleTime; refetches
// on window focus + after any mutation rather than polling.
export function createCoverProvidersQuery() {
return createQuery({
queryKey: qk.coverProviders(),
queryFn: getCoverProviders,
staleTime: 60_000
});
}
+2
View File
@@ -56,6 +56,8 @@ export const api = {
apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise<T>,
put: <T>(path: string, body: unknown): Promise<T> =>
apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }) as Promise<T>,
patch: <T>(path: string, body: unknown): Promise<T> =>
apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }) as Promise<T>,
del: (path: string): Promise<null> =>
apiFetch(path, { method: 'DELETE' }) as Promise<null>
};
+1
View File
@@ -36,6 +36,7 @@ export const qk = {
['adminQuarantineActions', { limit: limit ?? 50 }] as const,
scanStatus: () => ['scanStatus'] as const,
coverage: () => ['coverage'] as const,
coverProviders: () => ['coverProviders'] as const,
suggestions: (limit?: number) =>
['suggestions', { limit: limit ?? 12 }] as const,
home: () => ['home'] as const,