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 = withUser(req, 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") } }