381 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|