486 lines
17 KiB
Go
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)
|
|
}
|
|
}
|