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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user