Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
7.4 KiB
Go
215 lines
7.4 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"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"
|
|
)
|
|
|
|
// 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_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 mbids are set on the track and album when
|
|
// non-empty.
|
|
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, with the shared middleware's
|
|
// {"error":"not_authorized"} envelope, which this handler doesn't own.
|
|
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 verifies the success envelope: 200 with
|
|
// deleted_track_id and the optional cascade fields when they fired.
|
|
func TestHandleRemoveTrack_HappyPath(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")
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|