Files
silo-server/internal/metadata/tmdb/client_test.go
T
Silo Server Migration cc09e14154 feat(tmdb): cache discover and external ID responses
- Add TTL caches with singleflight for DiscoverSection, DiscoverPage, and GetExternalIDs to reduce upstream TMDB calls
- Reuse CastCarousel with a fullBleed variant on RequestDetail and drop the bespoke RequestCastRow
2026-05-25 02:02:33 -04:00

889 lines
26 KiB
Go

package tmdb
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func TestNewClientUsesProjectAPIKeyWhenEmpty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("api_key"); got != projectAPIKey {
t.Fatalf("api_key query = %q, want project API key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`))
}))
defer server.Close()
client := NewClient("", 1000)
client.SetBaseURL(server.URL)
if _, err := client.GetCollectionPreset(context.Background(), "trending", "all", "day", 10); err != nil {
t.Fatalf("GetCollectionPreset returned error: %v", err)
}
}
func TestGetCollectionPresetTrending(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/trending/all/day" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("page"); got != "1" {
t.Fatalf("page query = %q, want 1", got)
}
if got := r.URL.Query().Get("api_key"); got != "test-key" {
t.Fatalf("api_key query = %q, want test-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 2,
"results": [
{"id": 10, "media_type": "movie", "title": "Movie Title"},
{"id": 20, "media_type": "tv", "name": "Series Title"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
results, err := client.GetCollectionPreset(context.Background(), "trending", "all", "day", 10)
if err != nil {
t.Fatalf("GetCollectionPreset returned error: %v", err)
}
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(results))
}
if results[0] != (CollectionResult{ID: 10, MediaType: "movie", Title: "Movie Title"}) {
t.Fatalf("results[0] = %+v", results[0])
}
if results[1] != (CollectionResult{ID: 20, MediaType: "tv", Title: "Series Title"}) {
t.Fatalf("results[1] = %+v", results[1])
}
}
func TestDiscoverSectionCachesSuccess(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("page"); got != "1" {
t.Fatalf("page query = %q, want 1", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 11, "title": "Cached Movie", "overview": "from tmdb"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("first DiscoverSection returned error: %v", err)
}
second, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("second DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1", got)
}
for name, page := range map[string]*MediaPage{"first": first, "second": second} {
if page == nil || len(page.Results) != 1 {
t.Fatalf("%s page results = %#v, want one result", name, page)
}
if page.Results[0].ID != 11 || page.Results[0].Title != "Cached Movie" {
t.Fatalf("%s result = %+v", name, page.Results[0])
}
}
}
func TestDiscoverSectionCacheKeyIncludesPage(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
page := r.URL.Query().Get("page")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": ` + page + `,
"total_pages": 2,
"total_results": 2,
"results": [
{"id": ` + page + `, "title": "Movie ` + page + `"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("page 1 DiscoverSection returned error: %v", err)
}
second, err := client.DiscoverSection(context.Background(), "popular_movies", 2)
if err != nil {
t.Fatalf("page 2 DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 2 {
t.Fatalf("upstream calls = %d, want 2", got)
}
if first.Results[0].ID != 1 || second.Results[0].ID != 2 {
t.Fatalf("cached pages collapsed unexpectedly: first=%+v second=%+v", first.Results[0], second.Results[0])
}
}
func TestDiscoverSectionDoesNotCacheClientErrors(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
if call == 1 {
http.Error(w, `{"status_message":"bad section"}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 22, "title": "Recovered Movie"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
if _, err := client.DiscoverSection(context.Background(), "popular_movies", 1); err == nil {
t.Fatal("first DiscoverSection expected error")
}
page, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("second DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 2 {
t.Fatalf("upstream calls = %d, want 2", got)
}
if page == nil || len(page.Results) != 1 || page.Results[0].ID != 22 {
t.Fatalf("second page = %#v, want recovered movie", page)
}
}
func TestDiscoverSectionReturnsClonedCachedPage(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/popular" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 33, "title": "Immutable Movie"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("first DiscoverSection returned error: %v", err)
}
first.Results[0].Title = "mutated by caller"
second, err := client.DiscoverSection(context.Background(), "popular_movies", 1)
if err != nil {
t.Fatalf("second DiscoverSection returned error: %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1", got)
}
if second.Results[0].Title != "Immutable Movie" {
t.Fatalf("cached title = %q, want Immutable Movie", second.Results[0].Title)
}
}
func TestGetExternalIDsCachesSuccess(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path != "/movie/123/external_ids" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"imdb_id":"tt123","tvdb_id":456}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
first, err := client.GetExternalIDs(context.Background(), "movie", 123)
if err != nil {
t.Fatalf("first GetExternalIDs returned error: %v", err)
}
second, err := client.GetExternalIDs(context.Background(), "movie", 123)
if err != nil {
t.Fatalf("second GetExternalIDs returned error: %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1", got)
}
if first.IMDbID != "tt123" || first.TVDBID != 456 || second.IMDbID != "tt123" || second.TVDBID != 456 {
t.Fatalf("external IDs = first %+v second %+v", first, second)
}
}
func TestDiscoverMovieAppliesFilters(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/discover/movie" {
http.NotFound(w, r)
return
}
calls++
q := r.URL.Query()
if got := q.Get("sort_by"); got != "popularity.desc" {
t.Errorf("sort_by = %q, want popularity.desc", got)
}
if got := q.Get("with_genres"); got != "28,12" {
t.Errorf("with_genres = %q, want 28,12", got)
}
if got := q.Get("without_genres"); got != "99" {
t.Errorf("without_genres = %q, want 99", got)
}
if got := q.Get("vote_count.gte"); got != "300" {
t.Errorf("vote_count.gte = %q, want 300", got)
}
if got := q.Get("vote_average.gte"); got != "6.5" {
t.Errorf("vote_average.gte = %q, want 6.5", got)
}
if got := q.Get("primary_release_date.gte"); got != "2020-01-01" {
t.Errorf("primary_release_date.gte = %q, want 2020-01-01", got)
}
if got := q.Get("primary_release_date.lte"); got != "2025-12-31" {
t.Errorf("primary_release_date.lte = %q, want 2025-12-31", got)
}
if got := q.Get("certification_country"); got != "US" {
t.Errorf("certification_country = %q, want US", got)
}
if got := q.Get("certification"); got != "PG|PG-13" {
t.Errorf("certification = %q, want PG|PG-13", got)
}
if got := q.Get("with_runtime.gte"); got != "90" {
t.Errorf("with_runtime.gte = %q, want 90", got)
}
if got := q.Get("with_runtime.lte"); got != "180" {
t.Errorf("with_runtime.lte = %q, want 180", got)
}
if got := q.Get("with_original_language"); got != "en" {
t.Errorf("with_original_language = %q, want en", got)
}
if got := q.Get("api_key"); got != "test-key" {
t.Errorf("api_key = %q, want test-key", got)
}
if got := q.Get("page"); got != "1" {
t.Errorf("page = %q, want 1", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 2,
"results": [
{"id": 11, "title": "First Movie"},
{"id": 22, "title": "Second Movie"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
results, err := client.Discover(context.Background(), "movie", DiscoverParams{
SortBy: "popularity.desc",
WithGenres: []int{28, 12},
WithoutGenres: []int{99},
VoteCountGte: 300,
VoteAverageGte: 6.5,
ReleaseDateGte: "2020-01-01",
ReleaseDateLte: "2025-12-31",
Certifications: []string{"PG", "PG-13"},
WithRuntimeGte: 90,
WithRuntimeLte: 180,
OriginalLanguage: "en",
Limit: 10,
})
if err != nil {
t.Fatalf("Discover returned error: %v", err)
}
if calls != 1 {
t.Fatalf("expected 1 server call, got %d", calls)
}
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(results))
}
if results[0] != (CollectionResult{ID: 11, MediaType: "movie", Title: "First Movie"}) {
t.Errorf("results[0] = %+v", results[0])
}
if results[1] != (CollectionResult{ID: 22, MediaType: "movie", Title: "Second Movie"}) {
t.Errorf("results[1] = %+v", results[1])
}
}
func TestDiscoverTVUsesFirstAirDate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/discover/tv" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("sort_by"); got != "vote_average.desc" {
t.Errorf("sort_by = %q, want vote_average.desc", got)
}
if got := q.Get("first_air_date.gte"); got != "2010-01-01" {
t.Errorf("first_air_date.gte = %q, want 2010-01-01", got)
}
if got := q.Get("first_air_date.lte"); got != "2020-01-01" {
t.Errorf("first_air_date.lte = %q, want 2020-01-01", got)
}
// TV requests must NOT carry primary_release_date.* params.
if got := q.Get("primary_release_date.gte"); got != "" {
t.Errorf("primary_release_date.gte should be empty for tv, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 99, "name": "Some Show"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
results, err := client.Discover(context.Background(), "tv", DiscoverParams{
SortBy: "vote_average.desc",
ReleaseDateGte: "2010-01-01",
ReleaseDateLte: "2020-01-01",
Limit: 5,
})
if err != nil {
t.Fatalf("Discover tv: %v", err)
}
if len(results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(results))
}
if results[0] != (CollectionResult{ID: 99, MediaType: "tv", Title: "Some Show"}) {
t.Errorf("results[0] = %+v", results[0])
}
}
func TestDiscoverIncludesCompaniesAndNetworks(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if got := q.Get("with_companies"); got != "420,2" {
t.Errorf("with_companies = %q, want 420,2", got)
}
if got := q.Get("with_networks"); got != "213,49" {
t.Errorf("with_networks = %q, want 213,49", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
_, err := client.Discover(context.Background(), "movie", DiscoverParams{
SortBy: "popularity.desc",
WithCompanies: []int{420, 2},
WithNetworks: []int{213, 49},
Limit: 5,
})
if err != nil {
t.Fatalf("Discover returned error: %v", err)
}
}
func TestDiscoverRejectsInvalidMediaType(t *testing.T) {
client := NewClient("test-key", 1000)
_, err := client.Discover(context.Background(), "all", DiscoverParams{SortBy: "popularity.desc"})
if err == nil {
t.Fatal("expected error for invalid media type")
}
}
func TestDiscoverRequiresSortBy(t *testing.T) {
client := NewClient("test-key", 1000)
_, err := client.Discover(context.Background(), "movie", DiscoverParams{})
if err == nil {
t.Fatal("expected error when sort_by is empty")
}
}
func TestDiscoverPageMovieReturnsFullResults(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/discover/movie" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("sort_by"); got != "popularity.desc" {
t.Errorf("sort_by = %q, want popularity.desc", got)
}
if got := q.Get("with_companies"); got != "420" {
t.Errorf("with_companies = %q, want 420", got)
}
if got := q.Get("page"); got != "2" {
t.Errorf("page = %q, want 2", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 2,
"total_pages": 8,
"total_results": 160,
"results": [
{"id": 24428, "title": "The Avengers", "release_date": "2012-04-25", "poster_path": "/p.jpg", "overview": "earth's mightiest", "popularity": 100.5, "vote_average": 7.7}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{
SortBy: "popularity.desc",
WithCompanies: []int{420},
}, 2)
if err != nil {
t.Fatalf("DiscoverPage: %v", err)
}
if page.Page != 2 || page.TotalPages != 8 || page.TotalResults != 160 {
t.Fatalf("page = %+v", page)
}
if len(page.Results) != 1 {
t.Fatalf("results = %d, want 1", len(page.Results))
}
got := page.Results[0]
if got.ID != 24428 || got.MediaType != "movie" || got.Title != "The Avengers" || got.Year != 2012 {
t.Errorf("result = %+v", got)
}
if got.PosterPath != "/p.jpg" || got.Overview != "earth's mightiest" {
t.Errorf("result detail mismatch: %+v", got)
}
}
func TestDiscoverPageTVUsesFirstAirDate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/discover/tv" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("with_networks"); got != "213" {
t.Errorf("with_networks = %q, want 213", got)
}
if got := q.Get("first_air_date.gte"); got != "" {
t.Errorf("first_air_date.gte = %q, want empty", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{"id": 1399, "name": "Game of Thrones", "first_air_date": "2011-04-17", "poster_path": "/g.jpg"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.DiscoverPage(context.Background(), "tv", DiscoverParams{
SortBy: "vote_average.desc",
WithNetworks: []int{213},
}, 1)
if err != nil {
t.Fatalf("DiscoverPage tv: %v", err)
}
if len(page.Results) != 1 {
t.Fatalf("results = %d, want 1", len(page.Results))
}
got := page.Results[0]
if got.MediaType != "series" || got.Title != "Game of Thrones" || got.Year != 2011 {
t.Errorf("result = %+v", got)
}
}
func TestDiscoverPageRejectsInvalidMediaType(t *testing.T) {
client := NewClient("test-key", 1000)
_, err := client.DiscoverPage(context.Background(), "all", DiscoverParams{SortBy: "popularity.desc"}, 1)
if err == nil {
t.Fatal("expected error for invalid media type")
}
}
func TestDiscoverPageRequiresSortBy(t *testing.T) {
client := NewClient("test-key", 1000)
_, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{}, 1)
if err == nil {
t.Fatal("expected error when sort_by is empty")
}
}
func TestDiscoverPageDefaultsToPage1(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("page"); got != "1" {
t.Errorf("page = %q, want 1", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"page":1,"total_pages":1,"total_results":0,"results":[]}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
if _, err := client.DiscoverPage(context.Background(), "movie", DiscoverParams{SortBy: "popularity.desc"}, 0); err != nil {
t.Fatalf("DiscoverPage: %v", err)
}
}
func TestSearchMediaMovie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/movie" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("query"); got != "fight club" {
t.Fatalf("query = %q, want fight club", got)
}
if got := q.Get("include_adult"); got != "false" {
t.Fatalf("include_adult = %q, want false", got)
}
if got := q.Get("page"); got != "2" {
t.Fatalf("page = %q, want 2", got)
}
if got := q.Get("api_key"); got != "test-key" {
t.Fatalf("api_key query = %q, want test-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 2,
"total_pages": 5,
"total_results": 50,
"results": [
{
"id": 550,
"title": "Fight Club",
"overview": "overview",
"poster_path": "/poster.jpg",
"backdrop_path": "/backdrop.jpg",
"release_date": "1999-10-15",
"popularity": 10.5,
"vote_average": 8.4
}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.SearchMedia(context.Background(), "movie", "fight club", 2)
if err != nil {
t.Fatalf("SearchMedia returned error: %v", err)
}
if page.Page != 2 || page.TotalPages != 5 || len(page.Results) != 1 {
t.Fatalf("page = %+v, want page metadata and one result", page)
}
result := page.Results[0]
if result.ID != 550 || result.MediaType != "movie" || result.Year != 1999 {
t.Fatalf("result = %+v, want normalized movie result", result)
}
}
func TestSearchMediaAllUsesMultiSearchAndFiltersToMoviesAndSeries(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/multi" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if got := q.Get("query"); got != "fight club" {
t.Fatalf("query = %q, want fight club", got)
}
if got := q.Get("include_adult"); got != "false" {
t.Fatalf("include_adult = %q, want false", got)
}
if got := q.Get("page"); got != "2" {
t.Fatalf("page = %q, want 2", got)
}
if got := q.Get("api_key"); got != "test-key" {
t.Fatalf("api_key query = %q, want test-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 2,
"total_pages": 4,
"total_results": 40,
"results": [
{
"id": 550,
"media_type": "movie",
"title": "Fight Club",
"release_date": "1999-10-15"
},
{
"id": 1399,
"media_type": "tv",
"name": "Fight Club: The Series",
"first_air_date": "2020-01-01"
},
{
"id": 123,
"media_type": "person",
"name": "A Performer"
}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.SearchMedia(context.Background(), "all", "fight club", 2)
if err != nil {
t.Fatalf("SearchMedia returned error: %v", err)
}
if page.Page != 2 || page.TotalPages != 4 || page.TotalResults != 40 {
t.Fatalf("page metadata = %+v, want TMDB pagination metadata", page)
}
if len(page.Results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(page.Results))
}
if page.Results[0].ID != 550 || page.Results[0].MediaType != "movie" || page.Results[0].Year != 1999 {
t.Fatalf("results[0] = %+v, want normalized movie", page.Results[0])
}
if page.Results[1].ID != 1399 || page.Results[1].MediaType != "series" || page.Results[1].Year != 2020 {
t.Fatalf("results[1] = %+v, want normalized series", page.Results[1])
}
}
func TestDiscoverSectionTrendingSeries(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/trending/tv/week" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("page"); got != "1" {
t.Fatalf("page = %q, want 1", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"page": 1,
"total_pages": 1,
"total_results": 1,
"results": [
{
"id": 1399,
"name": "Game of Thrones",
"first_air_date": "2011-04-17"
}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
page, err := client.DiscoverSection(context.Background(), "trending_series", 1)
if err != nil {
t.Fatalf("DiscoverSection returned error: %v", err)
}
if len(page.Results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(page.Results))
}
result := page.Results[0]
if result.ID != 1399 || result.MediaType != "series" || result.Year != 2011 {
t.Fatalf("result = %+v, want normalized series result", result)
}
}
func TestGetCollection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/collection/86311" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("api_key"); got != "test-key" {
t.Fatalf("api_key query = %q, want test-key", got)
}
w.Header().Set("Content-Type", "application/json")
// Trimmed MCU-style payload — parts ordered chronologically by
// release date, with one part omitting media_type to exercise the
// "default to movie" branch.
_, _ = w.Write([]byte(`{
"id": 86311,
"name": "The Avengers Collection",
"parts": [
{"id": 24428, "media_type": "movie", "title": "The Avengers", "release_date": "2012-04-25"},
{"id": 99861, "media_type": "movie", "title": "Avengers: Age of Ultron", "release_date": "2015-04-22"},
{"id": 299536, "title": "Avengers: Infinity War", "release_date": "2018-04-25"}
]
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
got, err := client.GetCollection(context.Background(), 86311)
if err != nil {
t.Fatalf("GetCollection: %v", err)
}
if got == nil {
t.Fatal("GetCollection returned nil")
}
if got.ID != 86311 {
t.Errorf("ID = %d, want 86311", got.ID)
}
if got.Name != "The Avengers Collection" {
t.Errorf("Name = %q, want The Avengers Collection", got.Name)
}
if len(got.Parts) != 3 {
t.Fatalf("len(parts) = %d, want 3", len(got.Parts))
}
// Order assertion: TMDB returns parts in curated order; the client
// preserves that order so downstream sync writes items consistently.
wantOrder := []int{24428, 99861, 299536}
for i, want := range wantOrder {
if got.Parts[i].ID != want {
t.Errorf("parts[%d].ID = %d, want %d", i, got.Parts[i].ID, want)
}
}
// Media type defaulting: third part omitted media_type in the wire
// payload; client must default to "movie" so the resolver doesn't see
// an empty string.
if got.Parts[0].MediaType != "movie" {
t.Errorf("parts[0].MediaType = %q, want movie", got.Parts[0].MediaType)
}
if got.Parts[2].MediaType != "movie" {
t.Errorf("parts[2].MediaType = %q (omitted in payload), want movie default", got.Parts[2].MediaType)
}
if got.Parts[0].Title != "The Avengers" {
t.Errorf("parts[0].Title = %q, want The Avengers", got.Parts[0].Title)
}
if got.Parts[0].ReleaseDate != "2012-04-25" {
t.Errorf("parts[0].ReleaseDate = %q, want 2012-04-25", got.Parts[0].ReleaseDate)
}
}
func TestGetCollectionRejectsNonPositiveID(t *testing.T) {
client := NewClient("test-key", 1000)
if _, err := client.GetCollection(context.Background(), 0); err == nil {
t.Fatal("expected error on id=0")
}
if _, err := client.GetCollection(context.Background(), -7); err == nil {
t.Fatal("expected error on negative id")
}
}
func TestGetExternalIDs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/movie/123/external_ids" {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("api_key"); got != "test-key" {
t.Fatalf("api_key query = %q, want test-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"imdb_id": "tt0133093",
"tvdb_id": 12345
}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
ids, err := client.GetExternalIDs(context.Background(), "movie", 123)
if err != nil {
t.Fatalf("GetExternalIDs returned error: %v", err)
}
if ids == nil {
t.Fatal("GetExternalIDs returned nil ids")
}
if ids.IMDbID != "tt0133093" {
t.Fatalf("IMDbID = %q, want tt0133093", ids.IMDbID)
}
if ids.TVDBID != 12345 {
t.Fatalf("TVDBID = %d, want 12345", ids.TVDBID)
}
}