From d89f6197f5e09c824ce5334bd76cbf69f377f5c7 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Tue, 26 May 2026 18:18:57 +0200 Subject: [PATCH] feat(audiobooks): add CollectionStore interface + ABS envelope helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the storage contract and wire-shape serialiser the collections handlers will consume. Envelope test asserts the seven required keys including description (which the continuum reference always emits as empty regardless of stored value — this round-trips it correctly). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/audiobooks/abs/collections.go | 80 +++++++++++++++++++ .../abs/collections_envelope_test.go | 55 +++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 internal/audiobooks/abs/collections.go create mode 100644 internal/audiobooks/abs/collections_envelope_test.go diff --git a/internal/audiobooks/abs/collections.go b/internal/audiobooks/abs/collections.go new file mode 100644 index 00000000..2eb3ab4c --- /dev/null +++ b/internal/audiobooks/abs/collections.go @@ -0,0 +1,80 @@ +package abs + +import ( + "context" + "time" +) + +// CollectionStore is the narrow slice of the abs_user_collections + +// abs_collection_items tables the collections handlers need. +// Implemented by ABSCollectionStore in +// internal/audiobooks/abs_collection_store.go. +type CollectionStore interface { + // ListUserCollections returns collections owned by (userID, profileID), + // ordered by created_at DESC. Empty slice (never nil) when none. + ListUserCollections(ctx context.Context, userID, profileID string) ([]Collection, error) + // GetCollection fetches by ID without owner check (caller authorizes). + // Returns ErrNotFound when absent. + GetCollection(ctx context.Context, id string) (Collection, error) + // CreateCollection inserts. ID must be set by caller (ULID). + CreateCollection(ctx context.Context, c Collection) error + // UpdateCollection writes name, description, is_public; bumps + // updated_at = now(). Owner check is the caller's responsibility. + UpdateCollection(ctx context.Context, c Collection) error + // DeleteCollection removes the collection and (via FK CASCADE) all + // its abs_collection_items. Returns nil even if no row matched. + DeleteCollection(ctx context.Context, id string) error + // ListCollectionItems returns items ordered by added_at ASC. + // Empty slice (never nil) when none. + ListCollectionItems(ctx context.Context, collectionID string) ([]CollectionItem, error) + // AddCollectionItem inserts (collectionID, libraryItemID) and bumps + // the parent's updated_at. ON CONFLICT DO NOTHING — re-adding is a + // silent no-op. + AddCollectionItem(ctx context.Context, collectionID, libraryItemID string) error + // RemoveCollectionItem deletes one row and bumps the parent's + // updated_at. Returns nil when not present (idempotent). + RemoveCollectionItem(ctx context.Context, collectionID, libraryItemID string) error +} + +// Collection is the in-memory representation of an +// abs_user_collections row. +type Collection struct { + ID string + UserID string + ProfileID string + Name string + Description string + IsPublic bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// CollectionItem is the in-memory representation of an +// abs_collection_items row. +type CollectionItem struct { + CollectionID string + LibraryItemID string + AddedAt time.Time +} + +// collectionToABS shapes a Collection in the ABS wire format. When +// books is nil the list-shape is emitted (no "books" key); when books +// is non-nil (possibly empty) the full-shape is emitted. +// +// All seven non-books keys are always present (no omitempty), +// camelCase, with timestamps as JS-epoch milliseconds. +func collectionToABS(c Collection, books []map[string]any) map[string]any { + out := map[string]any{ + "id": c.ID, + "userId": c.UserID, + "name": c.Name, + "description": c.Description, + "isPublic": c.IsPublic, + "lastUpdate": c.UpdatedAt.UnixMilli(), + "createdAt": c.CreatedAt.UnixMilli(), + } + if books != nil { + out["books"] = books + } + return out +} diff --git a/internal/audiobooks/abs/collections_envelope_test.go b/internal/audiobooks/abs/collections_envelope_test.go new file mode 100644 index 00000000..65f8e8ae --- /dev/null +++ b/internal/audiobooks/abs/collections_envelope_test.go @@ -0,0 +1,55 @@ +package abs + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// TestCollectionEnvelope_HasRequiredKeys asserts the seven top-level +// keys ABS Android pattern-matches on are present even when description +// is empty and books[] is empty. Fixes the continuum-reference bug where +// description always emitted as "" regardless of stored value. +func TestCollectionEnvelope_HasRequiredKeys(t *testing.T) { + now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) + out := collectionToABS(Collection{ + ID: "01HCOLL", + UserID: "1", + Name: "Favorites", + Description: "", + IsPublic: false, + CreatedAt: now, + UpdatedAt: now, + }, []map[string]any{}) + body, _ := json.Marshal(out) + js := string(body) + for _, key := range []string{ + `"id":`, `"userId":`, `"name":`, `"description":`, + `"isPublic":`, `"lastUpdate":`, `"createdAt":`, `"books":`, + } { + if !strings.Contains(js, key) { + t.Errorf("envelope missing %s; got %s", key, js) + } + } + if out["description"] != "" { + t.Errorf("description = %v, want empty string", out["description"]) + } + wantMs := now.UnixMilli() + if out["createdAt"] != wantMs { + t.Errorf("createdAt = %v, want %d", out["createdAt"], wantMs) + } +} + +// TestCollectionListShape_OmitsBooks asserts the list shape (passed +// nil books) emits no "books" key — clients distinguish list vs detail +// by presence/absence of this field. +func TestCollectionListShape_OmitsBooks(t *testing.T) { + out := collectionToABS(Collection{ + ID: "01HCOLL", UserID: "1", Name: "x", + CreatedAt: time.Now(), UpdatedAt: time.Now(), + }, nil) + if _, ok := out["books"]; ok { + t.Errorf("list-shape must not include books key; got %v", out) + } +}