Files
silo-server/internal/api/handlers/sections_test.go
T

399 lines
15 KiB
Go

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])
}
}