clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / tinyclient / podcast_rss_test.go
podcast_rss_test.go 5.7K
1package tinyclient23import (4 "context"5 "encoding/json"6 "encoding/xml"7 "errors"8 "net/http"9 "net/http/httptest"10 "strings"11 "testing"1213 "github.com/FelineStateMachine/tinyrelay/internal/policy"14)1516type podcastRSSBackend struct {17 fakeBackend18 result any19 err error20 query map[string]any21 actor string22 base string23}2425func (b *podcastRSSBackend) URL() string {26 if b.base != "" {27 return b.base28 }29 return b.fakeBackend.URL()30}3132func (b *podcastRSSBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {33 b.actor = actor34 b.query = nil35 if len(params) > 0 {36 _ = json.Unmarshal(params[0], &b.query)37 }38 return b.result, b.err39}4041func TestPodcastRSSPageBuildsAudioEnclosures(t *testing.T) {42 author := strings.Repeat("a", 64)43 b := &podcastRSSBackend{44 fakeBackend: fakeBackend{policy: policy.Defaults(author)},45 result: map[string]any{"items": []any{46 map[string]any{"id": strings.Repeat("b", 64), "pubkey": strings.Repeat("a", 64), "kind": 54, "title": `A & <episode>`, "content": "A description", "created_at": 1700000000, "tags": [][]string{{"audio", "https://cdn.example/audio.mp3?a=1&b=2"}, {"imeta", "url https://cdn.example/audio.mp3?a=1&b=2", "m audio/mpeg", "size 42"}, {"imeta", "url https://cdn.example/photo.jpg", "m image/jpeg"}}},47 map[string]any{"id": strings.Repeat("c", 64), "kind": 1, "content": "no audio", "tags": [][]string{{"imeta", "url https://cdn.example/photo.jpg", "m image/jpeg"}}},48 }},49 }50 a := &App{backend: b}51 r := httptest.NewRequest(http.MethodGet, "/social/podcasts.rss?author="+author, nil)52 w := httptest.NewRecorder()53 a.podcastRSS(w, r)54 if w.Code != http.StatusOK || !strings.HasPrefix(w.Header().Get("Content-Type"), "application/rss+xml") {55 t.Fatalf("response: %d %q", w.Code, w.Header().Get("Content-Type"))56 }57 var feed podcastRSS58 if err := xml.Unmarshal(w.Body.Bytes(), &feed); err != nil {59 t.Fatalf("invalid RSS: %v\n%s", err, w.Body.String())60 }61 if len(feed.Channel.Items) != 1 || feed.Channel.Items[0].Enclosure.Length != 42 || feed.Channel.Items[0].Enclosure.Type != "audio/mpeg" {62 t.Fatalf("items: %#v", feed.Channel.Items)63 }64 if !strings.Contains(w.Body.String(), "A &amp; &lt;episode&gt;") || !strings.Contains(w.Body.String(), "a=1&amp;b=2") {65 t.Fatalf("escaped feed: %s", w.Body.String())66 }67 if b.query["kind"] != "podcasts" || b.query["limit"] != float64(100) || b.query["author"] != author {68 t.Fatalf("query: %#v", b.query)69 }70}7172func TestPodcastRSSPageUsesUnknownLengthAndPropagatesErrors(t *testing.T) {73 b := &podcastRSSBackend{74 fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))},75 result: map[string]any{76 "items": []any{map[string]any{77 "id": "episode", "pubkey": strings.Repeat("a", 64), "content": "no body reference", "kind": 54,78 "tags": [][]string{{"audio", "https://cdn.example/episode.ogg", "audio/ogg"}},79 }},80 },81 }82 a := &App{backend: b}83 w := httptest.NewRecorder()84 a.podcastRSS(w, httptest.NewRequest(http.MethodGet, "/social/podcasts.rss", nil))85 if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `length="0"`) {86 t.Fatalf("unknown length: %d %s", w.Code, w.Body.String())87 }88 b.err = errors.New("backend unavailable")89 w = httptest.NewRecorder()90 a.podcastRSS(w, httptest.NewRequest(http.MethodGet, "/social/podcasts.rss", nil))91 if w.Code != http.StatusForbidden {92 t.Fatalf("backend error status: %d", w.Code)93 }94 a.actor = nil95 a.actor = func(*http.Request) (string, error) { return "", errors.New("actor failure") }96 w = httptest.NewRecorder()97 a.podcastRSS(w, httptest.NewRequest(http.MethodGet, "/social/podcasts.rss", nil))98 if w.Code != http.StatusForbidden {99 t.Fatalf("actor error status: %d", w.Code)100 }101}102103func TestPodcastRSSRouteNormalizesSubscriptionAndSupportsHead(t *testing.T) {104 author := strings.Repeat("a", 64)105 b := &podcastRSSBackend{fakeBackend: fakeBackend{policy: policy.Defaults(author)}, base: "https://relay.example/r/team"}106 a, err := New(b, Options{Actor: func(*http.Request) (string, error) { return strings.Repeat("b", 64), nil }})107 if err != nil {108 t.Fatal(err)109 }110 path := "/social/podcasts.rss?author=" + identityNpub(author) + "&q=morning&cursor=old&kind=notes"111 w := httptest.NewRecorder()112 a.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))113 if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "https://relay.example/r/team/social/podcasts.rss?author="+author+"&amp;q=morning") || !strings.Contains(w.Body.String(), "https://relay.example/r/team/social/profile?author="+author) {114 t.Fatalf("tenant subscription: %d %s", w.Code, w.Body.String())115 }116 if b.query["author"] != author || b.query["cursor"] != nil || b.query["kind"] != "podcasts" || b.actor != strings.Repeat("b", 64) {117 t.Fatalf("subscription query: %#v actor=%s", b.query, b.actor)118 }119 w = httptest.NewRecorder()120 a.ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/social/podcasts.rss", nil))121 if w.Code != http.StatusOK || w.Body.Len() != 0 || w.Header().Get("Content-Length") == "" || w.Header().Get("Cache-Control") != "private, no-store" || b.query["author"] != nil {122 t.Fatalf("HEAD response: %d %v query=%v", w.Code, w.Header(), b.query)123 }124}125126func TestPodcastNativeEpisodeUsesEventGUID(t *testing.T) {127 row := map[string]any{"id": "episode-id", "pubkey": strings.Repeat("a", 64), "kind": 54, "content": "Episode one", "tags": [][]string{{"audio", "https://cdn.example/episode", "audio/mpeg", "100"}}}128 first, ok := podcastFeedItem("https://relay.example", row)129 if !ok {130 t.Fatal("episode omitted")131 }132 row["id"] = "changed-row-id"133 second, ok := podcastFeedItem("https://relay.example", row)134 if !ok || first.GUID.Value != "episode-id" || second.GUID.Value != "changed-row-id" || second.Title != "Episode one" {135 t.Fatalf("edited episode identity: %#v %#v", first, second)136 }137}