diff --git a/internal/audiobooks/abs/collections_handler.go b/internal/audiobooks/abs/collections_handler.go new file mode 100644 index 00000000..9b8e2f5c --- /dev/null +++ b/internal/audiobooks/abs/collections_handler.go @@ -0,0 +1,118 @@ +package abs + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" +) + +// collectionBody is the JSON body for POST and PATCH /collections[/{id}]. +// All fields are optional on PATCH; name is required on POST (checked +// in the handler, not via tag-driven validation). +type collectionBody struct { + Name *string `json:"name"` + Description *string `json:"description"` + IsPublic *bool `json:"isPublic"` +} + +// handleCreateCollection — POST /collections. +// Body: {name, description?, isPublic?}. Returns the created collection +// in full-shape (with an empty books[] array). +func (h *Handler) handleCreateCollection(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if h.deps.CollectionStore == nil { + http.Error(w, "collection store unavailable", http.StatusServiceUnavailable) + return + } + + var body collectionBody + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + if body.Name == nil || *body.Name == "" { + http.Error(w, "name required", http.StatusBadRequest) + return + } + + c := Collection{ + ID: ulid.Make().String(), + UserID: a.UserID, + ProfileID: a.ProfileID, + Name: *body.Name, + } + if body.Description != nil { + c.Description = *body.Description + } + if body.IsPublic != nil { + c.IsPublic = *body.IsPublic + } + if err := h.deps.CollectionStore.CreateCollection(r.Context(), c); err != nil { + slog.Error("abs collection create failed", "err", err, "user", a.UserID) + http.Error(w, "collection persist failed", http.StatusInternalServerError) + return + } + + // Re-fetch to pick up server-set timestamps. + persisted, err := h.deps.CollectionStore.GetCollection(r.Context(), c.ID) + if errors.Is(err, ErrNotFound) { + persisted = c + } else if err != nil { + slog.Warn("abs collection get-after-create failed", "err", err, "id", c.ID) + persisted = c + } + writeJSON(w, http.StatusOK, h.collectionFullShape(r, persisted)) +} + +// collectionFullShape renders a Collection in full-shape, hydrating +// books[] via MediaStore. Errors during hydration degrade to bare +// {id, libraryId} entries so the response always reflects DB truth. +func (h *Handler) collectionFullShape(r *http.Request, c Collection) map[string]any { + books := h.collectionBooks(r, c.ID) + return collectionToABS(c, books) +} + +// collectionBooks resolves the items in a collection to wire-shape book +// entries, hydrating titles/authors via MediaStore. Returns a non-nil +// slice (possibly empty) so collectionToABS emits the books key. +func (h *Handler) collectionBooks(r *http.Request, collectionID string) []map[string]any { + if h.deps.CollectionStore == nil { + return []map[string]any{} + } + rows, err := h.deps.CollectionStore.ListCollectionItems(r.Context(), collectionID) + if err != nil { + slog.Warn("abs collection list-items failed", "err", err, "collection", collectionID) + return []map[string]any{} + } + lib := h.resolveDefaultLibrary(r.Context()) + libID := audiobookLibraryID(lib) + out := make([]map[string]any, 0, len(rows)) + for _, it := range rows { + entry := map[string]any{ + "id": it.LibraryItemID, + "libraryId": libID, + } + if item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), it.LibraryItemID); err == nil && item != nil { + entry["media"] = map[string]any{ + "metadata": map[string]any{ + "title": item.Title, + }, + } + } + out = append(out, entry) + } + return out +} + +// chiURLID is a tiny shim around chi.URLParam(r, "id") so handler call +// sites read uniformly. Inlined where unambiguous. +func chiURLID(r *http.Request) string { return chi.URLParam(r, "id") } diff --git a/internal/audiobooks/abs/collections_handler_test.go b/internal/audiobooks/abs/collections_handler_test.go new file mode 100644 index 00000000..33b59536 --- /dev/null +++ b/internal/audiobooks/abs/collections_handler_test.go @@ -0,0 +1,245 @@ +package abs + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "sync" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// --------------------------------------------------------------------------- +// In-memory fakes +// --------------------------------------------------------------------------- + +// memCollectionStore is an in-memory CollectionStore for handler tests. +// Owner identity is tracked alongside the row (production stores user_id +// and profile_id; we mirror that so List can filter correctly). +type memCollectionStore struct { + mu sync.Mutex + rows map[string]Collection // id -> row + items map[string][]CollectionItem // collection_id -> items +} + +func newMemCollectionStore() *memCollectionStore { + return &memCollectionStore{ + rows: map[string]Collection{}, + items: map[string][]CollectionItem{}, + } +} + +func (m *memCollectionStore) ListUserCollections(_ context.Context, userID, profileID string) ([]Collection, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]Collection, 0) + for _, c := range m.rows { + if c.UserID == userID && c.ProfileID == profileID { + out = append(out, c) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out, nil +} + +func (m *memCollectionStore) GetCollection(_ context.Context, id string) (Collection, error) { + m.mu.Lock() + defer m.mu.Unlock() + c, ok := m.rows[id] + if !ok { + return Collection{}, ErrNotFound + } + return c, nil +} + +func (m *memCollectionStore) CreateCollection(_ context.Context, c Collection) error { + m.mu.Lock() + defer m.mu.Unlock() + m.rows[c.ID] = c + return nil +} + +func (m *memCollectionStore) UpdateCollection(_ context.Context, c Collection) error { + m.mu.Lock() + defer m.mu.Unlock() + existing, ok := m.rows[c.ID] + if !ok { + return ErrNotFound + } + existing.Name = c.Name + existing.Description = c.Description + existing.IsPublic = c.IsPublic + existing.UpdatedAt = time.Now() + m.rows[c.ID] = existing + return nil +} + +func (m *memCollectionStore) DeleteCollection(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.rows, id) + delete(m.items, id) // cascade + return nil +} + +func (m *memCollectionStore) ListCollectionItems(_ context.Context, collectionID string) ([]CollectionItem, error) { + m.mu.Lock() + defer m.mu.Unlock() + items := m.items[collectionID] + out := make([]CollectionItem, len(items)) + copy(out, items) + sort.Slice(out, func(i, j int) bool { return out[i].AddedAt.Before(out[j].AddedAt) }) + return out, nil +} + +func (m *memCollectionStore) AddCollectionItem(_ context.Context, collectionID, libraryItemID string) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, it := range m.items[collectionID] { + if it.LibraryItemID == libraryItemID { + return nil // ON CONFLICT DO NOTHING + } + } + m.items[collectionID] = append(m.items[collectionID], CollectionItem{ + CollectionID: collectionID, + LibraryItemID: libraryItemID, + AddedAt: time.Now(), + }) + if c, ok := m.rows[collectionID]; ok { + c.UpdatedAt = time.Now() + m.rows[collectionID] = c + } + return nil +} + +func (m *memCollectionStore) RemoveCollectionItem(_ context.Context, collectionID, libraryItemID string) error { + m.mu.Lock() + defer m.mu.Unlock() + items := m.items[collectionID] + out := items[:0] + for _, it := range items { + if it.LibraryItemID != libraryItemID { + out = append(out, it) + } + } + m.items[collectionID] = out + if c, ok := m.rows[collectionID]; ok { + c.UpdatedAt = time.Now() + m.rows[collectionID] = c + } + return nil +} + +// --------------------------------------------------------------------------- +// Test harness +// --------------------------------------------------------------------------- + +type collectionsHarness struct { + H *Handler + Coll *memCollectionStore + Pub *recordingPublisher +} + +func newCollectionsHarness(t *testing.T, knownItems ...string) *collectionsHarness { + t.Helper() + known := map[string]*models.MediaItem{} + for _, id := range knownItems { + known[id] = nil + } + pub := &recordingPublisher{} + store := newMemCollectionStore() + h := New(Dependencies{ + MediaStore: &stubMediaStore{known: known}, + CollectionStore: store, + Publisher: pub, + }) + return &collectionsHarness{H: h, Coll: store, Pub: pub} +} + +// dispatchABSWithParams drives a handler directly with arbitrary URL +// params + injected ctxAuth, bypassing the bearerAuth middleware. +// Generalised version of dispatchBookmark for surfaces with different +// URL-param shapes (collections use {id}, {bookId}; playlists use +// {id}, {libraryItemId}, {episodeId}). +func dispatchABSWithParams(method, path string, params map[string]string, body []byte, userID, profileID string, fn http.HandlerFunc) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + rctx := chi.NewRouteContext() + for k, v := range params { + rctx.URLParams.Add(k, v) + } + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, ctxKey{}, ctxAuth{UserID: userID, ProfileID: profileID}) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + fn(rec, req) + return rec +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +func TestCollection_Create_ReturnsFullShape(t *testing.T) { + hb := newCollectionsHarness(t) + body := []byte(`{"name":"Favorites","description":"My top picks"}`) + rec := dispatchABSWithParams(http.MethodPost, "/api/collections", nil, body, "1", "", hb.H.handleCreateCollection) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + if got["name"] != "Favorites" { + t.Errorf("name = %v, want Favorites", got["name"]) + } + if got["description"] != "My top picks" { + t.Errorf("description = %v, want 'My top picks'", got["description"]) + } + if got["userId"] != "1" { + t.Errorf("userId = %v, want 1", got["userId"]) + } + if got["isPublic"] != false { + t.Errorf("isPublic = %v, want false", got["isPublic"]) + } + for _, k := range []string{"id", "lastUpdate", "createdAt"} { + if _, ok := got[k]; !ok { + t.Errorf("response missing %q", k) + } + } + books, ok := got["books"].([]any) + if !ok || len(books) != 0 { + t.Errorf("books = %v (type %T), want empty array", got["books"], got["books"]) + } +} + +func TestCollection_Create_NameRequired_400(t *testing.T) { + hb := newCollectionsHarness(t) + body := []byte(`{"description":"only"}`) + rec := dispatchABSWithParams(http.MethodPost, "/api/collections", nil, body, "1", "", hb.H.handleCreateCollection) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestCollection_Create_InvalidBody_400(t *testing.T) { + hb := newCollectionsHarness(t) + rec := dispatchABSWithParams(http.MethodPost, "/api/collections", nil, []byte(`{not json`), "1", "", hb.H.handleCreateCollection) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index b49e5f1e..f73613bd 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -182,6 +182,9 @@ type Dependencies struct { // POST/PATCH/DELETE /me/item/{itemId}/bookmark endpoints. May be // nil; handlers respond 503 when unset. BookmarkStore BookmarkStore + // CollectionStore persists ABS user-collection rows (migrations 149 + 150). + // May be nil; handlers respond 503 when unset. + CollectionStore CollectionStore // SocketIO is the Socket.io server mounted at /abs/socket.io/. May be nil; // the route is only registered when a non-nil value is supplied. SocketIO SocketIOServer