Files
silo-server/internal/audiobooks/abs/rss_feeds_handler_test.go
T
RXWatcherandClaude Opus 4.7 82bafb8375 feat(audiobooks): public RSS XML route + per-file stream
GET /feed/{slug}.xml (and /feed/{slug}) generates a minimal RSS 2.0
document with one <item> per media_file. GET /feed/{slug}/file/{ino}
streams the underlying file when ino belongs to the feed's
library_item_id. Both routes are unauthenticated — slug is the
capability token; closed feeds 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:03:10 +02:00

222 lines
7.2 KiB
Go

package abs
import (
"context"
"encoding/json"
"errors"
"net/http"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
type memRSSFeedStore struct {
mu sync.Mutex
rows map[string]RSSFeed
}
func newMemRSSFeedStore() *memRSSFeedStore { return &memRSSFeedStore{rows: map[string]RSSFeed{}} }
func (m *memRSSFeedStore) ListUserFeeds(_ context.Context, userID, profileID string) ([]RSSFeed, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RSSFeed, 0)
for _, f := range m.rows {
if f.UserID == userID && f.ProfileID == profileID && f.ClosedAt == nil {
out = append(out, f)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
func (m *memRSSFeedStore) GetFeed(_ context.Context, id string) (RSSFeed, error) {
m.mu.Lock()
defer m.mu.Unlock()
f, ok := m.rows[id]
if !ok {
return RSSFeed{}, ErrNotFound
}
return f, nil
}
func (m *memRSSFeedStore) GetFeedBySlug(_ context.Context, slug string) (RSSFeed, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, f := range m.rows {
if f.Slug == slug && f.ClosedAt == nil {
return f, nil
}
}
return RSSFeed{}, ErrNotFound
}
func (m *memRSSFeedStore) CreateFeed(_ context.Context, f RSSFeed) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, existing := range m.rows {
if existing.Slug == f.Slug && existing.ClosedAt == nil {
return errors.New("unique violation duplicate key")
}
}
f.CreatedAt = time.Now()
m.rows[f.ID] = f
return nil
}
func (m *memRSSFeedStore) CloseFeed(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
f, ok := m.rows[id]
if !ok {
return nil
}
now := time.Now()
f.ClosedAt = &now
m.rows[id] = f
return nil
}
func newFeedsHarness(t *testing.T, knownItems ...string) (*Handler, *memRSSFeedStore) {
t.Helper()
known := map[string]*models.MediaItem{}
for _, id := range knownItems {
known[id] = nil
}
store := newMemRSSFeedStore()
h := New(Dependencies{MediaStore: &stubMediaStore{known: known}, RSSFeedStore: store})
return h, store
}
func TestFeed_Open_GeneratesSlug(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
slug, _ := got["slug"].(string)
if len(slug) != 16 {
t.Errorf("slug = %q (len %d), want 16-char auto-generated", slug, len(slug))
}
}
func TestFeed_Open_CustomSlug(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
map[string]string{"itemId": "book-1"}, []byte(`{"slug":"my-cool-feed"}`), "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["slug"] != "my-cool-feed" {
t.Errorf("slug = %v, want my-cool-feed", got["slug"])
}
}
func TestFeed_Open_InvalidSlug_400(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
map[string]string{"itemId": "book-1"}, []byte(`{"slug":"BAD!"}`), "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestFeed_Open_Collision_409(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
body := []byte(`{"slug":"taken-slug"}`)
_ = dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, body, "1", "", h.handleOpenItemFeed)
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, body, "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusConflict {
t.Errorf("status = %d, want 409", rec.Code)
}
}
func TestFeed_Open_UnknownItem_404(t *testing.T) {
h, _ := newFeedsHarness(t)
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/ghost/open",
map[string]string{"itemId": "ghost"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestFeed_List_OwnerOnly(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
_ = dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
rec := dispatchABSWithParams(http.MethodGet, "/api/feeds", nil, nil, "2", "", h.handleListRSSFeeds)
var env map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &env)
feeds, _ := env["feeds"].([]any)
if len(feeds) != 0 {
t.Errorf("user 2 sees %d feeds, want 0", len(feeds))
}
}
func TestFeed_Close_Owner(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
openRec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
var open map[string]any
_ = json.Unmarshal(openRec.Body.Bytes(), &open)
id, _ := open["id"].(string)
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/"+id+"/close", map[string]string{"id": id}, nil, "1", "", h.handleCloseFeed)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", rec.Code)
}
}
func TestFeed_Close_NonOwner_404(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
openRec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open", map[string]string{"itemId": "book-1"}, []byte(`{}`), "1", "", h.handleOpenItemFeed)
var open map[string]any
_ = json.Unmarshal(openRec.Body.Bytes(), &open)
id, _ := open["id"].(string)
rec := dispatchABSWithParams(http.MethodPost, "/api/feeds/"+id+"/close", map[string]string{"id": id}, nil, "2", "", h.handleCloseFeed)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestPublicFeed_UnknownSlug_404(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
rec := dispatchABSWithParams(http.MethodGet, "/feed/missing.xml",
map[string]string{"slug": "missing.xml"}, nil, "", "", h.handlePublicFeed)
if rec.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rec.Code)
}
}
func TestPublicFeed_HappyPath_XML(t *testing.T) {
h, _ := newFeedsHarness(t, "book-1")
openRec := dispatchABSWithParams(http.MethodPost, "/api/feeds/item/book-1/open",
map[string]string{"itemId": "book-1"}, []byte(`{"slug":"happy-feed"}`), "1", "", h.handleOpenItemFeed)
if openRec.Code != http.StatusOK {
t.Fatalf("seed open failed: %s", openRec.Body.String())
}
rec := dispatchABSWithParams(http.MethodGet, "/feed/happy-feed.xml",
map[string]string{"slug": "happy-feed.xml"}, nil, "", "", h.handlePublicFeed)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/rss+xml") {
t.Errorf("Content-Type = %q, want application/rss+xml", ct)
}
body := rec.Body.String()
for _, needle := range []string{"<rss", "<channel>", "<title>"} {
if !strings.Contains(body, needle) {
t.Errorf("body missing %q; got %s", needle, body)
}
}
}