Files
silo-server/internal/sections/trending_refresher_test.go
T
QuickandClaude Opus 4.8 f105361658 fix(sections): harden trending refresher per PR review
- Interleave Trakt movies/shows by rank so the mixed row shows both types
  instead of burying all series past the display limit.
- Treat any Trakt sub-fetch failure as fatal (errors.Join) so a partial
  result never overwrites the last-good snapshot with a media type missing.
- Skip non-title entries (TMDB trending/all returns media_type "person") in
  both ID batching and ordering so they can't match an unrelated library title.
- Guard the refresh task against a nil refresher.
- Tests: person skip, Trakt interleave, Trakt partial-failure preserves
  last-good, snapshot read error propagation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:28:59 -04:00

294 lines
9.8 KiB
Go

package sections
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/sections/recipes"
)
type fakeSectionLister struct {
configs []json.RawMessage
err error
}
func (f fakeSectionLister) ListTrendingDiscoverConfigs(context.Context) ([]json.RawMessage, error) {
return f.configs, f.err
}
type savedSnap struct {
contentIDs []string
entryCount int
status string
}
type attemptRec struct {
status string
message string
}
type fakeSnapshotStore struct {
saved map[string]savedSnap
attempts map[string]attemptRec
}
func newFakeSnapshotStore() *fakeSnapshotStore {
return &fakeSnapshotStore{saved: map[string]savedSnap{}, attempts: map[string]attemptRec{}}
}
func (f *fakeSnapshotStore) SaveSuccess(_ context.Context, source, window string, contentIDs []string, entryCount int, status string, _ time.Time) error {
f.saved[source+"|"+window] = savedSnap{contentIDs: contentIDs, entryCount: entryCount, status: status}
return nil
}
func (f *fakeSnapshotStore) RecordAttempt(_ context.Context, source, window, status, message string, _ time.Time) error {
f.attempts[source+"|"+window] = attemptRec{status: status, message: message}
return nil
}
type fakeTMDB struct {
entries []catalog.TMDBCollectionEntry
err error
}
func (f fakeTMDB) GetCollectionPreset(context.Context, string, string, string, int) ([]catalog.TMDBCollectionEntry, error) {
return f.entries, f.err
}
type fakeTrakt struct {
byMediaType map[string][]catalog.TraktCollectionEntry
errByType map[string]error
}
func (f fakeTrakt) GetCollectionPreset(_ context.Context, _, mediaType string, _ int, _ string) ([]catalog.TraktCollectionEntry, error) {
if err := f.errByType[mediaType]; err != nil {
return nil, err
}
return f.byMediaType[mediaType], nil
}
type fakeResolver struct {
byType map[string]*catalog.ExternalIDLookup
}
func (f fakeResolver) GetByExternalIDs(_ context.Context, _ catalog.ExternalIDBatch, itemType string) (*catalog.ExternalIDLookup, error) {
if lk, ok := f.byType[itemType]; ok {
return lk, nil
}
return &catalog.ExternalIDLookup{ByTMDB: map[string]string{}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}}, nil
}
func tmdbConfig(t *testing.T, source, window string) json.RawMessage {
t.Helper()
raw, err := json.Marshal(recipes.TrendingDiscoverParams{Source: source, Window: window})
if err != nil {
t.Fatalf("marshal config: %v", err)
}
return raw
}
func TestRefresherSavesOrderedContentIDs(t *testing.T) {
store := newFakeSnapshotStore()
r := &TrendingRefresher{
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "tmdb", "week")}},
Snapshots: store,
Resolver: fakeResolver{byType: map[string]*catalog.ExternalIDLookup{
"movie": {ByTMDB: map[string]string{"10": "c-movie"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
"series": {ByTMDB: map[string]string{"20": "c-series"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
}},
TMDBTrending: fakeTMDB{entries: []catalog.TMDBCollectionEntry{
{ID: 10, MediaType: "movie"},
{ID: 20, MediaType: "tv"},
}},
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
}
data, err := r.RunOnce(context.Background())
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
var result TrendingRefreshResult
if err := json.Unmarshal(data, &result); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if result.Combos != 1 || result.Refreshed != 1 || result.Failed != 0 || result.Empty != 0 {
t.Fatalf("result = %+v; want {Combos:1 Refreshed:1 Empty:0 Failed:0}", result)
}
got := store.saved["tmdb|week"]
want := []string{"c-movie", "c-series"}
if len(got.contentIDs) != len(want) || got.contentIDs[0] != want[0] || got.contentIDs[1] != want[1] {
t.Fatalf("saved content IDs = %v; want %v", got.contentIDs, want)
}
if got.status != "ok" || got.entryCount != 2 {
t.Fatalf("saved snap = %+v; want status ok, entryCount 2", got)
}
}
func TestRefresherFailurePreservesLastGood(t *testing.T) {
store := newFakeSnapshotStore()
r := &TrendingRefresher{
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "tmdb", "week")}},
Snapshots: store,
Resolver: fakeResolver{},
TMDBTrending: fakeTMDB{err: errors.New("tmdb 503")},
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
}
data, err := r.RunOnce(context.Background())
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if _, ok := store.saved["tmdb|week"]; ok {
t.Fatal("SaveSuccess must not be called on fetch failure (would clear last-good)")
}
att, ok := store.attempts["tmdb|week"]
if !ok || att.status != "error" {
t.Fatalf("attempt = %+v, ok=%v; want status error", att, ok)
}
var result TrendingRefreshResult
_ = json.Unmarshal(data, &result)
if result.Failed != 1 {
t.Fatalf("result.Failed = %d; want 1", result.Failed)
}
}
func TestRefresherEmptyProviderPreservesLastGood(t *testing.T) {
store := newFakeSnapshotStore()
r := &TrendingRefresher{
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "tmdb", "week")}},
Snapshots: store,
Resolver: fakeResolver{},
// TMDBTrending nil => provider unconfigured => empty entries, no error.
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
}
data, err := r.RunOnce(context.Background())
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if _, ok := store.saved["tmdb|week"]; ok {
t.Fatal("SaveSuccess must not be called when provider returns no entries")
}
att := store.attempts["tmdb|week"]
if att.status != "empty" {
t.Fatalf("attempt status = %q; want empty", att.status)
}
var result TrendingRefreshResult
_ = json.Unmarshal(data, &result)
if result.Empty != 1 {
t.Fatalf("result.Empty = %d; want 1", result.Empty)
}
}
func TestRefresherSkipsPersonEntries(t *testing.T) {
store := newFakeSnapshotStore()
r := &TrendingRefresher{
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "tmdb", "week")}},
Snapshots: store,
Resolver: fakeResolver{byType: map[string]*catalog.ExternalIDLookup{
// "99" is present in the movie lookup to simulate a person ID that
// collides with an unrelated library movie's TMDB ID.
"movie": {ByTMDB: map[string]string{"10": "c-movie", "99": "c-person-collision"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
"series": {ByTMDB: map[string]string{"20": "c-series"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
}},
TMDBTrending: fakeTMDB{entries: []catalog.TMDBCollectionEntry{
{ID: 10, MediaType: "movie"},
{ID: 99, MediaType: "person"},
{ID: 20, MediaType: "tv"},
}},
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
}
if _, err := r.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
got := store.saved["tmdb|week"].contentIDs
want := []string{"c-movie", "c-series"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("content IDs = %v; want %v (person entry must be skipped)", got, want)
}
}
func TestRefresherTraktInterleavesMoviesAndShows(t *testing.T) {
store := newFakeSnapshotStore()
r := &TrendingRefresher{
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "trakt", "week")}},
Snapshots: store,
Resolver: fakeResolver{byType: map[string]*catalog.ExternalIDLookup{
"movie": {ByTMDB: map[string]string{"1": "m1", "2": "m2"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
"series": {ByTMDB: map[string]string{"3": "s1"}, ByIMDb: map[string]string{}, ByTVDB: map[string]string{}},
}},
TraktTrending: fakeTrakt{byMediaType: map[string][]catalog.TraktCollectionEntry{
"movie": {{TMDBID: 1, MediaType: "movie"}, {TMDBID: 2, MediaType: "movie"}},
"tv": {{TMDBID: 3, MediaType: "tv"}},
}},
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
}
if _, err := r.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
// Interleaved order: movie[0], show[0], movie[1] => m1, s1, m2. A plain
// concat would have buried s1 after all movies.
got := store.saved["trakt|week"].contentIDs
want := []string{"m1", "s1", "m2"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
t.Fatalf("content IDs = %v; want %v (interleaved)", got, want)
}
}
func TestRefresherTraktPartialFailurePreservesLastGood(t *testing.T) {
store := newFakeSnapshotStore()
r := &TrendingRefresher{
Sections: fakeSectionLister{configs: []json.RawMessage{tmdbConfig(t, "trakt", "week")}},
Snapshots: store,
Resolver: fakeResolver{},
TraktTrending: fakeTrakt{
byMediaType: map[string][]catalog.TraktCollectionEntry{"movie": {{TMDBID: 1, MediaType: "movie"}}},
errByType: map[string]error{"tv": errors.New("trakt shows 500")},
},
Clock: recipes.FixedClock(time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)),
}
if _, err := r.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if _, ok := store.saved["trakt|week"]; ok {
t.Fatal("SaveSuccess must not run when one Trakt sub-fetch fails (would drop a media type)")
}
if store.attempts["trakt|week"].status != "error" {
t.Fatalf("attempt status = %q; want error", store.attempts["trakt|week"].status)
}
}
func TestDistinctTrendingCombosCollapsesTrakt(t *testing.T) {
configs := []json.RawMessage{
tmdbConfig(t, "trakt", "day"),
tmdbConfig(t, "trakt", "week"),
tmdbConfig(t, "tmdb", "day"),
tmdbConfig(t, "tmdb", "day"),
}
got := distinctTrendingCombos(configs)
if len(got) != 2 {
t.Fatalf("distinctTrendingCombos len = %d (%+v); want 2", len(got), got)
}
seen := map[trendingCombo]bool{}
for _, c := range got {
seen[c] = true
}
if !seen[trendingCombo{"trakt", "week"}] || !seen[trendingCombo{"tmdb", "day"}] {
t.Fatalf("combos = %+v; want {trakt week} and {tmdb day}", got)
}
}