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

165 lines
4.5 KiB
Go

package recipes
import (
"encoding/json"
"testing"
)
func TestHiddenGemsRecipeRegistered(t *testing.T) {
rec, ok := Get("hidden_gems")
if !ok {
t.Fatal("hidden_gems not registered")
}
if rec.Definition().Category != CategoryDiscovery {
t.Errorf("category = %v want discovery", rec.Definition().Category)
}
if !rec.Definition().AvoidDuplicates {
t.Errorf("hidden_gems should opt into AvoidDuplicates")
}
}
func TestHiddenGemsAcceptsParams(t *testing.T) {
rec, _ := Get("hidden_gems")
good := json.RawMessage(`{"min_rating":7.5,"max_play_count":2}`)
if err := rec.Validate(good); err != nil {
t.Errorf("good params rejected: %v", err)
}
}
func TestCriticallyAcclaimedRecipeRegistered(t *testing.T) {
rec, ok := Get("critically_acclaimed")
if !ok {
t.Fatal("critically_acclaimed not registered")
}
if rec.Definition().Category != CategoryDiscovery {
t.Errorf("category = %v want discovery", rec.Definition().Category)
}
if !rec.Definition().AvoidDuplicates {
t.Errorf("critically_acclaimed should opt into AvoidDuplicates")
}
presets := rec.Definition().Presets
if len(presets) != 1 {
t.Fatalf("expected 1 preset, got %d", len(presets))
}
if presets[0].Key != "ca_imdb" {
t.Errorf("preset key = %q want ca_imdb", presets[0].Key)
}
}
func TestCriticallyAcclaimedAcceptsParams(t *testing.T) {
rec, _ := Get("critically_acclaimed")
good := json.RawMessage(`{"min_score":8.0,"source":"imdb"}`)
if err := rec.Validate(good); err != nil {
t.Errorf("good params rejected: %v", err)
}
}
func TestAwardWinnersRecipeRegistered(t *testing.T) {
rec, ok := Get("award_winners")
if !ok {
t.Fatal("award_winners not registered")
}
if rec.Definition().Category != CategoryDiscovery {
t.Errorf("category = %v want discovery", rec.Definition().Category)
}
if !rec.Definition().AvoidDuplicates {
t.Errorf("award_winners should opt into AvoidDuplicates")
}
presets := rec.Definition().Presets
if len(presets) != 3 {
t.Fatalf("expected 3 presets (oscar, emmy, cannes), got %d", len(presets))
}
keys := map[string]bool{"aw_oscar": false, "aw_emmy": false, "aw_cannes": false}
for _, p := range presets {
keys[p.Key] = true
}
for k, found := range keys {
if !found {
t.Errorf("preset %q missing", k)
}
}
}
func TestForgottenFavoritesRecipeRegistered(t *testing.T) {
rec, ok := Get("forgotten_favorites")
if !ok {
t.Fatal("forgotten_favorites not registered")
}
if rec.Definition().Category != CategoryDiscovery {
t.Errorf("category = %v want discovery", rec.Definition().Category)
}
if !rec.Definition().AvoidDuplicates {
t.Errorf("forgotten_favorites should opt into AvoidDuplicates")
}
presets := rec.Definition().Presets
if len(presets) != 1 {
t.Fatalf("expected 1 preset, got %d", len(presets))
}
if presets[0].Key != "ff_default" {
t.Errorf("preset key = %q want ff_default", presets[0].Key)
}
}
func TestForgottenFavoritesAcceptsParams(t *testing.T) {
rec, _ := Get("forgotten_favorites")
good := json.RawMessage(`{"lookback_days":365}`)
if err := rec.Validate(good); err != nil {
t.Errorf("good params rejected: %v", err)
}
}
func TestFormatShowcaseRecipeRegistered(t *testing.T) {
if _, ok := Get("format_showcase"); !ok {
t.Fatal("format_showcase not registered")
}
}
func TestFormatShowcaseRecipeDefinition(t *testing.T) {
rec, ok := Get("format_showcase")
if !ok {
t.Fatal("format_showcase not registered")
}
def := rec.Definition()
if def.Category != CategoryDiscovery {
t.Errorf("category = %v want discovery", def.Category)
}
if def.AvoidDuplicates {
t.Errorf("format_showcase should not set AvoidDuplicates")
}
if len(def.Presets) != 4 {
t.Fatalf("expected 4 presets, got %d", len(def.Presets))
}
keys := map[string]bool{"fs_4k": false, "fs_4k_recent": false, "fs_dv": false, "fs_hdr": false}
for _, p := range def.Presets {
keys[p.Key] = true
}
for k, found := range keys {
if !found {
t.Errorf("preset %q missing", k)
}
}
}
func TestFormatShowcaseValidatesFormat(t *testing.T) {
rec, _ := Get("format_showcase")
validCases := []json.RawMessage{
nil,
json.RawMessage(`{}`),
json.RawMessage(`{"format":"4k"}`),
json.RawMessage(`{"format":"dolby_vision"}`),
json.RawMessage(`{"format":"hdr"}`),
json.RawMessage(`{"format":""}`),
}
for _, raw := range validCases {
if err := rec.Validate(raw); err != nil {
t.Errorf("valid params %s rejected: %v", raw, err)
}
}
bad := json.RawMessage(`{"format":"8k"}`)
if err := rec.Validate(bad); err == nil {
t.Errorf("invalid format should be rejected")
}
}