Files
arr/crates/arr-meta/tests/tmdb.rs
T
Miguel Palhas cbb21418a4 feat(meta): rich detail calls for movies and series
movie_detail/series_detail fetch credits, videos and external ids in
one upstream request via append_to_response; cast is truncated to the
top 10 billed in the crate and a trailer is chosen by rule (official
YouTube trailer, any YouTube trailer, YouTube teaser, none).
movie_videos/series_videos serve #144's search-row chip from the
videos endpoint alone. Everything rides the existing 24h cache.
2026-08-23 21:33:17 +01:00

809 lines
27 KiB
Rust

//! 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 _, 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());
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 - 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);
}