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

486 lines
17 KiB
Go

package handlers
import (
"context"
"encoding/json"
"testing"
"github.com/Silo-Server/silo-server/internal/collections/templates"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestNormalizeTraktPresetRequest(t *testing.T) {
preset, mediaType, profileID, err := normalizeTraktPresetRequest("recommended", "tv", "prof-1")
if err != nil {
t.Fatalf("normalizeTraktPresetRequest: %v", err)
}
if preset != "recommended" || mediaType != "tv" || profileID != "prof-1" {
t.Fatalf("got %q/%q/%q", preset, mediaType, profileID)
}
if _, _, _, err := normalizeTraktPresetRequest("recommended", "movie", ""); err == nil {
t.Fatal("expected profile_id error")
}
if _, _, _, err := normalizeTraktPresetRequest("popular", "all", ""); err == nil {
t.Fatal("expected media_type error")
}
}
func TestBuildTMDBCollectionSourceConfig(t *testing.T) {
limit := 12
raw, err := buildTMDBCollectionSourceConfig(86311, &limit)
if err != nil {
t.Fatalf("buildTMDBCollectionSourceConfig: %v", err)
}
// Wire-format assertion: mode is set, collection_id round-trips, limit
// round-trips. Empty fields (provider, preset, etc.) must not appear.
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["mode"] != collectionSourceModeTMDBCollection {
t.Errorf("mode = %v, want %q", got["mode"], collectionSourceModeTMDBCollection)
}
if cid, ok := got["collection_id"].(float64); !ok || int(cid) != 86311 {
t.Errorf("collection_id = %v, want 86311", got["collection_id"])
}
if lim, ok := got["limit"].(float64); !ok || int(lim) != 12 {
t.Errorf("limit = %v, want 12", got["limit"])
}
for _, unwanted := range []string{"preset", "media_type", "time_window", "provider", "url", "profile_id"} {
if _, ok := got[unwanted]; ok {
t.Errorf("unexpected field %q in payload: %#v", unwanted, got)
}
}
// Decode into the catalog source config struct via JSON so we pin the
// round-trip stability of every field name and the omitempty behavior
// of collection_id. This protects against a silent rename on the
// catalog side breaking the bundle apply path.
type sourceConfigShape struct {
Mode string `json:"mode"`
CollectionID int `json:"collection_id,omitempty"`
Limit *int `json:"limit,omitempty"`
}
var decoded sourceConfigShape
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode source config shape: %v", err)
}
if decoded.Mode != collectionSourceModeTMDBCollection {
t.Fatalf("decoded mode = %q", decoded.Mode)
}
if decoded.CollectionID != 86311 {
t.Fatalf("decoded collection_id = %d, want 86311", decoded.CollectionID)
}
if decoded.Limit == nil || *decoded.Limit != 12 {
t.Fatalf("decoded limit = %v, want 12", decoded.Limit)
}
}
func TestBuildTMDBCollectionSourceConfig_PlaceholderOmitsCollectionID(t *testing.T) {
// CollectionID == 0 is a documented placeholder; with omitempty the JSON
// must drop the field entirely so older sync code that didn't know about
// the field still works.
raw, err := buildTMDBCollectionSourceConfig(0, nil)
if err != nil {
t.Fatalf("buildTMDBCollectionSourceConfig: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["mode"] != collectionSourceModeTMDBCollection {
t.Fatalf("mode = %v, want %q", got["mode"], collectionSourceModeTMDBCollection)
}
if _, ok := got["collection_id"]; ok {
t.Errorf("collection_id should be omitted when zero, got %v", got["collection_id"])
}
if _, ok := got["limit"]; ok {
t.Errorf("limit should be omitted when nil, got %v", got["limit"])
}
}
func TestBuildTMDBCollectionSourceURL(t *testing.T) {
if url := buildTMDBCollectionSourceURL(86311); url != "tmdb://collection/86311" {
t.Fatalf("source url = %q", url)
}
if url := buildTMDBCollectionSourceURL(0); url != "tmdb://collection/0" {
t.Fatalf("placeholder source url = %q", url)
}
}
func TestBuildTMDBDiscoverSourceConfigRoundTrips(t *testing.T) {
limit := 50
spec := importTMDBDiscoverSpecBody{
WithGenres: []int{28, 12},
WithoutGenres: []int{99},
SortBy: "popularity.desc",
VoteCountGte: 300,
VoteAverageGte: 6.5,
ReleaseDateGte: "2010-01-01",
ReleaseDateLte: "2025-12-31",
Certifications: []string{"PG", "PG-13"},
WithRuntimeGte: 60,
WithRuntimeLte: 240,
OriginalLanguage: "en",
}
raw, err := buildTMDBDiscoverSourceConfig("movie", spec, &limit)
if err != nil {
t.Fatalf("buildTMDBDiscoverSourceConfig: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["mode"] != "tmdb_discover" {
t.Errorf("mode = %v, want tmdb_discover", got["mode"])
}
if got["media_type"] != "movie" {
t.Errorf("media_type = %v, want movie", got["media_type"])
}
if got["limit"].(float64) != 50 {
t.Errorf("limit = %v, want 50", got["limit"])
}
discover, ok := got["discover"].(map[string]any)
if !ok {
t.Fatalf("discover missing or wrong shape: %#v", got["discover"])
}
if discover["sort_by"] != "popularity.desc" {
t.Errorf("discover.sort_by = %v, want popularity.desc", discover["sort_by"])
}
if discover["vote_count_gte"].(float64) != 300 {
t.Errorf("discover.vote_count_gte = %v, want 300", discover["vote_count_gte"])
}
if discover["vote_average_gte"].(float64) != 6.5 {
t.Errorf("discover.vote_average_gte = %v, want 6.5", discover["vote_average_gte"])
}
if discover["release_date_gte"] != "2010-01-01" {
t.Errorf("discover.release_date_gte = %v, want 2010-01-01", discover["release_date_gte"])
}
if discover["original_language"] != "en" {
t.Errorf("discover.original_language = %v, want en", discover["original_language"])
}
genres, ok := discover["with_genres"].([]any)
if !ok || len(genres) != 2 {
t.Fatalf("discover.with_genres = %#v", discover["with_genres"])
}
if genres[0].(float64) != 28 || genres[1].(float64) != 12 {
t.Errorf("discover.with_genres values = %v %v", genres[0], genres[1])
}
certs, ok := discover["certifications"].([]any)
if !ok || len(certs) != 2 {
t.Fatalf("discover.certifications = %#v", discover["certifications"])
}
if certs[0].(string) != "PG" || certs[1].(string) != "PG-13" {
t.Errorf("discover.certifications values = %v %v", certs[0], certs[1])
}
// Round-trip into the same runtime shape that catalog.SyncCollectionWithOptions
// uses so the on-disk JSON layout stays compatible with the sync decoder.
type runtimeDiscover struct {
WithGenres []int `json:"with_genres,omitempty"`
WithoutGenres []int `json:"without_genres,omitempty"`
SortBy string `json:"sort_by"`
VoteCountGte int `json:"vote_count_gte,omitempty"`
VoteAverageGte float64 `json:"vote_average_gte,omitempty"`
ReleaseDateGte string `json:"release_date_gte,omitempty"`
ReleaseDateLte string `json:"release_date_lte,omitempty"`
Certifications []string `json:"certifications,omitempty"`
CertificationLte string `json:"certification_lte,omitempty"`
WithRuntimeGte int `json:"with_runtime_gte,omitempty"`
WithRuntimeLte int `json:"with_runtime_lte,omitempty"`
OriginalLanguage string `json:"original_language,omitempty"`
}
type runtimeConfig struct {
Mode string `json:"mode"`
MediaType string `json:"media_type"`
Limit *int `json:"limit,omitempty"`
Discover *runtimeDiscover `json:"discover,omitempty"`
}
var cfg runtimeConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("decode into runtime config: %v", err)
}
if cfg.Mode != "tmdb_discover" {
t.Errorf("runtime mode = %q, want tmdb_discover", cfg.Mode)
}
if cfg.MediaType != "movie" {
t.Errorf("runtime media_type = %q, want movie", cfg.MediaType)
}
if cfg.Limit == nil || *cfg.Limit != 50 {
t.Errorf("runtime limit = %v, want 50", cfg.Limit)
}
if cfg.Discover == nil {
t.Fatal("runtime discover missing")
}
if cfg.Discover.SortBy != "popularity.desc" {
t.Errorf("runtime sort_by = %q", cfg.Discover.SortBy)
}
if cfg.Discover.VoteCountGte != 300 || cfg.Discover.VoteAverageGte != 6.5 {
t.Errorf("runtime vote thresholds = %d / %v", cfg.Discover.VoteCountGte, cfg.Discover.VoteAverageGte)
}
if cfg.Discover.OriginalLanguage != "en" {
t.Errorf("runtime original_language = %q", cfg.Discover.OriginalLanguage)
}
if len(cfg.Discover.WithGenres) != 2 || cfg.Discover.WithGenres[0] != 28 || cfg.Discover.WithGenres[1] != 12 {
t.Errorf("runtime with_genres = %v", cfg.Discover.WithGenres)
}
}
func TestBuildTMDBDiscoverSourceConfigMinimal(t *testing.T) {
raw, err := buildTMDBDiscoverSourceConfig("tv", importTMDBDiscoverSpecBody{
SortBy: "vote_average.desc",
}, nil)
if err != nil {
t.Fatalf("buildTMDBDiscoverSourceConfig: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["mode"] != "tmdb_discover" || got["media_type"] != "tv" {
t.Errorf("mode/media_type = %v / %v", got["mode"], got["media_type"])
}
if _, ok := got["limit"]; ok {
t.Errorf("limit should be omitted when nil, got = %#v", got["limit"])
}
discover, ok := got["discover"].(map[string]any)
if !ok {
t.Fatalf("discover missing: %#v", got["discover"])
}
if discover["sort_by"] != "vote_average.desc" {
t.Errorf("discover.sort_by = %v", discover["sort_by"])
}
if _, ok := discover["with_genres"]; ok {
t.Errorf("with_genres should be omitted when empty")
}
}
func TestValidateTMDBDiscoverRequest(t *testing.T) {
good := importTMDBDiscoverRequest{
LibraryID: 1,
Title: "Action",
MediaType: "movie",
Spec: importTMDBDiscoverSpecBody{SortBy: "popularity.desc"},
}
if err := validateTMDBDiscoverRequest(good); err != nil {
t.Fatalf("valid request rejected: %v", err)
}
bad := good
bad.MediaType = "all"
if err := validateTMDBDiscoverRequest(bad); err == nil {
t.Fatal("expected error for media_type=all")
}
bad = good
bad.Spec.SortBy = ""
if err := validateTMDBDiscoverRequest(bad); err == nil {
t.Fatal("expected error for missing sort_by")
}
bad = good
limit := 0
bad.Limit = &limit
if err := validateTMDBDiscoverRequest(bad); err == nil {
t.Fatal("expected error for limit=0")
}
}
func TestBuildTraktSourceConfig(t *testing.T) {
limit := 25
raw, err := buildTraktSourceConfig("recommended", "movie", "prof-1", &limit)
if err != nil {
t.Fatalf("buildTraktSourceConfig: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["mode"] != "trakt_preset" || got["provider"] != "trakt" ||
got["preset"] != "recommended" || got["media_type"] != "movie" ||
got["profile_id"] != "prof-1" || got["limit"].(float64) != 25 {
t.Fatalf("source config = %#v", got)
}
if url := buildTraktSourceURL("recommended", "movie", "prof-1"); url != "trakt://recommended/movie/prof-1" {
t.Fatalf("source url = %q", url)
}
}
func TestNormalizeCollectionManagementFields(t *testing.T) {
mode, source, key, err := normalizeCollectionManagementFields("section", "recipe_gallery", "trakt:trending:movie:library:1")
if err != nil {
t.Fatalf("normalize section management: %v", err)
}
if mode != "section" || source != "recipe_gallery" || key != "trakt:trending:movie:library:1" {
t.Fatalf("got %q/%q/%q", mode, source, key)
}
mode, source, key, err = normalizeCollectionManagementFields("", "ignored", "ignored")
if err != nil {
t.Fatalf("normalize manual management: %v", err)
}
if mode != "manual" || source != "" || key != "" {
t.Fatalf("manual fields should be cleared, got %q/%q/%q", mode, source, key)
}
if _, _, _, err := normalizeCollectionManagementFields("section", "", ""); err == nil {
t.Fatal("expected section-managed collection without key to fail")
}
mode, source, key, err = normalizeCollectionManagementFields("template_bundle", "core_defaults", "core_defaults:x:library:1")
if err != nil {
t.Fatalf("normalize template bundle management: %v", err)
}
if mode != "template_bundle" || source != "core_defaults" || key != "core_defaults:x:library:1" {
t.Fatalf("got %q/%q/%q", mode, source, key)
}
}
func TestTemplateBundleEligibilityByLibraryType(t *testing.T) {
movieTemplate := templates.Template{ID: "movie", MediaKind: templates.MediaMovie}
tvTemplate := templates.Template{ID: "tv", MediaKind: templates.MediaTV}
movies := &models.MediaFolder{ID: 1, Name: "Movies", Type: "movies"}
series := &models.MediaFolder{ID: 2, Name: "TV", Type: "series"}
mixed := &models.MediaFolder{ID: 3, Name: "Mixed", Type: "mixed"}
if !templateEligibleForLibrary(movieTemplate, movies) {
t.Fatal("movie template should be eligible for movie libraries")
}
if templateEligibleForLibrary(tvTemplate, movies) {
t.Fatal("tv template should not be eligible for movie libraries")
}
if templateEligibleForLibrary(movieTemplate, series) {
t.Fatal("movie template should not be eligible for series libraries")
}
if !templateEligibleForLibrary(tvTemplate, series) {
t.Fatal("tv template should be eligible for series libraries")
}
if !templateEligibleForLibrary(movieTemplate, mixed) || !templateEligibleForLibrary(tvTemplate, mixed) {
t.Fatal("mixed libraries should accept movie and tv templates")
}
}
func TestTemplateBundleManagementKeyIsStable(t *testing.T) {
got := templateBundleManagementKey("core_defaults", "tmdb_popular_movies", 42)
want := "core_defaults:tmdb_popular_movies:library:42"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestTemplateBundleFeaturedDryRun(t *testing.T) {
bundle, ok := templates.GetBundle("core_defaults")
if !ok {
t.Fatal("core_defaults bundle missing")
}
tmpl, ok := templates.Get("tmdb_trending_movies_week")
if !ok {
t.Fatal("tmdb_trending_movies_week template missing")
}
library := &models.MediaFolder{ID: 1, Name: "Movies", Type: "movies"}
refs := map[templateBundleCollectionRefKey]templateBundleCollectionRef{
{LibraryID: library.ID, TemplateID: tmpl.ID}: {
Template: tmpl,
Library: library,
},
}
handler := &LibraryCollectionHandler{}
featured, failed := handler.applyTemplateBundleFeaturedSections(
context.Background(),
bundle,
&templateBundleFeaturedRequest{
Home: &templateBundleFeaturedHome{LibraryID: library.ID, TemplateID: tmpl.ID},
Libraries: map[int]string{library.ID: tmpl.ID},
},
map[int]*models.MediaFolder{library.ID: library},
refs,
true,
)
if len(failed) != 0 {
t.Fatalf("failed = %+v", failed)
}
if len(featured) != 2 {
t.Fatalf("featured count = %d, want 2", len(featured))
}
if featured[0].Surface != "home" || featured[0].Reason != "would_create" {
t.Fatalf("home featured = %+v", featured[0])
}
if featured[1].Surface != "library" || featured[1].LibraryID != library.ID {
t.Fatalf("library featured = %+v", featured[1])
}
}
func TestTemplateBundleFeaturedSectionConfig(t *testing.T) {
tmpl := templates.Template{ID: "tmdb_trending_movies_week"}
library := &models.MediaFolder{ID: 42}
raw, err := templateBundleFeaturedSectionConfig("core_defaults", "library", templateBundleCollectionRef{
CollectionID: "lc_123",
Template: tmpl,
Library: library,
})
if err != nil {
t.Fatalf("config: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got["library_collection_id"] != "lc_123" ||
got["generated_source"] != "template_bundle_featured" ||
got["template_bundle"] != "core_defaults" ||
got["template_id"] != "tmdb_trending_movies_week" ||
got["surface"] != "library" ||
got["library_id"].(float64) != 42 {
t.Fatalf("unexpected config: %#v", got)
}
}
func TestAllCollectionLibrariesSelected(t *testing.T) {
selected := map[int]struct{}{1: {}, 2: {}}
if !allCollectionLibrariesSelected(&models.LibraryCollection{LibraryIDs: []int{1, 2}}, selected) {
t.Fatal("collection scoped only to selected libraries should be deletable")
}
if allCollectionLibrariesSelected(&models.LibraryCollection{LibraryIDs: []int{1, 3}}, selected) {
t.Fatal("collection shared with unselected libraries should not be deletable")
}
}
func TestLibraryCollectionGroupResponseUsesAPIFieldNames(t *testing.T) {
body, err := json.Marshal(listGroupsResponse{
Groups: toLibraryCollectionGroupResponses([]models.LibraryCollectionGroup{{
ID: "lcg_movies",
LibraryID: 1,
Name: "Movies",
Slug: "movies",
Kind: models.GroupKindRegular,
DefaultSortMode: models.GroupSortManual,
SortOrder: 2,
}}),
UngroupedSortOrder: 9999,
})
if err != nil {
t.Fatalf("marshal group response: %v", err)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("unmarshal group response: %v", err)
}
groups := got["groups"].([]any)
group := groups[0].(map[string]any)
if group["id"] != "lcg_movies" ||
group["name"] != "Movies" ||
group["default_sort_mode"] != "manual" ||
group["sort_order"].(float64) != 2 {
t.Fatalf("unexpected group JSON: %#v", group)
}
if _, ok := group["ID"]; ok {
t.Fatalf("group response leaked Go field names: %#v", group)
}
}