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" ) 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: "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) } }) } } 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 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) != 7 { t.Fatalf("expected 7 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[5].SectionType != sections.SectionRecommendedForYou { t.Fatalf("section 5 type = %q, want %q", got[5].SectionType, sections.SectionRecommendedForYou) } } 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]) } }