Files
minstrel/internal/api/admin_tracks_test.go
T
bvandeusen 295d9da361 feat(server/m7-cover-sources): artist art enricher (thumb + fanart)
Parallels EnrichAlbum but with no sidecar layer (artists have no
per-artist directory in a music library). Iterates
EnabledArtistProviders, writes thumb.jpg + fanart.jpg to
<data_dir>/artist-art/<artist_id>/, stamps artist_art_source +
artist_art_sources_version atomically.

Atomicity: provider returns whichever images it has — partial
success (thumb-only or fanart-only) is persisted with whichever
paths landed, source still stamped. ErrNotFound from every enabled
provider settles the row to 'none' with the current version stamp.
Any ErrTransient leaves the row NULL for next-pass retry.

CleanupArtistArt removes <data_dir>/artist-art/<artist_id>/ on
artist delete; idempotent on missing dir. Hooked into the artist-
delete cascade in tracks/service.go after the transaction commits;
non-fatal on failure (logs but doesn't fail the delete).

EnrichArtistBatch drains rows from ListArtistsMissingArt; the
orchestrator's 4th stage (added in T10) calls this. Tests cover
the eligibility table, atomicity (thumb-only / fanart-only),
all-404-settles, ErrTransient-leaves-NULL, filesystem layout, and
CleanupArtistArt idempotency.

tracks.Service gains a dataDir field; tracks.NewService takes it
as a new parameter. All three call sites updated (server/server.go,
api/auth_test.go, api/admin_tracks_test.go). The dataDir flows from
cfg.Storage.DataDir → server.New → s.DataDir → tracks.NewService
without any change to cmd/minstrel/main.go.
2026-05-06 12:59:53 -04:00

311 lines
11 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
// fakeLidarrUnmonitorer captures UnmonitorTrack calls for assertion and lets
// tests dictate the return error. Used to drive both the Lidarr-success and
// Lidarr-failure branches without spinning up a stub HTTP server.
type fakeLidarrUnmonitorer struct {
calls []fakeUnmonitorCall
err error
}
type fakeUnmonitorCall struct {
trackMbid string
albumMbid string
}
func (f *fakeLidarrUnmonitorer) UnmonitorTrack(_ context.Context, trackMbid, albumMbid string) error {
f.calls = append(f.calls, fakeUnmonitorCall{trackMbid: trackMbid, albumMbid: albumMbid})
return f.err
}
// installTracksLidarrStub replaces h.tracks with a fresh tracks.Service whose
// LidarrUnmonitorer is the supplied fake. Returns the fake so tests can
// assert call counts and inject error returns.
func installTracksLidarrStub(t *testing.T, h *handlers) *fakeLidarrUnmonitorer {
t.Helper()
fake := &fakeLidarrUnmonitorer{}
h.tracks = tracks.NewService(h.pool, h.logger, fake, h.dataDir)
return fake
}
// newAdminTracksRouter builds a test chi router with the admin-tracks
// endpoint. RequireAdmin middleware is applied; tests inject the user into
// context manually (RequireUser is bypassed). Mirrors the pattern from
// admin_lidarr_test.go and admin_quarantine_test.go.
func newAdminTracksRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
})
return r
}
// doAdminTracksReq fires an HTTP request against the admin-tracks router
// with the given user in context.
func doAdminTracksReq(t *testing.T, h *handlers, method, path string, user dbq.User) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewBuffer(nil))
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
w := httptest.NewRecorder()
newAdminTracksRouter(h).ServeHTTP(w, req)
return w
}
// doAdminTracksReqNoUser fires a request directly at the handler with NO
// user injected into context. Used to exercise the defensive 401 branch
// inside the handler — real traffic hits RequireUser upstream, but the
// handler defends against routing misconfigurations and we test that path
// here. We bypass the admin router entirely since RequireAdmin would
// short-circuit with 500 internal_error on missing-user-in-context (which
// is its programmer-error contract); the handler's own 401 path is what
// we want to assert.
func doAdminTracksReqNoUser(t *testing.T, h *handlers, method, path, idParam string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewBuffer(nil))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", idParam)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
h.handleRemoveTrack(w, req)
return w
}
// seedTrackForRemoveTest creates a fresh artist+album+track on disk and
// returns the track row. The mbid string is set on both track and album
// when non-empty so the unmonitor branch has the data it needs.
func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumMbid string) (dbq.Track, string) {
t.Helper()
artist := seedArtist(t, h.pool, "RemoveTest Artist "+unique)
dir := t.TempDir()
path := filepath.Join(dir, "track-"+unique+".mp3")
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
q := dbq.New(h.pool)
var albumMbidPtr *string
if albumMbid != "" {
albumMbidPtr = &albumMbid
}
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "RemoveTest Album " + unique,
SortTitle: "RemoveTest Album " + unique,
ArtistID: artist.ID,
Mbid: albumMbidPtr,
})
if err != nil {
t.Fatalf("UpsertAlbum: %v", err)
}
one := int32(1)
var trackMbidPtr *string
if trackMbid != "" {
trackMbidPtr = &trackMbid
}
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "RemoveTest Track " + unique,
AlbumID: album.ID,
ArtistID: artist.ID,
TrackNumber: &one,
DurationMs: 30000,
FilePath: path,
FileSize: 5,
FileFormat: "mp3",
Mbid: trackMbidPtr,
})
if err != nil {
t.Fatalf("UpsertTrack: %v", err)
}
return tr, path
}
// TestHandleRemoveTrack_NonAdmin403 verifies the RequireAdmin middleware
// rejects non-admin callers. Asserts the existing middleware shape
// ({"error":"not_authorized"}) — spec §5 says `unauthorized`, but the
// shared middleware emits not_authorized today, and this handler doesn't
// own that envelope.
func TestHandleRemoveTrack_NonAdmin403(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
nonAdmin := seedUser(t, pool, "regular", "pw", false)
fakeID := "00000000-0000-0000-0000-000000000001"
w := doAdminTracksReq(t, h, http.MethodDelete, "/api/admin/tracks/"+fakeID, nonAdmin)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "not_authorized" {
t.Errorf("error = %q, want not_authorized", resp["error"])
}
}
// TestHandleRemoveTrack_NoSession401 exercises the defensive no-user-in-
// context branch in the handler itself. Real traffic hits RequireUser
// upstream (which emits 401 with text "unauthenticated"), but the handler
// also defends against routing misconfigurations and we test that
// directly.
func TestHandleRemoveTrack_NoSession401(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
tr, _ := seedTrackForRemoveTest(t, h, "no-session", "", "")
w := doAdminTracksReqNoUser(t, h, http.MethodDelete,
"/api/admin/tracks/"+uuidToString(tr.ID), uuidToString(tr.ID))
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body = %s", w.Code, w.Body.String())
}
var env errorBody
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if env.Error.Code != "unauthenticated" {
t.Errorf("error.code = %q, want unauthenticated", env.Error.Code)
}
}
// TestHandleRemoveTrack_UnknownID404 verifies ErrNotFound from the service
// maps to 404 not_found in the spec envelope.
func TestHandleRemoveTrack_UnknownID404(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "admin", "pw", true)
// Well-formed UUID with no matching row in the DB.
missing := "00000000-0000-0000-0000-0000000000aa"
w := doAdminTracksReq(t, h, http.MethodDelete, "/api/admin/tracks/"+missing, admin)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
}
var env errorBody
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if env.Error.Code != "not_found" {
t.Errorf("error.code = %q, want not_found", env.Error.Code)
}
}
// TestHandleRemoveTrack_HappyPath_NoUnmonitor verifies the success envelope
// when the operator does NOT request unmonitor: 200 with deleted_track_id
// (and the optional cascade fields when they fired) but NO
// lidarr_unmonitor_failed key in the body.
func TestHandleRemoveTrack_HappyPath_NoUnmonitor(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "admin", "pw", true)
// Track with no mbid; sole track on its album/artist so the cascade
// fills both deleted_album_id and deleted_artist_id.
tr, path := seedTrackForRemoveTest(t, h, "noumon-happy", "", "")
w := doAdminTracksReq(t, h, http.MethodDelete,
"/api/admin/tracks/"+uuidToString(tr.ID), admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got removeTrackResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.DeletedTrackID != uuidToString(tr.ID) {
t.Errorf("deleted_track_id = %q, want %q", got.DeletedTrackID, uuidToString(tr.ID))
}
if got.DeletedAlbumID == nil {
t.Error("deleted_album_id missing; expected sole-track cascade")
}
if got.DeletedArtistID == nil {
t.Error("deleted_artist_id missing; expected sole-track cascade")
}
if got.LidarrUnmonitorFailed != nil {
t.Errorf("lidarr_unmonitor_failed should be omitted when unmonitor not requested; got %v",
*got.LidarrUnmonitorFailed)
}
// Verify the raw JSON has no `lidarr_unmonitor_failed` key — omitempty
// on a *bool drops it, but we double-check the wire to catch
// regressions.
var raw map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode raw: %v", err)
}
if _, ok := raw["lidarr_unmonitor_failed"]; ok {
t.Error("wire body contained lidarr_unmonitor_failed when unmonitor was not requested")
}
// File is gone, DB row is gone.
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("file still exists: %v", err)
}
if _, err := dbq.New(pool).GetTrackByID(context.Background(), tr.ID); err == nil {
t.Error("track row still exists")
}
}
// TestHandleRemoveTrack_UnmonitorFailureFlag verifies that when the
// operator sends ?unmonitor=true AND the Lidarr call fails, the response
// is still 200 (file + DB delete completed) but carries
// lidarr_unmonitor_failed: true.
func TestHandleRemoveTrack_UnmonitorFailureFlag(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "admin", "pw", true)
fake := installTracksLidarrStub(t, h)
fake.err = errors.New("lidarr exploded")
tr, path := seedTrackForRemoveTest(t, h, "umon-fail", "track-mbid-x", "album-mbid-x")
w := doAdminTracksReq(t, h, http.MethodDelete,
"/api/admin/tracks/"+uuidToString(tr.ID)+"?unmonitor=true", admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got removeTrackResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.DeletedTrackID != uuidToString(tr.ID) {
t.Errorf("deleted_track_id = %q, want %q", got.DeletedTrackID, uuidToString(tr.ID))
}
if got.LidarrUnmonitorFailed == nil || !*got.LidarrUnmonitorFailed {
t.Errorf("lidarr_unmonitor_failed = %v, want true", got.LidarrUnmonitorFailed)
}
// Lidarr was actually called with the right mbids.
if len(fake.calls) != 1 {
t.Fatalf("UnmonitorTrack call count = %d, want 1", len(fake.calls))
}
if fake.calls[0].trackMbid != "track-mbid-x" || fake.calls[0].albumMbid != "album-mbid-x" {
t.Errorf("unmonitor call = %+v, want {track-mbid-x album-mbid-x}", fake.calls[0])
}
// Destructive part still happened.
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("file still exists: %v", err)
}
if _, err := dbq.New(pool).GetTrackByID(context.Background(), tr.ID); err == nil {
t.Error("track row still exists")
}
}