Files
arr/crates/arr-e2e/src/lib.rs
T
naps62-yolo e9c055675b
ci / rust (push) Failing after 1m36s
ci / web (push) Successful in 26s
e2e / e2e (push) Failing after 59s
test(e2e): cross-process harness over the §12 seams (#66)
2026-08-22 21:26:16 +01:00

316 lines
11 KiB
Rust

//! 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<PathBuf> = 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::<serde_json::Value>(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()
}