From a1d295ebac2868809fff613f44709fd5428a7095 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 6 May 2026 09:54:56 -0400 Subject: [PATCH] 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. --- internal/api/admin_coverage.go | 38 ++++++++ internal/api/admin_coverage_test.go | 146 ++++++++++++++++++++++++++++ internal/api/api.go | 2 + 3 files changed, 186 insertions(+) create mode 100644 internal/api/admin_coverage.go create mode 100644 internal/api/admin_coverage_test.go diff --git a/internal/api/admin_coverage.go b/internal/api/admin_coverage.go new file mode 100644 index 00000000..eb43a2da --- /dev/null +++ b/internal/api/admin_coverage.go @@ -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, + }) +} diff --git a/internal/api/admin_coverage_test.go b/internal/api/admin_coverage_test.go new file mode 100644 index 00000000..45e2f482 --- /dev/null +++ b/internal/api/admin_coverage_test.go @@ -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()) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index bde9aaa9..fecb7520 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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)