242 lines
8.8 KiB
Rust
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
|
|
}
|