//! 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 and consumed by the daemon // (issue #107), so the fixture's releases materialise across the // process boundary. 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 = wait_for_releases(base, movie_id).await; assert_eq!(releases.len(), 2); assert!(releases.iter().any(|release| release["name"] .as_str() .is_some_and(|name| name.contains("Dune.Part.Two.2024.2160p.WEB-DL")))); } /// The manual search runs on a background task, not inline with the HTTP /// response, so give it a moment to land instead of racing it. async fn wait_for_releases(base: &str, movie_id: i64) -> Vec { for _ in 0..50 { let releases: Vec = reqwest::get(format!("{base}/api/movies/{movie_id}/releases")) .await .expect("list releases") .json() .await .expect("releases json"); if !releases.is_empty() { return releases; } tokio::time::sleep(std::time::Duration::from_millis(100)).await; } panic!("no releases appeared after the manual search"); } /// 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. The /// real service must report an unfinished torrent before its seed limit clears. #[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("/tmp/arr-e2e"); 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 .remove_torrent(added.id, true) .await .expect("cleanup"); } 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 }