test(e2e): cross-process harness over the §12 seams (#66)
ci / rust (push) Failing after 1m36s
ci / web (push) Successful in 26s
e2e / e2e (push) Failing after 59s

This commit was merged in pull request #66.
This commit is contained in:
2026-08-22 21:26:16 +01:00
parent c7b57b13e6
commit e9c055675b
14 changed files with 681 additions and 61 deletions
+2 -1
View File
@@ -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;
+32
View File
@@ -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<String>,
pub transmission_url: Option<String>,
pub tmdb_api_key: Option<String>,
pub tmdb_url: Option<String>,
pub jellyfin_url: Option<String>,
pub jellyfin_api_key: Option<String>,
pub ntfy_url: Option<String>,
@@ -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<String>,
pub transmission_url: String,
pub tmdb_api_key: Option<String>,
/// 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<String>,
pub jellyfin_url: String,
pub jellyfin_api_key: Option<String>,
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();
+12 -7
View File
@@ -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))
+8 -1
View File
@@ -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]
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<caps>
<server version="1.0" title="Alpha Tracker" />
<searching>
<search available="yes" supportedParams="q" />
<tv-search available="yes" supportedParams="q,season,ep,tvdbid,imdbid" />
<movie-search available="yes" supportedParams="q,imdbid,tmdbid" />
</searching>
</caps>
@@ -0,0 +1,4 @@
[
{ "id": 3, "name": "Alpha Tracker", "enable": true },
{ "id": 42, "name": "Disabled Tracker", "enable": false }
]
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed">
<channel>
<title>Alpha Tracker</title>
<item>
<title><![CDATA[Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX]]></title>
<guid isPermaLink="false">alpha-redacted-1</guid>
<link>https://indexer.invalid/download/alpha-redacted-1</link>
<pubDate>Sat, 16 Mar 2024 12:30:00 +0000</pubDate>
<enclosure url="https://indexer.invalid/download/alpha-redacted-1" length="23622320128" type="application/x-bittorrent" />
<torznab:attr name="size" value="23622320128" />
<torznab:attr name="seeders" value="128" />
</item>
<item>
<title><![CDATA[Dune.Part.Two.2024.1080p.WEB-DL.DDP5.1.H.264-FLUX]]></title>
<guid isPermaLink="false">alpha-redacted-2</guid>
<link>https://indexer.invalid/download/alpha-redacted-2</link>
<pubDate>Sat, 16 Mar 2024 12:28:00 +0000</pubDate>
<enclosure url="https://indexer.invalid/download/alpha-redacted-2" length="8589934592" type="application/x-bittorrent" />
<torznab:attr name="size" value="8589934592" />
<torznab:attr name="seeders" value="412" />
</item>
</channel>
</rss>
@@ -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"]
}
@@ -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
}
]
}
]
}
}
+301 -51
View File
@@ -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<u8> {
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<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()
}
+172
View File
@@ -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<serde_json::Value> =
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<u8> {
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
}