Files
minstrel/internal/api/playlists_test.go
T
bvandeusen 0a1e76564d feat(server/m7-352): 403 system_playlist_readonly on playlist edit endpoints
Push kind='system' guard into each of the five Service edit methods
(Update, Delete, AppendTracks, RemoveTrack, Reorder) immediately after
the ownership check; map the typed error to 403 in writePlaylistErr;
add table-driven test covering all five verbs against a seeded system
playlist.
2026-05-04 09:29:37 -04:00

618 lines
22 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())
}
}
// TestListPlaylists_KindFilterDefaultsToUser verifies that an absent ?kind=
// parameter defaults to "user" and excludes system-kind playlists.
func TestListPlaylists_KindFilterDefaultsToUser(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "klistdefault", "pw", false)
// Create one user-kind playlist via the service.
userPL, err := h.playlists.Create(context.Background(), u.ID, "My PL", "", false)
if err != nil {
t.Fatalf("create user playlist: %v", err)
}
// Insert a system-kind playlist directly via SQL.
if _, err := pool.Exec(context.Background(), `
INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
VALUES ($1, 'For You', '', false, 'system', 'for_you')
`, u.ID); err != nil {
t.Fatalf("seed system playlist: %v", err)
}
w := doPlaylistsReq(h, u, "GET", "/api/playlists", nil)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp listPlaylistsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Owned) != 1 {
t.Fatalf("default should return 1 user-kind playlist; got %d", len(resp.Owned))
}
if resp.Owned[0].Kind != "user" {
t.Errorf("expected kind=user; got %q", resp.Owned[0].Kind)
}
if resp.Owned[0].ID != uuidToString(userPL.ID) {
t.Errorf("returned wrong playlist id")
}
}
// TestListPlaylists_KindSystem verifies ?kind=system returns only
// system-generated playlists and excludes user-created ones.
func TestListPlaylists_KindSystem(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "klistsys", "pw", false)
if _, err := h.playlists.Create(context.Background(), u.ID, "User PL", "", false); err != nil {
t.Fatalf("create user playlist: %v", err)
}
if _, err := pool.Exec(context.Background(), `
INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
VALUES ($1, 'For You', '', false, 'system', 'for_you')
`, u.ID); err != nil {
t.Fatalf("seed system playlist: %v", err)
}
w := doPlaylistsReq(h, u, "GET", "/api/playlists?kind=system", nil)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp listPlaylistsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Owned) != 1 {
t.Fatalf("kind=system should return 1 system playlist; got %d", len(resp.Owned))
}
if resp.Owned[0].Kind != "system" {
t.Errorf("expected kind=system; got %q", resp.Owned[0].Kind)
}
}
// TestListPlaylists_KindAll verifies ?kind=all returns all playlists
// regardless of kind.
func TestListPlaylists_KindAll(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "klistall", "pw", false)
if _, err := h.playlists.Create(context.Background(), u.ID, "User PL", "", false); err != nil {
t.Fatalf("create user playlist: %v", err)
}
if _, err := pool.Exec(context.Background(), `
INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
VALUES ($1, 'For You', '', false, 'system', 'for_you')
`, u.ID); err != nil {
t.Fatalf("seed system playlist: %v", err)
}
w := doPlaylistsReq(h, u, "GET", "/api/playlists?kind=all", nil)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp listPlaylistsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Owned) != 2 {
t.Fatalf("kind=all should return both playlists; got %d", len(resp.Owned))
}
}
// TestListPlaylists_BadKindReturns400 verifies an unrecognised kind value
// is rejected with 400 bad_kind.
func TestListPlaylists_BadKindReturns400(t *testing.T) {
h, pool := testHandlers(t)
u := seedUser(t, pool, "klistbad", "pw", false)
_ = pool
w := doPlaylistsReq(h, u, "GET", "/api/playlists?kind=garbage", nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400; got %d, body=%s", w.Code, w.Body.String())
}
}
// TestEditEndpoints_403OnSystemPlaylist verifies that all five edit verbs
// return 403 system_playlist_readonly when the target playlist has kind='system'.
func TestEditEndpoints_403OnSystemPlaylist(t *testing.T) {
h, pool := testHandlers(t)
u := seedUser(t, pool, "sysedit", "pw", false)
// Seed a system playlist directly via SQL (BuildSystemPlaylists requires
// play_events seeding which we already exercise in playlists package tests).
var sid string
if err := pool.QueryRow(context.Background(), `
INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
VALUES ($1, 'For You', '', false, 'system', 'for_you')
RETURNING id::text
`, u.ID).Scan(&sid); err != nil {
t.Fatalf("seed system playlist: %v", err)
}
cases := []struct {
name string
method string
path string
body []byte
}{
{"PATCH", "PATCH", "/api/playlists/" + sid, []byte(`{"name":"new name"}`)},
{"DELETE_playlist", "DELETE", "/api/playlists/" + sid, nil},
{"POST_tracks", "POST", "/api/playlists/" + sid + "/tracks", []byte(`{"track_ids":["00000000-0000-0000-0000-000000000000"]}`)},
{"PUT_tracks_reorder", "PUT", "/api/playlists/" + sid + "/tracks", []byte(`{"ordered_positions":[]}`)},
{"DELETE_track_at_position", "DELETE", "/api/playlists/" + sid + "/tracks/0", nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
w := doPlaylistsReq(h, u, c.method, c.path, c.body)
if w.Code != http.StatusForbidden {
t.Fatalf("status: got %d want 403; body=%s", w.Code, w.Body.String())
}
var env errorBody
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v", err)
}
if env.Error.Code != "system_playlist_readonly" {
t.Errorf("code: got %q want system_playlist_readonly; full body=%s", env.Error.Code, w.Body.String())
}
})
}
}