summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/service/podcast.go178
-rw-r--r--internal/service/podcast_test.go529
2 files changed, 637 insertions, 70 deletions
diff --git a/internal/service/podcast.go b/internal/service/podcast.go
index 5e56860..d609935 100644
--- a/internal/service/podcast.go
+++ b/internal/service/podcast.go
@@ -64,24 +64,26 @@ type PodcastServiceStore interface {
// ------------------------------------------------------------------
type podcastService struct {
- store PodcastServiceStore
- clock clock.Clock
- mediaRoot string
- helper *accessHelper
- prober probe.Prober
- thumbGen thumb.Generator
- httpClient *http.Client
- checkInterval int // minutes
+ store PodcastServiceStore
+ clock clock.Clock
+ mediaRoot string
+ helper *accessHelper
+ prober probe.Prober
+ thumbGen thumb.Generator
+ httpClient *http.Client
+ checkInterval int // minutes
+ parseFeed func(string) (*podcast.ParsedFeed, error)
+ parseFeedReader func(io.Reader) (*podcast.ParsedFeed, error)
+ downloadCover func(*http.Client, string, string) error
}
// NewPodcastService creates a PodcastService with the given dependencies.
-// NewPodcastService creates a PodcastService with the given dependencies.
// checkInterval should be the number of minutes between background feed checks.
func NewPodcastService(store PodcastServiceStore, clk clock.Clock, mediaRoot string, helper *accessHelper, prober probe.Prober, thumbGen thumb.Generator, checkInterval int) *podcastService {
if checkInterval <= 0 {
checkInterval = 60
}
- return &podcastService{
+ s := &podcastService{
store: store,
clock: clk,
mediaRoot: mediaRoot,
@@ -91,6 +93,11 @@ func NewPodcastService(store PodcastServiceStore, clk clock.Clock, mediaRoot str
httpClient: &http.Client{Timeout: 30 * time.Second},
checkInterval: checkInterval,
}
+ // Wire package-level helpers so tests can inject fakes.
+ s.parseFeed = podcast.ParseFeed
+ s.parseFeedReader = podcast.ParseFeedReader
+ s.downloadCover = podcast.DownloadCoverImage
+ return s
}
// ------------------------------------------------------------------
@@ -98,60 +105,95 @@ func NewPodcastService(store PodcastServiceStore, clk clock.Clock, mediaRoot str
// ------------------------------------------------------------------
func (s *podcastService) SubscribeFeed(ctx context.Context, feedURL, setName string, userID int64) (*model.PodcastFeed, error) {
- // Only admins can create podcast sets.
- user, err := s.store.GetUserByID(ctx, userID)
+ if err := s.verifyAdmin(ctx, userID); err != nil {
+ return nil, err
+ }
+
+ parsed, err := s.parseFeed(feedURL)
if err != nil {
- return nil, fmt.Errorf("get user: %w", err)
+ return nil, fmt.Errorf("parse feed: %w", err)
}
- if user == nil || !user.IsAdmin {
- return nil, ErrForbidden
+
+ safeName, setPath := s.resolveSetPath(setName, parsed.Title)
+
+ set, err := s.createPodcastSet(ctx, parsed.Title, safeName, setPath, userID)
+ if err != nil {
+ return nil, err
}
- // Parse feed to validate URL and extract metadata.
- parsed, err := podcast.ParseFeed(feedURL)
+ feed, err := s.createPodcastFeed(ctx, parsed, set.ID, feedURL, setPath)
if err != nil {
- return nil, fmt.Errorf("parse feed: %w", err)
+ s.rollbackSet(ctx, set.ID, setPath)
+ return nil, err
+ }
+
+ s.downloadCover(s.httpClient, parsed.ImageURL, setPath)
+ s.insertPodcastEpisodes(ctx, parsed, feed.ID)
+
+ now := s.clock.Now()
+ feed.LastCheckedAt = &now
+ _ = s.store.UpdateFeed(ctx, feed)
+
+ return feed, nil
+}
+
+func (s *podcastService) verifyAdmin(ctx context.Context, userID int64) error {
+ user, err := s.store.GetUserByID(ctx, userID)
+ if err != nil {
+ return fmt.Errorf("get user: %w", err)
+ }
+ if user == nil || !user.IsAdmin {
+ return ErrForbidden
}
+ return nil
+}
- // Sanitize set name for filesystem.
+func (s *podcastService) resolveSetPath(setName, fallbackTitle string) (string, string) {
safeName := sanitizeSetName(setName)
if safeName == "" {
- safeName = sanitizeSetName(parsed.Title)
+ safeName = sanitizeSetName(fallbackTitle)
}
if safeName == "" {
safeName = "podcast"
}
- setPath := filepath.Join(s.mediaRoot, safeName)
+ return safeName, filepath.Join(s.mediaRoot, safeName)
+}
- // Create folder on disk.
+func (s *podcastService) createPodcastSet(ctx context.Context, title, safeName, setPath string, userID int64) (*model.Set, error) {
+ // Create the directory first so we can clean it up easily on DB errors.
if err := os.MkdirAll(setPath, 0o755); err != nil {
return nil, fmt.Errorf("mkdir set path: %w", err)
}
- // Create set row.
set := &model.Set{
- Name: parsed.Title,
+ Name: title,
RootPath: safeName,
IsPodcast: true,
CreatedAt: s.clock.Now(),
}
setID, err := s.store.CreateSet(ctx, set)
if err != nil {
- os.Remove(setPath)
+ os.RemoveAll(setPath)
return nil, fmt.Errorf("create set: %w", err)
}
set.ID = setID
- // Grant owner permission to the creating user.
perm := &model.SetPermission{SetID: setID, UserID: userID, Role: model.RoleOwner}
if err := s.store.GrantPermission(ctx, perm); err != nil {
- // Rollback set on error.
_ = s.store.DeleteSet(ctx, setID)
- os.Remove(setPath)
+ os.RemoveAll(setPath)
return nil, fmt.Errorf("grant permission: %w", err)
}
- // Insert podcast feed row.
+ return set, nil
+}
+
+func (s *podcastService) rollbackSet(ctx context.Context, setID int64, setPath string) {
+ _ = s.store.DeleteSet(ctx, setID)
+ os.RemoveAll(setPath)
+}
+
+func (s *podcastService) createPodcastFeed(ctx context.Context, parsed *podcast.ParsedFeed, setID int64, feedURL, setPath string) (*model.PodcastFeed, error) {
feed := &model.PodcastFeed{
SetID: setID,
FeedURL: feedURL,
@@ -163,18 +205,13 @@ func (s *podcastService) SubscribeFeed(ctx context.Context, feedURL, setName str
}
feedID, err := s.store.CreateFeed(ctx, feed)
if err != nil {
- _ = s.store.DeleteSet(ctx, setID)
- os.Remove(setPath)
return nil, fmt.Errorf("create feed: %w", err)
}
feed.ID = feedID
+ return feed, nil
+}
- // Download cover image.
- if parsed.ImageURL != "" {
- _ = podcast.DownloadCoverImage(s.httpClient, parsed.ImageURL, setPath)
- }
-
- // Insert episodes.
+func (s *podcastService) insertPodcastEpisodes(ctx context.Context, parsed *podcast.ParsedFeed, feedID int64) {
for _, ep := range parsed.Episodes {
episode := &model.PodcastEpisode{
FeedID: feedID,
@@ -189,13 +226,6 @@ func (s *podcastService) SubscribeFeed(ctx context.Context, feedURL, setName str
}
_, _ = s.store.CreateEpisode(ctx, episode)
}
-
- // Mark feed as checked.
- now := s.clock.Now()
- feed.LastCheckedAt = &now
- _ = s.store.UpdateFeed(ctx, feed)
-
- return feed, nil
}
func (s *podcastService) EditFeed(ctx context.Context, feedID int64, feedURL string, checkInterval int, userID int64) error {
@@ -479,24 +509,41 @@ func (s *podcastService) checkFeed(ctx context.Context, feed model.PodcastFeed)
return fmt.Errorf("feed check status %d", resp.StatusCode)
}
- parsed, err := podcast.ParseFeedReader(resp.Body)
+ parsed, err := s.parseFeedReader(resp.Body)
if err != nil {
return err
}
- // Update feed metadata.
+ if err := s.updateFeedFromParsed(ctx, &feed, parsed, resp.Header.Get("ETag")); err != nil {
+ return err
+ }
+
+ if err := s.upsertFeedEpisodes(ctx, &feed, parsed); err != nil {
+ // Non-fatal: continue.
+ }
+
+ if parsed.ImageURL != "" {
+ set, err := s.store.GetSetByID(ctx, feed.SetID)
+ if err == nil && set != nil {
+ setPath := filepath.Join(s.mediaRoot, set.RootPath)
+ s.downloadCover(s.httpClient, parsed.ImageURL, setPath)
+ }
+ }
+
+ return nil
+}
+
+func (s *podcastService) updateFeedFromParsed(ctx context.Context, feed *model.PodcastFeed, parsed *podcast.ParsedFeed, etag string) error {
feed.Title = parsed.Title
feed.Description = parsed.Description
feed.ImageURL = parsed.ImageURL
- feed.LastETag = resp.Header.Get("ETag")
+ feed.LastETag = etag
now := s.clock.Now()
feed.LastCheckedAt = &now
+ return s.store.UpdateFeed(ctx, feed)
+}
- if err := s.store.UpdateFeed(ctx, &feed); err != nil {
- return err
- }
-
- // Upsert episodes.
+func (s *podcastService) upsertFeedEpisodes(ctx context.Context, feed *model.PodcastFeed, parsed *podcast.ParsedFeed) error {
for _, ep := range parsed.Episodes {
existing, err := s.store.GetEpisodeByGUID(ctx, feed.ID, ep.GUID)
if err != nil {
@@ -504,29 +551,19 @@ func (s *podcastService) checkFeed(ctx context.Context, feed model.PodcastFeed)
}
if existing == nil {
episode := &model.PodcastEpisode{
- FeedID: feed.ID,
- GUID: ep.GUID,
- Title: ep.Title,
- Description: ep.Description,
- PublishedAt: ep.PublishedAt,
- EpisodeURL: ep.EpisodeURL,
+ FeedID: feed.ID,
+ GUID: ep.GUID,
+ Title: ep.Title,
+ Description: ep.Description,
+ PublishedAt: ep.PublishedAt,
+ EpisodeURL: ep.EpisodeURL,
DurationSeconds: ep.DurationSeconds,
- FileSize: ep.FileSize,
- CreatedAt: s.clock.Now(),
+ FileSize: ep.FileSize,
+ CreatedAt: s.clock.Now(),
}
_, _ = s.store.CreateEpisode(ctx, episode)
}
}
-
- // Re-download cover if changed.
- if parsed.ImageURL != "" {
- set, err := s.store.GetSetByID(ctx, feed.SetID)
- if err == nil && set != nil {
- setPath := filepath.Join(s.mediaRoot, set.RootPath)
- _ = podcast.DownloadCoverImage(s.httpClient, parsed.ImageURL, setPath)
- }
- }
-
return nil
}
@@ -550,3 +587,4 @@ func sanitizeFilename(name string) string {
name = strings.TrimSpace(name)
return name
}
+
diff --git a/internal/service/podcast_test.go b/internal/service/podcast_test.go
new file mode 100644
index 0000000..f28a326
--- /dev/null
+++ b/internal/service/podcast_test.go
@@ -0,0 +1,529 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "codeberg.org/snonux/player/internal/clock"
+ "codeberg.org/snonux/player/internal/model"
+ "codeberg.org/snonux/player/internal/podcast"
+ "codeberg.org/snonux/player/internal/repository"
+)
+
+func setupPodcastService(t *testing.T) (*podcastService, *repository.MockStore) {
+ t.Helper()
+ mediaRoot := t.TempDir()
+ clk := &clock.MockClock{T: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}
+ store := repository.NewMockStore()
+ helper := &accessHelper{store: store}
+ svc := NewPodcastService(store, clk, mediaRoot, helper, nil, nil, 60)
+ return svc, store
+}
+
+func TestPodcastService_SubscribeFeed_Ok(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+
+ store.UserRepo = repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ }
+ var setID int64
+ store.SetRepo = repository.MockSetRepo{
+ CreateSetFunc: func(ctx context.Context, set *model.Set) (int64, error) {
+ setID++
+ return setID, nil
+ },
+ }
+ var feedID int64
+ store.PodcastRepo = repository.MockPodcastRepo{
+ CreateFeedFunc: func(ctx context.Context, feed *model.PodcastFeed) (int64, error) {
+ feedID++
+ return feedID, nil
+ },
+ UpdateFeedFunc: func(ctx context.Context, feed *model.PodcastFeed) error { return nil },
+ CreateEpisodeFunc: func(ctx context.Context, ep *model.PodcastEpisode) (int64, error) { return 1, nil },
+ }
+
+ svc.parseFeed = func(url string) (*podcast.ParsedFeed, error) {
+ return &podcast.ParsedFeed{
+ Title: "Test Feed",
+ Description: "desc",
+ ImageURL: "http://example.com/cover.jpg",
+ Episodes: []podcast.Episode{
+ {GUID: "ep-1", Title: "Episode 1", EpisodeURL: "http://example.com/1.mp3"},
+ },
+ }, nil
+ }
+ coverCalled := false
+ svc.downloadCover = func(c *http.Client, u, p string) error {
+ coverCalled = true
+ return nil
+ }
+
+ feed, err := svc.SubscribeFeed(ctx, "http://rss.example.com/feed.xml", "my-podcast", 1)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if feed == nil {
+ t.Fatal("expected feed, got nil")
+ }
+ if feed.Title != "Test Feed" {
+ t.Errorf("title = %q, want Test Feed", feed.Title)
+ }
+ if feed.LastCheckedAt == nil {
+ t.Error("expected LastCheckedAt set")
+ }
+ if !coverCalled {
+ t.Error("expected cover download to be called")
+ }
+ if setID != 1 {
+ t.Errorf("expected set created once, got %d", setID)
+ }
+ if feedID != 1 {
+ t.Errorf("expected feed created once, got %d", feedID)
+ }
+
+ setPath := filepath.Join(svc.mediaRoot, "my-podcast")
+ if _, err := os.Stat(setPath); os.IsNotExist(err) {
+ t.Error("expected set directory to exist on disk")
+ }
+}
+
+func TestPodcastService_SubscribeFeed_NonAdmin(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+ store.UserRepo = repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: false}, nil
+ },
+ }
+
+ _, err := svc.SubscribeFeed(ctx, "http://x", "name", 1)
+ if !errors.Is(err, ErrForbidden) {
+ t.Fatalf("expected ErrForbidden, got %v", err)
+ }
+}
+
+func TestPodcastService_SubscribeFeed_NilUser(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+ store.UserRepo = repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return nil, nil
+ },
+ }
+
+ _, err := svc.SubscribeFeed(ctx, "http://x", "name", 1)
+ if !errors.Is(err, ErrForbidden) {
+ t.Fatalf("expected ErrForbidden, got %v", err)
+ }
+}
+
+func TestPodcastService_SubscribeFeed_UserError(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+ boom := errors.New("boom")
+ store.UserRepo = repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return nil, boom
+ },
+ }
+
+ _, err := svc.SubscribeFeed(ctx, "http://x", "name", 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !errors.Is(err, boom) {
+ t.Fatalf("expected wrapped boom, got %v", err)
+ }
+}
+
+func TestPodcastService_SubscribeFeed_ParseError(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+ var setCreated bool
+ store.UserRepo = repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ }
+ store.SetRepo = repository.MockSetRepo{
+ CreateSetFunc: func(ctx context.Context, set *model.Set) (int64, error) {
+ setCreated = true
+ return 1, nil
+ },
+ }
+ svc.parseFeed = func(url string) (*podcast.ParsedFeed, error) {
+ return nil, errors.New("parse fail")
+ }
+
+ _, err := svc.SubscribeFeed(ctx, "bad", "name", 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if setCreated {
+ t.Error("expected no set created when parse fails")
+ }
+ setPath := filepath.Join(svc.mediaRoot, "name")
+ if _, err := os.Stat(setPath); !os.IsNotExist(err) {
+ t.Error("expected no set directory when parse fails")
+ }
+}
+
+func TestPodcastService_SubscribeFeed_CreateSetError(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+ store.UserRepo = repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ }
+ boom := errors.New("boom")
+ store.SetRepo = repository.MockSetRepo{
+ CreateSetFunc: func(ctx context.Context, set *model.Set) (int64, error) {
+ return 0, boom
+ },
+ }
+ svc.parseFeed = func(url string) (*podcast.ParsedFeed, error) {
+ return &podcast.ParsedFeed{Title: "T"}, nil
+ }
+
+ _, err := svc.SubscribeFeed(ctx, "http://x", "name", 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !errors.Is(err, boom) {
+ t.Fatalf("expected wrapped boom, got %v", err)
+ }
+ setPath := filepath.Join(svc.mediaRoot, "name")
+ if _, err := os.Stat(setPath); !os.IsNotExist(err) {
+ t.Error("expected set directory to be cleaned up")
+ }
+}
+
+func TestPodcastService_SubscribeFeed_GrantPermissionError(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+ boom := errors.New("boom")
+ var deletedSetID int64
+ store.UserRepo = repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ }
+ store.SetRepo = repository.MockSetRepo{
+ CreateSetFunc: func(ctx context.Context, set *model.Set) (int64, error) {
+ return 42, nil
+ },
+ DeleteSetFunc: func(ctx context.Context, id int64) error {
+ deletedSetID = id
+ return nil
+ },
+ }
+ store.SetPermissionRepo = repository.MockSetPermissionRepo{
+ GrantPermissionFunc: func(ctx context.Context, perm *model.SetPermission) error {
+ return boom
+ },
+ }
+ svc.parseFeed = func(url string) (*podcast.ParsedFeed, error) {
+ return &podcast.ParsedFeed{Title: "T"}, nil
+ }
+
+ _, err := svc.SubscribeFeed(ctx, "http://x", "name", 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !errors.Is(err, boom) {
+ t.Fatalf("expected wrapped boom, got %v", err)
+ }
+ if deletedSetID != 42 {
+ t.Fatalf("expected set rollback (delete %d), got %d", 42, deletedSetID)
+ }
+ setPath := filepath.Join(svc.mediaRoot, "name")
+ if _, err := os.Stat(setPath); !os.IsNotExist(err) {
+ t.Error("expected set directory to be cleaned up on permission error")
+ }
+}
+
+func TestPodcastService_SubscribeFeed_CreateFeedError(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+ boom := errors.New("boom")
+ var deletedSetID int64
+ store.UserRepo = repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ }
+ store.SetRepo = repository.MockSetRepo{
+ CreateSetFunc: func(ctx context.Context, set *model.Set) (int64, error) {
+ return 42, nil
+ },
+ DeleteSetFunc: func(ctx context.Context, id int64) error {
+ deletedSetID = id
+ return nil
+ },
+ }
+ store.SetPermissionRepo = repository.MockSetPermissionRepo{
+ GrantPermissionFunc: func(ctx context.Context, perm *model.SetPermission) error {
+ return nil
+ },
+ }
+ store.PodcastRepo = repository.MockPodcastRepo{
+ CreateFeedFunc: func(ctx context.Context, feed *model.PodcastFeed) (int64, error) {
+ return 0, boom
+ },
+ }
+ svc.parseFeed = func(url string) (*podcast.ParsedFeed, error) {
+ return &podcast.ParsedFeed{Title: "T"}, nil
+ }
+
+ _, err := svc.SubscribeFeed(ctx, "http://x", "name", 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !errors.Is(err, boom) {
+ t.Fatalf("expected wrapped boom, got %v", err)
+ }
+ if deletedSetID != 42 {
+ t.Fatalf("expected set rollback (delete %d), got %d", 42, deletedSetID)
+ }
+ setPath := filepath.Join(svc.mediaRoot, "name")
+ if _, err := os.Stat(setPath); !os.IsNotExist(err) {
+ t.Error("expected set directory to be cleaned up on feed error")
+ }
+}
+
+func TestPodcastService_ResolveSetPath(t *testing.T) {
+ svc, _ := setupPodcastService(t)
+
+ tests := []struct {
+ setName string
+ title string
+ want string
+ }{
+ {"my-podcast", "Some Title", "my-podcast"},
+ {"", "Some Title", "Some Title"},
+ {"", "", "podcast"},
+ {"../../etc", "", "------etc"},
+ }
+
+ for _, tt := range tests {
+ name, path := svc.resolveSetPath(tt.setName, tt.title)
+ if name != tt.want {
+ t.Errorf("resolveSetPath(%q, %q) name = %q, want %q", tt.setName, tt.title, name, tt.want)
+ }
+ if path != filepath.Join(svc.mediaRoot, tt.want) {
+ t.Errorf("resolveSetPath(%q, %q) path mismatch", tt.setName, tt.title)
+ }
+ }
+}
+
+func TestPodcastService_SanitizeSetName(t *testing.T) {
+ cases := []struct {
+ in string
+ want string
+ }{
+ {"A/B", "A-B"},
+ {"A\\\\B", "A--B"},
+ {"A.B", "A-B"},
+ {" spaced ", "spaced"},
+ {"", ""},
+ {"normal", "normal"},
+ }
+
+ for _, c := range cases {
+ got := sanitizeSetName(c.in)
+ if got != c.want {
+ t.Errorf("sanitizeSetName(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestPodcastService_SanitizeFilename(t *testing.T) {
+ cases := []struct {
+ in string
+ want string
+ }{
+ {"A/B", "A-B"},
+ {"A:B", "A-B"},
+ {" spaced ", "spaced"},
+ {"", ""},
+ {"normal", "normal"},
+ }
+
+ for _, c := range cases {
+ got := sanitizeFilename(c.in)
+ if got != c.want {
+ t.Errorf("sanitizeFilename(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestPodcastService_DownloadEpisode_NonAdmin(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+ store.UserRepo = repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: false}, nil
+ },
+ }
+ store.PodcastRepo = repository.MockPodcastRepo{
+ GetEpisodeByIDFunc: func(ctx context.Context, id int64) (*model.PodcastEpisode, error) {
+ return &model.PodcastEpisode{ID: id, FeedID: 1}, nil
+ },
+ GetFeedByIDFunc: func(ctx context.Context, id int64) (*model.PodcastFeed, error) {
+ return &model.PodcastFeed{ID: id, SetID: 1}, nil
+ },
+ }
+ store.SetPermissionRepo = repository.MockSetPermissionRepo{
+ GetPermissionFunc: func(ctx context.Context, setID, userID int64) (*model.SetPermission, error) {
+ return nil, nil
+ },
+ }
+
+ _, err := svc.DownloadEpisode(ctx, 1, 1)
+ if !errors.Is(err, ErrForbidden) {
+ t.Fatalf("expected ErrForbidden, got %v", err)
+ }
+}
+
+func TestPodcastService_UpsertFeedEpisodes(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+
+ var created []model.PodcastEpisode
+ store.PodcastRepo = repository.MockPodcastRepo{
+ GetEpisodeByGUIDFunc: func(ctx context.Context, feedID int64, guid string) (*model.PodcastEpisode, error) {
+ return nil, nil
+ },
+ CreateEpisodeFunc: func(ctx context.Context, ep *model.PodcastEpisode) (int64, error) {
+ created = append(created, *ep)
+ return int64(len(created)), nil
+ },
+ }
+
+ feed := &model.PodcastFeed{ID: 7}
+ parsed := &podcast.ParsedFeed{
+ Episodes: []podcast.Episode{
+ {GUID: "g1", Title: "One"},
+ {GUID: "g2", Title: "Two"},
+ },
+ }
+
+ err := svc.upsertFeedEpisodes(ctx, feed, parsed)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(created) != 2 {
+ t.Fatalf("expected 2 episodes created, got %d", len(created))
+ }
+ if created[0].FeedID != 7 || created[0].GUID != "g1" {
+ t.Errorf("episode 0 mismatch: %+v", created[0])
+ }
+ if created[1].GUID != "g2" {
+ t.Errorf("episode 1 mismatch: %+v", created[1])
+ }
+}
+
+func TestPodcastService_UpsertFeedEpisodes_SkipsExisting(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+
+ created := 0
+ store.PodcastRepo = repository.MockPodcastRepo{
+ GetEpisodeByGUIDFunc: func(ctx context.Context, feedID int64, guid string) (*model.PodcastEpisode, error) {
+ if guid == "g1" {
+ return &model.PodcastEpisode{GUID: "g1"}, nil
+ }
+ return nil, nil
+ },
+ CreateEpisodeFunc: func(ctx context.Context, ep *model.PodcastEpisode) (int64, error) {
+ created++
+ return 1, nil
+ },
+ }
+
+ feed := &model.PodcastFeed{ID: 7}
+ parsed := &podcast.ParsedFeed{
+ Episodes: []podcast.Episode{
+ {GUID: "g1", Title: "One"},
+ {GUID: "g2", Title: "Two"},
+ },
+ }
+
+ _ = svc.upsertFeedEpisodes(ctx, feed, parsed)
+ if created != 1 {
+ t.Fatalf("expected 1 new episode created, got %d", created)
+ }
+}
+
+func TestPodcastService_UpdateFeedFromParsed(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+
+ var updated *model.PodcastFeed
+ store.PodcastRepo = repository.MockPodcastRepo{
+ UpdateFeedFunc: func(ctx context.Context, feed *model.PodcastFeed) error {
+ updated = feed
+ return nil
+ },
+ }
+
+ feed := &model.PodcastFeed{ID: 1}
+ parsed := &podcast.ParsedFeed{
+ Title: "New Title",
+ Description: "New Desc",
+ ImageURL: "http://img",
+ }
+
+ err := svc.updateFeedFromParsed(ctx, feed, parsed, "etag-123")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if updated == nil {
+ t.Fatal("expected feed updated")
+ }
+ if updated.Title != "New Title" {
+ t.Errorf("title = %q", updated.Title)
+ }
+ if updated.LastETag != "etag-123" {
+ t.Errorf("etag = %q", updated.LastETag)
+ }
+ if updated.LastCheckedAt == nil {
+ t.Fatal("expected LastCheckedAt")
+ }
+}
+
+func TestPodcastService_InsertPodcastEpisodes(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+
+ var created []model.PodcastEpisode
+ store.PodcastRepo = repository.MockPodcastRepo{
+ CreateEpisodeFunc: func(ctx context.Context, ep *model.PodcastEpisode) (int64, error) {
+ created = append(created, *ep)
+ return int64(len(created)), nil
+ },
+ }
+
+ parsed := &podcast.ParsedFeed{
+ Episodes: []podcast.Episode{
+ {GUID: "g1", Title: "One"},
+ },
+ }
+ svc.insertPodcastEpisodes(ctx, parsed, 99)
+ if len(created) != 1 {
+ t.Fatalf("expected 1 episode, got %d", len(created))
+ }
+ if created[0].FeedID != 99 || created[0].GUID != "g1" {
+ t.Errorf("episode mismatch: %+v", created[0])
+ }
+}