//! arr-e2e — cross-process integration test harness. See DESIGN.md §12. //! //! Tests built on this harness talk to a real Transmission container and to //! `wiremock` fakes serving recorded responses for Prowlarr and TMDB. Never a //! live tracker: trackers rate-limit and it would leak credentials into CI. //! //! The harness boots the actual `arr` binary as a child process, so a test //! crosses the same process boundary production does: bootstrap config from //! the environment, migrations against a fresh database, the HTTP API over a //! real socket. // Dev-dependencies belong to the integration tests; the lib's own test build // links them without using them. Same per-target quirk as arr-meta. #[cfg(test)] use {arr_dl as _, arr_indexer as _, arr_meta as _, chrono as _, uuid as _}; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::OnceLock; use std::time::{Duration, Instant}; use wiremock::matchers::{header, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; /// The API key both fakes expect, and the one [`Daemon::spawn`] configures. pub const API_KEY: &str = "arr-e2e-key"; /// How long the daemon has to answer its first health check. const BOOT_TIMEOUT: Duration = Duration::from_secs(30); /// Recorded responses served by the fakes, kept in `fixtures/` so a captured /// real response can replace one without touching code. pub mod fixtures { pub const PROWLARR_INDEXERS: &str = include_str!("../fixtures/prowlarr-indexers.json"); pub const PROWLARR_CAPS: &str = include_str!("../fixtures/prowlarr-caps.xml"); pub const PROWLARR_MOVIE_SEARCH: &str = include_str!("../fixtures/prowlarr-movie-search.xml"); pub const TMDB_CONFIGURATION: &str = include_str!("../fixtures/tmdb-configuration.json"); pub const TMDB_MOVIE_DUNE: &str = include_str!("../fixtures/tmdb-movie-dune.json"); } /// The enabled indexer in [`fixtures::PROWLARR_INDEXERS`]. pub const INDEXER_ID: i64 = 3; /// The movie in [`fixtures::TMDB_MOVIE_DUNE`]. pub const TMDB_MOVIE_ID: u32 = 693_134; /// The Transmission RPC endpoint tests should use: `TRANSMISSION_RPC_URL` /// when set (CI points it at the service container), a local container's /// default port otherwise. Never the production LXC. #[must_use] pub fn transmission_url() -> String { std::env::var("TRANSMISSION_RPC_URL") .unwrap_or_else(|_| "http://127.0.0.1:9091/transmission/rpc".into()) } /// A `wiremock` Prowlarr: the REST indexer enumeration plus one enabled /// indexer's Torznab endpoint, all serving recorded fixtures. #[derive(Debug)] pub struct FakeProwlarr { server: MockServer, } impl FakeProwlarr { /// Start the fake and mount every recorded route. /// /// # Panics /// /// Panics when the mock server cannot bind a local port. pub async fn start() -> Self { let server = MockServer::start().await; // Health probe. Prowlarr answers /ping without a key. Mock::given(method("GET")) .and(path("/ping")) .respond_with(ResponseTemplate::new(200)) .mount(&server) .await; // REST enumeration, keyed like the real thing. Mock::given(method("GET")) .and(path("/api/v1/indexer")) .and(header("X-Api-Key", API_KEY)) .respond_with( ResponseTemplate::new(200) .set_body_raw(fixtures::PROWLARR_INDEXERS, "application/json"), ) .mount(&server) .await; // The enabled indexer's Torznab endpoint. let torznab = format!("/{INDEXER_ID}/api"); Mock::given(method("GET")) .and(path(torznab.clone())) .and(query_param("apikey", API_KEY)) .and(query_param("t", "caps")) .respond_with( ResponseTemplate::new(200).set_body_raw(fixtures::PROWLARR_CAPS, "application/xml"), ) .mount(&server) .await; for operation in ["movie", "search"] { Mock::given(method("GET")) .and(path(torznab.clone())) .and(query_param("apikey", API_KEY)) .and(query_param("t", operation)) .respond_with( ResponseTemplate::new(200) .set_body_raw(fixtures::PROWLARR_MOVIE_SEARCH, "application/xml"), ) .mount(&server) .await; } Self { server } } /// Base URL, in the shape `ARR_PROWLARR_URL` and `ProwlarrClient` expect. #[must_use] pub fn url(&self) -> String { self.server.uri() } } /// A `wiremock` TMDB serving recorded fixtures under the real `/3` prefix. #[derive(Debug)] pub struct FakeTmdb { server: MockServer, } impl FakeTmdb { /// Start the fake and mount every recorded route. /// /// # Panics /// /// Panics when the mock server cannot bind a local port. pub async fn start() -> Self { let server = MockServer::start().await; // Health probe. Mock::given(method("GET")) .and(path("/3/configuration")) .respond_with( ResponseTemplate::new(200) .set_body_raw(fixtures::TMDB_CONFIGURATION, "application/json"), ) .mount(&server) .await; Mock::given(method("GET")) .and(path(format!("/3/movie/{TMDB_MOVIE_ID}"))) .respond_with( ResponseTemplate::new(200) .set_body_raw(fixtures::TMDB_MOVIE_DUNE, "application/json"), ) .mount(&server) .await; Self { server } } /// Base URL including the `/3` prefix, in the shape `ARR_TMDB_URL` and /// `TmdbClient` expect. #[must_use] pub fn url(&self) -> String { format!("{}/3", self.server.uri()) } } /// The `arr` binary running as a child process against a fresh temporary /// database and media root. Killed on drop. #[derive(Debug)] pub struct Daemon { child: Child, base_url: String, // Owns the database and media root for the child's lifetime. _dir: tempfile::TempDir, } impl Daemon { /// Build and boot the daemon against the given upstreams, waiting until /// its health endpoint answers. /// /// # Panics /// /// Panics when the binary cannot be built or spawned, or when the health /// endpoint does not answer within the boot timeout. pub async fn spawn(prowlarr_url: &str, tmdb_url: &str, transmission_url: &str) -> Self { let dir = tempfile::tempdir().expect("create daemon tempdir"); let media_root = dir.path().join("media"); std::fs::create_dir(&media_root).expect("create media root"); let port = free_port(); let base_url = format!("http://127.0.0.1:{port}"); let child = Command::new(daemon_binary()) .env_remove("ARR_CONFIG_FILE") .env("ARR_BIND_ADDR", format!("127.0.0.1:{port}")) .env("ARR_DATABASE_PATH", dir.path().join("arr.db")) .env("ARR_MEDIA_ROOT", &media_root) .env("ARR_PROWLARR_URL", prowlarr_url) .env("ARR_PROWLARR_API_KEY", API_KEY) .env("ARR_TMDB_URL", tmdb_url) .env("ARR_TMDB_API_KEY", API_KEY) .env("ARR_TRANSMISSION_URL", transmission_url) .stdin(Stdio::null()) .spawn() .expect("spawn arr daemon"); let mut daemon = Self { child, base_url, _dir: dir, }; daemon.wait_until_healthy().await; daemon } /// The daemon's API base URL, such as `http://127.0.0.1:41234`. #[must_use] pub fn base_url(&self) -> &str { &self.base_url } /// Fetch `/api/health` and return the parsed report. /// /// # Panics /// /// Panics when the request fails or the body is not JSON. pub async fn health(&self) -> serde_json::Value { reqwest::get(format!("{}/api/health", self.base_url)) .await .expect("health request") .json() .await .expect("health body is JSON") } async fn wait_until_healthy(&mut self) { let deadline = Instant::now() + BOOT_TIMEOUT; let url = format!("{}/api/health", self.base_url); loop { if let Some(status) = self.child.try_wait().expect("poll daemon") { panic!("arr daemon exited during boot: {status}"); } if let Ok(response) = reqwest::get(&url).await { if response.status().is_success() { return; } } assert!( Instant::now() < deadline, "arr daemon did not become healthy within {BOOT_TIMEOUT:?}" ); tokio::time::sleep(Duration::from_millis(100)).await; } } } impl Drop for Daemon { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } /// Build the `arr` binary once per test run and return its path. /// /// `cargo nextest run -p arr-e2e` compiles only this crate's test binaries, /// so the daemon is built here, and cargo itself reports where the executable /// landed — no guessing at the target layout the test runner uses. Cargo's /// own file locking serialises this against concurrent test processes, and a /// warm target directory makes it a no-op. fn daemon_binary() -> &'static PathBuf { static BINARY: OnceLock = OnceLock::new(); BINARY.get_or_init(|| { let mut build = Command::new(env!("CARGO")); build .args(["build", "-p", "arr-daemon", "--bin", "arr"]) .args(["--message-format", "json-render-diagnostics"]) .stderr(Stdio::inherit()); // The test process carries the per-crate vars cargo set for *this* // crate (CARGO_MANIFEST_DIR, CARGO_PKG_*). Inherited into a nested // cargo they poison its fingerprints, so every shell build and every // test run rebuild the world from each other. Strip them. for (key, _) in std::env::vars_os() { let poisoned = key.to_str().is_some_and(|key| { key.starts_with("CARGO_PKG_") || key.starts_with("CARGO_MANIFEST_") }); if poisoned { build.env_remove(key); } } let output = build.output().expect("run cargo build"); assert!(output.status.success(), "cargo build -p arr-daemon failed"); let stdout = String::from_utf8(output.stdout).expect("cargo build output is UTF-8"); stdout .lines() .filter_map(|line| serde_json::from_str::(line).ok()) .find(|message| { message["reason"] == "compiler-artifact" && message["target"]["name"] == "arr" }) .and_then(|message| message["executable"].as_str().map(PathBuf::from)) .expect("cargo build reported the arr executable") }) } /// A port the daemon can bind. Released before the child starts, so a /// collision is possible but vanishingly unlikely within one test run. fn free_port() -> u16 { std::net::TcpListener::bind("127.0.0.1:0") .expect("bind an ephemeral port") .local_addr() .expect("read the bound address") .port() }