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