From 3a424c6f7371f5519a2fc1f3871b96d3a3f22687 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 6 May 2026 10:12:57 -0400 Subject: [PATCH] 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. --- web/src/lib/api/admin.coverage.test.ts | 40 ++++++++++++++++++++++++++ web/src/lib/api/admin.ts | 26 +++++++++++++++++ web/src/lib/api/queries.ts | 1 + 3 files changed, 67 insertions(+) create mode 100644 web/src/lib/api/admin.coverage.test.ts diff --git a/web/src/lib/api/admin.coverage.test.ts b/web/src/lib/api/admin.coverage.test.ts new file mode 100644 index 00000000..c6b5b296 --- /dev/null +++ b/web/src/lib/api/admin.coverage.test.ts @@ -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).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).mockResolvedValueOnce(empty); + const got = await getCoverageRollup(); + expect(got.total).toBe(0); + expect(got.with_art + got.pending + got.settled).toBe(got.total); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 815badbd..91b2620d 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -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 { + return api.get('/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 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index dd0f6f4d..a8d38b59 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -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,