Final review flagged two small gaps in the delete tests: - TestCollection_Delete_Owner_204 now seeds a book and asserts the items table is empty after delete (proves FK CASCADE works). - TestPlaylist_Delete_Owner_FiresRemovedEvent now also asserts the post-delete GET returns 404 (symmetry with the collection test). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
714 lines
25 KiB
Go
714 lines
25 KiB
Go
package abs
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// memPlaylistStore is an in-memory PlaylistStore for handler tests.
|
|
type memPlaylistStore struct {
|
|
mu sync.Mutex
|
|
rows map[string]Playlist // id -> row
|
|
items map[string][]PlaylistItem // playlist_id -> items
|
|
}
|
|
|
|
func newMemPlaylistStore() *memPlaylistStore {
|
|
return &memPlaylistStore{
|
|
rows: map[string]Playlist{},
|
|
items: map[string][]PlaylistItem{},
|
|
}
|
|
}
|
|
|
|
func (m *memPlaylistStore) ListUserPlaylists(_ context.Context, userID, profileID string) ([]Playlist, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]Playlist, 0)
|
|
for _, p := range m.rows {
|
|
if p.UserID == userID && p.ProfileID == profileID {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
|
return out, nil
|
|
}
|
|
|
|
func (m *memPlaylistStore) GetPlaylist(_ context.Context, id string) (Playlist, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
p, ok := m.rows[id]
|
|
if !ok {
|
|
return Playlist{}, ErrNotFound
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (m *memPlaylistStore) CreatePlaylist(_ context.Context, p Playlist) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.rows[p.ID] = p
|
|
return nil
|
|
}
|
|
|
|
func (m *memPlaylistStore) UpdatePlaylist(_ context.Context, p Playlist) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
existing, ok := m.rows[p.ID]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
existing.Name = p.Name
|
|
existing.Description = p.Description
|
|
existing.CoverItem = p.CoverItem
|
|
existing.IsPublic = p.IsPublic
|
|
existing.UpdatedAt = time.Now()
|
|
m.rows[p.ID] = existing
|
|
return nil
|
|
}
|
|
|
|
func (m *memPlaylistStore) DeletePlaylist(_ context.Context, id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.rows, id)
|
|
delete(m.items, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *memPlaylistStore) ListPlaylistItems(_ context.Context, playlistID string) ([]PlaylistItem, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
items := m.items[playlistID]
|
|
out := make([]PlaylistItem, len(items))
|
|
copy(out, items)
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Position < out[j].Position })
|
|
return out, nil
|
|
}
|
|
|
|
func (m *memPlaylistStore) AddPlaylistItem(_ context.Context, playlistID, libraryItemID, episodeID string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, it := range m.items[playlistID] {
|
|
if it.LibraryItemID == libraryItemID && it.EpisodeID == episodeID {
|
|
return nil // ON CONFLICT DO NOTHING
|
|
}
|
|
}
|
|
maxPos := 0
|
|
for _, it := range m.items[playlistID] {
|
|
if it.Position > maxPos {
|
|
maxPos = it.Position
|
|
}
|
|
}
|
|
m.items[playlistID] = append(m.items[playlistID], PlaylistItem{
|
|
PlaylistID: playlistID,
|
|
LibraryItemID: libraryItemID,
|
|
EpisodeID: episodeID,
|
|
Position: maxPos + 1,
|
|
AddedAt: time.Now(),
|
|
})
|
|
if p, ok := m.rows[playlistID]; ok {
|
|
p.UpdatedAt = time.Now()
|
|
m.rows[playlistID] = p
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *memPlaylistStore) RemovePlaylistItem(_ context.Context, playlistID, libraryItemID, episodeID string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
items := m.items[playlistID]
|
|
out := items[:0]
|
|
for _, it := range items {
|
|
if it.LibraryItemID != libraryItemID || it.EpisodeID != episodeID {
|
|
out = append(out, it)
|
|
}
|
|
}
|
|
m.items[playlistID] = out
|
|
if p, ok := m.rows[playlistID]; ok {
|
|
p.UpdatedAt = time.Now()
|
|
m.rows[playlistID] = p
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type playlistsHarness struct {
|
|
H *Handler
|
|
Play *memPlaylistStore
|
|
Pub *recordingPublisher
|
|
}
|
|
|
|
func newPlaylistsHarness(t *testing.T, knownItems ...string) *playlistsHarness {
|
|
t.Helper()
|
|
known := map[string]*models.MediaItem{}
|
|
for _, id := range knownItems {
|
|
known[id] = nil
|
|
}
|
|
pub := &recordingPublisher{}
|
|
store := newMemPlaylistStore()
|
|
h := New(Dependencies{
|
|
MediaStore: &stubMediaStore{known: known},
|
|
PlaylistStore: store,
|
|
Publisher: pub,
|
|
})
|
|
return &playlistsHarness{H: h, Play: store, Pub: pub}
|
|
}
|
|
|
|
func TestPlaylist_Create_ReturnsFullShape(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
body := []byte(`{"name":"queue","description":"d","isPublic":true}`)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, body, "1", "", hb.H.handleCreatePlaylist)
|
|
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"] != "queue" {
|
|
t.Errorf("name = %v, want queue", got["name"])
|
|
}
|
|
if got["isPublic"] != true {
|
|
t.Errorf("isPublic = %v, want true", got["isPublic"])
|
|
}
|
|
items, _ := got["items"].([]any)
|
|
if items == nil {
|
|
t.Errorf("items missing on full-shape: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_Create_NameRequired_400(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, []byte(`{}`), "1", "", hb.H.handleCreatePlaylist)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_Create_FiresPlaylistAddedEvent(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, []byte(`{"name":"queue"}`), "7", "", hb.H.handleCreatePlaylist)
|
|
evts := hb.Pub.snapshot()
|
|
if len(evts) != 1 {
|
|
t.Fatalf("events = %d, want 1", len(evts))
|
|
}
|
|
if evts[0].Event != "playlist_added" {
|
|
t.Errorf("event = %q, want playlist_added", evts[0].Event)
|
|
}
|
|
if evts[0].UserID != "7" {
|
|
t.Errorf("event userID = %q, want 7", evts[0].UserID)
|
|
}
|
|
payload, _ := evts[0].Payload.(map[string]any)
|
|
if payload["name"] != "queue" {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_Patch_UpdatesCover(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"x"}`)
|
|
|
|
body := []byte(`{"cover_item":"01HCOVER"}`)
|
|
rec := dispatchABSWithParams(http.MethodPatch, "/api/playlists/"+id, map[string]string{"id": id}, body, "1", "", hb.H.handleUpdatePlaylist)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
var got map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
|
if got["coverPath"] != "01HCOVER" {
|
|
t.Errorf("coverPath = %v, want 01HCOVER", got["coverPath"])
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_Patch_FiresUpdatedEvent(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
id := createPlaylistForUser(t, hb, "7", "", `{"name":"x"}`)
|
|
// snapshot count after create
|
|
before := len(hb.Pub.snapshot())
|
|
|
|
_ = dispatchABSWithParams(http.MethodPatch, "/api/playlists/"+id, map[string]string{"id": id}, []byte(`{"name":"renamed"}`), "7", "", hb.H.handleUpdatePlaylist)
|
|
|
|
evts := hb.Pub.snapshot()
|
|
if len(evts) != before+1 {
|
|
t.Fatalf("events = %d (delta %d), want exactly 1 new event", len(evts), len(evts)-before)
|
|
}
|
|
if evts[len(evts)-1].Event != "playlist_updated" {
|
|
t.Errorf("event = %q, want playlist_updated", evts[len(evts)-1].Event)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_Patch_NonOwner_404(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
|
|
|
|
rec := dispatchABSWithParams(http.MethodPatch, "/api/playlists/"+id, map[string]string{"id": id}, []byte(`{"name":"hijack"}`), "2", "", hb.H.handleUpdatePlaylist)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rec.Code)
|
|
}
|
|
p, _ := hb.Play.GetPlaylist(context.Background(), id)
|
|
if p.Name != "mine" {
|
|
t.Errorf("non-owner mutation leaked: name = %q", p.Name)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_Delete_Owner_FiresRemovedEvent(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
id := createPlaylistForUser(t, hb, "7", "", `{"name":"x"}`)
|
|
before := len(hb.Pub.snapshot())
|
|
|
|
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id, map[string]string{"id": id}, nil, "7", "", hb.H.handleDeletePlaylist)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
evts := hb.Pub.snapshot()
|
|
if len(evts) != before+1 {
|
|
t.Fatalf("events = %d, want exactly 1 new event", len(evts)-before)
|
|
}
|
|
if evts[len(evts)-1].Event != "playlist_removed" {
|
|
t.Errorf("event = %q, want playlist_removed", evts[len(evts)-1].Event)
|
|
}
|
|
// Post-delete GET must 404 (symmetry with collection delete test).
|
|
rec2 := dispatchABSWithParams(http.MethodGet, "/api/playlists/"+id, map[string]string{"id": id}, nil, "7", "", hb.H.handleGetPlaylist)
|
|
if rec2.Code != http.StatusNotFound {
|
|
t.Errorf("post-delete GET status = %d, want 404", rec2.Code)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_Delete_NonOwner_404(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
|
|
|
|
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id, map[string]string{"id": id}, nil, "2", "", hb.H.handleDeletePlaylist)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rec.Code)
|
|
}
|
|
if _, err := hb.Play.GetPlaylist(context.Background(), id); err != nil {
|
|
t.Errorf("playlist wrongly deleted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_AddItem_AudiobookHydrates(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1")
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
|
|
body := []byte(`{"libraryItemId":"book-1"}`)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
|
|
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)
|
|
items, _ := got["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1", len(items))
|
|
}
|
|
entry := items[0].(map[string]any)
|
|
if entry["libraryItemId"] != "book-1" {
|
|
t.Errorf("libraryItemId = %v, want book-1", entry["libraryItemId"])
|
|
}
|
|
if _, has := entry["title"]; !has {
|
|
t.Errorf("audiobook item missing 'title' hydration: %v", entry)
|
|
}
|
|
if pos, _ := entry["position"].(float64); pos != 1 {
|
|
t.Errorf("first item position = %v, want 1", entry["position"])
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_AddItem_AppendsAtNextPosition(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1", "book-2", "book-3")
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
|
|
for _, b := range []string{"book-1", "book-2", "book-3"} {
|
|
body := []byte(`{"libraryItemId":"` + b + `"}`)
|
|
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
|
|
}
|
|
rec := dispatchABSWithParams(http.MethodGet, "/api/playlists/"+id, map[string]string{"id": id}, nil, "1", "", hb.H.handleGetPlaylist)
|
|
var got map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
|
items, _ := got["items"].([]any)
|
|
if len(items) != 3 {
|
|
t.Fatalf("items len = %d, want 3", len(items))
|
|
}
|
|
for i, raw := range items {
|
|
entry := raw.(map[string]any)
|
|
wantPos := float64(i + 1)
|
|
if entry["position"] != wantPos {
|
|
t.Errorf("items[%d] position = %v, want %v", i, entry["position"], wantPos)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_AddItem_Idempotent(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1")
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
|
|
body := []byte(`{"libraryItemId":"book-1"}`)
|
|
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item", map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item", map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("second add status = %d", rec.Code)
|
|
}
|
|
var got map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
|
items, _ := got["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Errorf("items len = %d, want 1 (idempotent)", len(items))
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_AddItem_Episode_AcceptsAndEchoes(t *testing.T) {
|
|
hb := newPlaylistsHarness(t /* no known items - episode skips validation */)
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
|
|
body := []byte(`{"libraryItemId":"podcast-x","episodeId":"ep-1"}`)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
|
|
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)
|
|
items, _ := got["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1", len(items))
|
|
}
|
|
entry := items[0].(map[string]any)
|
|
if entry["episodeId"] != "ep-1" {
|
|
t.Errorf("episodeId = %v, want ep-1", entry["episodeId"])
|
|
}
|
|
if _, has := entry["title"]; has {
|
|
t.Errorf("episode item must NOT be hydrated: %v", entry)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_AddItem_UnknownAudiobook_404(t *testing.T) {
|
|
hb := newPlaylistsHarness(t /* no known items */)
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
|
|
body := []byte(`{"libraryItemId":"ghost"}`)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404 (item not found); body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_AddItem_LibraryItemIdRequired_400(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1")
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
|
|
body := []byte(`{"libraryItemId":""}`)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, body, "1", "", hb.H.handleAddPlaylistItem)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_AddItem_NonOwner_404(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1")
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
|
|
|
|
body := []byte(`{"libraryItemId":"book-1"}`)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, body, "2", "", hb.H.handleAddPlaylistItem)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_AddItem_FiresUpdatedEvent(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1")
|
|
id := createPlaylistForUser(t, hb, "7", "", `{"name":"q"}`)
|
|
before := len(hb.Pub.snapshot())
|
|
|
|
body := []byte(`{"libraryItemId":"book-1"}`)
|
|
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, body, "7", "", hb.H.handleAddPlaylistItem)
|
|
|
|
evts := hb.Pub.snapshot()
|
|
if len(evts) != before+1 {
|
|
t.Fatalf("events = %d, want exactly 1 new event", len(evts)-before)
|
|
}
|
|
if evts[len(evts)-1].Event != "playlist_updated" {
|
|
t.Errorf("event = %q, want playlist_updated", evts[len(evts)-1].Event)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_RemoveItem_Single(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1", "book-2")
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, []byte(`{"libraryItemId":"book-1"}`), "1", "", hb.H.handleAddPlaylistItem)
|
|
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, []byte(`{"libraryItemId":"book-2"}`), "1", "", hb.H.handleAddPlaylistItem)
|
|
|
|
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id+"/item/book-1",
|
|
map[string]string{"id": id, "libraryItemId": "book-1"}, nil, "1", "", hb.H.handleRemovePlaylistItem)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
var got map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
|
items, _ := got["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1", len(items))
|
|
}
|
|
if items[0].(map[string]any)["libraryItemId"] != "book-2" {
|
|
t.Errorf("remaining item = %v, want book-2", items[0])
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_RemoveItem_Idempotent(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
|
|
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id+"/item/book-99",
|
|
map[string]string{"id": id, "libraryItemId": "book-99"}, nil, "1", "", hb.H.handleRemovePlaylistItem)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200 (idempotent)", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_RemoveItem_WithEpisode(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
|
|
// Seed two items at the same libraryItemId — one with episode, one without.
|
|
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, []byte(`{"libraryItemId":"podcast-x","episodeId":"ep-1"}`), "1", "", hb.H.handleAddPlaylistItem)
|
|
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, []byte(`{"libraryItemId":"podcast-x","episodeId":"ep-2"}`), "1", "", hb.H.handleAddPlaylistItem)
|
|
|
|
// Remove ep-1 specifically.
|
|
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id+"/item/podcast-x/ep-1",
|
|
map[string]string{"id": id, "libraryItemId": "podcast-x", "episodeId": "ep-1"}, nil, "1", "", hb.H.handleRemovePlaylistEpisode)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
var got map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
|
items, _ := got["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1 (ep-2 should remain)", len(items))
|
|
}
|
|
if items[0].(map[string]any)["episodeId"] != "ep-2" {
|
|
t.Errorf("remaining item episodeId = %v, want ep-2", items[0])
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_RemoveItem_NonOwner_404(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1")
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
|
|
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, []byte(`{"libraryItemId":"book-1"}`), "1", "", hb.H.handleAddPlaylistItem)
|
|
|
|
rec := dispatchABSWithParams(http.MethodDelete, "/api/playlists/"+id+"/item/book-1",
|
|
map[string]string{"id": id, "libraryItemId": "book-1"}, nil, "2", "", hb.H.handleRemovePlaylistItem)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rec.Code)
|
|
}
|
|
items, _ := hb.Play.ListPlaylistItems(context.Background(), id)
|
|
if len(items) != 1 {
|
|
t.Errorf("items len = %d, want 1 (non-owner remove leaked)", len(items))
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_BatchAdd_TolerantOfPartialFailures(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1", "book-2") // book-3 unknown
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
|
|
body := []byte(`{"items":[{"libraryItemId":"book-1"},{"libraryItemId":"book-3"},{"libraryItemId":"book-2"}]}`)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/add",
|
|
map[string]string{"id": id}, body, "1", "", hb.H.handleBatchAddPlaylistItems)
|
|
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)
|
|
items, _ := got["items"].([]any)
|
|
if len(items) != 2 {
|
|
t.Errorf("items len = %d, want 2 (book-3 skipped)", len(items))
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_BatchAdd_FiresOneUpdatedEvent(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1", "book-2")
|
|
id := createPlaylistForUser(t, hb, "7", "", `{"name":"q"}`)
|
|
before := len(hb.Pub.snapshot())
|
|
|
|
body := []byte(`{"items":[{"libraryItemId":"book-1"},{"libraryItemId":"book-2"}]}`)
|
|
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/add",
|
|
map[string]string{"id": id}, body, "7", "", hb.H.handleBatchAddPlaylistItems)
|
|
|
|
evts := hb.Pub.snapshot()
|
|
if len(evts)-before != 1 {
|
|
t.Errorf("event delta = %d, want exactly 1", len(evts)-before)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_BatchAdd_EmptyItems_OKNoOp(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
|
|
body := []byte(`{"items":[]}`)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/add",
|
|
map[string]string{"id": id}, body, "1", "", hb.H.handleBatchAddPlaylistItems)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_BatchAdd_InvalidBody_400(t *testing.T) {
|
|
hb := newPlaylistsHarness(t)
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/add",
|
|
map[string]string{"id": id}, []byte(`{not json`), "1", "", hb.H.handleBatchAddPlaylistItems)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_BatchAdd_NonOwner_404(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1")
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"mine"}`)
|
|
|
|
body := []byte(`{"items":[{"libraryItemId":"book-1"}]}`)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/add",
|
|
map[string]string{"id": id}, body, "2", "", hb.H.handleBatchAddPlaylistItems)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestPlaylist_BatchRemove(t *testing.T) {
|
|
hb := newPlaylistsHarness(t, "book-1", "book-2", "book-3")
|
|
id := createPlaylistForUser(t, hb, "1", "", `{"name":"q"}`)
|
|
for _, b := range []string{"book-1", "book-2", "book-3"} {
|
|
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/item",
|
|
map[string]string{"id": id}, []byte(`{"libraryItemId":"`+b+`"}`), "1", "", hb.H.handleAddPlaylistItem)
|
|
}
|
|
|
|
body := []byte(`{"items":[{"libraryItemId":"book-1"},{"libraryItemId":"book-3"}]}`)
|
|
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists/"+id+"/batch/remove",
|
|
map[string]string{"id": id}, body, "1", "", hb.H.handleBatchRemovePlaylistItems)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
var got map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
|
items, _ := got["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1 (book-2 should remain)", len(items))
|
|
}
|
|
if items[0].(map[string]any)["libraryItemId"] != "book-2" {
|
|
t.Errorf("remaining item = %v, want book-2", items[0])
|
|
}
|
|
}
|