feat(audiobooks): add CollectionStore interface + ABS envelope helper

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) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 18:18:57 +02:00
co-authored by Claude Opus 4.7
parent 54c6a3b979
commit d89f6197f5
2 changed files with 135 additions and 0 deletions
+80
View File
@@ -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
}
@@ -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)
}
}