package handlers import ( "bytes" "context" "fmt" "net/http" "net/http/httptest" "sort" "testing" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/sections" "github.com/Silo-Server/silo-server/internal/userstore" ) type stubSectionEpisodeFetcher struct { calls int meta map[string]sections.SectionItemMeta } func (s *stubSectionEpisodeFetcher) FetchEpisodesByContentIDs(_ context.Context, _ []string, _ catalog.AccessFilter) ([]*models.MediaItem, map[string]sections.SectionItemMeta, error) { s.calls++ return nil, s.meta, nil } func TestSectionBackdropPathUsesExpectedVariants(t *testing.T) { tests := []struct { name string sectionType sections.SectionType path string want string }{ { name: "continue watching uses w1280", sectionType: sections.SectionContinueWatching, path: "tmdb/movies/550/backdrop/original.webp", want: "tmdb/movies/550/backdrop/w1280.webp", }, { name: "next up uses w1280", sectionType: sections.SectionNextUp, path: "/tmdb/movies/550/backdrop/original.webp", want: "/tmdb/movies/550/backdrop/w1280.webp", }, { name: "other sections use w1920", sectionType: sections.SectionRecentlyAdded, path: "/tmdb/shows/1399/backdrop/original.jpg", want: "/tmdb/shows/1399/backdrop/w1920.jpg", }, { name: "continue watching episode still clamps to w500", sectionType: sections.SectionContinueWatching, path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp", }, { name: "featured section episode still clamps to w500", sectionType: sections.SectionRecentlyAdded, path: "tvdb/series/73141/seasons/22/episodes/9/still/original.webp", want: "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp", }, { name: "http paths pass through", sectionType: sections.SectionContinueWatching, path: "https://images.example.com/backdrop/original.jpg", want: "https://images.example.com/backdrop/original.jpg", }, { name: "plugin paths pass through", sectionType: sections.SectionContinueWatching, path: "plugin://tmdb/backdrop/original.jpg", want: "plugin://tmdb/backdrop/original.jpg", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := sectionBackdropPath(tt.sectionType, tt.path); got != tt.want { t.Fatalf("sectionBackdropPath(%q) = %q, want %q", tt.path, got, tt.want) } }) } } func TestBuildSectionsResponseEnrichesEpisodeMetadata(t *testing.T) { seasonNumber := 1 episodeNumber := 1 seriesID := "series-1" fetcher := &stubSectionEpisodeFetcher{ meta: map[string]sections.SectionItemMeta{ "episode-1": { SeriesID: &seriesID, SeriesTitle: "American Dad!", SeasonNumber: &seasonNumber, EpisodeNumber: &episodeNumber, }, }, } h := &SectionHandler{episodeFetcher: fetcher} withItems := []sections.SectionWithItems{ { ResolvedSection: sections.ResolvedSection{ID: "released", SectionType: sections.SectionCustomFilter, Title: "Released"}, Items: []*models.MediaItem{{ ContentID: "episode-1", Type: "episode", Title: "Dumbston Checks In", Status: "matched", }}, }, } req := httptest.NewRequest(http.MethodGet, "/sections", nil) resp := h.buildSectionsResponse(req, withItems) if fetcher.calls != 1 { t.Fatalf("episode metadata fetch calls = %d, want 1", fetcher.calls) } item := resp.Sections[0].Items[0] if item.SeriesTitle != "American Dad!" { t.Fatalf("series title = %q, want %q", item.SeriesTitle, "American Dad!") } if item.SeasonNumber == nil || *item.SeasonNumber != 1 { t.Fatalf("season number = %v, want 1", item.SeasonNumber) } if item.EpisodeNumber == nil || *item.EpisodeNumber != 1 { t.Fatalf("episode number = %v, want 1", item.EpisodeNumber) } } func TestBuildSectionsResponseKeepsExistingEpisodeMeta(t *testing.T) { seasonNumber := 2 episodeNumber := 6 seriesID := "series-existing" fetcher := &stubSectionEpisodeFetcher{ meta: map[string]sections.SectionItemMeta{ "episode-1": { SeriesTitle: "Fetched Series", }, }, } h := &SectionHandler{episodeFetcher: fetcher} withItems := []sections.SectionWithItems{ { ResolvedSection: sections.ResolvedSection{ID: "next", SectionType: sections.SectionNextUp, Title: "Next"}, Items: []*models.MediaItem{{ ContentID: "episode-1", Type: "episode", Title: "Episode 6", Status: "matched", }}, ItemMeta: map[string]sections.SectionItemMeta{ "episode-1": { SeriesID: &seriesID, SeriesTitle: "Only Child", SeasonNumber: &seasonNumber, EpisodeNumber: &episodeNumber, }, }, }, } req := httptest.NewRequest(http.MethodGet, "/sections", nil) resp := h.buildSectionsResponse(req, withItems) if fetcher.calls != 0 { t.Fatalf("episode metadata fetch calls = %d, want 0", fetcher.calls) } item := resp.Sections[0].Items[0] if item.SeriesTitle != "Only Child" { t.Fatalf("series title = %q, want %q", item.SeriesTitle, "Only Child") } if item.SeasonNumber == nil || *item.SeasonNumber != 2 { t.Fatalf("season number = %v, want 2", item.SeasonNumber) } if item.EpisodeNumber == nil || *item.EpisodeNumber != 6 { t.Fatalf("episode number = %v, want 6", item.EpisodeNumber) } } type countingSectionImageResolver struct { batchCalls int singleCalls int variant string paths []string } func (r *countingSectionImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string { r.singleCalls++ return "single:" + variant + ":" + path } func (r *countingSectionImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string { resolved := r.ResolveImageURLsWithExpiry(context.Background(), paths, variant) urls := make(map[string]string, len(resolved)) for path, value := range resolved { urls[path] = value.URL } return urls } func (r *countingSectionImageResolver) ResolveImageURLWithExpiry(_ context.Context, path string, variant string) catalog.ResolvedImageURL { r.singleCalls++ return catalog.ResolvedImageURL{URL: "single:" + variant + ":" + path} } func (r *countingSectionImageResolver) ResolveImageURLsWithExpiry(_ context.Context, paths []string, variant string) map[string]catalog.ResolvedImageURL { r.batchCalls++ r.variant = variant r.paths = append([]string{}, paths...) resolved := make(map[string]catalog.ResolvedImageURL, len(paths)) for _, path := range paths { resolved[path] = catalog.ResolvedImageURL{URL: "resolved:" + path} } return resolved } func TestBuildSectionsResponseBatchResolvesImageURLs(t *testing.T) { resolver := &countingSectionImageResolver{} detailSvc := &catalog.DetailService{} detailSvc.SetImageResolver(resolver) h := &SectionHandler{DetailSvc: detailSvc} items := make([]*models.MediaItem, 0, 100) for i := range 100 { items = append(items, &models.MediaItem{ ContentID: fmt.Sprintf("item-%03d", i), Type: "movie", Title: fmt.Sprintf("Movie %03d", i), PosterPath: "/poster/original.jpg", BackdropPath: "/backdrop/original.jpg", LogoPath: "/logo/original.png", }) } withItems := []sections.SectionWithItems{ { ResolvedSection: sections.ResolvedSection{ID: "continue", SectionType: sections.SectionContinueWatching, Title: "Continue"}, Items: items, }, { ResolvedSection: sections.ResolvedSection{ID: "next-up", SectionType: sections.SectionNextUp, Title: "Next Up"}, Items: []*models.MediaItem{{ ContentID: "item-extra", Type: "movie", Title: "Movie Extra", PosterPath: "/poster/original.jpg", BackdropPath: "/backdrop/original.jpg", LogoPath: "/logo/original.png", }}, }, } req := httptest.NewRequest(http.MethodGet, "/sections", nil) resp := h.buildSectionsResponse(req, withItems) if resolver.batchCalls != 1 { t.Fatalf("batch calls = %d, want 1", resolver.batchCalls) } if resolver.singleCalls != 0 { t.Fatalf("single calls = %d, want 0", resolver.singleCalls) } if resolver.variant != "featured" { t.Fatalf("variant = %q, want featured", resolver.variant) } sort.Strings(resolver.paths) wantPaths := []string{"/backdrop/w1280.jpg", "/logo/original.png", "/poster/w500.jpg"} if fmt.Sprint(resolver.paths) != fmt.Sprint(wantPaths) { t.Fatalf("resolved paths = %v, want %v", resolver.paths, wantPaths) } if got := resp.Sections[0].Items[0].PosterURL; got != "resolved:/poster/w500.jpg" { t.Fatalf("poster URL = %q", got) } if got := resp.Sections[0].Items[0].BackdropURL; got != "resolved:/backdrop/w1280.jpg" { t.Fatalf("backdrop URL = %q", got) } if got := resp.Sections[0].Items[0].LogoURL; got != "resolved:/logo/original.png" { t.Fatalf("logo URL = %q", got) } } func TestValidateSectionConfigAcceptsContinueTypes(t *testing.T) { tests := []string{ `{"continue_type":"watching"}`, `{"continue_type":"listening"}`, `{"continue_type":"reading"}`, `{"filter_type":"audiobook"}`, } for _, config := range tests { t.Run(config, func(t *testing.T) { if msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(config)); !ok { t.Fatalf("validateSectionConfig(%s) rejected config: %s", config, msg) } }) } } func TestValidateSectionConfigRejectsUnknownContinueType(t *testing.T) { msg, ok := validateSectionConfig(sections.SectionContinueWatching, []byte(`{"continue_type":"scrolling"}`)) if ok { t.Fatal("validateSectionConfig accepted unknown continue_type") } if msg != "continue_type must be 'watching', 'listening', or 'reading'" { t.Fatalf("message = %q", msg) } } func TestInjectNextUpAfterContiguousContinueRows(t *testing.T) { in := []sections.ResolvedSection{ { ID: "cw", SectionType: sections.SectionContinueWatching, Title: "Continue Watching", Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching), }, { ID: "cl", SectionType: sections.SectionContinueWatching, Title: "Continue Listening", Config: sections.ContinueTypeConfig(sections.ContinueTypeListening), }, {ID: "recent", SectionType: sections.SectionRecentlyAdded, Title: "Recently Added"}, } got := injectNextUpSection(in) gotIDs := make([]string, 0, len(got)) for _, section := range got { gotIDs = append(gotIDs, section.ID) } wantIDs := []string{"cw", "cl", "system-next-up", "recent"} if len(gotIDs) != len(wantIDs) { t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs) } for i := range wantIDs { if gotIDs[i] != wantIDs[i] { t.Fatalf("section ids = %v, want %v", gotIDs, wantIDs) } } } func TestDropEmptySeasonalSectionsRemovesOnlyEmptySeasonal(t *testing.T) { in := []sections.SectionWithItems{ // empty seasonal — drop {ResolvedSection: sections.ResolvedSection{ID: "a", SectionType: sections.SectionSeasonalThemed}}, // non-empty seasonal — keep {ResolvedSection: sections.ResolvedSection{ID: "b", SectionType: sections.SectionSeasonalThemed}, Items: []*models.MediaItem{{ContentID: "1"}}}, // empty non-seasonal — keep {ResolvedSection: sections.ResolvedSection{ID: "c", SectionType: sections.SectionRecentlyAdded}}, // non-empty non-seasonal — keep {ResolvedSection: sections.ResolvedSection{ID: "d", SectionType: sections.SectionHiddenGems}, Items: []*models.MediaItem{{ContentID: "2"}}}, } out := dropEmptySeasonalSections(in) if len(out) != 3 { t.Fatalf("expected 3, got %d", len(out)) } for _, w := range out { if w.ID == "a" { t.Errorf("empty seasonal section was not dropped") } } } func TestDropEmptySeasonalSectionsHandlesNilAndEmpty(t *testing.T) { if got := dropEmptySeasonalSections(nil); len(got) != 0 { t.Errorf("expected empty/nil result for nil input, got %v", got) } if got := dropEmptySeasonalSections([]sections.SectionWithItems{}); len(got) != 0 { t.Errorf("expected empty result for empty input, got %v", got) } } func TestLibraryDefaultSectionsUsesFolderType(t *testing.T) { got := libraryDefaultSections(&models.MediaFolder{Type: "series"}, 12) if len(got) != 6 { t.Fatalf("expected 6 series default sections, got %d", len(got)) } if got[1].Title != "Recently Added TV" { t.Fatalf("section 1 title = %q, want %q", got[1].Title, "Recently Added TV") } if got[2].Title != "Recently Released Episodes" { t.Fatalf("section 2 title = %q, want %q", got[2].Title, "Recently Released Episodes") } if got[4].SectionType != sections.SectionRecommendedForYou { t.Fatalf("section 4 type = %q, want %q", got[4].SectionType, sections.SectionRecommendedForYou) } } func TestFilterResolvedSectionsByAccessHidesBlockedLibraryRows(t *testing.T) { sectionsIn := []sections.ResolvedSection{ { ID: "continue", SectionType: sections.SectionContinueWatching, Title: "Continue Watching", Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching), }, { ID: "allowed-library", SectionType: sections.SectionRecentlyAdded, Title: "Recently Added in Allowed", Config: sections.GeneratedHomeLibraryRecentConfig(11), }, { ID: "blocked-library", SectionType: sections.SectionRecentlyAdded, Title: "Recently Added in Blocked", Config: sections.GeneratedHomeLibraryRecentConfig(42), }, } got := filterResolvedSectionsByAccess(sectionsIn, catalog.AccessFilter{ AllowedLibraryIDs: []int{11}, }) if len(got) != 2 { t.Fatalf("filtered sections length = %d, want 2", len(got)) } if got[0].ID != "continue" || got[1].ID != "allowed-library" { t.Fatalf("filtered section ids = [%s %s], want [continue allowed-library]", got[0].ID, got[1].ID) } } func TestFilterResolvedSectionsByAccessHidesDisabledOnlyRows(t *testing.T) { sectionsIn := []sections.ResolvedSection{ { ID: "disabled-library", SectionType: sections.SectionRecentlyReleased, Title: "Recently Released in Disabled", Config: sections.GeneratedHomeLibraryRecentConfig(42), }, { ID: "mixed-libraries", SectionType: sections.SectionRecentlyReleased, Title: "Mixed", Config: []byte(`{"filter_library_ids":[11,42]}`), }, } got := filterResolvedSectionsByAccess(sectionsIn, catalog.AccessFilter{ DisabledLibraryIDs: []int{42}, }) if len(got) != 1 { t.Fatalf("filtered sections length = %d, want 1", len(got)) } if got[0].ID != "mixed-libraries" { t.Fatalf("filtered section id = %s, want mixed-libraries", got[0].ID) } } func TestApplyDiversityFilterSkipsDuplicatesInLaterAvoidSection(t *testing.T) { in := []sections.SectionWithItems{ {ResolvedSection: sections.ResolvedSection{ID: "ra", SectionType: sections.SectionRecentlyAdded}, Items: []*models.MediaItem{{ContentID: "abc"}}}, {ResolvedSection: sections.ResolvedSection{ID: "hg", SectionType: sections.SectionHiddenGems}, Items: []*models.MediaItem{{ContentID: "abc"}, {ContentID: "def"}}}, } out := applyDiversityFilter(in) if len(out) != 2 { t.Fatalf("expected 2 sections, got %d", len(out)) } if len(out[0].Items) != 1 || out[0].Items[0].ContentID != "abc" { t.Errorf("first section should keep abc") } if len(out[1].Items) != 1 || out[1].Items[0].ContentID != "def" { t.Errorf("hidden_gems should drop abc, keep def; got %v", out[1].Items) } if out[1].TotalCount != 1 { t.Errorf("hidden_gems TotalCount should be 1, got %d", out[1].TotalCount) } } func TestApplyDiversityFilterPreservesItemsInNonAvoidSections(t *testing.T) { in := []sections.SectionWithItems{ {ResolvedSection: sections.ResolvedSection{ID: "ra", SectionType: sections.SectionRecentlyAdded}, Items: []*models.MediaItem{{ContentID: "abc"}}}, {ResolvedSection: sections.ResolvedSection{ID: "rr", SectionType: sections.SectionRecentlyReleased}, Items: []*models.MediaItem{{ContentID: "abc"}}}, } out := applyDiversityFilter(in) if len(out[0].Items) != 1 || len(out[1].Items) != 1 { t.Errorf("non-avoid sections should keep their items; got %d/%d", len(out[0].Items), len(out[1].Items)) } } func TestApplyDiversityFilterAvoidSectionDoesNotShadowItself(t *testing.T) { in := []sections.SectionWithItems{ {ResolvedSection: sections.ResolvedSection{ID: "hg", SectionType: sections.SectionHiddenGems}, Items: []*models.MediaItem{{ContentID: "abc"}}}, } out := applyDiversityFilter(in) if len(out[0].Items) != 1 { t.Errorf("first section should not filter itself; got %d items", len(out[0].Items)) } } func TestApplyDiversityFilterHandlesNilAndEmpty(t *testing.T) { if got := applyDiversityFilter(nil); got != nil && len(got) != 0 { t.Errorf("nil input returned non-empty: %v", got) } if got := applyDiversityFilter([]sections.SectionWithItems{}); len(got) != 0 { t.Errorf("empty input returned non-empty: %v", got) } } // TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled verifies that // a non-admin profile attempting to save a user-added admin_curated_list override // gets 403 when the allow_profile_custom_sections setting is disabled (the default). func TestSaveProfileOverridesRejectsAdminOnlyRecipeWhenSettingDisabled(t *testing.T) { // StoreProvider is nil so we test the gate in isolation. // The gate runs BEFORE the StoreProvider check, so a 403 is returned before // the nil-StoreProvider 500 path is reached. h := &SectionHandler{} body := []byte(`{"scope":"home","library_id":"","overrides":[{"is_user_added":true,"user_section_type":"admin_curated_list","user_config":{"item_ids":["a"]}}]}`) req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") // Authenticate as a non-admin profile. ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1}) ctx = apimw.SetProfileID(ctx, "p1") req = req.WithContext(ctx) rec := httptest.NewRecorder() h.HandleSaveProfileOverrides(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 Forbidden, got %d body=%s", rec.Code, rec.Body.String()) } } // TestSaveProfileOverridesUnknownRecipeReturnsBadRequest verifies that an // unregistered user_section_type is rejected before reaching the store. func TestSaveProfileOverridesUnknownRecipeReturnsBadRequest(t *testing.T) { h := &SectionHandler{} body := []byte(`{"scope":"home","library_id":"","overrides":[{"is_user_added":true,"user_section_type":"no_such_recipe","user_config":{}}]}`) req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1}) ctx = apimw.SetProfileID(ctx, "p1") req = req.WithContext(ctx) rec := httptest.NewRecorder() h.HandleSaveProfileOverrides(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 Bad Request, got %d body=%s", rec.Code, rec.Body.String()) } } // TestSaveProfileOverridesLegacyShapeCannotBypassGate verifies that a non-admin // cannot smuggle an admin-only recipe past the gate by omitting is_user_added // and using the legacy section_type/config fields. The resolver treats any // override with empty section_id as user-added, so the save handler must too. func TestSaveProfileOverridesLegacyShapeCannotBypassGate(t *testing.T) { h := &SectionHandler{} // section_id is empty and is_user_added is omitted — pre-fix this skipped // the gate entirely, even though the resolver would still surface this as // a user-added admin_curated_list section. body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"admin_curated_list","config":{"item_ids":["a"]}}]}`) req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1}) ctx = apimw.SetProfileID(ctx, "p1") req = req.WithContext(ctx) rec := httptest.NewRecorder() h.HandleSaveProfileOverrides(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("expected 403 Forbidden for legacy-shape bypass, got %d body=%s", rec.Code, rec.Body.String()) } } // TestSaveProfileOverridesLegacyShapeUnknownRecipeIsRejected ensures that // legacy-shape user-added overrides whose section_type is not a registered // recipe are rejected as 400, not silently passed through. func TestSaveProfileOverridesLegacyShapeUnknownRecipeIsRejected(t *testing.T) { h := &SectionHandler{} body := []byte(`{"scope":"home","library_id":"","overrides":[{"section_id":"","section_type":"no_such_recipe","config":{}}]}`) req := httptest.NewRequest(http.MethodPut, "/profile/sections", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") ctx := apimw.SetClaims(req.Context(), &auth.Claims{Role: "user", UserID: 1}) ctx = apimw.SetProfileID(ctx, "p1") req = req.WithContext(ctx) rec := httptest.NewRecorder() h.HandleSaveProfileOverrides(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 Bad Request, got %d body=%s", rec.Code, rec.Body.String()) } } // TestToSectionOverridesPropagatesUserAddedFields verifies the storage→resolver // shim copies the four user-added fields (IsUserAdded / UserSectionType / // UserConfig / UserTitle). Without this propagation the resolver sees zero // values and silently drops profile-built sections — even after the SQLite // persistence layer correctly stores them. func TestToSectionOverridesPropagatesUserAddedFields(t *testing.T) { stored := []userstore.SectionOverride{ { ID: "ov-1", SectionID: "", IsUserAdded: true, UserSectionType: "hidden_gems", UserConfig: `{"min_rating":7.5}`, UserTitle: "Hidden Gems", }, { ID: "ov-2", SectionID: "admin-1", SectionType: "recently_added", Title: "Renamed", }, } got := toSectionOverrides(stored) if len(got) != 2 { t.Fatalf("got %d overrides, want 2", len(got)) } if !got[0].IsUserAdded { t.Error("user-added IsUserAdded = false, want true") } if string(got[0].UserSectionType) != "hidden_gems" { t.Errorf("UserSectionType = %q, want hidden_gems", got[0].UserSectionType) } if string(got[0].UserConfig) != `{"min_rating":7.5}` { t.Errorf("UserConfig = %q, want %q", string(got[0].UserConfig), `{"min_rating":7.5}`) } if got[0].UserTitle != "Hidden Gems" { t.Errorf("UserTitle = %q, want Hidden Gems", got[0].UserTitle) } // Admin-section customization should leave the user-added fields zero. if got[1].IsUserAdded || got[1].UserSectionType != "" || len(got[1].UserConfig) != 0 || got[1].UserTitle != "" { t.Errorf("legacy customization leaked user-added fields: %+v", got[1]) } }