diff options
| -rw-r--r-- | internal/service/podcast.go | 178 | ||||
| -rw-r--r-- | internal/service/podcast_test.go | 529 |
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]) + } +} |
