feat(audiobooks): GET /playlists + GET /playlists/{id}
List wraps the result in {"playlists": [...]} and emits list-shape
(no items[]). Detail handler returns full-shape for owner or for any
caller when isPublic=true; otherwise 404 matching the bookmarks
anti-enumeration pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
f5dd7f79ed
commit
7cdbfbb637
@@ -129,3 +129,55 @@ func (h *Handler) playlistItems(r *http.Request, playlistID string) []map[string
|
||||
// playlistURLID is a tiny shim around chi.URLParam(r, "id") to read
|
||||
// uniformly with the collections handler's chiURLID.
|
||||
func playlistURLID(r *http.Request) string { return chi.URLParam(r, "id") }
|
||||
|
||||
// handleListPlaylists — GET /playlists.
|
||||
// Returns the caller's playlists wrapped in {"playlists": [...]}.
|
||||
// List-shape (no items[]).
|
||||
func (h *Handler) handleListPlaylists(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := absAuthFrom(r)
|
||||
if !ok || a.UserID == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if h.deps.PlaylistStore == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"playlists": []any{}})
|
||||
return
|
||||
}
|
||||
rows, err := h.deps.PlaylistStore.ListUserPlaylists(r.Context(), a.UserID, a.ProfileID)
|
||||
if err != nil {
|
||||
slog.Error("abs playlist list failed", "err", err, "user", a.UserID)
|
||||
http.Error(w, "playlist list failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, p := range rows {
|
||||
out = append(out, playlistToABS(p, nil))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"playlists": out})
|
||||
}
|
||||
|
||||
// handleGetPlaylist — GET /playlists/{id}.
|
||||
// Owner gets full-shape; non-owner gets full-shape only when isPublic.
|
||||
// Otherwise 404 (no existence leak).
|
||||
func (h *Handler) handleGetPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := absAuthFrom(r)
|
||||
if !ok || a.UserID == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if h.deps.PlaylistStore == nil {
|
||||
http.Error(w, "playlist not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
p, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), playlistURLID(r))
|
||||
if errors.Is(err, ErrNotFound) || (err == nil && p.UserID != a.UserID && !p.IsPublic) {
|
||||
http.Error(w, "playlist not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("abs playlist get failed", "err", err)
|
||||
http.Error(w, "playlist get failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.playlistFullShape(r, p))
|
||||
}
|
||||
|
||||
@@ -205,3 +205,104 @@ func TestPlaylist_Create_FiresPlaylistAddedEvent(t *testing.T) {
|
||||
t.Errorf("payload name = %v, want queue", payload["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func createPlaylistForUser(t *testing.T, hb *playlistsHarness, userID, profileID, body string) string {
|
||||
t.Helper()
|
||||
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, []byte(body), userID, profileID, hb.H.handleCreatePlaylist)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("seed POST status = %d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
id, _ := got["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("seed POST returned no id; body=%s", rec.Body.String())
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestPlaylist_List_WrappedEnvelope(t *testing.T) {
|
||||
hb := newPlaylistsHarness(t)
|
||||
_ = createPlaylistForUser(t, hb, "1", "", `{"name":"a"}`)
|
||||
_ = createPlaylistForUser(t, hb, "1", "", `{"name":"b"}`)
|
||||
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists", nil, nil, "1", "", hb.H.handleListPlaylists)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var env map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
list, ok := env["playlists"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("response missing 'playlists' key; body=%s", rec.Body.String())
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Errorf("list len = %d, want 2", len(list))
|
||||
}
|
||||
for _, p := range list {
|
||||
entry := p.(map[string]any)
|
||||
if _, has := entry["items"]; has {
|
||||
t.Errorf("list entry has items key (should be detail-only): %v", entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylist_List_ProfileIsolation(t *testing.T) {
|
||||
hb := newPlaylistsHarness(t)
|
||||
pA := "00000000-0000-0000-0000-0000000000aa"
|
||||
pB := "00000000-0000-0000-0000-0000000000bb"
|
||||
_ = createPlaylistForUser(t, hb, "1", pA, `{"name":"A"}`)
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists", nil, nil, "1", pB, hb.H.handleListPlaylists)
|
||||
var env map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &env)
|
||||
list, _ := env["playlists"].([]any)
|
||||
if len(list) != 0 {
|
||||
t.Errorf("profile B sees %d playlists, want 0", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylist_Get_Owner_ReturnsFullShape(t *testing.T) {
|
||||
hb := newPlaylistsHarness(t)
|
||||
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
|
||||
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists/"+id, map[string]string{"id": id}, nil, "1", "", hb.H.handleGetPlaylist)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got["name"] != "mine" {
|
||||
t.Errorf("name = %v, want 'mine'", got["name"])
|
||||
}
|
||||
if _, has := got["items"]; !has {
|
||||
t.Errorf("items missing on full-shape: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylist_Get_NonOwner_Private_404(t *testing.T) {
|
||||
hb := newPlaylistsHarness(t)
|
||||
id := createPlaylistForUser(t, hb, "1", "", `{"name":"private"}`)
|
||||
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleGetPlaylist)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylist_Get_NonOwner_Public_OK(t *testing.T) {
|
||||
hb := newPlaylistsHarness(t)
|
||||
id := createPlaylistForUser(t, hb, "1", "", `{"name":"public","isPublic":true}`)
|
||||
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleGetPlaylist)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylist_Get_Unknown_404(t *testing.T) {
|
||||
hb := newPlaylistsHarness(t)
|
||||
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists/01HZZZ", map[string]string{"id": "01HZZZ"}, nil, "1", "", hb.H.handleGetPlaylist)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user