Files
arr/crates/arr-meta/tests/tmdb.rs
T
2026-08-23 18:59:35 +01:00

532 lines
18 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};
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}");
}
/// §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);
}