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>
222 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|