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:
co-authored by
Claude Opus 4.7
parent
54c6a3b979
commit
d89f6197f5
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user