package providers import ( "context" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) // tmdbSrv answers both search endpoints from one handler, so a test can watch // what each side did without two servers to keep in sync. func tmdbSrv(t *testing.T, fn func(endpoint string, r *http.Request) (string, int)) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, status := fn(r.URL.Path, r) w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write([]byte(body)) })) t.Cleanup(srv.Close) return srv } func TestTMDBMapsMovieAndTV(t *testing.T) { // Each endpoint records only its own request: the two handlers run on // different goroutines, so a shared capture would be a race. var gotMovieKey, gotMovieQuery, gotMovieYear, gotTVKey, gotTVYear string srv := tmdbSrv(t, func(endpoint string, r *http.Request) (string, int) { q := r.URL.Query() switch endpoint { case "/search/movie": gotMovieKey, gotMovieQuery, gotMovieYear = q.Get("api_key"), q.Get("query"), q.Get("year") return `{"results":[ {"id": 603, "title": "The Matrix", "overview": "A hacker learns the truth.", "release_date": "1999-03-31", "poster_path": "/matrix.jpg", "popularity": 88.1} ]}`, http.StatusOK case "/search/tv": gotTVKey, gotTVYear = q.Get("api_key"), q.Get("first_air_date_year") return `{"results":[ {"id": 1399, "name": "Game of Thrones", "overview": "Noble houses fight for a throne.", "first_air_date": "2011-04-17", "poster_path": "/got.jpg", "popularity": 120.5} ]}`, http.StatusOK } return `{"results":[]}`, http.StatusOK }) p := NewTMDB("secret-key") p.BaseURL = srv.URL cands, err := p.Search(context.Background(), Query{Title: "The Matrix", Year: 1999}) if err != nil { t.Fatalf("Search: %v", err) } if len(cands) != 2 { t.Fatalf("got %d candidates, want 2", len(cands)) } // Merged by TMDB popularity: the show (120.5) outranks the film (88.1). show, movie := cands[0], cands[1] if show.Title != "Game of Thrones" || movie.Title != "The Matrix" { t.Fatalf("merge order wrong: %q then %q", show.Title, movie.Title) } if movie.Provider != "tmdb" || movie.ExternalID != "movie/603" { t.Errorf("movie id = %q (provider %q), want movie/603", movie.ExternalID, movie.Provider) } if movie.Year != 1999 { t.Errorf("movie Year = %d, want 1999", movie.Year) } if movie.Description != "A hacker learns the truth." { t.Errorf("movie Description = %q", movie.Description) } if movie.CoverURL != "https://image.tmdb.org/t/p/w500/matrix.jpg" { t.Errorf("movie CoverURL = %q", movie.CoverURL) } if show.ExternalID != "tv/1399" || show.Year != 2011 { t.Errorf("show id/year = %q/%d, want tv/1399/2011", show.ExternalID, show.Year) } if show.CoverURL != "https://image.tmdb.org/t/p/w500/got.jpg" { t.Errorf("show CoverURL = %q", show.CoverURL) } if gotMovieKey != "secret-key" || gotTVKey != "secret-key" { t.Errorf("api_key = movie %q, tv %q, want secret-key", gotMovieKey, gotTVKey) } if gotMovieQuery != "The Matrix" { t.Errorf("movie query = %q", gotMovieQuery) } // The two namespaces spell the year filter differently; both must get it. if gotMovieYear != "1999" || gotTVYear != "1999" { t.Errorf("year filter = movie %q, tv %q, want 1999", gotMovieYear, gotTVYear) } } func TestTMDBSearchesConcurrently(t *testing.T) { var arrived int32 var overlapped atomic.Bool both := make(chan struct{}) srv := tmdbSrv(t, func(_ string, _ *http.Request) (string, int) { // Neither endpoint answers until both requests are in flight, so a // sequential implementation deadlocks here rather than passing. if atomic.AddInt32(&arrived, 1) == 2 { overlapped.Store(true) close(both) } select { case <-both: case <-time.After(2 * time.Second): } return `{"results":[{"id": 1, "title": "Anything", "popularity": 1}]}`, http.StatusOK }) p := NewTMDB("secret-key") p.BaseURL = srv.URL cands, err := p.Search(context.Background(), Query{Title: "Anything"}) if err != nil { t.Fatalf("Search: %v", err) } if len(cands) != 2 { t.Errorf("got %d candidates, want one per endpoint", len(cands)) } if n := atomic.LoadInt32(&arrived); n != 2 { t.Errorf("endpoints hit %d times, want 2", n) } if !overlapped.Load() { t.Error("the two searches did not overlap: they ran one after the other") } } // TMDB keeps films and television in one numeric id space: film 603 and show // 603 are different works. A bare id would write them identically into // provider_ids, so the namespace has to be part of what gets stored. func TestTMDBExternalIDIsNamespaced(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // The same id from both endpoints is the collision under test. if strings.Contains(r.URL.Path, "/movie") { _, _ = w.Write([]byte(`{"results":[{"id":603,"title":"Film 603","release_date":"1999-03-31","popularity":10}]}`)) return } _, _ = w.Write([]byte(`{"results":[{"id":603,"name":"Show 603","first_air_date":"2011-04-17","popularity":20}]}`)) })) defer srv.Close() p := NewTMDB("secret-key") p.BaseURL = srv.URL cands, err := p.Search(context.Background(), Query{Title: "603"}) if err != nil { t.Fatalf("Search: %v", err) } if len(cands) != 2 { t.Fatalf("got %d candidates, want 2", len(cands)) } if cands[0].ExternalID == cands[1].ExternalID { t.Fatalf("a film and a show sharing id 603 both produced ExternalID %q", cands[0].ExternalID) } } func TestTMDBOneEndpointFailureKeepsOtherResults(t *testing.T) { // The movie endpoint only fails once the TV response has been written, so // this is an ordering guarantee, not a race the test happens to win. tvDone := make(chan struct{}) srv := tmdbSrv(t, func(endpoint string, _ *http.Request) (string, int) { switch endpoint { case "/search/tv": close(tvDone) return `{"results":[{"id": 1396, "name": "Breaking Bad", "popularity": 200}]}`, http.StatusOK default: select { case <-tvDone: case <-time.After(2 * time.Second): } return `{"status_message":"boom"}`, http.StatusInternalServerError } }) p := NewTMDB("secret-key") p.BaseURL = srv.URL cands, err := p.Search(context.Background(), Query{Title: "Breaking Bad"}) if err != nil { t.Fatalf("Search: %v (one failing endpoint must not discard the other)", err) } if len(cands) != 1 || cands[0].Title != "Breaking Bad" { t.Fatalf("got %+v, want just the TV match", cands) } } func TestTMDBEmptyResultsAreNotAnError(t *testing.T) { srv := tmdbSrv(t, func(_ string, _ *http.Request) (string, int) { return `{"page":1,"results":[],"total_pages":1,"total_results":0}`, http.StatusOK }) p := NewTMDB("secret-key") p.BaseURL = srv.URL cands, err := p.Search(context.Background(), Query{Title: "Nothing At All Like This"}) if err != nil { t.Fatalf("Search: %v", err) } if len(cands) != 0 { t.Errorf("got %d candidates, want 0", len(cands)) } } func TestTMDBWithoutUsableTitleDoesNotCallOut(t *testing.T) { var hits int32 srv := tmdbSrv(t, func(_ string, _ *http.Request) (string, int) { atomic.AddInt32(&hits, 1) return `{"results":[{"id": 1, "title": "Unwanted", "popularity": 1}]}`, http.StatusOK }) p := NewTMDB("secret-key") p.BaseURL = srv.URL for _, q := range []Query{{}, {Title: " "}} { cands, err := p.Search(context.Background(), q) if err != nil { t.Fatalf("Search(%+v): %v", q, err) } if len(cands) != 0 { t.Errorf("Search(%+v) returned %d candidates, want 0", q, len(cands)) } } if n := atomic.LoadInt32(&hits); n != 0 { t.Errorf("TMDB was called %d times with no usable title, want 0", n) } } func TestTMDBWithoutKeyDoesNothing(t *testing.T) { p := NewTMDB("") cands, err := p.Search(context.Background(), Query{Title: "The Matrix"}) if err != nil { t.Fatalf("Search: %v", err) } if len(cands) != 0 { t.Errorf("got %d candidates with no key, want 0", len(cands)) } }