feat(server/m7-coverage-gauge): GET /api/admin/library/coverage

New admin handler returns the library-wide cover-art coverage rollup
{total, with_art, pending, settled, pending_no_mbid} for the admin
dashboard gauge. Always 200; zeros on empty library. Same RequireAdmin
middleware as /api/admin/scan/status. Lives under a new /library/
admin sub-namespace, parallel to /scan/ (per-run state) and /covers/
(actions).

Tests gated on MINSTREL_TEST_DATABASE_URL: empty library returns all
zeros, mixed rows produce correct bucket math (verifying the
with_art + pending + settled = total invariant), non-admin returns 403.
This commit is contained in:
2026-05-06 09:54:56 -04:00
parent ff5868bdab
commit a1d295ebac
3 changed files with 186 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
package api
import (
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// coverageRollupResp is the wire shape for GET /api/admin/library/coverage.
// All fields are non-negative counts. with_art + pending + settled = total;
// pending_no_mbid is a subset of pending.
type coverageRollupResp struct {
Total int64 `json:"total"`
WithArt int64 `json:"with_art"`
Pending int64 `json:"pending"`
Settled int64 `json:"settled"`
PendingNoMbid int64 `json:"pending_no_mbid"`
}
// handleGetLibraryCoverage implements GET /api/admin/library/coverage.
// Surfaces a library-wide rollup of albums.cover_art_source for the admin
// dashboard gauge. Always 200; zeros on an empty library.
func (h *handlers) handleGetLibraryCoverage(w http.ResponseWriter, r *http.Request) {
q := dbq.New(h.pool)
row, err := q.GetAlbumCoverageRollup(r.Context())
if err != nil {
h.logger.Error("admin: get coverage rollup", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
writeJSON(w, http.StatusOK, coverageRollupResp{
Total: row.Total,
WithArt: row.WithArt,
Pending: row.Pending,
Settled: row.Settled,
PendingNoMbid: row.PendingNoMbid,
})
}
+146
View File
@@ -0,0 +1,146 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)
// newAdminCoverageRouter builds a test chi router exposing only the
// coverage endpoint, mirroring the pattern in admin_scan_test.go.
func newAdminCoverageRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
})
return r
}
func TestAdminLibraryCoverage_EmptyLibraryReturnsZeros(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
admin := seedUser(t, pool, "covgauge1", "pw", true)
req := httptest.NewRequest(http.MethodGet, "/api/admin/library/coverage", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminCoverageRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp coverageRollupResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Total != 0 || resp.WithArt != 0 || resp.Pending != 0 || resp.Settled != 0 || resp.PendingNoMbid != 0 {
t.Errorf("expected all zeros on empty library; got %+v", resp)
}
}
func TestAdminLibraryCoverage_MixedRowsReturnCorrectBuckets(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
admin := seedUser(t, pool, "covgauge2", "pw", true)
// Seed an artist for FK satisfaction.
var artistID string
if err := pool.QueryRow(context.Background(), `
INSERT INTO artists (name, sort_name) VALUES ('Coverage Test', 'coverage test')
RETURNING id
`).Scan(&artistID); err != nil {
t.Fatalf("seed artist: %v", err)
}
// Seed albums covering each bucket. Use distinct titles to avoid the
// unique (artist_id, title) constraint. Distinct mbids where set so
// the partial unique index on mbid doesn't trip.
type seed struct {
title string
source *string
mbid *string
}
none := "none"
sidecar := "sidecar"
mbcaa := "mbcaa"
mbid1 := "11111111-1111-1111-1111-111111111111"
mbid2 := "22222222-2222-2222-2222-222222222222"
mbid3 := "33333333-3333-3333-3333-333333333333"
rows := []seed{
{title: "WithArtSidecar", source: &sidecar, mbid: &mbid1}, // with_art
{title: "WithArtMbcaa", source: &mbcaa, mbid: &mbid2}, // with_art
{title: "PendingHasMbid", source: nil, mbid: &mbid3}, // pending (eligible)
{title: "PendingNoMbid", source: nil, mbid: nil}, // pending + pending_no_mbid
{title: "Settled", source: &none, mbid: nil}, // settled (no mbid is fine here)
}
for _, s := range rows {
if _, err := pool.Exec(context.Background(), `
INSERT INTO albums (artist_id, title, sort_title, cover_art_source, mbid)
VALUES ($1, $2, $3, $4, $5)
`, artistID, s.title, s.title, s.source, s.mbid); err != nil {
t.Fatalf("seed album %q: %v", s.title, err)
}
}
req := httptest.NewRequest(http.MethodGet, "/api/admin/library/coverage", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminCoverageRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp coverageRollupResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Total != 5 {
t.Errorf("Total = %d, want 5", resp.Total)
}
if resp.WithArt != 2 {
t.Errorf("WithArt = %d, want 2", resp.WithArt)
}
if resp.Pending != 2 {
t.Errorf("Pending = %d, want 2", resp.Pending)
}
if resp.Settled != 1 {
t.Errorf("Settled = %d, want 1", resp.Settled)
}
if resp.PendingNoMbid != 1 {
t.Errorf("PendingNoMbid = %d, want 1", resp.PendingNoMbid)
}
// Invariant: with_art + pending + settled == total.
if resp.WithArt+resp.Pending+resp.Settled != resp.Total {
t.Errorf("invariant violated: %d + %d + %d != %d",
resp.WithArt, resp.Pending, resp.Settled, resp.Total)
}
}
func TestAdminLibraryCoverage_NonAdminReturns403(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
user := seedUser(t, pool, "covgauge3", "pw", false)
req := httptest.NewRequest(http.MethodGet, "/api/admin/library/coverage", nil)
req = withUser(req, user)
rec := httptest.NewRecorder()
newAdminCoverageRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
}
}
+2
View File
@@ -114,6 +114,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/scan/status", h.handleGetScanStatus)
admin.Post("/scan/run", h.handleTriggerScan)
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
})
authed.Get("/playlists", h.handleListPlaylists)