//! 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"); 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); }