Files
silo-server/internal/catalog/discovery_repo_test.go
11704a1701 feat(sections): fix broken home section templates and add six new ones (#332)
* feat(sections): fix broken home section templates and add six new ones

Fixes templates that silently produced nothing:
- award_winners: hide from gallery (resolver is a stub until award data
  exists); saved sections keep resolving
- seasonal_themed: christmas/st_patricks/thanksgiving get an interim
  title-keyword resolver, and multi-theme selection skips themes without
  an executable query so a data-less theme can no longer black out the
  section during its own window (previously killed the section all of
  December)
- taste_match: empty genre now auto-picks the profile's strongest taste
  cluster (fallback: server top genre); the default preset was permanently
  empty
- because_you_watched: honor the recipe's anchor_item_id key (fetcher only
  read legacy source_item_id, so pinning an anchor did nothing)
- editorial_spotlight: reject subject_type=franchise (validated but could
  never resolve); fix drawer misrepresenting pinned presets as auto-rotate
- admin_curated_list: add a catalog-search item picker so Editor's Picks
  is actually addable; block saving an empty list; hide admin_only recipes
  from profile-facing galleries
- discovery fetchers (hidden_gems, forgotten_favorites,
  critically_acclaimed): honor single/multi library scope, intersected
  with viewer access; implement hidden_gems max_play_count

New templates: returning_shows (new season of shows you've watched),
genre_roulette (rotating top-genre spotlight with title override),
anniversaries (milestone release anniversaries this month), short_watches
(well-rated movies under a runtime cap), family_movie_night seasonal
theme (Fri/Sat evenings), and a "New in 4K" format_showcase preset via a
new sort=recent param.

Adds a blanket test asserting every visible gallery preset's defaults
pass its own recipe validation — the gap that let taste_match and
Editor's Picks ship broken. New SQL shapes validated with EXPLAIN against
the dev database.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sections): address PR #332 review findings

Codex review:
- returning_shows: the new-season file check now applies the effective
  library scope (section scope ∩ viewer-allowed, minus disabled) to
  media_files.media_folder_id, so an episode file that only exists in an
  out-of-scope folder can no longer surface the series
- buildLibraryScope: replaced the media_item_libraries row join with
  EXISTS / NOT EXISTS semi-joins. An item in several in-scope libraries
  now yields exactly one row in the non-GROUP BY rails (short_watches,
  anniversaries, seasonal keyword, format_showcase, new_to_library, ...),
  and the disabled-library check is item-level, closing the join-row leak
  where membership in an allowed library masked membership in a disabled
  one. Deny-only mode keeps the positive-membership guard, mirroring
  catalog's appendDiscoveryLibraryScope.

CodeRabbit review:
- recommendations reader: a taste cluster whose cached items are entirely
  filtered out now falls through to the next cluster / global fallback
  instead of returning an empty row
- genre_roulette: multi-library scopes get distinct rotation seeds
- returning_shows: reject negative lookback_days at validation
- shared oneOf() enum validator replaces per-recipe switch duplication
- SeasonalTitleOverride usable-filter contract covered by a direct test
- web NumberParamField: integer-only guard + step=1 (backend fields are
  Go ints; fractional values failed unmarshalling at save)
- curated list picker: search failures show an error instead of a
  misleading "No matches."; pre-existing item_ids hydrate display titles
  via the watch-detail endpoint instead of rendering raw ids

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:47:20 -04:00

560 lines
18 KiB
Go

package catalog
// discovery_repo_test.go exercises the SQL generation helpers in
// discovery_repo.go using the same pure-unit approach used throughout this
// package: build a query string and assert that key fragments are present,
// without needing a live database.
import (
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// ListByRatingThreshold SQL generation tests
// ---------------------------------------------------------------------------
func TestRatingThreshold_BasicQuery(t *testing.T) {
query, args := buildRatingThresholdQuery(RatingFilter{
Min: 7.5,
Limit: 20,
})
assertQualifiedItemSelect(t, query)
if !strings.Contains(query, "mi.rating_imdb >= $1") {
t.Fatalf("expected rating threshold predicate, got:\n%s", query)
}
if !strings.Contains(query, "ORDER BY mi.rating_imdb DESC NULLS LAST") {
t.Fatalf("expected rating sort order, got:\n%s", query)
}
if !strings.Contains(query, "mi.content_id ASC") {
t.Fatalf("expected content_id tiebreaker, got:\n%s", query)
}
if !strings.Contains(query, "LIMIT $2") {
t.Fatalf("expected LIMIT clause at $2, got:\n%s", query)
}
if len(args) != 2 {
t.Fatalf("expected 2 args (min rating + limit), got %d: %v", len(args), args)
}
if args[0] != 7.5 {
t.Fatalf("expected first arg 7.5, got %v", args[0])
}
if args[1] != 20 {
t.Fatalf("expected second arg 20 (limit), got %v", args[1])
}
}
func TestRatingThreshold_NoLimitOmitsClause(t *testing.T) {
query, args := buildRatingThresholdQuery(RatingFilter{Min: 8.0})
if strings.Contains(query, "LIMIT") {
t.Fatalf("expected no LIMIT clause, got:\n%s", query)
}
if len(args) != 1 {
t.Fatalf("expected 1 arg, got %d", len(args))
}
}
func TestRatingThreshold_LibraryIDUsesSemiJoin(t *testing.T) {
libID := 42
query, args := buildRatingThresholdQuery(RatingFilter{
Min: 7.0,
LibraryID: &libID,
})
assertNoLibraryJoin(t, query)
if !strings.Contains(query, "EXISTS (SELECT 1 FROM media_item_libraries mil_scope_in") {
t.Fatalf("expected library EXISTS predicate, got:\n%s", query)
}
if !strings.Contains(query, "mil_scope_in.media_folder_id = ANY($2)") {
t.Fatalf("expected library id predicate at $2, got:\n%s", query)
}
if len(args) != 2 {
t.Fatalf("expected 2 args (min + library id slice), got %d", len(args))
}
assertIntSliceArg(t, args, 1, []int{42})
}
func TestRatingThreshold_AllowedLibraries(t *testing.T) {
query, args := buildRatingThresholdQuery(RatingFilter{
Min: 7.0,
Filter: AccessFilter{
AllowedLibraryIDs: []int{1, 2},
},
})
assertNoLibraryJoin(t, query)
if !strings.Contains(query, "EXISTS (SELECT 1 FROM media_item_libraries mil_scope_in") {
t.Fatalf("expected allowed library EXISTS predicate, got:\n%s", query)
}
if !strings.Contains(query, "mil_scope_in.media_folder_id = ANY($2)") {
t.Fatalf("expected allowed library ANY clause, got:\n%s", query)
}
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d: %v", len(args), args)
}
assertIntSliceArg(t, args, 1, []int{1, 2})
}
func TestRatingThreshold_DisabledLibrariesUseItemLevelExclusion(t *testing.T) {
query, args := buildRatingThresholdQuery(RatingFilter{
Min: 7.0,
Filter: AccessFilter{
DisabledLibraryIDs: []int{99},
},
})
assertNoLibraryJoin(t, query)
if !strings.Contains(query, "NOT EXISTS (SELECT 1 FROM media_item_libraries mil_scope_out") {
t.Fatalf("expected disabled library NOT EXISTS predicate, got:\n%s", query)
}
if !strings.Contains(query, "mil_scope_out.media_folder_id = ANY($2)") {
t.Fatalf("expected disabled library ANY clause, got:\n%s", query)
}
if len(args) != 2 {
t.Fatalf("expected 2 args, got %d: %v", len(args), args)
}
assertIntSliceArg(t, args, 1, []int{99})
}
func TestRatingThreshold_EmptyAllowedLibrariesReturnsEmptyQuery(t *testing.T) {
query, _ := buildRatingThresholdQuery(RatingFilter{
Min: 7.0,
Filter: AccessFilter{
AllowedLibraryIDs: []int{},
},
})
if query != "" {
t.Fatalf("expected empty query for empty allowed libraries, got %q", query)
}
}
// ---------------------------------------------------------------------------
// ListUnplayedHighRated SQL generation tests
// ---------------------------------------------------------------------------
func TestUnplayedHighRated_BasicQuery(t *testing.T) {
query, args := buildUnplayedHighRatedQuery(UnplayedFilter{
MinRating: 7.5,
Limit: 25,
UserID: 7,
ProfileID: "profile-abc",
})
assertQualifiedItemSelect(t, query)
if !strings.Contains(query, "mi.rating_imdb >= $1") {
t.Fatalf("expected rating threshold predicate, got:\n%s", query)
}
if !strings.Contains(query, "NOT EXISTS") {
t.Fatalf("expected NOT EXISTS subquery, got:\n%s", query)
}
if !strings.Contains(query, "user_watch_history uwh") {
t.Fatalf("expected user_watch_history in subquery, got:\n%s", query)
}
if !strings.Contains(query, "uwh.user_id = $2") {
t.Fatalf("expected user_id predicate at $2, got:\n%s", query)
}
if !strings.Contains(query, "uwh.profile_id = $3") {
t.Fatalf("expected profile_id predicate at $3, got:\n%s", query)
}
if !strings.Contains(query, "uwh.media_item_id = mi.content_id") {
t.Fatalf("expected media_item_id = mi.content_id correlation, got:\n%s", query)
}
if !strings.Contains(query, "ORDER BY mi.rating_imdb DESC NULLS LAST") {
t.Fatalf("expected rating sort order, got:\n%s", query)
}
if !strings.Contains(query, "LIMIT $4") {
t.Fatalf("expected LIMIT at $4, got:\n%s", query)
}
if len(args) != 4 {
t.Fatalf("expected 4 args (minRating, userID, profileID, limit), got %d: %v", len(args), args)
}
}
func TestUnplayedHighRated_NoLimitOmitsClause(t *testing.T) {
query, _ := buildUnplayedHighRatedQuery(UnplayedFilter{
MinRating: 7.0,
UserID: 1,
ProfileID: "p",
})
if strings.Contains(query, "LIMIT") {
t.Fatalf("expected no LIMIT clause, got:\n%s", query)
}
}
func TestUnplayedHighRated_NoLibraryScope(t *testing.T) {
query, _ := buildUnplayedHighRatedQuery(UnplayedFilter{
MinRating: 7.0,
UserID: 1,
ProfileID: "p",
})
if strings.Contains(query, "media_item_libraries") {
t.Fatalf("expected no library scope when no library filter is set, got:\n%s", query)
}
}
func TestUnplayedHighRated_AllowedLibraries(t *testing.T) {
query, args := buildUnplayedHighRatedQuery(UnplayedFilter{
MinRating: 7.0,
UserID: 3,
ProfileID: "p",
Filter: AccessFilter{
AllowedLibraryIDs: []int{10, 11},
},
})
assertNoLibraryJoin(t, query)
if !strings.Contains(query, "EXISTS (SELECT 1 FROM media_item_libraries mil_scope_in") {
t.Fatalf("expected allowed library EXISTS predicate, got:\n%s", query)
}
if !strings.Contains(query, "mil_scope_in.media_folder_id = ANY($4)") {
t.Fatalf("expected allowed library ANY clause at $4, got:\n%s", query)
}
if len(args) != 4 {
t.Fatalf("expected 4 args, got %d: %v", len(args), args)
}
assertIntSliceArg(t, args, 3, []int{10, 11})
}
func TestUnplayedHighRated_EmptyAllowedLibrariesReturnsEmptyQuery(t *testing.T) {
query, _ := buildUnplayedHighRatedQuery(UnplayedFilter{
MinRating: 7.0,
UserID: 1,
ProfileID: "p",
Filter: AccessFilter{
AllowedLibraryIDs: []int{},
},
})
if query != "" {
t.Fatalf("expected empty query for empty allowed libraries, got %q", query)
}
}
func TestUnplayedHighRated_ContentRatingFilter(t *testing.T) {
query, args := buildUnplayedHighRatedQuery(UnplayedFilter{
MinRating: 6.0,
UserID: 2,
ProfileID: "p",
Filter: AccessFilter{MaxContentRating: "PG-13"},
})
if !strings.Contains(query, "mi.content_rating = ANY(") {
t.Fatalf("expected content_rating = ANY filter, got:\n%s", query)
}
// args: minRating, userID, profileID, then content rating slice (one arg).
if len(args) != 4 {
t.Fatalf("expected exactly 4 args (minRating, userID, profileID, rating slice); got %d: %v", len(args), args)
}
}
// ---------------------------------------------------------------------------
// ListForgottenFavorites SQL generation tests
// ---------------------------------------------------------------------------
func TestForgottenFavorites_BasicQuery(t *testing.T) {
query, args := buildForgottenFavoritesQuery(ForgottenFavoritesFilter{
LookbackDays: 365,
Limit: 20,
UserID: 5,
ProfileID: "profile-xyz",
})
assertQualifiedItemSelect(t, query)
if !strings.Contains(query, "mi.rating_imdb >= 7.0") {
t.Fatalf("expected 7.0 rating floor, got:\n%s", query)
}
if !strings.Contains(query, "NOT EXISTS") {
t.Fatalf("expected NOT EXISTS subquery, got:\n%s", query)
}
if !strings.Contains(query, "user_watch_history uwh") {
t.Fatalf("expected user_watch_history in subquery, got:\n%s", query)
}
if !strings.Contains(query, "uwh.user_id = $1") {
t.Fatalf("expected user_id predicate at $1, got:\n%s", query)
}
if !strings.Contains(query, "uwh.profile_id = $2") {
t.Fatalf("expected profile_id predicate at $2, got:\n%s", query)
}
if !strings.Contains(query, "uwh.watched_at >= NOW()") {
t.Fatalf("expected watched_at recency filter, got:\n%s", query)
}
if !strings.Contains(query, "$3 || ' days'") {
t.Fatalf("expected lookback_days at $3, got:\n%s", query)
}
if !strings.Contains(query, "ORDER BY mi.rating_imdb DESC NULLS LAST") {
t.Fatalf("expected rating sort order, got:\n%s", query)
}
if !strings.Contains(query, "LIMIT $4") {
t.Fatalf("expected LIMIT at $4, got:\n%s", query)
}
if len(args) != 4 {
t.Fatalf("expected 4 args (userID, profileID, lookbackDays, limit), got %d: %v", len(args), args)
}
}
func TestForgottenFavorites_NoLimitOmitsClause(t *testing.T) {
query, _ := buildForgottenFavoritesQuery(ForgottenFavoritesFilter{
LookbackDays: 365,
UserID: 1,
ProfileID: "p",
})
if strings.Contains(query, "LIMIT") {
t.Fatalf("expected no LIMIT clause, got:\n%s", query)
}
}
func TestForgottenFavorites_NoLibraryScope(t *testing.T) {
query, _ := buildForgottenFavoritesQuery(ForgottenFavoritesFilter{
LookbackDays: 365,
UserID: 1,
ProfileID: "p",
})
if strings.Contains(query, "media_item_libraries") {
t.Fatalf("expected no library scope when no library filter is set, got:\n%s", query)
}
}
func TestForgottenFavorites_AllowedLibraries(t *testing.T) {
query, args := buildForgottenFavoritesQuery(ForgottenFavoritesFilter{
LookbackDays: 180,
UserID: 3,
ProfileID: "p",
Filter: AccessFilter{
AllowedLibraryIDs: []int{10, 11},
},
})
assertNoLibraryJoin(t, query)
if !strings.Contains(query, "EXISTS (SELECT 1 FROM media_item_libraries mil_scope_in") {
t.Fatalf("expected allowed library EXISTS predicate, got:\n%s", query)
}
if !strings.Contains(query, "mil_scope_in.media_folder_id = ANY($4)") {
t.Fatalf("expected allowed library ANY clause at $4, got:\n%s", query)
}
if len(args) != 4 {
t.Fatalf("expected 4 args, got %d: %v", len(args), args)
}
assertIntSliceArg(t, args, 3, []int{10, 11})
}
func TestForgottenFavorites_EmptyAllowedLibrariesReturnsEmptyQuery(t *testing.T) {
query, _ := buildForgottenFavoritesQuery(ForgottenFavoritesFilter{
LookbackDays: 365,
UserID: 1,
ProfileID: "p",
Filter: AccessFilter{
AllowedLibraryIDs: []int{},
},
})
if query != "" {
t.Fatalf("expected empty query for empty allowed libraries, got %q", query)
}
}
func TestForgottenFavorites_DefaultLookbackApplied(t *testing.T) {
query, args := buildForgottenFavoritesQuery(ForgottenFavoritesFilter{
LookbackDays: 0,
UserID: 1,
ProfileID: "p",
})
if !strings.Contains(query, "NOT EXISTS") {
t.Fatalf("expected NOT EXISTS subquery, got:\n%s", query)
}
if len(args) < 3 {
t.Fatalf("expected at least 3 args, got %d", len(args))
}
if args[2] != 365 {
t.Fatalf("expected lookback default 365, got %v", args[2])
}
}
func TestDiscoveryQueries_QualifyContentIDAndAvoidLibraryJoin(t *testing.T) {
restricted := AccessFilter{AllowedLibraryIDs: []int{1, 2}}
cases := []struct {
name string
query string
}{
{"rating_threshold", mustQuery(buildRatingThresholdQuery(RatingFilter{Min: 7, Filter: restricted}))},
{"unplayed_high_rated", mustQuery(buildUnplayedHighRatedQuery(UnplayedFilter{MinRating: 7, UserID: 1, ProfileID: "p", Filter: restricted}))},
{"forgotten_favorites", mustQuery(buildForgottenFavoritesQuery(ForgottenFavoritesFilter{UserID: 1, ProfileID: "p", LookbackDays: 365, Filter: restricted}))},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertQualifiedItemSelect(t, tc.query)
assertNoLibraryJoin(t, tc.query)
if !strings.Contains(tc.query, "EXISTS (SELECT 1 FROM media_item_libraries mil_scope_in") {
t.Fatalf("expected allowed library EXISTS predicate, got:\n%s", tc.query)
}
if strings.Contains(tc.query, "SELECT content_id,") {
t.Fatalf("SELECT list uses an unqualified content_id, got:\n%s", tc.query)
}
})
}
}
func TestDiscoveryQueries_DisabledLibrariesUseNotExists(t *testing.T) {
filter := AccessFilter{DisabledLibraryIDs: []int{9}}
cases := []struct {
name string
query string
}{
{"rating_threshold", mustQuery(buildRatingThresholdQuery(RatingFilter{Min: 7, Filter: filter}))},
{"unplayed_high_rated", mustQuery(buildUnplayedHighRatedQuery(UnplayedFilter{MinRating: 7, UserID: 1, ProfileID: "p", Filter: filter}))},
{"forgotten_favorites", mustQuery(buildForgottenFavoritesQuery(ForgottenFavoritesFilter{UserID: 1, ProfileID: "p", LookbackDays: 365, Filter: filter}))},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertNoLibraryJoin(t, tc.query)
assertDenyOnlyRequiresLibraryMembership(t, tc.query)
if !strings.Contains(tc.query, "NOT EXISTS (SELECT 1 FROM media_item_libraries mil_scope_out") {
t.Fatalf("expected disabled library NOT EXISTS predicate, got:\n%s", tc.query)
}
})
}
}
func assertQualifiedItemSelect(t *testing.T, query string) {
t.Helper()
if !strings.HasPrefix(query, "SELECT mi.content_id,") {
t.Fatalf("SELECT list is not qualified with the mi alias, got:\n%s", query)
}
}
func assertNoLibraryJoin(t *testing.T, query string) {
t.Helper()
if strings.Contains(query, "JOIN media_item_libraries") {
t.Fatalf("plain library JOIN can fan out discovery rows, got:\n%s", query)
}
}
func assertDenyOnlyRequiresLibraryMembership(t *testing.T, query string) {
t.Helper()
membershipPredicate := "EXISTS (SELECT 1 FROM media_item_libraries mil_scope_any WHERE mil_scope_any.content_id = mi.content_id)"
if !strings.Contains(query, membershipPredicate) {
t.Fatalf("deny-only library filters must require positive library membership, got:\n%s", query)
}
if strings.Contains(query, "mil_scope_any.media_folder_id") {
t.Fatalf("positive membership predicate should not bind a specific library, got:\n%s", query)
}
}
func assertIntSliceArg(t *testing.T, args []any, idx int, want []int) {
t.Helper()
if idx >= len(args) {
t.Fatalf("missing arg %d in %v", idx, args)
}
got, ok := args[idx].([]int)
if !ok {
t.Fatalf("arg %d has type %T, want []int", idx, args[idx])
}
if len(got) != len(want) {
t.Fatalf("arg %d = %v, want %v", idx, got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("arg %d = %v, want %v", idx, got, want)
}
}
}
func mustQuery(query string, _ []any) string { return query }
func TestUnplayedHighRated_LibraryScopeIntersectsViewerAccess(t *testing.T) {
lib := 5
query, args := buildUnplayedHighRatedQuery(UnplayedFilter{
MinRating: 7.5,
UserID: 1,
ProfileID: "p1",
LibraryID: &lib,
Filter: AccessFilter{AllowedLibraryIDs: []int{7, 9}},
})
// Section scope {5} ∩ viewer-allowed {7,9} is empty: no query at all.
if query != "" {
t.Fatalf("expected empty query for disjoint scope, got %q", query)
}
_ = args
}
func TestUnplayedHighRated_MultiLibraryScope(t *testing.T) {
query, args := buildUnplayedHighRatedQuery(UnplayedFilter{
MinRating: 7.5,
UserID: 1,
ProfileID: "p1",
LibraryIDs: []int{3, 4},
})
if !strings.Contains(query, "mil_scope_in.media_folder_id = ANY(") {
t.Errorf("expected library semi-join for multi-library scope, query: %s", query)
}
found := false
for _, arg := range args {
if ids, ok := arg.([]int); ok && len(ids) == 2 && ids[0] == 3 && ids[1] == 4 {
found = true
}
}
if !found {
t.Errorf("expected [3 4] library ids in args, got %v", args)
}
}
func TestUnplayedHighRated_MaxPlaysUsesCountThreshold(t *testing.T) {
query, args := buildUnplayedHighRatedQuery(UnplayedFilter{
MinRating: 7.5,
MaxPlays: 2,
UserID: 1,
ProfileID: "p1",
})
if !strings.Contains(query, "SELECT COUNT(*)") {
t.Errorf("MaxPlays > 0 should use a COUNT threshold: %s", query)
}
// The strict never-started NOT EXISTS over watch history must be replaced
// by the COUNT form (the manga-exclusion NOT EXISTS is unrelated).
if strings.Contains(query, "NOT EXISTS (\n\t\t\tSELECT 1\n\t\t\tFROM user_watch_history") {
t.Errorf("MaxPlays > 0 should not use the never-started NOT EXISTS: %s", query)
}
found := false
for _, arg := range args {
if v, ok := arg.(int); ok && v == 2 {
found = true
}
}
if !found {
t.Errorf("expected max plays 2 in args, got %v", args)
}
}
func TestForgottenFavorites_SingleLibraryScope(t *testing.T) {
lib := 3
query, _ := buildForgottenFavoritesQuery(ForgottenFavoritesFilter{
LookbackDays: 365,
UserID: 1,
ProfileID: "p1",
LibraryID: &lib,
})
if !strings.Contains(query, "mil_scope_in.media_folder_id = ANY(") {
t.Errorf("expected library semi-join for single-library scope, query: %s", query)
}
}
func TestRatingThreshold_MultiLibraryScope(t *testing.T) {
query, args := buildRatingThresholdQuery(RatingFilter{
Min: 8.0,
LibraryIDs: []int{2, 6},
})
if !strings.Contains(query, "mil_scope_in.media_folder_id = ANY(") {
t.Errorf("expected library semi-join for multi-library scope, query: %s", query)
}
found := false
for _, arg := range args {
if ids, ok := arg.([]int); ok && len(ids) == 2 && ids[0] == 2 && ids[1] == 6 {
found = true
}
}
if !found {
t.Errorf("expected [2 6] library ids in args, got %v", args)
}
}