From e9c055675ba48fc451e587c9cfc6d112afe133e7 Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Sat, 22 Aug 2026 21:26:16 +0100 Subject: [PATCH] =?UTF-8?q?test(e2e):=20cross-process=20harness=20over=20t?= =?UTF-8?q?he=20=C2=A712=20seams=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/e2e.yml | 7 + Cargo.lock | 7 + Justfile | 13 +- crates/arr-api/src/state.rs | 3 +- crates/arr-daemon/src/config.rs | 32 ++ crates/arr-daemon/src/main.rs | 19 +- crates/arr-e2e/Cargo.toml | 9 +- crates/arr-e2e/fixtures/prowlarr-caps.xml | 9 + .../arr-e2e/fixtures/prowlarr-indexers.json | 4 + .../fixtures/prowlarr-movie-search.xml | 24 ++ .../arr-e2e/fixtures/tmdb-configuration.json | 8 + crates/arr-e2e/fixtures/tmdb-movie-dune.json | 83 +++++ crates/arr-e2e/src/lib.rs | 352 +++++++++++++++--- crates/arr-e2e/tests/e2e.rs | 172 +++++++++ 14 files changed, 681 insertions(+), 61 deletions(-) create mode 100644 crates/arr-e2e/fixtures/prowlarr-caps.xml create mode 100644 crates/arr-e2e/fixtures/prowlarr-indexers.json create mode 100644 crates/arr-e2e/fixtures/prowlarr-movie-search.xml create mode 100644 crates/arr-e2e/fixtures/tmdb-configuration.json create mode 100644 crates/arr-e2e/fixtures/tmdb-movie-dune.json create mode 100644 crates/arr-e2e/tests/e2e.rs diff --git a/.gitea/workflows/e2e.yml b/.gitea/workflows/e2e.yml index 6a6275b..7175982 100644 --- a/.gitea/workflows/e2e.yml +++ b/.gitea/workflows/e2e.yml @@ -11,7 +11,9 @@ on: - 'crates/arr-e2e/**' - 'crates/arr-dl/**' - 'crates/arr-indexer/**' + - 'crates/arr-meta/**' - 'crates/arr-probe/**' + - 'crates/arr-api/**' - 'crates/arr-daemon/**' - '.gitea/workflows/e2e.yml' @@ -68,6 +70,11 @@ jobs: curl -LsSf https://get.nexte.st/latest/linux \ | tar zxf - -C "$HOME/.cargo/bin" + - name: build daemon + # The harness spawns this binary; building it here keeps the compile + # out of the first test's boot window. + run: cargo build -p arr-daemon --bin arr + - name: e2e env: TRANSMISSION_RPC_URL: http://transmission:9091/transmission/rpc diff --git a/Cargo.lock b/Cargo.lock index 96373d3..17849bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,8 +125,15 @@ name = "arr-e2e" version = "0.1.0" dependencies = [ "arr-dl", + "arr-indexer", + "arr-meta", + "chrono", + "reqwest", + "serde_json", + "tempfile", "tokio", "uuid", + "wiremock", ] [[package]] diff --git a/Justfile b/Justfile index d2986c2..3aec3cc 100644 --- a/Justfile +++ b/Justfile @@ -27,10 +27,21 @@ deps: test: cargo nextest run --workspace --exclude arr-e2e -# End-to-end tests. Needs a Transmission container; not part of the push gate. +# End-to-end tests. Needs a Transmission container (`just e2e-up`); not part +# of the push gate. Never point this at the production Transmission LXC. e2e: cargo nextest run -p arr-e2e +# Throwaway local Transmission for `just e2e`, mirroring the CI service. +e2e-up: + docker run --rm -d --name arr-e2e-transmission \ + -e PUID=1000 -e PGID=1000 -p 9091:9091 \ + linuxserver/transmission:latest + +# Stop and discard the local Transmission container. +e2e-down: + docker stop arr-e2e-transmission + # Frontend lint and typecheck. No-op until the SPA exists (issue #6). Probes # for package.json, not the directory: `just gen-client` writes into web/ and # must not turn the gate on before there is a project to check. diff --git a/crates/arr-api/src/state.rs b/crates/arr-api/src/state.rs index 0030cd5..76ea5fd 100644 --- a/crates/arr-api/src/state.rs +++ b/crates/arr-api/src/state.rs @@ -52,7 +52,8 @@ impl Upstreams { self } - /// Point TMDB somewhere other than the real API. Tests only. + /// Point TMDB somewhere other than the real API. Tests and the + /// `ARR_TMDB_URL` e2e seam only. #[must_use] pub fn with_tmdb_url(mut self, url: String) -> Self { self.tmdb_url = url; diff --git a/crates/arr-daemon/src/config.rs b/crates/arr-daemon/src/config.rs index afceee6..3b4c1ab 100644 --- a/crates/arr-daemon/src/config.rs +++ b/crates/arr-daemon/src/config.rs @@ -21,6 +21,7 @@ pub const ENV_PROWLARR_URL: &str = "ARR_PROWLARR_URL"; pub const ENV_PROWLARR_API_KEY: &str = "ARR_PROWLARR_API_KEY"; pub const ENV_TRANSMISSION_URL: &str = "ARR_TRANSMISSION_URL"; pub const ENV_TMDB_API_KEY: &str = "ARR_TMDB_API_KEY"; +pub const ENV_TMDB_URL: &str = "ARR_TMDB_URL"; pub const ENV_JELLYFIN_URL: &str = "ARR_JELLYFIN_URL"; pub const ENV_JELLYFIN_API_KEY: &str = "ARR_JELLYFIN_API_KEY"; pub const ENV_NTFY_URL: &str = "ARR_NTFY_URL"; @@ -89,6 +90,7 @@ pub struct EnvOverrides { pub prowlarr_api_key: Option, pub transmission_url: Option, pub tmdb_api_key: Option, + pub tmdb_url: Option, pub jellyfin_url: Option, pub jellyfin_api_key: Option, pub ntfy_url: Option, @@ -105,6 +107,7 @@ impl EnvOverrides { prowlarr_api_key: std::env::var(ENV_PROWLARR_API_KEY).ok(), transmission_url: std::env::var(ENV_TRANSMISSION_URL).ok(), tmdb_api_key: std::env::var(ENV_TMDB_API_KEY).ok(), + tmdb_url: std::env::var(ENV_TMDB_URL).ok(), jellyfin_url: std::env::var(ENV_JELLYFIN_URL).ok(), jellyfin_api_key: std::env::var(ENV_JELLYFIN_API_KEY).ok(), ntfy_url: std::env::var(ENV_NTFY_URL).ok(), @@ -122,6 +125,9 @@ pub struct Config { pub prowlarr_api_key: Option, pub transmission_url: String, pub tmdb_api_key: Option, + /// E2E seam only, env-only. `None` means the client's built-in TMDB + /// address; DESIGN.md §10 keeps the real URL out of configuration. + pub tmdb_url: Option, pub jellyfin_url: String, pub jellyfin_api_key: Option, pub ntfy_url: String, @@ -170,6 +176,7 @@ impl Config { .or(file.transmission_url) .unwrap_or_else(|| DEFAULT_TRANSMISSION_URL.to_string()), tmdb_api_key: env.tmdb_api_key, + tmdb_url: env.tmdb_url, jellyfin_url: env .jellyfin_url .or(file.jellyfin_url) @@ -277,6 +284,31 @@ prowlarr_url = "http://prowlarr.internal:9696" assert_eq!(config.jellyfin_api_key.as_deref(), Some("secret-3")); } + #[test] + fn tmdb_url_is_an_env_only_seam() { + let config = Config::resolve(EnvOverrides::default()).unwrap(); + assert_eq!(config.tmdb_url, None); + + let env = EnvOverrides { + tmdb_url: Some("http://127.0.0.1:9/3".into()), + ..EnvOverrides::default() + }; + let config = Config::resolve(env).unwrap(); + assert_eq!(config.tmdb_url.as_deref(), Some("http://127.0.0.1:9/3")); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("arr.toml"); + std::fs::write(&path, "tmdb_url = \"http://127.0.0.1:9/3\"\n").unwrap(); + let env = EnvOverrides { + config_file: Some(path.to_string_lossy().into_owned()), + ..EnvOverrides::default() + }; + assert!(matches!( + Config::resolve(env), + Err(ConfigError::TomlDecode(_)) + )); + } + #[test] fn a_secret_in_the_config_file_is_a_parse_error() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 6d9b587..2004fc2 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -83,15 +83,20 @@ async fn run() -> Result<(), Error> { // needs its own TMDB client for `movie/lookup`. let mut compat = CompatState::new(database.clone()); if let Some(key) = &config.tmdb_api_key { - compat = compat.with_tmdb(Arc::new(TmdbClient::new(key)?)); + let mut tmdb = TmdbClient::builder(key.clone()); + if let Some(tmdb_url) = &config.tmdb_url { + tmdb = tmdb.base_url(tmdb_url.clone()); + } + compat = compat.with_tmdb(Arc::new(tmdb.build()?)); } - let state = AppState::new( - Upstreams::new(config.prowlarr_url, config.transmission_url) - .with_prowlarr_api_key(config.prowlarr_api_key) - .with_tmdb_api_key(config.tmdb_api_key), - )? - .with_database(database); + let mut upstreams = Upstreams::new(config.prowlarr_url, config.transmission_url) + .with_prowlarr_api_key(config.prowlarr_api_key) + .with_tmdb_api_key(config.tmdb_api_key); + if let Some(tmdb_url) = config.tmdb_url { + upstreams = upstreams.with_tmdb_url(tmdb_url); + } + let state = AppState::new(upstreams)?.with_database(database); let app = arr_api::router(state) .merge(arr_compat::router(compat)) diff --git a/crates/arr-e2e/Cargo.toml b/crates/arr-e2e/Cargo.toml index 3f2fe6d..aab3fac 100644 --- a/crates/arr-e2e/Cargo.toml +++ b/crates/arr-e2e/Cargo.toml @@ -7,10 +7,17 @@ repository.workspace = true publish = false [dependencies] +reqwest = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true } +wiremock = { workspace = true } [dev-dependencies] arr-dl = { workspace = true } -tokio = { workspace = true } +arr-indexer = { workspace = true } +arr-meta = { workspace = true } +chrono = { workspace = true } uuid = { workspace = true } [lints] diff --git a/crates/arr-e2e/fixtures/prowlarr-caps.xml b/crates/arr-e2e/fixtures/prowlarr-caps.xml new file mode 100644 index 0000000..07a9bc7 --- /dev/null +++ b/crates/arr-e2e/fixtures/prowlarr-caps.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/arr-e2e/fixtures/prowlarr-indexers.json b/crates/arr-e2e/fixtures/prowlarr-indexers.json new file mode 100644 index 0000000..6f9c609 --- /dev/null +++ b/crates/arr-e2e/fixtures/prowlarr-indexers.json @@ -0,0 +1,4 @@ +[ + { "id": 3, "name": "Alpha Tracker", "enable": true }, + { "id": 42, "name": "Disabled Tracker", "enable": false } +] diff --git a/crates/arr-e2e/fixtures/prowlarr-movie-search.xml b/crates/arr-e2e/fixtures/prowlarr-movie-search.xml new file mode 100644 index 0000000..9a54a08 --- /dev/null +++ b/crates/arr-e2e/fixtures/prowlarr-movie-search.xml @@ -0,0 +1,24 @@ + + + + Alpha Tracker + + <![CDATA[Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX]]> + alpha-redacted-1 + https://indexer.invalid/download/alpha-redacted-1 + Sat, 16 Mar 2024 12:30:00 +0000 + + + + + + <![CDATA[Dune.Part.Two.2024.1080p.WEB-DL.DDP5.1.H.264-FLUX]]> + alpha-redacted-2 + https://indexer.invalid/download/alpha-redacted-2 + Sat, 16 Mar 2024 12:28:00 +0000 + + + + + + diff --git a/crates/arr-e2e/fixtures/tmdb-configuration.json b/crates/arr-e2e/fixtures/tmdb-configuration.json new file mode 100644 index 0000000..d76f61e --- /dev/null +++ b/crates/arr-e2e/fixtures/tmdb-configuration.json @@ -0,0 +1,8 @@ +{ + "images": { + "base_url": "http://image.tmdb.org/t/p/", + "secure_base_url": "https://image.tmdb.org/t/p/", + "poster_sizes": ["w92", "w154", "w185", "w342", "w500", "w780", "original"] + }, + "change_keys": ["adult", "air_date", "also_known_as"] +} diff --git a/crates/arr-e2e/fixtures/tmdb-movie-dune.json b/crates/arr-e2e/fixtures/tmdb-movie-dune.json new file mode 100644 index 0000000..2d98628 --- /dev/null +++ b/crates/arr-e2e/fixtures/tmdb-movie-dune.json @@ -0,0 +1,83 @@ +{ + "adult": false, + "backdrop_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg", + "budget": 190000000, + "genres": [{ "id": 878, "name": "Science Fiction" }, { "id": 12, "name": "Adventure" }], + "homepage": "https://www.dunemovie.com", + "id": 693134, + "imdb_id": "tt15239678", + "origin_country": ["US"], + "original_language": "en", + "original_title": "Dune: Part Two", + "overview": "Follow the mythic journey of Paul Atreides as he unites with Chani and the Fremen while on a path of revenge against the conspirators who destroyed his family.", + "popularity": 234.53, + "poster_path": "/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg", + "production_countries": [ + { "iso_3166_1": "US", "name": "United States of America" }, + { "iso_3166_1": "CA", "name": "Canada" } + ], + "release_date": "2024-02-27", + "revenue": 711844358, + "runtime": 167, + "spoken_languages": [{ "english_name": "English", "iso_639_1": "en", "name": "English" }], + "status": "Released", + "tagline": "Long live the fighters.", + "title": "Dune: Part Two", + "video": false, + "vote_average": 8.157, + "vote_count": 6104, + "release_dates": { + "results": [ + { + "iso_3166_1": "US", + "release_dates": [ + { + "certification": "PG-13", + "descriptors": [], + "iso_639_1": "", + "note": "", + "release_date": "2024-03-01T00:00:00.000Z", + "type": 3 + }, + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "", + "release_date": "2024-04-16T00:00:00.000Z", + "type": 4 + }, + { + "certification": "PG-13", + "descriptors": [], + "iso_639_1": "", + "note": "", + "release_date": "2024-05-14T00:00:00.000Z", + "type": 5 + } + ] + }, + { + "iso_3166_1": "PT", + "release_dates": [ + { + "certification": "M/12", + "descriptors": [], + "iso_639_1": "", + "note": "", + "release_date": "2024-02-28T00:00:00.000Z", + "type": 3 + }, + { + "certification": "", + "descriptors": [], + "iso_639_1": "", + "note": "", + "release_date": "2024-04-10T00:00:00.000Z", + "type": 4 + } + ] + } + ] + } +} diff --git a/crates/arr-e2e/src/lib.rs b/crates/arr-e2e/src/lib.rs index f509fc0..1f12eff 100644 --- a/crates/arr-e2e/src/lib.rs +++ b/crates/arr-e2e/src/lib.rs @@ -1,65 +1,315 @@ -//! arr-e2e — cross-process integration tests. See DESIGN.md §12. +//! arr-e2e — cross-process integration test harness. See DESIGN.md §12. //! -//! Tests here talk to a real Transmission container and to `wiremock` stubs -//! standing in for Prowlarr and TMDB. Never a live tracker. +//! 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)] -mod tests { - use std::path::PathBuf; +use {arr_dl as _, arr_indexer as _, arr_meta as _, chrono as _, uuid as _}; - use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; - #[tokio::test] - async fn transmission_add_list_and_remove() { - let endpoint = std::env::var("TRANSMISSION_RPC_URL") - .unwrap_or_else(|_| "http://127.0.0.1:9091/transmission/rpc".into()); - let client = TransmissionClient::new(&endpoint).expect("valid endpoint"); - let name = format!("arr-e2e-{}", uuid::Uuid::new_v4()); - let metainfo = torrent_with_name(&name); - let download_dir = PathBuf::from("/tmp/arr-e2e"); - let request = || AddTorrent { - source: TorrentSource::Metainfo(metainfo.clone()), - label: "movies-main".into(), - download_dir: download_dir.clone(), - seed_ratio_limit: 1.5, - seed_idle_limit_minutes: 60, - }; +use wiremock::matchers::{header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; - let first = client.add_torrent(request()).await.expect("add torrent"); - assert!(!first.was_duplicate); +/// The API key both fakes expect, and the one [`Daemon::spawn`] configures. +pub const API_KEY: &str = "arr-e2e-key"; - let torrents = client.list_torrents().await.expect("list torrents"); - let listed = torrents - .iter() - .find(|torrent| torrent.id == first.id) - .expect("added torrent is authoritative in list"); - assert_eq!(listed.name, name); - assert_eq!(listed.hash, first.hash); - assert_eq!(listed.download_dir, download_dir); - assert_eq!(listed.labels, ["movies-main"]); - assert!((0.0..=1.0).contains(&listed.progress)); +/// How long the daemon has to answer its first health check. +const BOOT_TIMEOUT: Duration = Duration::from_secs(30); - client - .remove_torrent(first.id, false) - .await - .expect("remove without data"); +/// 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"); +} - let second = client.add_torrent(request()).await.expect("add again"); - client - .remove_torrent(second.id, true) - .await - .expect("remove with data"); +/// The enabled indexer in [`fixtures::PROWLARR_INDEXERS`]. +pub const INDEXER_ID: i64 = 3; - let torrents = client.list_torrents().await.expect("list after remove"); - assert!(torrents.iter().all(|torrent| torrent.id != second.id)); +/// 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 } } - fn torrent_with_name(name: &str) -> Vec { - let piece_hash = [0_u8; 20]; - let mut bytes = format!("d4:infod6:lengthi1e4:name{}:{name}", name.len()).into_bytes(); - bytes.extend_from_slice(b"12:piece lengthi16384e6:pieces20:"); - bytes.extend_from_slice(&piece_hash); - bytes.extend_from_slice(b"ee"); - bytes + /// 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() +} diff --git a/crates/arr-e2e/tests/e2e.rs b/crates/arr-e2e/tests/e2e.rs new file mode 100644 index 0000000..ba135ba --- /dev/null +++ b/crates/arr-e2e/tests/e2e.rs @@ -0,0 +1,172 @@ +//! Cross-process end-to-end tests over the DESIGN.md §12 seams: the real +//! `arr` binary, a real Transmission container, and `wiremock` fakes serving +//! recorded Prowlarr and TMDB responses. Never a live tracker. +//! +//! The full add-to-imported run lands here once the reconcile loop (#21), +//! grab pipeline (#22) and import pipeline (#23) exist; today these prove the +//! harness itself end to end. + +// The harness library links these; this test target does not use them all +// directly. Same per-target quirk as arr-meta's tests. +use {tempfile as _, wiremock as _}; + +use std::path::PathBuf; + +use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; +use arr_e2e::{ + transmission_url, Daemon, FakeProwlarr, FakeTmdb, API_KEY, INDEXER_ID, TMDB_MOVIE_ID, +}; +use arr_indexer::{ProwlarrClient, SearchRequest}; +use arr_meta::TmdbClient; +use chrono::NaiveDate; + +/// The daemon boots as its own process against faked Prowlarr and TMDB plus a +/// real Transmission, reports every upstream healthy, and serves the movie +/// API over the wire. +#[tokio::test] +async fn daemon_runs_against_fakes_and_a_real_transmission() { + let prowlarr = FakeProwlarr::start().await; + let tmdb = FakeTmdb::start().await; + let daemon = Daemon::spawn(&prowlarr.url(), &tmdb.url(), &transmission_url()).await; + let base = daemon.base_url(); + + let health = daemon.health().await; + assert_eq!(health["status"], "ok", "health report: {health}"); + assert_eq!(health["prowlarr"]["status"], "ok"); + assert_eq!(health["transmission"]["status"], "ok"); + assert_eq!(health["tmdb"]["status"], "ok"); + + // Movie CRUD across the process boundary, against the migrated seed data. + let movie: serde_json::Value = reqwest::Client::new() + .post(format!("{base}/api/movies")) + .json(&serde_json::json!({ + "tmdb_id": TMDB_MOVIE_ID, "title": "Dune: Part Two", "year": 2024, + "original_language": "en", "root_id": 1 + })) + .send() + .await + .expect("create movie") + .json() + .await + .expect("movie json"); + let movie_id = movie["id"].as_i64().expect("movie id"); + assert_eq!(movie["wanted"], true); + + // The manual-search trigger is accepted; its consumer is the reconcile + // loop (#21), so no releases materialise yet. + let search = reqwest::Client::new() + .post(format!("{base}/api/movies/{movie_id}/search")) + .send() + .await + .expect("trigger search"); + assert_eq!(search.status(), reqwest::StatusCode::ACCEPTED); + + let releases: Vec = + reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) + .await + .expect("list releases") + .json() + .await + .expect("releases json"); + assert!(releases.is_empty()); +} + +/// The recorded fixtures satisfy the real Prowlarr and TMDB clients, so a +/// pipeline wired to the fakes exercises the same parsing production does. +#[tokio::test] +async fn recorded_fixtures_satisfy_the_real_clients() { + let prowlarr = FakeProwlarr::start().await; + let client = ProwlarrClient::new(prowlarr.url(), API_KEY).expect("prowlarr client"); + + let indexers = client.indexers().await.expect("enumerate indexers"); + assert_eq!(indexers.len(), 1, "disabled indexers are filtered out"); + let indexer = &indexers[0]; + assert_eq!(indexer.id, INDEXER_ID); + assert!(indexer.capabilities_error.is_none()); + assert!(indexer.capabilities.movie.supports_id_search()); + + let releases = client + .search_indexer( + INDEXER_ID, + &SearchRequest::Movie { + imdb_id: "tt15239678".into(), + }, + ) + .await + .expect("movie search"); + assert_eq!(releases.len(), 2); + assert_eq!( + releases[0].name, + "Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX" + ); + assert_eq!(releases[0].size, Some(23_622_320_128)); + assert_eq!(releases[0].seeders, Some(128)); + + let tmdb = FakeTmdb::start().await; + let movie = TmdbClient::builder(API_KEY) + .base_url(tmdb.url()) + .build() + .expect("tmdb client") + .movie(TMDB_MOVIE_ID) + .await + .expect("movie lookup"); + assert_eq!(movie.title, "Dune: Part Two"); + assert_eq!(movie.original_language, "en"); + assert_eq!(movie.imdb_id.as_deref(), Some("tt15239678")); + let after_digital = NaiveDate::from_ymd_opt(2024, 5, 1).expect("valid date"); + assert!(movie.is_digitally_released(after_digital)); +} + +/// Torrent lifecycle against the real Transmission container: RPC semantics +/// are the seam most likely to surprise (DESIGN.md §12). +#[tokio::test] +async fn transmission_add_list_and_remove() { + let client = TransmissionClient::new(&transmission_url()).expect("valid endpoint"); + let name = format!("arr-e2e-{}", uuid::Uuid::new_v4()); + let metainfo = torrent_with_name(&name); + let download_dir = PathBuf::from("/tmp/arr-e2e"); + let request = || AddTorrent { + source: TorrentSource::Metainfo(metainfo.clone()), + label: "movies-main".into(), + download_dir: download_dir.clone(), + seed_ratio_limit: 1.5, + seed_idle_limit_minutes: 60, + }; + + let first = client.add_torrent(request()).await.expect("add torrent"); + assert!(!first.was_duplicate); + + let torrents = client.list_torrents().await.expect("list torrents"); + let listed = torrents + .iter() + .find(|torrent| torrent.id == first.id) + .expect("added torrent is authoritative in list"); + assert_eq!(listed.name, name); + assert_eq!(listed.hash, first.hash); + assert_eq!(listed.download_dir, download_dir); + assert_eq!(listed.labels, ["movies-main"]); + assert!((0.0..=1.0).contains(&listed.progress)); + + client + .remove_torrent(first.id, false) + .await + .expect("remove without data"); + + let second = client.add_torrent(request()).await.expect("add again"); + client + .remove_torrent(second.id, true) + .await + .expect("remove with data"); + + let torrents = client.list_torrents().await.expect("list after remove"); + assert!(torrents.iter().all(|torrent| torrent.id != second.id)); +} + +fn torrent_with_name(name: &str) -> Vec { + let piece_hash = [0_u8; 20]; + let mut bytes = format!("d4:infod6:lengthi1e4:name{}:{name}", name.len()).into_bytes(); + bytes.extend_from_slice(b"12:piece lengthi16384e6:pieces20:"); + bytes.extend_from_slice(&piece_hash); + bytes.extend_from_slice(b"ee"); + bytes +}