532 lines
18 KiB
Rust
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);
|
|
}
|