47d2f61161
Six findings from the 2026-06-02 multi-system drift audit (Scribe parent task #552): - **#553 (web)** Tailwind class fix: web admin playback-errors Delete confirm button was using `bg-action-danger`, an undefined token — swap to `bg-action-destructive` to match every other destructive button. Restored the Oxblood signal that distinguishes Delete from Cancel. - **#555 (web)** Type the `source` field on `play_started` in the EventRequest discriminated union. Server's eventRequest accepts it; web's TS type was missing the slot, so a "drop extra properties" refactor could silently strip the source tag and break system- playlist rotation attribution. - **#556 + #557 (server)** Coverage rollup whitelist was pinned to ('sidecar','embedded','mbcaa','theaudiodb'); migration 0020 added 'deezer' and 'lastfm' as valid cover_art_source values but those never got wired in, so albums with art from those providers silently counted as MISSING in the admin Coverage dashboard. The rollup test was seeding only the pre-0020 sources, masking the gap in CI. Extend the query to include deezer + lastfm; seed the test with one row per valid source (regression-guards future additions). - **#558 (web)** Auth gate was blocking /forgot-password and /reset-password/<token> — both are entered without a session by definition, so the email-link reset flow was bouncing signed-out users to /login. Add /forgot-password to the public set and a /reset-password/ prefix matcher. New tests assert both routes reach their pages without redirect. - **#571 (server)** Library scanner was indexing only .mp3/.m4a/.flac /.ogg while the stream handler (media.go) had been extended to serve .opus, .aac, and .wav. A user with .opus files in their library never saw them in artist/album listings because the scanner skipped indexing — silent data loss. Aligned the scanner to match the media handler. Scribe statuses updated to in_progress; flipping to done after the push since these are mechanical and verified directly against the cited file:lines.
160 lines
5.6 KiB
Go
160 lines
5.6 KiB
Go
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.
|
|
artist := seedArtist(t, pool, "Coverage Test")
|
|
|
|
// 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
|
|
}
|
|
sourceNone := "none"
|
|
sourceSidecar := "sidecar"
|
|
sourceMbcaa := "mbcaa"
|
|
sourceEmbedded := "embedded"
|
|
sourceTheaudiodb := "theaudiodb"
|
|
sourceDeezer := "deezer"
|
|
sourceLastfm := "lastfm"
|
|
mbid1 := "11111111-1111-1111-1111-111111111111"
|
|
mbid2 := "22222222-2222-2222-2222-222222222222"
|
|
mbid3 := "33333333-3333-3333-3333-333333333333"
|
|
mbid4 := "44444444-4444-4444-4444-444444444444"
|
|
mbid5 := "55555555-5555-5555-5555-555555555555"
|
|
mbid6 := "66666666-6666-6666-6666-666666666666"
|
|
mbid7 := "77777777-7777-7777-7777-777777777777"
|
|
// Cover every cover_art_source value the migrations allow so a future
|
|
// addition without updating the rollup query trips this test —
|
|
// regression guard for drift #557, the gap that hid #556 (deezer +
|
|
// lastfm omission introduced by migration 0020 but never wired into
|
|
// the rollup whitelist).
|
|
rows := []seed{
|
|
{title: "WithArtSidecar", source: &sourceSidecar, mbid: &mbid1}, // with_art
|
|
{title: "WithArtMbcaa", source: &sourceMbcaa, mbid: &mbid2}, // with_art
|
|
{title: "WithArtEmbedded", source: &sourceEmbedded, mbid: &mbid4}, // with_art
|
|
{title: "WithArtTheaudiodb", source: &sourceTheaudiodb, mbid: &mbid5}, // with_art
|
|
{title: "WithArtDeezer", source: &sourceDeezer, mbid: &mbid6}, // with_art
|
|
{title: "WithArtLastfm", source: &sourceLastfm, mbid: &mbid7}, // with_art
|
|
{title: "PendingHasMbid", source: nil, mbid: &mbid3}, // pending (eligible)
|
|
{title: "PendingNoMbid", source: nil, mbid: nil}, // pending + pending_no_mbid
|
|
{title: "Settled", source: &sourceNone, 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)
|
|
`, artist.ID, 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 != 9 {
|
|
t.Errorf("Total = %d, want 9", resp.Total)
|
|
}
|
|
// Six with_art rows — one per valid cover_art_source value
|
|
// (sidecar, mbcaa, embedded, theaudiodb, deezer, lastfm).
|
|
if resp.WithArt != 6 {
|
|
t.Errorf("WithArt = %d, want 6", 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())
|
|
}
|
|
}
|