test(e2e): cross-process harness over the §12 seams (#66)
This commit was merged in pull request #66.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user