feat(web/m7-coverage-gauge): coverage API client + query

Adds CoverageRollup type, getCoverageRollup() against
/api/admin/library/coverage, and createCoverageQuery() with the same
3s/30s pacing used by scan-status. New qk.coverage() key follows the
existing zero-arg tuple pattern. Vitest covers the GET path and the
empty-library zero-state plus the with_art + pending + settled = total
invariant.
This commit is contained in:
2026-05-06 10:12:57 -04:00
parent 97cca4411a
commit 3a424c6f73
3 changed files with 67 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getCoverageRollup, type CoverageRollup } from './admin';
vi.mock('./client', () => ({
api: { get: vi.fn(), post: vi.fn() }
}));
import { api } from './client';
describe('admin coverage API', () => {
beforeEach(() => vi.clearAllMocks());
it('getCoverageRollup GETs the correct path', async () => {
const sample: CoverageRollup = {
total: 18026,
with_art: 12400,
pending: 5400,
settled: 226,
pending_no_mbid: 320
};
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await getCoverageRollup();
expect(api.get).toHaveBeenCalledWith('/api/admin/library/coverage');
expect(got).toEqual(sample);
});
it('returns zeros on an empty library', async () => {
const empty: CoverageRollup = {
total: 0,
with_art: 0,
pending: 0,
settled: 0,
pending_no_mbid: 0
};
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(empty);
const got = await getCoverageRollup();
expect(got.total).toBe(0);
expect(got.with_art + got.pending + got.settled).toBe(got.total);
});
});
+26
View File
@@ -236,3 +236,29 @@ export function createScanStatusQuery() {
refetchInterval: 3_000
});
}
// Library coverage --------------------------------------------------------
export type CoverageRollup = {
total: number;
with_art: number;
pending: number;
settled: number;
pending_no_mbid: number;
};
export async function getCoverageRollup(): Promise<CoverageRollup> {
return api.get<CoverageRollup>('/api/admin/library/coverage');
}
// Same pacing as scan-status — 3s while polling, 30s stale-time. The
// query is cheap server-side (one aggregate per call) and we want
// the gauge to tick during a scan.
export function createCoverageQuery() {
return createQuery({
queryKey: qk.coverage(),
queryFn: getCoverageRollup,
staleTime: 30_000,
refetchInterval: 3_000
});
}
+1
View File
@@ -35,6 +35,7 @@ export const qk = {
adminQuarantineActions: (limit?: number) =>
['adminQuarantineActions', { limit: limit ?? 50 }] as const,
scanStatus: () => ['scanStatus'] as const,
coverage: () => ['coverage'] as const,
suggestions: (limit?: number) =>
['suggestions', { limit: limit ?? 12 }] as const,
home: () => ['home'] as const,