a8fd33d4ed
Nine golangci-lint failures accumulated across M7 #352, #372, and
6d1709c that were never surfaced because test-go never ran green long
enough to reach the lint step. The first push to land cleanly through
vet (the M7 #363 slice) ran lint and exposed them.
- errcheck: discard the error on defer Close() in collage.go,
collage_test.go, and server_test.go.
- gofmt -s: re-run on config.go and playlists_test.go.
- goimports: move the stray "time" import from the local-module group
to stdlib in server_test.go.
- revive unused-parameter: rename ctx to _ on stubScanner.Scan and
fakeLidarrUnmonitorer.UnmonitorTrack (test stubs); on
tracks.Service's adminID, add //nolint:revive directive rather than
renaming because the HTTP handler passes admin.ID (a real authed-user
UUID) and the existing comment already flags it as reserved for the
audit-log follow-up.
457 lines
16 KiB
Go
457 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// newPlaylistsRouter mounts the 9 playlist routes for an in-test chi
|
|
// instance. RequireUser is NOT applied — tests inject the user into
|
|
// context manually so we exercise the handler-owned auth defense
|
|
// directly. Mirrors the pattern from newAdminTracksRouter.
|
|
func newPlaylistsRouter(h *handlers) chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Get("/api/playlists", h.handleListPlaylists)
|
|
r.Post("/api/playlists", h.handleCreatePlaylist)
|
|
r.Get("/api/playlists/{id}", h.handleGetPlaylist)
|
|
r.Patch("/api/playlists/{id}", h.handleUpdatePlaylist)
|
|
r.Delete("/api/playlists/{id}", h.handleDeletePlaylist)
|
|
r.Post("/api/playlists/{id}/tracks", h.handleAppendTracks)
|
|
r.Delete("/api/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
|
|
r.Put("/api/playlists/{id}/tracks", h.handleReorderPlaylist)
|
|
r.Get("/api/playlists/{id}/cover", h.handleGetPlaylistCover)
|
|
return r
|
|
}
|
|
|
|
// doPlaylistsReq fires an HTTP request with the given user injected into
|
|
// the request context. Body may be nil.
|
|
func doPlaylistsReq(h *handlers, user dbq.User, method, path string, body []byte) *httptest.ResponseRecorder {
|
|
var rdr *bytes.Reader
|
|
if body != nil {
|
|
rdr = bytes.NewReader(body)
|
|
} else {
|
|
rdr = bytes.NewReader(nil)
|
|
}
|
|
req := httptest.NewRequest(method, path, rdr)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
|
|
w := httptest.NewRecorder()
|
|
newPlaylistsRouter(h).ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
// seedSimpleTrack inserts an artist + album + track triple under unique
|
|
// titles. Used by the append/reorder test that doesn't care about
|
|
// genre / file content. seedTrack from library_fixtures_test.go takes
|
|
// IDs the caller already has; we collapse the three calls here.
|
|
func seedSimpleTrack(t *testing.T, pool *pgxpool.Pool, title, artist string) dbq.Track {
|
|
t.Helper()
|
|
a := seedArtist(t, pool, artist)
|
|
al := seedAlbum(t, pool, a.ID, artist+" - "+title+" Album", 0)
|
|
return seedTrack(t, pool, al.ID, a.ID, title, 1, 1000)
|
|
}
|
|
|
|
// TestPlaylists_NoSession401 verifies the handler-owned defensive 401
|
|
// path on each route. Real traffic hits RequireUser upstream, but the
|
|
// handler also defends against routing misconfigurations.
|
|
func TestPlaylists_NoSession401(t *testing.T) {
|
|
h, _ := testHandlers(t)
|
|
r := newPlaylistsRouter(h)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/playlists", nil)
|
|
w := httptest.NewRecorder()
|
|
// Note: NO user in context.
|
|
r.ServeHTTP(w, req)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestPlaylists_CreateThenGet exercises the happy-path end-to-end:
|
|
// POST creates a row, the response carries the id, GET round-trips
|
|
// the same fields.
|
|
func TestPlaylists_CreateThenGet(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
user := seedUser(t, pool, "alice", "pw", false)
|
|
|
|
body := []byte(`{"name":"Saturday morning","description":"Mellow","is_public":false}`)
|
|
w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("create status = %d, body = %s", w.Code, w.Body.String())
|
|
}
|
|
var created playlistRowView
|
|
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
|
|
t.Fatalf("decode create: %v; body = %s", err, w.Body.String())
|
|
}
|
|
if created.ID == "" {
|
|
t.Fatal("created.id empty")
|
|
}
|
|
if created.Name != "Saturday morning" {
|
|
t.Errorf("name = %q, want Saturday morning", created.Name)
|
|
}
|
|
if created.IsPublic {
|
|
t.Error("is_public = true, want false")
|
|
}
|
|
if created.OwnerUsername != user.Username {
|
|
t.Errorf("owner_username = %q, want %q", created.OwnerUsername, user.Username)
|
|
}
|
|
|
|
w2 := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+created.ID, nil)
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("get status = %d, body = %s", w2.Code, w2.Body.String())
|
|
}
|
|
var got playlistDetailView
|
|
if err := json.Unmarshal(w2.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode get: %v; body = %s", err, w2.Body.String())
|
|
}
|
|
if got.Name != "Saturday morning" {
|
|
t.Errorf("get name = %q", got.Name)
|
|
}
|
|
if got.Tracks == nil {
|
|
t.Error("tracks is nil; want empty slice")
|
|
}
|
|
if len(got.Tracks) != 0 {
|
|
t.Errorf("tracks len = %d, want 0", len(got.Tracks))
|
|
}
|
|
}
|
|
|
|
// TestPlaylists_CreateEmptyName400 verifies the service's
|
|
// ErrInvalidInput surfaces as bad_request on the wire.
|
|
func TestPlaylists_CreateEmptyName400(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
user := seedUser(t, pool, "alice", "pw", false)
|
|
|
|
body := []byte(`{"name":"","description":""}`)
|
|
w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
|
|
}
|
|
var env errorBody
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if env.Error.Code != "bad_request" {
|
|
t.Errorf("error.code = %q, want bad_request", env.Error.Code)
|
|
}
|
|
}
|
|
|
|
// TestPlaylists_PatchOwnerOnly verifies the ErrForbidden → 403
|
|
// not_authorized path. Bob creates nothing; Alice creates a playlist;
|
|
// Bob tries to PATCH it.
|
|
func TestPlaylists_PatchOwnerOnly(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
alice := seedUser(t, pool, "alice", "pw", false)
|
|
bob := seedUser(t, pool, "bob", "pw", false)
|
|
|
|
createBody := []byte(`{"name":"Mine","is_public":true}`)
|
|
cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", createBody)
|
|
if cw.Code != http.StatusOK {
|
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
|
}
|
|
var created playlistRowView
|
|
if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
patchBody := []byte(`{"name":"Hijacked"}`)
|
|
pw := doPlaylistsReq(h, bob, http.MethodPatch, "/api/playlists/"+created.ID, patchBody)
|
|
if pw.Code != http.StatusForbidden {
|
|
t.Fatalf("non-owner patch status = %d, want 403; body = %s", pw.Code, pw.Body.String())
|
|
}
|
|
var env errorBody
|
|
if err := json.Unmarshal(pw.Body.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if env.Error.Code != "not_authorized" {
|
|
t.Errorf("error.code = %q, want not_authorized", env.Error.Code)
|
|
}
|
|
}
|
|
|
|
// TestPlaylists_GetNotFound verifies a well-formed UUID with no row
|
|
// surfaces as 404 not_found.
|
|
func TestPlaylists_GetNotFound(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
user := seedUser(t, pool, "alice", "pw", false)
|
|
|
|
missing := "00000000-0000-0000-0000-000000000099"
|
|
w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+missing, nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPlaylists_ListSplitsOwnedAndPublic verifies the response carries
|
|
// owned vs. public buckets correctly. Alice owns one private + one
|
|
// public; Bob sees only Alice's public.
|
|
func TestPlaylists_ListSplitsOwnedAndPublic(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
alice := seedUser(t, pool, "alice", "pw", false)
|
|
bob := seedUser(t, pool, "bob", "pw", false)
|
|
|
|
for _, body := range [][]byte{
|
|
[]byte(`{"name":"AlicePrivate","is_public":false}`),
|
|
[]byte(`{"name":"AlicePublic","is_public":true}`),
|
|
} {
|
|
w := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", body)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("create: %d %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Bob lists: should see exactly one public playlist owned by Alice.
|
|
bw := doPlaylistsReq(h, bob, http.MethodGet, "/api/playlists", nil)
|
|
if bw.Code != http.StatusOK {
|
|
t.Fatalf("list: %d %s", bw.Code, bw.Body.String())
|
|
}
|
|
var bobResp listPlaylistsResponse
|
|
if err := json.Unmarshal(bw.Body.Bytes(), &bobResp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(bobResp.Owned) != 0 {
|
|
t.Errorf("bob.owned len = %d, want 0", len(bobResp.Owned))
|
|
}
|
|
if len(bobResp.Public) != 1 || bobResp.Public[0].Name != "AlicePublic" {
|
|
t.Errorf("bob.public = %+v, want one [AlicePublic]", bobResp.Public)
|
|
}
|
|
|
|
// Alice lists: sees both as owned, public bucket empty.
|
|
aw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists", nil)
|
|
if aw.Code != http.StatusOK {
|
|
t.Fatalf("list alice: %d %s", aw.Code, aw.Body.String())
|
|
}
|
|
var aliceResp listPlaylistsResponse
|
|
if err := json.Unmarshal(aw.Body.Bytes(), &aliceResp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(aliceResp.Owned) != 2 {
|
|
t.Errorf("alice.owned len = %d, want 2", len(aliceResp.Owned))
|
|
}
|
|
if len(aliceResp.Public) != 0 {
|
|
t.Errorf("alice.public len = %d, want 0", len(aliceResp.Public))
|
|
}
|
|
}
|
|
|
|
// TestPlaylists_AppendThenReorder appends three tracks and then reverses
|
|
// their order via the reorder endpoint. The post-mutation detail body
|
|
// must show Track 3 before Track 1.
|
|
func TestPlaylists_AppendThenReorder(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
user := seedUser(t, pool, "alice", "pw", false)
|
|
t1 := seedSimpleTrack(t, pool, "Track 1", "Artist")
|
|
t2 := seedSimpleTrack(t, pool, "Track 2", "Artist")
|
|
t3 := seedSimpleTrack(t, pool, "Track 3", "Artist")
|
|
|
|
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
|
[]byte(`{"name":"R"}`))
|
|
if cw.Code != http.StatusOK {
|
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
|
}
|
|
var created playlistRowView
|
|
if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{
|
|
uuidToString(t1.ID), uuidToString(t2.ID), uuidToString(t3.ID),
|
|
}})
|
|
aw := doPlaylistsReq(h, user, http.MethodPost,
|
|
"/api/playlists/"+created.ID+"/tracks", appendPayload)
|
|
if aw.Code != http.StatusOK {
|
|
t.Fatalf("append: %d %s", aw.Code, aw.Body.String())
|
|
}
|
|
var afterAppend playlistDetailView
|
|
if err := json.Unmarshal(aw.Body.Bytes(), &afterAppend); err != nil {
|
|
t.Fatalf("decode append: %v", err)
|
|
}
|
|
if len(afterAppend.Tracks) != 3 {
|
|
t.Fatalf("after append, tracks len = %d, want 3", len(afterAppend.Tracks))
|
|
}
|
|
|
|
// Reverse: positions [2, 1, 0].
|
|
rw := doPlaylistsReq(h, user, http.MethodPut,
|
|
"/api/playlists/"+created.ID+"/tracks",
|
|
[]byte(`{"ordered_positions":[2,1,0]}`))
|
|
if rw.Code != http.StatusOK {
|
|
t.Fatalf("reorder: %d %s", rw.Code, rw.Body.String())
|
|
}
|
|
|
|
gw := doPlaylistsReq(h, user, http.MethodGet,
|
|
"/api/playlists/"+created.ID, nil)
|
|
if gw.Code != http.StatusOK {
|
|
t.Fatalf("get-after-reorder: %d %s", gw.Code, gw.Body.String())
|
|
}
|
|
body := gw.Body.String()
|
|
pos3 := strings.Index(body, `"title":"Track 3"`)
|
|
pos1 := strings.Index(body, `"title":"Track 1"`)
|
|
if pos3 == -1 || pos1 == -1 {
|
|
t.Fatalf("titles missing from body: %s", body)
|
|
}
|
|
if pos3 > pos1 {
|
|
t.Errorf("after reverse, Track 3 should appear before Track 1 in JSON; body = %s", body)
|
|
}
|
|
}
|
|
|
|
// TestPlaylists_RemoveTrack appends two tracks, removes position 0, and
|
|
// verifies the surviving track sits at position 0 with the renumbered
|
|
// list length 1.
|
|
func TestPlaylists_RemoveTrack(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
user := seedUser(t, pool, "alice", "pw", false)
|
|
t1 := seedSimpleTrack(t, pool, "First", "Artist")
|
|
t2 := seedSimpleTrack(t, pool, "Second", "Artist")
|
|
|
|
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
|
[]byte(`{"name":"X"}`))
|
|
if cw.Code != http.StatusOK {
|
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
|
}
|
|
var created playlistRowView
|
|
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
|
|
|
appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{
|
|
uuidToString(t1.ID), uuidToString(t2.ID),
|
|
}})
|
|
aw := doPlaylistsReq(h, user, http.MethodPost,
|
|
"/api/playlists/"+created.ID+"/tracks", appendPayload)
|
|
if aw.Code != http.StatusOK {
|
|
t.Fatalf("append: %d %s", aw.Code, aw.Body.String())
|
|
}
|
|
|
|
rw := doPlaylistsReq(h, user, http.MethodDelete,
|
|
"/api/playlists/"+created.ID+"/tracks/0", nil)
|
|
if rw.Code != http.StatusOK {
|
|
t.Fatalf("remove: %d %s", rw.Code, rw.Body.String())
|
|
}
|
|
var afterRemove playlistDetailView
|
|
if err := json.Unmarshal(rw.Body.Bytes(), &afterRemove); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(afterRemove.Tracks) != 1 {
|
|
t.Fatalf("after remove, tracks len = %d, want 1", len(afterRemove.Tracks))
|
|
}
|
|
if afterRemove.Tracks[0].Title != "Second" {
|
|
t.Errorf("survivor title = %q, want Second", afterRemove.Tracks[0].Title)
|
|
}
|
|
if afterRemove.Tracks[0].Position != 0 {
|
|
t.Errorf("survivor position = %d, want 0 (renumbered)", afterRemove.Tracks[0].Position)
|
|
}
|
|
}
|
|
|
|
// TestPlaylists_DeleteOwnerOnly verifies non-owner DELETE is 403 and
|
|
// owner DELETE is 204.
|
|
func TestPlaylists_DeleteOwnerOnly(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
alice := seedUser(t, pool, "alice", "pw", false)
|
|
bob := seedUser(t, pool, "bob", "pw", false)
|
|
|
|
cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists",
|
|
[]byte(`{"name":"D","is_public":true}`))
|
|
if cw.Code != http.StatusOK {
|
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
|
}
|
|
var created playlistRowView
|
|
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
|
|
|
bw := doPlaylistsReq(h, bob, http.MethodDelete, "/api/playlists/"+created.ID, nil)
|
|
if bw.Code != http.StatusForbidden {
|
|
t.Errorf("bob delete status = %d, want 403", bw.Code)
|
|
}
|
|
|
|
aw := doPlaylistsReq(h, alice, http.MethodDelete, "/api/playlists/"+created.ID, nil)
|
|
if aw.Code != http.StatusNoContent {
|
|
t.Errorf("alice delete status = %d, want 204; body = %s", aw.Code, aw.Body.String())
|
|
}
|
|
|
|
// Subsequent GET must 404.
|
|
gw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists/"+created.ID, nil)
|
|
if gw.Code != http.StatusNotFound {
|
|
t.Errorf("get-after-delete status = %d, want 404", gw.Code)
|
|
}
|
|
}
|
|
|
|
// TestPlaylists_BadUUID400 verifies a malformed playlist id returns
|
|
// bad_request rather than reaching the service layer.
|
|
func TestPlaylists_BadUUID400(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
user := seedUser(t, pool, "alice", "pw", false)
|
|
|
|
w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/not-a-uuid", nil)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestPlaylists_AppendInvalidTrackUUID400 verifies a malformed track id
|
|
// in the append body returns bad_request before reaching the DB.
|
|
func TestPlaylists_AppendInvalidTrackUUID400(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
user := seedUser(t, pool, "alice", "pw", false)
|
|
|
|
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
|
[]byte(`{"name":"A"}`))
|
|
if cw.Code != http.StatusOK {
|
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
|
}
|
|
var created playlistRowView
|
|
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
|
|
|
body := []byte(`{"track_ids":["not-a-uuid"]}`)
|
|
w := doPlaylistsReq(h, user, http.MethodPost,
|
|
"/api/playlists/"+created.ID+"/tracks", body)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400; body = %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPlaylists_CoverNoneReturns404 verifies that a freshly-created
|
|
// (empty) playlist has no cached cover and the /cover endpoint returns
|
|
// 404 not_found rather than serving a missing file.
|
|
func TestPlaylists_CoverNoneReturns404(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
user := seedUser(t, pool, "alice", "pw", false)
|
|
|
|
cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
|
|
[]byte(`{"name":"NoCover"}`))
|
|
if cw.Code != http.StatusOK {
|
|
t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
|
|
}
|
|
var created playlistRowView
|
|
_ = json.Unmarshal(cw.Body.Bytes(), &created)
|
|
|
|
w := doPlaylistsReq(h, user, http.MethodGet,
|
|
"/api/playlists/"+created.ID+"/cover", nil)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404; body = %s", w.Code, w.Body.String())
|
|
}
|
|
}
|