Files
silo-server/internal/sections/recently_added_query_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

61 lines
2.0 KiB
Go

package sections
import (
"encoding/json"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
)
func TestBuildRecentlyAddedQueryUsesLibraryMembershipFastPathForSingleLibrary(t *testing.T) {
t.Parallel()
query, args := buildRecentlyAddedQuery(ResolvedSection{
ItemLimit: 12,
Config: json.RawMessage(`{"generated_source":"home_library_recent","filter_library_id":1,"filter_type":"movie"}`),
}, nil, []int{1, 2}, catalog.AccessFilter{MaxContentRating: "PG-13"})
for _, want := range []string{
"FROM media_item_libraries mil JOIN media_items mi ON mi.content_id = mil.content_id",
"mil.media_folder_id = $1",
"mil.media_folder_id IN ($2, $3)",
"mi.type = $4",
"ORDER BY mil.first_seen_at DESC, mil.content_id ASC",
} {
if !strings.Contains(query, want) {
t.Fatalf("query missing %q:\n%s", want, query)
}
}
if strings.Contains(query, "ORDER BY mi.created_at DESC") {
t.Fatalf("single-library fast path should not order by media_items.created_at:\n%s", query)
}
if got, want := args[len(args)-1], 12; got != want {
t.Fatalf("limit arg = %v, want %v", got, want)
}
}
func TestBuildRecentlyAddedQueryKeepsGenericPathForMultiLibraryConfig(t *testing.T) {
t.Parallel()
query, _ := buildRecentlyAddedQuery(ResolvedSection{
ItemLimit: 12,
Config: json.RawMessage(`{"filter_library_ids":[1,2],"filter_type":"movie"}`),
}, nil, nil, catalog.AccessFilter{})
for _, want := range []string{
"FROM media_items mi",
// Library scope is a semi-join so an item in several selected
// libraries yields one row (see buildLibraryScope).
"EXISTS (SELECT 1 FROM media_item_libraries mil_scope_in WHERE mil_scope_in.content_id = mi.content_id AND mil_scope_in.media_folder_id = ANY($2))",
"ORDER BY mi.created_at DESC, mi.content_id ASC",
} {
if !strings.Contains(query, want) {
t.Fatalf("query missing %q:\n%s", want, query)
}
}
if strings.Contains(query, "ORDER BY mil.first_seen_at DESC") {
t.Fatalf("multi-library generic path should not use the single-library first_seen_at sort:\n%s", query)
}
}