diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-06 07:57:20 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-06 07:57:20 +0300 |
| commit | 5e1d54ecc9182abf3fd77ffa0e28f5f51b43f372 (patch) | |
| tree | 93ceb78b39421b52c7e0c25f9523191b262469b1 /internal/api | |
| parent | 69a3a88b85ed0a56bf442af4fec420c871d337fa (diff) | |
Add podcast E2E integration tests
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/handlers_podcast_test.go | 314 |
1 files changed, 314 insertions, 0 deletions
diff --git a/internal/api/handlers_podcast_test.go b/internal/api/handlers_podcast_test.go new file mode 100644 index 0000000..7942914 --- /dev/null +++ b/internal/api/handlers_podcast_test.go @@ -0,0 +1,314 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "codeberg.org/snonux/player/internal" + "codeberg.org/snonux/player/internal/auth" + "codeberg.org/snonux/player/internal/clock" + "codeberg.org/snonux/player/internal/model" + "codeberg.org/snonux/player/internal/probe" + "codeberg.org/snonux/player/internal/repository" + "codeberg.org/snonux/player/internal/service" + "codeberg.org/snonux/player/internal/thumb" +) + +// newPodcastTestServer is like newTestServer but accepts a real podcast service. +func newPodcastTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm *auth.SessionManager, cfg *internal.Config, + browseSvc service.MediaBrowseService, + writeSvc service.MediaWriteService, + shareSvc service.MediaShareService, + tagSvc service.MediaTagService, + favSvc service.MediaFavoriteService, + noteSvc service.MediaNoteService, + adminSvc service.AdminService, + progressSvc service.ProgressService, + authSvc service.AuthService, + podcastSvc service.PodcastEpisodeService, + fs http.FileSystem, +) *Server { + t.Helper() + if fs == nil { + fs = newTestFS(map[string]string{ + "index.html": "index", + "login.html": "login", + "bootstrap.html": "bootstrap", + "share.html": "share", + }) + } + if authSvc == nil { + authSvc = &service.MockAuthService{ + CountUsersFunc: func(context.Context) (int, error) { return 1, nil }, + GetUserByIDFunc: func(context.Context, int64) (*model.User, error) { return &model.User{ID: 1, IsAdmin: true}, nil }, + } + } + return NewServer(store, hasher, sm, cfg, browseSvc, writeSvc, shareSvc, tagSvc, favSvc, noteSvc, adminSvc, progressSvc, authSvc, podcastSvc, fs, nil) +} + +// setupPodcastE2E creates a full server with a real SQLite store and real services. +func setupPodcastE2E(t *testing.T) (srv *Server, store repository.Store, sm *auth.SessionManager, adminID int64, cleanup func()) { + t.Helper() + + dbStore, err := repository.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + + ctx := context.Background() + now := time.Now() + clk := &clock.MockClock{T: now} + + adminID, err = dbStore.CreateUser(ctx, &model.User{ + Username: "admin", + PasswordHash: "hashed", + IsAdmin: true, + CreatedAt: now, + }) + if err != nil { + t.Fatalf("create admin: %v", err) + } + + hasher := &staticHasher{fixed: "hashed"} + sm = auth.NewSessionManager(dbStore, clk, time.Hour) + authSvc := service.NewAuthService(dbStore, clk, hasher, sm) + + mediaRoot := t.TempDir() + helper := service.NewAccessHelper(dbStore) + prober := &probe.MockProber{} + thumbGen := &thumb.MockGenerator{} + + mediaSvc := service.NewMediaService(dbStore, clk, mediaRoot, thumbGen, prober) + podcastSvc := service.NewPodcastService(dbStore, clk, mediaRoot, helper, prober, thumbGen, 60) + + cfg := &internal.Config{ + SessionTimeoutHours: 24, + MaxUploadSizeMB: 10, + MediaRoot: mediaRoot, + } + + srv = newPodcastTestServer(t, dbStore, hasher, sm, cfg, mediaSvc, mediaSvc, mediaSvc, mediaSvc, mediaSvc, mediaSvc, nil, nil, authSvc, podcastSvc, nil) + + cleanup = func() { + dbStore.Close() + } + + return srv, dbStore, sm, adminID, cleanup +} + +func TestPodcastE2E_FullFlow(t *testing.T) { + srv, store, sm, adminID, cleanup := setupPodcastE2E(t) + defer cleanup() + + // Audio server serves dummy MP3 bytes. + audioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "audio/mpeg") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("dummy audio data")) + })) + defer audioServer.Close() + + rssBody := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"> + <channel> + <title>Test Podcast</title> + <description>A test podcast</description> + <item> + <title>Episode 1</title> + <guid>ep-1</guid> + <pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate> + <enclosure url="%s/audio.mp3" length="1234" type="audio/mpeg"/> + <itunes:duration>00:05:00</itunes:duration> + </item> + </channel> +</rss>`, audioServer.URL) + + rssServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(rssBody)) + })) + defer rssServer.Close() + + badRSSServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not a valid feed")) + })) + defer badRSSServer.Close() + + cookie := addSessionCookie(t, store, sm, adminID) + + var podcastSetID int64 + var episodeID int64 + var downloadedMediaID int64 + + t.Run("subscribe podcast", func(t *testing.T) { + body := fmt.Sprintf(`{"feed_url":"%s/rss.xml","set_name":"test-podcast"}`, rssServer.URL) + req := httptest.NewRequest(http.MethodPost, "/api/podcasts", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + var feed model.PodcastFeed + if err := json.Unmarshal(rr.Body.Bytes(), &feed); err != nil { + t.Fatalf("unmarshal feed: %v", err) + } + if feed.ID <= 0 { + t.Fatalf("expected feed id > 0, got %d", feed.ID) + } + podcastSetID = feed.SetID + }) + + t.Run("list podcasts", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/podcasts", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + var sets []model.Set + if err := json.Unmarshal(rr.Body.Bytes(), &sets); err != nil { + t.Fatalf("unmarshal sets: %v", err) + } + + found := false + for _, s := range sets { + if s.ID == podcastSetID && s.IsPodcast { + found = true + break + } + } + if !found { + t.Fatalf("expected podcast set %d in list", podcastSetID) + } + }) + + t.Run("list episodes", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/podcasts/%d/episodes", podcastSetID), nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + var episodes []model.PodcastEpisodeWithStatus + if err := json.Unmarshal(rr.Body.Bytes(), &episodes); err != nil { + t.Fatalf("unmarshal episodes: %v", err) + } + if len(episodes) < 1 { + t.Fatalf("expected at least 1 episode, got %d", len(episodes)) + } + episodeID = episodes[0].ID + }) + + t.Run("download episode", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/podcasts/episodes/%d/download", episodeID), nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + var media model.Media + if err := json.Unmarshal(rr.Body.Bytes(), &media); err != nil { + t.Fatalf("unmarshal media: %v", err) + } + if media.ID <= 0 { + t.Fatalf("expected media id > 0, got %d", media.ID) + } + downloadedMediaID = media.ID + }) + + t.Run("toggle complete", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/podcasts/episodes/%d/complete", episodeID), nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("expected %d, got %d", http.StatusNoContent, rr.Code) + } + }) + + t.Run("media list includes downloaded episode", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/media", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + var mediaList []model.Media + if err := json.Unmarshal(rr.Body.Bytes(), &mediaList); err != nil { + t.Fatalf("unmarshal media list: %v", err) + } + + found := false + for _, m := range mediaList { + if m.ID == downloadedMediaID { + found = true + break + } + } + if !found { + t.Fatalf("expected downloaded media %d in list", downloadedMediaID) + } + }) + + // Error cases. + t.Run("subscribe invalid url", func(t *testing.T) { + body := fmt.Sprintf(`{"feed_url":"%s","set_name":"bad"}`, badRSSServer.URL) + req := httptest.NewRequest(http.MethodPost, "/api/podcasts", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusInternalServerError && rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 or 500, got %d", rr.Code) + } + }) + + t.Run("episodes non-existent set", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/podcasts/99999/episodes", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) + + t.Run("download non-existent episode", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/podcasts/episodes/99999/download", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("expected %d, got %d", http.StatusNotFound, rr.Code) + } + }) +} |
