Files
silo-server/internal/catalog/library_collection_service_test.go
T

381 lines
12 KiB
Go

package catalog
import (
"context"
"errors"
"strings"
"testing"
)
// TestPickCandidatesByPriority_ReturnsAllInOrder pins the fallback semantic
// that the legacy resolveMDBListEntry preserved: when external IDs resolve
// to different content_ids, all candidates are returned in priority order so
// the caller can pick the first library-resident match. Series priority is
// TVDB > TMDB > IMDb.
func TestPickCandidatesByPriority_ReturnsAllInOrder(t *testing.T) {
lookup := &ExternalIDLookup{
ByTVDB: map[string]string{"100": "tvdb-hit"},
ByTMDB: map[string]string{"200": "tmdb-hit"},
ByIMDb: map[string]string{"tt300": "imdb-hit"},
}
tvdbID := 100
entry := mdblistEntry{TVDBID: &tvdbID, ID: 200, IMDbID: "tt300"}
candidates := pickCandidatesByPriority(lookup, entry, "series")
expected := []string{"tvdb-hit", "tmdb-hit", "imdb-hit"}
if len(candidates) != 3 {
t.Fatalf("expected 3 candidates; got %v", candidates)
}
for i, want := range expected {
if candidates[i] != want {
t.Errorf("candidates[%d] = %q; want %q", i, candidates[i], want)
}
}
}
// TestPickCandidatesByPriority_DedupsAcrossProviders verifies that when all
// three external IDs resolve to the same content_id, that ID is returned
// exactly once (so the membership check + chosen-match loop don't redundant-
// scan the same candidate).
func TestPickCandidatesByPriority_DedupsAcrossProviders(t *testing.T) {
lookup := &ExternalIDLookup{
ByTVDB: map[string]string{"100": "shared"},
ByTMDB: map[string]string{"200": "shared"},
ByIMDb: map[string]string{"tt300": "shared"},
}
tvdbID := 100
entry := mdblistEntry{TVDBID: &tvdbID, ID: 200, IMDbID: "tt300"}
candidates := pickCandidatesByPriority(lookup, entry, "series")
if len(candidates) != 1 || candidates[0] != "shared" {
t.Fatalf("expected single deduped candidate 'shared'; got %v", candidates)
}
}
func TestTraktCandidatesByPriority_ShowUsesTVDBBeforeTMDB(t *testing.T) {
lookup := &ExternalIDLookup{
ByTVDB: map[string]string{"100": "tvdb-hit"},
ByTMDB: map[string]string{"200": "tmdb-hit"},
ByIMDb: map[string]string{"tt300": "imdb-hit"},
}
candidates := traktCandidatesByPriority(lookup, TraktCollectionEntry{
TVDBID: 100,
TMDBID: 200,
IMDbID: "tt300",
}, "series")
want := []string{"tvdb-hit", "tmdb-hit", "imdb-hit"}
if len(candidates) != len(want) {
t.Fatalf("candidates = %v, want %v", candidates, want)
}
for i := range want {
if candidates[i] != want[i] {
t.Fatalf("candidates = %v, want %v", candidates, want)
}
}
}
// fakeTMDBFranchiseFetcher is a stand-in for tmdbFranchiseAdapter used in
// catalog-package tests. It records the IDs it was asked to fetch so callers
// can assert that the configured CollectionID flows end-to-end without
// truncation, and returns canned entries in the order TMDB would have.
type fakeTMDBFranchiseFetcher struct {
calls []int
entries []TMDBCollectionEntry
err error
}
func (f *fakeTMDBFranchiseFetcher) GetCollection(_ context.Context, id int) ([]TMDBCollectionEntry, error) {
f.calls = append(f.calls, id)
if f.err != nil {
return nil, f.err
}
// Return a fresh slice so the caller can sort/mutate without
// corrupting the fixture for later assertions.
out := make([]TMDBCollectionEntry, len(f.entries))
copy(out, f.entries)
return out, nil
}
func TestFakeTMDBFranchiseFetcherSatisfiesInterface(t *testing.T) {
// Static-typed assertion the fake implements the interface — a regression
// guard so future signature changes break here, not in router wiring.
var _ TMDBCollectionByIDFetcher = (*fakeTMDBFranchiseFetcher)(nil)
}
func TestFakeTMDBFranchiseFetcherReturnsEntriesInOrder(t *testing.T) {
want := []TMDBCollectionEntry{
{ID: 1726, MediaType: "movie", Title: "Iron Man"},
{ID: 10138, MediaType: "movie", Title: "Iron Man 2"},
{ID: 68721, MediaType: "movie", Title: "Iron Man 3"},
}
f := &fakeTMDBFranchiseFetcher{entries: want}
got, err := f.GetCollection(context.Background(), 131292)
if err != nil {
t.Fatalf("fetcher: %v", err)
}
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
}
for i, w := range want {
if got[i] != w {
t.Errorf("got[%d] = %+v, want %+v", i, got[i], w)
}
}
if len(f.calls) != 1 || f.calls[0] != 131292 {
t.Errorf("calls = %v, want [131292]", f.calls)
}
}
func TestFakeTMDBFranchiseFetcherPropagatesError(t *testing.T) {
sentinel := errors.New("tmdb: down")
f := &fakeTMDBFranchiseFetcher{err: sentinel}
if _, err := f.GetCollection(context.Background(), 1); !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want sentinel", err)
}
}
// TestValidateTMDBFranchiseConfig pins the failure-message format that
// surfaces to the admin in the sync_runs table when a placeholder template
// is applied without filling in the real TMDB collection ID.
//
// This is the unit-testable slice of syncTMDBFranchiseCollection — the
// fetch-and-match body requires a real repository for sync run recording
// and is exercised by the broader sync integration coverage rather than a
// dedicated catalog-package unit test (the user prefers fast tests; see
// the project's "no testcontainers" note).
func TestValidateTMDBFranchiseConfig(t *testing.T) {
cases := []struct {
name string
collectionID int
wantEmpty bool
wantContains string
}{
{
name: "valid id",
collectionID: 86311,
wantEmpty: true,
},
{
name: "placeholder zero id",
collectionID: 0,
wantContains: "collection_id",
},
{
name: "negative id",
collectionID: -1,
wantContains: "must be > 0",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := validateTMDBFranchiseConfig(c.collectionID)
if c.wantEmpty {
if got != "" {
t.Errorf("got %q, want empty (valid)", got)
}
return
}
if got == "" {
t.Fatalf("got empty string, want non-empty message")
}
if !strings.Contains(got, c.wantContains) {
t.Errorf("got %q, want substring %q", got, c.wantContains)
}
})
}
}
// fakeTMDBDiscoverFetcher stands in for tmdbDiscoverAdapter in unit tests.
// It records the (mediaType, params, limit) it was asked for so callers can
// assert the discover spec flowed end-to-end without truncation, and returns
// canned entries in the order TMDB would have.
type fakeTMDBDiscoverFetcher struct {
calls []fakeTMDBDiscoverCall
entries []TMDBCollectionEntry
err error
}
type fakeTMDBDiscoverCall struct {
MediaType string
Params TMDBDiscoverParams
Limit int
}
func (f *fakeTMDBDiscoverFetcher) Discover(_ context.Context, mediaType string, params TMDBDiscoverParams, limit int) ([]TMDBCollectionEntry, error) {
f.calls = append(f.calls, fakeTMDBDiscoverCall{MediaType: mediaType, Params: params, Limit: limit})
if f.err != nil {
return nil, f.err
}
out := make([]TMDBCollectionEntry, len(f.entries))
copy(out, f.entries)
return out, nil
}
func TestFakeTMDBDiscoverFetcherSatisfiesInterface(t *testing.T) {
// Static-typed assertion the fake implements the interface — a regression
// guard so future signature changes break here, not in router wiring.
var _ TMDBDiscoverFetcher = (*fakeTMDBDiscoverFetcher)(nil)
}
func TestFakeTMDBDiscoverFetcherPropagatesError(t *testing.T) {
sentinel := errors.New("tmdb: down")
f := &fakeTMDBDiscoverFetcher{err: sentinel}
_, err := f.Discover(context.Background(), "movie", TMDBDiscoverParams{SortBy: "popularity.desc"}, 10)
if !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want sentinel", err)
}
}
// TestFakeTMDBDiscoverFetcherRecordsParams verifies the full discover params
// payload reaches the fetcher untouched. This is the unit-testable slice of
// syncTMDBDiscoverCollection — the post-fetch matcher requires a real
// repository (the project's "no testcontainers" policy keeps that off the
// per-package unit suite).
func TestFakeTMDBDiscoverFetcherRecordsParams(t *testing.T) {
want := []TMDBCollectionEntry{
{ID: 11, MediaType: "movie", Title: "First"},
{ID: 22, MediaType: "movie", Title: "Second"},
{ID: 33, MediaType: "movie", Title: "Third"},
}
f := &fakeTMDBDiscoverFetcher{entries: want}
params := TMDBDiscoverParams{
WithGenres: []int{28, 12},
SortBy: "popularity.desc",
VoteCountGte: 300,
VoteAverageGte: 6.5,
ReleaseDateGte: "2010-01-01",
Certifications: []string{"PG-13"},
WithRuntimeGte: 60,
WithRuntimeLte: 240,
OriginalLanguage: "en",
}
got, err := f.Discover(context.Background(), "movie", params, 50)
if err != nil {
t.Fatalf("fetcher: %v", err)
}
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d", len(got), len(want))
}
for i, w := range want {
if got[i] != w {
t.Errorf("got[%d] = %+v, want %+v", i, got[i], w)
}
}
if len(f.calls) != 1 {
t.Fatalf("calls = %d, want 1", len(f.calls))
}
call := f.calls[0]
if call.MediaType != "movie" {
t.Errorf("media_type = %q, want movie", call.MediaType)
}
if call.Limit != 50 {
t.Errorf("limit = %d, want 50", call.Limit)
}
if call.Params.SortBy != "popularity.desc" {
t.Errorf("sort_by = %q", call.Params.SortBy)
}
if len(call.Params.WithGenres) != 2 || call.Params.WithGenres[0] != 28 || call.Params.WithGenres[1] != 12 {
t.Errorf("with_genres = %v", call.Params.WithGenres)
}
if call.Params.VoteCountGte != 300 || call.Params.VoteAverageGte != 6.5 {
t.Errorf("vote thresholds = %d / %v", call.Params.VoteCountGte, call.Params.VoteAverageGte)
}
if call.Params.OriginalLanguage != "en" {
t.Errorf("original_language = %q", call.Params.OriginalLanguage)
}
}
// TestValidateTMDBDiscoverConfig pins the failure-message format that surfaces
// to the admin when a discover-mode collection's source_config is incomplete.
func TestValidateTMDBDiscoverConfig(t *testing.T) {
cases := []struct {
name string
cfg libraryCollectionSourceConfig
wantMessagePart string
wantMediaType string
}{
{
name: "valid",
cfg: libraryCollectionSourceConfig{
MediaType: "movie",
Discover: &libraryCollectionDiscoverConfig{SortBy: "popularity.desc"},
},
wantMediaType: "movie",
},
{
name: "defaults media_type to movie when blank",
cfg: libraryCollectionSourceConfig{
Discover: &libraryCollectionDiscoverConfig{SortBy: "popularity.desc"},
},
wantMediaType: "movie",
},
{
name: "missing discover spec",
cfg: libraryCollectionSourceConfig{MediaType: "movie"},
wantMessagePart: "discover spec",
},
{
name: "invalid media_type",
cfg: libraryCollectionSourceConfig{
MediaType: "all",
Discover: &libraryCollectionDiscoverConfig{SortBy: "popularity.desc"},
},
wantMessagePart: "media_type",
},
{
name: "missing sort_by",
cfg: libraryCollectionSourceConfig{
MediaType: "movie",
Discover: &libraryCollectionDiscoverConfig{},
},
wantMessagePart: "sort_by",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
reason, mediaType := validateTMDBDiscoverConfig(c.cfg)
if c.wantMessagePart == "" {
if reason != "" {
t.Fatalf("got %q, want empty", reason)
}
if mediaType != c.wantMediaType {
t.Errorf("mediaType = %q, want %q", mediaType, c.wantMediaType)
}
return
}
if reason == "" {
t.Fatal("got empty reason, want non-empty")
}
if !strings.Contains(reason, c.wantMessagePart) {
t.Errorf("got %q, want substring %q", reason, c.wantMessagePart)
}
if mediaType != "" {
t.Errorf("mediaType = %q, want empty when invalid", mediaType)
}
})
}
}
func TestTraktCandidatesByPriority_MovieUsesTMDBBeforeIMDb(t *testing.T) {
lookup := &ExternalIDLookup{
ByTVDB: map[string]string{"100": "tvdb-hit"},
ByTMDB: map[string]string{"200": "tmdb-hit"},
ByIMDb: map[string]string{"tt300": "imdb-hit"},
}
candidates := traktCandidatesByPriority(lookup, TraktCollectionEntry{
TVDBID: 100,
TMDBID: 200,
IMDbID: "tt300",
}, "movie")
want := []string{"tmdb-hit", "imdb-hit"}
if len(candidates) != len(want) {
t.Fatalf("candidates = %v, want %v", candidates, want)
}
for i := range want {
if candidates[i] != want[i] {
t.Fatalf("candidates = %v, want %v", candidates, want)
}
}
}