Files
arr/crates/arr-e2e/tests/e2e.rs
T
Miguel Palhas 59c5f4fae7
ci / web (pull_request) Successful in 36s
ci / rust (pull_request) Successful in 1m24s
e2e / e2e (pull_request) Failing after 1m4s
feat(daemon): reap seeded torrents
2026-08-22 23:53:19 +01:00

242 lines
8.8 KiB
Rust

//! 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));
}
/// Transmission, not arr's import state, owns the done-seeding boundary. A
/// zero ratio makes the transition quick while still exercising real RPC.
#[tokio::test]
async fn transmission_reports_done_only_after_its_seed_limit() {
let client = TransmissionClient::new(&transmission_url()).expect("valid endpoint");
let name = format!("arr-e2e-reaper-{}", uuid::Uuid::new_v4());
let download_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../target/e2e-downloads")
.canonicalize()
.expect("canonical download directory");
std::fs::write(download_dir.join(&name), [0_u8]).expect("seed torrent payload");
let metainfo = torrent_with_name(&name);
let request = |ratio| AddTorrent {
source: TorrentSource::Metainfo(metainfo.clone()),
label: "movies-main".into(),
download_dir: download_dir.clone(),
seed_ratio_limit: ratio,
seed_idle_limit_minutes: 60,
};
let added = client
.add_torrent(request(100.0))
.await
.expect("add torrent");
let before = client
.list_torrents()
.await
.expect("list before limit")
.into_iter()
.find(|torrent| torrent.id == added.id)
.expect("torrent before limit");
assert!(
!before.is_finished,
"the reaper must leave this torrent alone"
);
client
.add_torrent(request(0.0))
.await
.expect("lower duplicate's ratio limit");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let torrent = client
.list_torrents()
.await
.expect("list after limit")
.into_iter()
.find(|torrent| torrent.id == added.id)
.expect("torrent after limit");
if torrent.is_finished {
break;
}
assert!(
std::time::Instant::now() < deadline,
"seed limit was not enforced: {torrent:?}"
);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
client
.remove_torrent(added.id, true)
.await
.expect("cleanup");
}
fn torrent_with_name(name: &str) -> Vec<u8> {
// SHA-1 of the single zero byte written by the seeding-boundary test.
let piece_hash = [
0x5b, 0xa9, 0x3c, 0x9d, 0xb0, 0xcf, 0xf9, 0x3f, 0x52, 0xb5, 0x21, 0xd7, 0x42, 0x0e, 0x43,
0xf6, 0xed, 0xa2, 0x78, 0x4f,
];
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
}