clone

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

feat/tinyagent
tinyrelay / internal / daemon / social_podcasts_test.go
social_podcasts_test.go 6.9K
1package daemon23import (4 "context"5 "encoding/json"6 "encoding/xml"7 "net/http"8 "net/http/httptest"9 "strconv"10 "strings"11 "testing"12 "time"1314 "github.com/FelineStateMachine/tinyrelay/internal/event"15 "github.com/FelineStateMachine/tinyrelay/internal/podcasts"16 "github.com/FelineStateMachine/tinyrelay/internal/relay"17 "github.com/FelineStateMachine/tinyrelay/internal/storage"18)1920func TestSocialBrowseNativePodcastsAndMixedFeed(t *testing.T) {21 _, tenant := testTenant(t)22 owner := tenant.Policy().Owner23 secret := testOwnerSecret24 show := signedEvent(t, secret, podcasts.KindMetadata, 100, [][]string{{"title", "Relay Radio"}, {"image", "https://example.test/cover.jpg"}}, "")25 episode := signedEvent(t, secret, podcasts.KindEpisode, 101, [][]string{{"title", "First episode"}, {"audio", "https://example.test/first.mp3", "audio/mpeg"}}, "The show notes")26 for _, row := range []event.Event{show, episode} {27 if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {28 t.Fatal(err)29 }30 }31 value, err := tenant.Execute(context.Background(), owner, "browsepodcasts", []json.RawMessage{rawJSON(map[string]any{"author": episode.PubKey})})32 if err != nil {33 t.Fatal(err)34 }35 result := value.(map[string]any)36 items := result["items"].([]socialItem)37 if len(items) != 1 || items[0].Kind != podcasts.KindEpisode || items[0].Podcast == nil || items[0].Podcast.Title != "Relay Radio" {38 t.Fatalf("podcast browse = %#v", result)39 }40 mixed, err := tenant.Execute(context.Background(), owner, "browsesocial", nil)41 if err != nil {42 t.Fatal(err)43 }44 if items := mixed.(map[string]any)["items"].([]socialItem); len(items) != 1 || items[0].Kind != podcasts.KindEpisode {45 t.Fatalf("mixed feed = %#v", items)46 }47}4849func TestSocialPodcastThreadRequiresNativeAudio(t *testing.T) {50 _, tenant := testTenant(t)51 row := signedEvent(t, testOwnerSecret, 54, time.Now().Unix(), [][]string{{"title", "Unrelated kind 54 event"}}, "No podcast audio")52 if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt}); err != nil {53 t.Fatal(err)54 }55 if _, err := tenant.socialPost(context.Background(), tenant.Policy().Owner, row.ID); err == nil {56 t.Fatal("kind 54 without native audio should not open as a podcast")57 }58}5960func TestSocialPodcastSearchPaginatesPastOtherEvents(t *testing.T) {61 _, tenant := testTenant(t)62 ctx := context.Background()63 var wanted []string64 for i := 1; i <= 8; i++ {65 tags := [][]string{{"title", "Other"}}66 if i%2 == 0 {67 tags = append(tags, []string{"audio", "https://example.test/" + strconv.Itoa(i) + ".mp3"})68 }69 if i == 2 || i == 6 {70 tags = append(tags, []string{"description", "walking"})71 }72 row := signedEvent(t, testOwnerSecret, 54, int64(i), tags, "show notes")73 if i == 2 || i == 6 {74 wanted = append([]string{row.ID}, wanted...)75 }76 if _, err := tenant.store.Save(ctx, row, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {77 t.Fatal(err)78 }79 }80 params := map[string]any{"author": tenant.Policy().Owner, "q": "walking", "limit": 1}81 for i, want := range wanted {82 value, err := tenant.Execute(ctx, tenant.Policy().Owner, "browsepodcasts", []json.RawMessage{rawJSON(params)})83 if err != nil {84 t.Fatal(err)85 }86 page := value.(map[string]any)87 items := page["items"].([]socialItem)88 if len(items) != 1 || items[0].ID != want {89 t.Fatalf("page %d: %#v", i, page)90 }91 params["cursor"] = page["next_cursor"]92 if i == 0 && params["cursor"] == "" {93 t.Fatal("missing continuation after filtered events")94 }95 }96}9798func TestPodcastPublicationViewAndRSSFollowNativeEvents(t *testing.T) {99 app, tenant := testTenant(t)100 ctx, now := context.Background(), time.Now().Unix()101 show := signedEvent(t, testOwnerSecret, 10154, now-5, [][]string{{"title", "First show name"}, {"description", "Our show"}}, "")102 episode := signedEvent(t, testOwnerSecret, 54, now-4, [][]string{{"title", "A native episode"}, {"audio", "https://example.test/episode.mp3", "audio/mpeg"}}, "## Show notes")103 for _, row := range []event.Event{show, episode} {104 if _, err := tenant.router.Publish(ctx, row, relay.Session{PubKeys: []string{row.PubKey}}); err != nil {105 t.Fatal(err)106 }107 }108 var intents int109 if err := tenant.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM work_intents WHERE kind='view-publish' AND target='podcasts' AND event_id IN (?,?)`, show.ID, episode.ID).Scan(&intents); err != nil || intents != 2 {110 t.Fatalf("publication view intents=%d: %v", intents, err)111 }112 readRSS := func() string {113 t.Helper()114 w := httptest.NewRecorder()115 app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://relay.test/r/main/social/podcasts.rss?author="+show.PubKey, nil))116 if w.Code != 200 {117 t.Fatalf("RSS status %d: %s", w.Code, w.Body.String())118 }119 var root struct{ XMLName xml.Name }120 if err := xml.Unmarshal(w.Body.Bytes(), &root); err != nil || root.XMLName.Local != "rss" {121 t.Fatalf("RSS: %v %s", err, w.Body.String())122 }123 return w.Body.String()124 }125 if rss := readRSS(); !strings.Contains(rss, "First show name") || !strings.Contains(rss, episode.ID) {126 t.Fatalf("published RSS: %s", rss)127 }128 replacement := signedEvent(t, testOwnerSecret, 10154, now-3, [][]string{{"title", "Updated show name"}, {"description", "Our show"}}, "")129 if _, err := tenant.router.Publish(ctx, replacement, relay.Session{PubKeys: []string{show.PubKey}}); err != nil {130 t.Fatal(err)131 }132 if rss := readRSS(); !strings.Contains(rss, "Updated show name") || strings.Contains(rss, "First show name") || !strings.Contains(rss, episode.ID) {133 t.Fatalf("updated RSS: %s", rss)134 }135 deletion := signedEvent(t, testOwnerSecret, 5, now-2, [][]string{{"e", episode.ID}}, "")136 if _, err := tenant.router.Publish(ctx, deletion, relay.Session{PubKeys: []string{show.PubKey}}); err != nil {137 t.Fatal(err)138 }139 if rss := readRSS(); strings.Contains(rss, "<item>") || !strings.Contains(rss, "Updated show name") {140 t.Fatalf("deleted episode RSS: %s", rss)141 }142 w := httptest.NewRecorder()143 app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://relay.test/r/main/view/podcasts", nil))144 if w.Code != 200 || strings.Contains(w.Body.String(), episode.ID) || !strings.Contains(w.Body.String(), "Updated show name") {145 t.Fatalf("signed view after deletion: %d %s", w.Code, w.Body.String())146 }147}148149func TestSocialEpisodeCommentsRequireNativeRoot(t *testing.T) {150 root := event.Event{ID: "episode", PubKey: "show", Kind: podcasts.KindEpisode}151 unrelated := event.Event{ID: "other", PubKey: "show", Kind: podcasts.KindEpisode}152 comment := event.Event{Kind: 1111, Tags: [][]string{{"E", root.ID}, {"K", "54"}}}153 if !socialCommentFor(comment, root) {154 t.Fatal("native episode comment not associated")155 }156 if socialCommentFor(comment, unrelated) {157 t.Fatal("native episode comment associated with unrelated episode")158 }159 comment.Tags = [][]string{{"E", root.ID}, {"K", "30023"}}160 if socialCommentFor(comment, root) {161 t.Fatal("non-native root kind accepted for episode")162 }163}