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>
620 lines
22 KiB
Go
620 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 = withUser(req, 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())
|
|
}
|
|
})
|
|
}
|
|
}
|