Files
silo-server/internal/metadata/tmdb/client_test.go
54e184df85 feat(requests): enforce per-profile rating limits in discovery (#505)
* feat(requests): enforce per-profile rating limits in discovery

- Resolve each profile's max content rating and filter discovery, detail, and browse results against it, failing closed on missing ratings
- Reject request submissions for titles above the viewer's ceiling
- Add TMDB GetCertification backed by release_dates/content_ratings with a long-lived cache and singleflight
- Push certification.lte to TMDB for studio/network/genre browse as a cost pre-filter
- Backfill restricted section pages from a fixed window of TMDB pages to keep carousels populated and pagination stable

* fix(requests): address discovery rating review findings

- Preserve backfill overflow: sections use plain TMDB cursor semantics
  plus an additive next_page field instead of fixed windows, so an early
  stop never drops allowed titles from unconsumed pages (bit hardest at
  permissive R/TV-MA ceilings).
- Bound cold-path cost: DiscoverAll backfills at most 2 TMDB pages per
  section (vs 5 for a direct section request), capping worst-case cold
  certification hydration at 240 lookups instead of 600.
- Keep the TMDB prefilter a superset: rank-3 ceilings now push down
  certification.lte=NC-17/TV-MA rather than R, so titles the local
  ladder allows can't vanish upstream unrecoverably.
- Fail closed on foreign certifications: enforcement-path lookups use
  new US-only pickers (a Canadian PG no longer reads as US PG), while
  the display path keeps its any-country fallback. US multi-entry
  disagreements prefer the theatrical/real rating over festival NR.
- Detach shared certification fetches from the first caller's context
  (WithoutCancel + 30s bound) so one disconnecting client can't fail
  the singleflight result for concurrent waiters.
- Advertise enforcement via rating_restrictions_enforced on
  /requests/status so clients can feature-detect instead of
  version-sniffing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(requests): harden rating enforcement per second review pass

- GetDetail gates on the US-only enforcement certification (cached
  GetCertification) instead of the display rating, whose any-country
  fallback let a foreign "PG" pass the US ladder.
- pickUSMovieCertification takes the strictest recognized US rating when
  multiple release entries disagree ([PG, R] -> R); entry order is not
  meaningful and enforcement must not admit a title on its most lenient
  certificate.
- Certification singleflight uses DoChan so a canceled caller returns
  ctx.Err() immediately instead of blocking up to 30s on the detached
  shared fetch (which still completes for surviving waiters).
- Viewer rating ceiling resolves once per request and threads through
  discover/browse/detail enrichment (enrichPageWithCeiling); DiscoverAll
  drops from 12 scope resolutions per load to 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 22:34:30 -04:00

1141 lines
35 KiB
Go

package tmdb
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
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)
}
}
func TestGetCertificationUsesSubResourceEndpoints(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/movie/603/release_dates":
// Type 3 (theatrical) US entry wins over the earlier NR entry.
_, _ = w.Write([]byte(`{"results":[
{"iso_3166_1":"DE","release_dates":[{"certification":"16","type":3}]},
{"iso_3166_1":"US","release_dates":[{"certification":"NR","type":2},{"certification":"R","type":3}]}
]}`))
case "/tv/1396/content_ratings":
_, _ = w.Write([]byte(`{"results":[
{"iso_3166_1":"DE","rating":"16"},
{"iso_3166_1":"US","rating":"TV-MA"}
]}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
movieCert, err := client.GetCertification(context.Background(), "movie", 603)
if err != nil {
t.Fatalf("movie GetCertification returned error: %v", err)
}
if movieCert != "R" {
t.Fatalf("movie certification = %q, want R", movieCert)
}
// Both Silo-facing "series" and TMDB-facing "tv" resolve TV titles.
for _, mediaType := range []string{"series", "tv"} {
tvCert, err := client.GetCertification(context.Background(), mediaType, 1396)
if err != nil {
t.Fatalf("tv GetCertification(%q) returned error: %v", mediaType, err)
}
if tvCert != "TV-MA" {
t.Fatalf("tv certification (%q) = %q, want TV-MA", mediaType, tvCert)
}
}
if _, err := client.GetCertification(context.Background(), "bogus", 1); err == nil {
t.Fatal("GetCertification accepted invalid media type")
}
}
// TestGetCertificationIgnoresForeignFallback pins the enforcement-path
// contract: a title with only foreign certifications resolves to "" (fail
// closed on the US ladder), unlike the display path, which falls back to any
// country. A Canadian "PG" must not read as US PG.
func TestGetCertificationIgnoresForeignFallback(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/movie/1/release_dates":
_, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"CA","release_dates":[{"certification":"PG","type":3}]}]}`))
case "/tv/2/content_ratings":
_, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"AU","rating":"PG"}]}`))
case "/movie/3/release_dates":
// Festival NR + theatrical PG-13 in the US: the rated entry wins.
_, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"US","release_dates":[{"certification":"NR","type":2},{"certification":"PG-13","type":3}]}]}`))
case "/movie/4/release_dates":
// Two US theatrical entries that disagree (PG re-release + R):
// enforcement must take the STRICTEST, not the first.
_, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"US","release_dates":[{"certification":"PG","type":3},{"certification":"R","type":3}]}]}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
if cert, err := client.GetCertification(context.Background(), "movie", 1); err != nil || cert != "" {
t.Fatalf("foreign-only movie cert = %q, %v; want empty", cert, err)
}
if cert, err := client.GetCertification(context.Background(), "tv", 2); err != nil || cert != "" {
t.Fatalf("foreign-only tv cert = %q, %v; want empty", cert, err)
}
if cert, err := client.GetCertification(context.Background(), "movie", 3); err != nil || cert != "PG-13" {
t.Fatalf("US theatrical cert = %q, %v; want PG-13", cert, err)
}
if cert, err := client.GetCertification(context.Background(), "movie", 4); err != nil || cert != "R" {
t.Fatalf("disagreeing US certs = %q, %v; want strictest (R)", cert, err)
}
}
// TestGetCertificationCallerStopsWaitingOnCancel pins the DoChan behavior: a
// caller whose context dies mid-flight returns promptly with ctx.Err() while
// the shared detached fetch continues for the surviving waiters.
func TestGetCertificationCallerStopsWaitingOnCancel(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-release
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"US","rating":"TV-PG"}]}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
_, err := client.GetCertification(ctx, "tv", 1396)
errCh <- err
}()
<-started
cancel()
select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled caller error = %v, want context.Canceled", err)
}
case <-time.After(2 * time.Second):
t.Fatal("cancelled caller still blocked on the shared fetch")
}
// The detached fetch is still completable for a fresh caller.
close(release)
if cert, err := client.GetCertification(context.Background(), "tv", 1396); err != nil || cert != "TV-PG" {
t.Fatalf("surviving caller cert = %q, %v; want TV-PG", cert, err)
}
}
// TestGetCertificationSurvivesFirstCallerCancellation pins the singleflight
// context fix: the shared fetch runs detached from the initiating caller, so
// that caller disconnecting must not poison the result for concurrent waiters.
func TestGetCertificationSurvivesFirstCallerCancellation(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-release
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"US","rating":"TV-PG"}]}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
firstCtx, cancelFirst := context.WithCancel(context.Background())
firstErr := make(chan error, 1)
go func() {
_, err := client.GetCertification(firstCtx, "tv", 1396)
firstErr <- err
}()
<-started
secondCert := make(chan string, 1)
secondErr := make(chan error, 1)
go func() {
cert, err := client.GetCertification(context.Background(), "tv", 1396)
secondCert <- cert
secondErr <- err
}()
// Let the second caller pile onto the in-flight singleflight key, then
// kill the initiating caller's context before the upstream responds.
time.Sleep(50 * time.Millisecond)
cancelFirst()
time.Sleep(50 * time.Millisecond)
close(release)
if err := <-secondErr; err != nil {
t.Fatalf("second caller failed after first caller cancelled: %v", err)
}
if cert := <-secondCert; cert != "TV-PG" {
t.Fatalf("second caller cert = %q, want TV-PG", cert)
}
<-firstErr // first caller may or may not error; just reap it
}
func TestGetCertificationCachesIncludingEmpty(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Content-Type", "application/json")
// No certification anywhere: the common case, which must be cached
// too or fail-closed filtering refetches it on every page load.
_, _ = w.Write([]byte(`{"results":[]}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
for i := 0; i < 3; i++ {
cert, err := client.GetCertification(context.Background(), "movie", 42)
if err != nil {
t.Fatalf("GetCertification returned error: %v", err)
}
if cert != "" {
t.Fatalf("certification = %q, want empty", cert)
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1 (empty result must be cached)", got)
}
}
func TestGetCertificationSingleflightsConcurrentCallers(t *testing.T) {
var calls atomic.Int32
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
<-release
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"iso_3166_1":"US","rating":"TV-PG"}]}`))
}))
defer server.Close()
client := NewClient("test-key", 1000)
client.SetBaseURL(server.URL)
const callers = 8
results := make(chan string, callers)
errs := make(chan error, callers)
for i := 0; i < callers; i++ {
go func() {
cert, err := client.GetCertification(context.Background(), "tv", 1396)
results <- cert
errs <- err
}()
}
// Give the goroutines time to pile onto the singleflight, then release
// the one in-flight upstream request.
time.Sleep(50 * time.Millisecond)
close(release)
for i := 0; i < callers; i++ {
if err := <-errs; err != nil {
t.Fatalf("GetCertification returned error: %v", err)
}
if cert := <-results; cert != "TV-PG" {
t.Fatalf("certification = %q, want TV-PG", cert)
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("upstream calls = %d, want 1 (singleflight)", got)
}
}