316 lines
11 KiB
Rust
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()
|
|
}
|