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()) } }) } }