//! TMDB client tests. Everything runs against `wiremock` — DESIGN.md §12 rules //! out live calls, which would rate-limit and leak a key into CI. // Same per-target quirk as in `lib.rs`: an integration test links the library's // dependencies without using them directly. use {reqwest as _, serde as _, serde_json as _, tempfile as _, thiserror as _, tracing as _}; use std::time::Duration; use arr_meta::{Error, TmdbClient, UNTITLED_EPISODE}; use chrono::NaiveDate; use wiremock::matchers::{header_exists, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; const SEARCH_DUNE: &str = include_str!("fixtures/search_dune.json"); const MOVIE_DUNE: &str = include_str!("fixtures/movie_dune.json"); const MOVIE_CIDADE_DE_DEUS: &str = include_str!("fixtures/movie_cidade_de_deus.json"); const MOVIE_UNRELEASED: &str = include_str!("fixtures/movie_unreleased.json"); const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only.json"); const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json"); const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json"); const FIND_IMDB_SERIES: &str = include_str!("fixtures/find_imdb_series.json"); const MOVIE_DETAIL_DUNE: &str = include_str!("fixtures/movie_detail_dune.json"); const MOVIE_VIDEOS_DUNE: &str = include_str!("fixtures/movie_videos_dune.json"); const SERIES_DETAIL_BLUEY: &str = include_str!("fixtures/series_detail_bluey.json"); const SERIES_VIDEOS_BLUEY: &str = include_str!("fixtures/series_videos_bluey.json"); fn client(server: &MockServer) -> TmdbClient { TmdbClient::builder("test-key") .base_url(format!("{}/3", server.uri())) .build() .expect("client builds") } fn date(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } async fn mount_movie(server: &MockServer, tmdb_id: u32, body: &str) { Mock::given(method("GET")) .and(path(format!("/3/movie/{tmdb_id}"))) .respond_with(ResponseTemplate::new(200).set_body_string(body)) .mount(server) .await; } #[tokio::test] async fn search_parses_results_and_normalises_empty_strings() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/search/movie")) .respond_with(ResponseTemplate::new(200).set_body_string(SEARCH_DUNE)) .mount(&server) .await; let results = client(&server) .search_movies("dune", None) .await .expect("search succeeds"); assert_eq!(results.len(), 2); let released = &results[0]; assert_eq!(released.tmdb_id, 693_134); assert_eq!(released.title, "Dune: Part Two"); assert_eq!(released.original_language, "en"); assert_eq!(released.release_date, Some(date(2024, 2, 27))); assert_eq!(released.year(), Some(2024)); assert!(released.poster_path.is_some()); // TMDB sends "" rather than null for a date it does not have, and for an // overview it does not have either. let announced = &results[1]; assert_eq!(announced.release_date, None); assert_eq!(announced.year(), None); assert_eq!(announced.overview, None); assert_eq!(announced.poster_path, None); } #[tokio::test] async fn search_sends_the_query_year_and_api_key() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/search/movie")) .and(query_param("query", "dune")) .and(query_param("year", "2024")) .and(query_param("api_key", "test-key")) .and(query_param("include_adult", "false")) .respond_with(ResponseTemplate::new(200).set_body_string(SEARCH_DUNE)) .expect(1) .mount(&server) .await; client(&server) .search_movies(" dune ", Some(2024)) .await .expect("search succeeds"); } #[tokio::test] async fn movie_detail_asks_for_release_dates_in_one_call() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/693134")) .and(query_param("append_to_response", "release_dates")) .and(header_exists("user-agent")) .respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DUNE)) .expect(1) .mount(&server) .await; client(&server) .movie(693_134) .await .expect("lookup succeeds"); } #[tokio::test] async fn movie_detail_carries_the_policy_relevant_fields() { let server = MockServer::start().await; mount_movie(&server, 693_134, MOVIE_DUNE).await; let movie = client(&server) .movie(693_134) .await .expect("lookup succeeds"); assert_eq!(movie.tmdb_id, 693_134); assert_eq!(movie.imdb_id.as_deref(), Some("tt15239678")); assert_eq!(movie.title, "Dune: Part Two"); assert_eq!(movie.original_title, "Dune: Part Two"); assert_eq!(movie.original_language, "en"); assert_eq!(movie.origin_countries, vec!["US".to_owned()]); assert_eq!(movie.release_date, Some(date(2024, 2, 27))); assert_eq!(movie.runtime, Some(167)); assert_eq!(movie.status, "Released"); assert_eq!(movie.year(), Some(2024)); } /// §5.2: a Brazilian film's own soundtrack must pass the pt-BR dub rule, which /// only works if the origin country survives alongside the bare `pt` code. #[tokio::test] async fn brazilian_film_keeps_its_language_and_origin_country() { let server = MockServer::start().await; mount_movie(&server, 598, MOVIE_CIDADE_DE_DEUS).await; let movie = client(&server).movie(598).await.expect("lookup succeeds"); assert_eq!(movie.original_language, "pt"); assert_eq!(movie.origin_countries, vec!["BR".to_owned()]); } /// §6.2. Earliest digital date anywhere: the release existing in one region is /// a release that exists on the indexers. #[tokio::test] async fn digital_release_is_the_earliest_across_every_country() { let server = MockServer::start().await; mount_movie(&server, 693_134, MOVIE_DUNE).await; let movie = client(&server) .movie(693_134) .await .expect("lookup succeeds"); // US digital is 2024-04-16, PT digital is 2024-04-10. Theatrical (type 3) // and physical (type 5) entries must not be mistaken for it. assert_eq!(movie.digital_release, Some(date(2024, 4, 10))); assert!(movie.is_digitally_released(date(2024, 4, 10))); assert!(movie.is_digitally_released(date(2026, 1, 1))); assert!(!movie.is_digitally_released(date(2024, 4, 9))); } /// §6.2, the case that costs Radarr the most queries: no digital date means no /// targeted search at all. #[tokio::test] async fn theatrical_only_movie_has_no_digital_release() { let server = MockServer::start().await; mount_movie(&server, 1_022_789, MOVIE_THEATRICAL_ONLY).await; let movie = client(&server) .movie(1_022_789) .await .expect("lookup succeeds"); assert_eq!(movie.release_date, Some(date(2026, 8, 1))); assert_eq!(movie.digital_release, None); assert!(!movie.is_digitally_released(date(2026, 8, 22))); } #[tokio::test] async fn announced_digital_date_does_not_count_until_it_arrives() { let server = MockServer::start().await; mount_movie(&server, 1_211_073, MOVIE_FUTURE_DIGITAL).await; let movie = client(&server) .movie(1_211_073) .await .expect("lookup succeeds"); assert_eq!(movie.digital_release, Some(date(2026, 11, 20))); assert!(!movie.is_digitally_released(date(2026, 8, 22))); assert!(movie.is_digitally_released(date(2026, 11, 20))); } #[tokio::test] async fn unreleased_movie_has_no_dates_and_no_imdb_id() { let server = MockServer::start().await; mount_movie(&server, 1_156_593, MOVIE_UNRELEASED).await; let movie = client(&server) .movie(1_156_593) .await .expect("lookup succeeds"); assert_eq!(movie.release_date, None); assert_eq!(movie.digital_release, None); assert_eq!(movie.imdb_id, None); assert_eq!(movie.runtime, None); assert!(movie.origin_countries.is_empty()); // TMDB reports vote_average: 0 for an unrated title — absence, not zero // (#156), so it is normalised away like any other zero-valued null. assert_eq!(movie.vote_average, None); assert!(!movie.is_digitally_released(date(2026, 8, 22))); } /// §8: metadata refresh is a daily tick, so repeated lookups within a day must /// not become repeated requests. #[tokio::test] async fn responses_are_served_from_the_cache() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DUNE)) .expect(1) .mount(&server) .await; let client = client(&server); let first = client.movie(693_134).await.expect("lookup succeeds"); let second = client.movie(693_134).await.expect("cached lookup succeeds"); assert_eq!(first, second); } #[tokio::test] async fn cache_entries_expire_and_can_be_cleared() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DUNE)) .expect(3) .mount(&server) .await; let client = TmdbClient::builder("test-key") .base_url(format!("{}/3", server.uri())) .cache_ttl(Duration::from_millis(50)) .build() .expect("client builds"); client.movie(693_134).await.expect("lookup succeeds"); tokio::time::sleep(Duration::from_millis(120)).await; client.movie(693_134).await.expect("lookup succeeds"); client.clear_cache(); client.movie(693_134).await.expect("lookup succeeds"); } #[tokio::test] async fn different_searches_do_not_share_a_cache_entry() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/search/movie")) .respond_with(ResponseTemplate::new(200).set_body_string(SEARCH_DUNE)) .expect(3) .mount(&server) .await; let client = client(&server); client.search_movies("dune", None).await.expect("succeeds"); client .search_movies("dune", Some(2024)) .await .expect("succeeds"); client.search_movies("bluey", None).await.expect("succeeds"); // A repeat of the first is the cached one. client.search_movies("dune", None).await.expect("succeeds"); } #[tokio::test] async fn unknown_id_is_not_found() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/1")) .respond_with(ResponseTemplate::new(404).set_body_string( r#"{"success":false,"status_code":34,"status_message":"The resource you requested could not be found."}"#, )) .mount(&server) .await; let error = client(&server).movie(1).await.expect_err("404 is an error"); match error { Error::NotFound { resource } => assert_eq!(resource, "movie/1"), other => panic!("expected NotFound, got {other:?}"), } } #[tokio::test] async fn rejected_key_is_unauthorized() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with(ResponseTemplate::new(401).set_body_string( r#"{"success":false,"status_code":7,"status_message":"Invalid API key: You must be granted a valid key."}"#, )) .mount(&server) .await; let error = client(&server) .movie(693_134) .await .expect_err("401 is an error"); assert!(matches!(error, Error::Unauthorized), "got {error:?}"); } #[tokio::test] async fn rate_limit_surfaces_retry_after() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with( ResponseTemplate::new(429) .insert_header("retry-after", "13") .set_body_string(r#"{"status_code":25,"status_message":"Your request count is over the allowed limit."}"#), ) .mount(&server) .await; let error = client(&server) .movie(693_134) .await .expect_err("429 is an error"); match error { Error::RateLimited { retry_after } => { assert_eq!(retry_after, Some(Duration::from_secs(13))); } other => panic!("expected RateLimited, got {other:?}"), } } /// A TMDB outage must not pin a title into a bad state for a whole day. #[tokio::test] async fn failures_are_not_cached() { let server = MockServer::start().await; let client = client(&server); { let _failing = Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with(ResponseTemplate::new(500).set_body_string("upstream is down")) .expect(1) .mount_as_scoped(&server) .await; let error = client.movie(693_134).await.expect_err("500 is an error"); match error { Error::Unexpected { status, body } => { assert_eq!(status, 500); assert_eq!(body, "upstream is down"); } other => panic!("expected Unexpected, got {other:?}"), } } Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DUNE)) .expect(1) .mount(&server) .await; let movie = client.movie(693_134).await.expect("retry succeeds"); assert_eq!(movie.tmdb_id, 693_134); } /// A body that does not decode must not enter the cache, or one bad response /// becomes a day-long outage for that title. #[tokio::test] async fn malformed_json_is_a_decode_error_and_is_not_cached() { let server = MockServer::start().await; let client = client(&server); { let _garbage = Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with(ResponseTemplate::new(200).set_body_string("{ not json")) .expect(1) .mount_as_scoped(&server) .await; let error = client .movie(693_134) .await .expect_err("garbage is an error"); assert!(matches!(error, Error::Decode(_)), "got {error:?}"); } Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DUNE)) .expect(1) .mount(&server) .await; let movie = client.movie(693_134).await.expect("retry succeeds"); assert_eq!(movie.tmdb_id, 693_134); } /// The cache key is derived from the encoded URL, so a query that happens to /// contain `&year=` cannot collide with the same query plus a real year. #[tokio::test] async fn a_query_containing_separators_does_not_collide_with_a_year() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/search/movie")) .respond_with(ResponseTemplate::new(200).set_body_string(SEARCH_DUNE)) .expect(2) .mount(&server) .await; let client = client(&server); client .search_movies("dune&year=2024", None) .await .expect("succeeds"); client .search_movies("dune", Some(2024)) .await .expect("succeeds"); } /// The API key is appended at send time, so it can never become part of a /// cache key. #[tokio::test] async fn a_query_that_spells_out_the_api_key_is_still_just_a_query() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/search/movie")) .and(query_param("query", "dune&api_key=stolen")) .and(query_param("api_key", "test-key")) .respond_with(ResponseTemplate::new(200).set_body_string(SEARCH_DUNE)) .expect(1) .mount(&server) .await; client(&server) .search_movies("dune&api_key=stolen", None) .await .expect("succeeds"); } #[tokio::test] async fn debug_output_does_not_leak_the_api_key() { let server = MockServer::start().await; let rendered = format!("{:?}", client(&server)); assert!(!rendered.contains("test-key"), "{rendered}"); assert!(rendered.contains("redacted"), "{rendered}"); } /// TMDB leaves an unaired episode's name as `""`. It must not reach the /// library as an empty string — search haystacks, §7.4 filenames and the /// compat shim all treat it as real text (#153) — so it becomes a placeholder /// that a later refresh replaces once TMDB names it. #[tokio::test] async fn season_episode_without_a_name_gets_the_placeholder_title() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/tv/82728/season/1")) .respond_with(ResponseTemplate::new(200).set_body_string( r#"{"season_number": 1, "episodes": [ {"episode_number": 1, "name": "Magic Xylophone", "air_date": "2018-10-01"}, {"episode_number": 2, "name": "", "air_date": null} ]}"#, )) .mount(&server) .await; let season = client(&server) .season(82_728, 1) .await .expect("lookup succeeds"); assert_eq!(season.episodes[0].title, "Magic Xylophone"); assert_eq!(season.episodes[1].title, UNTITLED_EPISODE); } /// §6.1: `t=tvsearch` is addressed by TVDB id, and `/tv/{id}/external_ids` is /// where TMDB keeps the mapping. #[tokio::test] async fn series_external_ids_carries_the_tvdb_id() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/tv/82728/external_ids")) .respond_with(ResponseTemplate::new(200).set_body_string(SERIES_EXTERNAL_IDS)) .expect(1) .mount(&server) .await; let ids = client(&server) .series_external_ids(82_728) .await .expect("lookup succeeds"); assert_eq!(ids.tvdb_id, Some(361_391)); } /// §9.2: a pasted `tt` id must resolve a series the same way it resolves a /// movie. TMDB answers `/find` with both kinds in one response; the `tv_results` /// half is what this reads. #[tokio::test] async fn find_by_imdb_resolves_series() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/find/tt7614372")) .and(query_param("external_source", "imdb_id")) .respond_with(ResponseTemplate::new(200).set_body_string(FIND_IMDB_SERIES)) .expect(1) .mount(&server) .await; let results = client(&server) .find_by_imdb("tt7614372") .await .expect("lookup succeeds"); assert!(results.movies.is_empty()); assert_eq!(results.series.len(), 1); let series = &results.series[0]; assert_eq!(series.tmdb_id, 82_728); assert_eq!(series.title, "Bluey"); assert_eq!(series.year(), Some(2018)); } /// A series TMDB has no TVDB id for stays null rather than zero, so callers /// can tell "unknown" from a real id and fall back to the text query. #[tokio::test] async fn series_without_a_tvdb_id_maps_to_none() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/tv/1/external_ids")) .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "tvdb_id": null}"#)) .mount(&server) .await; let ids = client(&server) .series_external_ids(1) .await .expect("lookup succeeds"); assert_eq!(ids.tvdb_id, None); } // --- detail (#143, §9.6) ---------------------------------------------------- /// A whole detail page costs one upstream request: credits, videos and /// external ids ride along in the same response. #[tokio::test] async fn movie_detail_asks_for_credits_videos_and_external_ids_in_one_call() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/693134")) .and(query_param( "append_to_response", "credits,videos,external_ids", )) .respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE)) .expect(1) .mount(&server) .await; client(&server) .movie_detail(693_134) .await .expect("lookup succeeds"); } #[tokio::test] async fn movie_detail_parses_the_rich_fields() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE)) .mount(&server) .await; let movie = client(&server) .movie_detail(693_134) .await .expect("lookup succeeds"); assert_eq!(movie.tmdb_id, 693_134); assert_eq!( movie.overview.as_deref(), Some("Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators who destroyed his family.") ); assert_eq!(movie.tagline.as_deref(), Some("Long live the fighters.")); assert_eq!(movie.genres.len(), 2); assert_eq!(movie.genres[0].id, 878); assert_eq!(movie.genres[0].name, "Science Fiction"); assert!(movie.poster_path.is_some()); assert!(movie.backdrop_path.is_some()); assert!((movie.vote_average.expect("rated") - 8.152).abs() < f64::EPSILON); assert_eq!(movie.vote_count, 6_249); assert_eq!(movie.homepage.as_deref(), Some("https://www.dunemovie.com")); assert_eq!(movie.status, "Released"); assert_eq!(movie.runtime, Some(167)); assert_eq!(movie.imdb_id.as_deref(), Some("tt15239678")); } /// §9.6: cast is the top 10 billed, truncated in the crate so no caller has to /// remember to. The fixture carries 12 entries; only the first 10 by `order` /// survive. #[tokio::test] async fn movie_detail_cast_is_truncated_to_ten_by_order() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE)) .mount(&server) .await; let movie = client(&server) .movie_detail(693_134) .await .expect("lookup succeeds"); assert_eq!(movie.cast.len(), 10); assert_eq!(movie.cast[0].tmdb_id, 5_530); assert_eq!(movie.cast[0].name, "Timothée Chalamet"); assert_eq!(movie.cast[0].character, "Paul Atreides"); assert_eq!(movie.cast[0].order, 0); // Truncation keeps the lowest `order` values, not the first rows sent. assert_eq!(movie.cast[9].name, "Souheila Yacoub"); assert!(movie.cast.iter().all(|member| member.tmdb_id != 10_952)); } /// §9.6: an official `YouTube` trailer, then any `YouTube` trailer, then a /// `YouTube` teaser. The fixture puts a fan trailer and a Vimeo entry before /// the official one; neither wins. #[tokio::test] async fn movie_detail_picks_the_official_youtube_trailer() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE)) .mount(&server) .await; let movie = client(&server) .movie_detail(693_134) .await .expect("lookup succeeds"); let trailer = movie.trailer.expect("a trailer is chosen"); assert_eq!(trailer.key, "Way9Dexny3w"); assert_eq!(trailer.site, "YouTube"); assert_eq!(trailer.kind, "Trailer"); assert!(trailer.official); } #[tokio::test] async fn series_detail_parses_the_rich_fields() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/tv/82728")) .and(query_param( "append_to_response", "credits,videos,external_ids", )) .respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY)) .expect(1) .mount(&server) .await; let series = client(&server) .series_detail(82_728) .await .expect("lookup succeeds"); assert_eq!(series.tmdb_id, 82_728); assert_eq!(series.status, "Returning Series"); assert_eq!(series.episode_runtime, Some(7)); assert_eq!(series.tvdb_id, Some(361_391)); assert!(series.tagline.is_none()); assert_eq!(series.genres[0].name, "Animation"); // Cast comes out in TMDB's `order`, not in the order the rows arrived. assert_eq!(series.cast[0].name, "David McCormack"); // No trailer exists for this series, so the teaser tier is what fires. let trailer = series.trailer.expect("the teaser is chosen"); assert_eq!(trailer.key, "bluey_teaser"); } /// Detail goes through the same cache as everything else — two calls, one /// request. #[tokio::test] async fn movie_and_series_detail_are_served_from_the_cache() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/693134")) .respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE)) .expect(1) .mount(&server) .await; Mock::given(method("GET")) .and(path("/3/tv/82728")) .respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY)) .expect(1) .mount(&server) .await; let tmdb = client(&server); let _ = tmdb.movie_detail(693_134).await.expect("succeeds"); let _ = tmdb.movie_detail(693_134).await.expect("cached"); let _ = tmdb.series_detail(82_728).await.expect("succeeds"); let _ = tmdb.series_detail(82_728).await.expect("cached"); } /// #144's search-row chip resolves through the videos-only call: one request, /// no full detail response. #[tokio::test] async fn movie_videos_fetches_only_the_videos_endpoint() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/693134/videos")) .respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_VIDEOS_DUNE)) .expect(1) .mount(&server) .await; let trailer = client(&server) .movie_videos(693_134) .await .expect("lookup succeeds") .expect("an official trailer is chosen"); assert_eq!(trailer.key, "Way9Dexny3w"); } #[tokio::test] async fn series_videos_applies_the_same_selection_rule() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/tv/82728/videos")) .respond_with(ResponseTemplate::new(200).set_body_string(SERIES_VIDEOS_BLUEY)) .expect(1) .mount(&server) .await; let trailer = client(&server) .series_videos(82_728) .await .expect("lookup succeeds") .expect("the teaser is chosen"); assert_eq!(trailer.key, "bluey_teaser"); } /// Only `YouTube` can be a trailer. A title whose videos are all Vimeo has none. #[tokio::test] async fn vimeo_only_video_lists_yield_no_trailer() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/1/videos")) .respond_with( ResponseTemplate::new(200).set_body_string( r#"{"id": 1, "results": [ {"key": "vimeo_one", "site": "Vimeo", "type": "Trailer", "name": "Trailer", "official": true} ]}"#, ), ) .mount(&server) .await; let trailer = client(&server) .movie_videos(1) .await .expect("lookup succeeds"); assert_eq!(trailer, None); } #[tokio::test] async fn empty_video_list_yields_no_trailer() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/3/movie/1/videos")) .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "results": []}"#)) .mount(&server) .await; let trailer = client(&server) .movie_videos(1) .await .expect("lookup succeeds"); assert_eq!(trailer, None); }