Files
silo-server/internal/jellycompat/list_seasons_batch_test.go
T

182 lines
6.4 KiB
Go

package jellycompat
import (
"context"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// stubItemAccessSource always permits access. The Search/GetByIDs methods are
// not exercised by ListSeasons.
type stubItemAccessSource struct{}
func (stubItemAccessSource) EnsureAccessible(_ context.Context, _ string, _ catalog.AccessFilter) error {
return nil
}
func (stubItemAccessSource) Search(_ context.Context, _ string, _ []string, _, _ int, _ catalog.AccessFilter) ([]*models.MediaItem, int, error) {
panic("unused")
}
func (stubItemAccessSource) GetByIDs(_ context.Context, _ []string) ([]*models.MediaItem, error) {
panic("unused")
}
// stubSeasonListSource returns a fixed slice from ListBySeries. Other
// methods panic so the test catches unexpected use.
type stubSeasonListSource struct {
seasons []*models.Season
}
func (s *stubSeasonListSource) ListBySeries(_ context.Context, _ string) ([]*models.Season, error) {
return s.seasons, nil
}
func (s *stubSeasonListSource) GetBySeriesAndNumber(_ context.Context, _ string, _ int) (*models.Season, error) {
panic("unused")
}
func (s *stubSeasonListSource) GetByID(_ context.Context, _ string) (*models.Season, error) {
panic("unused")
}
// countingEpisodeListSource counts calls to ListBySeason and
// ListBySeriesGroupedBySeason. ListBySeason should be zero in the batched
// ListSeasons path.
type countingEpisodeListSource struct {
listBySeasonCalls int
listGroupedCalls int
groupedReturn map[int][]*models.Episode
}
func (c *countingEpisodeListSource) ListBySeason(_ context.Context, seriesID string, seasonNum int) ([]*models.Episode, error) {
c.listBySeasonCalls++
return c.groupedReturn[seasonNum], nil
}
func (c *countingEpisodeListSource) ListBySeriesGroupedBySeason(_ context.Context, _ string) (map[int][]*models.Episode, error) {
c.listGroupedCalls++
out := make(map[int][]*models.Episode, len(c.groupedReturn))
for k, v := range c.groupedReturn {
out[k] = v
}
return out, nil
}
// TestListSeasons_UsesBatchEpisodeFetch verifies ListSeasons makes exactly
// one ListBySeriesGroupedBySeason call (replacing N per-season ListBySeason
// calls) and exactly one ListProgressByMediaItems call (replacing N
// per-season fetches inside enrichSeasonUserData) (audit 2026-05-01 §2.2).
func TestListSeasons_UsesBatchEpisodeFetch(t *testing.T) {
provider := newProgressCountingStoreProvider()
episodeRepo := &countingEpisodeListSource{
groupedReturn: map[int][]*models.Episode{
1: {
{ContentID: "e1", SeriesID: "series-abc", SeasonNumber: 1, EpisodeNumber: 1},
{ContentID: "e2", SeriesID: "series-abc", SeasonNumber: 1, EpisodeNumber: 2},
},
2: {
{ContentID: "e3", SeriesID: "series-abc", SeasonNumber: 2, EpisodeNumber: 1},
},
},
}
seasonRepo := &stubSeasonListSource{
seasons: []*models.Season{
{ContentID: "s1", SeriesID: "series-abc", SeasonNumber: 1},
{ContentID: "s2", SeriesID: "series-abc", SeasonNumber: 2},
},
}
svc := &directContentService{
itemRepo: stubItemAccessSource{},
seasonRepo: seasonRepo,
episodeRepo: episodeRepo,
storeProvider: provider,
}
session := &Session{StreamAppUserID: 1, ProfileID: "profile-1"}
result, err := svc.ListSeasons(context.Background(), session, "series-abc", nil)
if err != nil {
t.Fatalf("ListSeasons returned error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 seasons in result; got %d", len(result))
}
if episodeRepo.listBySeasonCalls != 0 {
t.Fatalf("ListSeasons must not call ListBySeason on the batched path; got %d calls",
episodeRepo.listBySeasonCalls)
}
if episodeRepo.listGroupedCalls != 1 {
t.Fatalf("ListSeasons should call ListBySeriesGroupedBySeason exactly once; got %d",
episodeRepo.listGroupedCalls)
}
if provider.store.listProgressCalls != 1 {
t.Fatalf("ListSeasons should batch into 1 ListProgressByMediaItems call; got %d",
provider.store.listProgressCalls)
}
// The single batch must include every episode from every season.
if got, want := len(provider.store.lastListedMediaItemIDs), 3; got != want {
t.Fatalf("expected single progress batch to cover all %d episodes; got %d ids",
want, got)
}
}
// TestListSeasons_FallbackUsesGroupedEpisodes verifies that when the
// seasons table is empty, ListSeasons still uses the single grouped fetch
// and derives synthetic seasons without issuing an extra aggregation
// query (audit 2026-05-01 §2.2 fallback path).
func TestListSeasons_FallbackUsesGroupedEpisodes(t *testing.T) {
provider := newProgressCountingStoreProvider()
episodeRepo := &countingEpisodeListSource{
groupedReturn: map[int][]*models.Episode{
1: {
{ContentID: "e1", SeriesID: "series-abc", SeasonNumber: 1, EpisodeNumber: 1},
},
2: {
{ContentID: "e2", SeriesID: "series-abc", SeasonNumber: 2, EpisodeNumber: 1},
{ContentID: "e3", SeriesID: "series-abc", SeasonNumber: 2, EpisodeNumber: 2},
},
},
}
// seasons table empty triggers the fallback path.
seasonRepo := &stubSeasonListSource{seasons: nil}
svc := &directContentService{
itemRepo: stubItemAccessSource{},
seasonRepo: seasonRepo,
episodeRepo: episodeRepo,
storeProvider: provider,
}
session := &Session{StreamAppUserID: 1, ProfileID: "profile-1"}
result, err := svc.ListSeasons(context.Background(), session, "series-abc", nil)
if err != nil {
t.Fatalf("ListSeasons returned error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 synthetic seasons; got %d", len(result))
}
// Seasons must be returned in ascending season-number order.
if result[0].SeasonNumber != 1 || result[1].SeasonNumber != 2 {
t.Fatalf("expected seasons in ascending order; got %d, %d",
result[0].SeasonNumber, result[1].SeasonNumber)
}
// Episode counts derived from grouped, no per-season ListBySeason fan-out.
if result[0].EpisodeCount != 1 || result[1].EpisodeCount != 2 {
t.Fatalf("synthetic season EpisodeCount mismatch; got %d, %d",
result[0].EpisodeCount, result[1].EpisodeCount)
}
if episodeRepo.listBySeasonCalls != 0 {
t.Fatalf("fallback path must not call ListBySeason; got %d", episodeRepo.listBySeasonCalls)
}
if episodeRepo.listGroupedCalls != 1 {
t.Fatalf("fallback path should call ListBySeriesGroupedBySeason exactly once; got %d",
episodeRepo.listGroupedCalls)
}
if provider.store.listProgressCalls != 1 {
t.Fatalf("fallback path should batch into 1 ListProgressByMediaItems call; got %d",
provider.store.listProgressCalls)
}
}