Files
arr/crates/arr-compat/src/tests.rs
T
naps62-yolo 38bd4102bb
ci / web (push) Successful in 55s
ci / rust (push) Successful in 1m36s
e2e / e2e (push) Successful in 1m24s
Sonarr v3 shim for Jellyseerr (#84)
2026-08-22 23:33:09 +01:00

517 lines
18 KiB
Rust

//! The shim is only worth anything if Jellyseerr's actual call sequence works,
//! so the tests follow that sequence rather than the module layout: connect,
//! read the folders and profiles, look a film up, add it.
use std::sync::Arc;
use arr_db::Db;
use arr_meta::TmdbClient;
use serde_json::{json, Value};
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::{router, CompatState};
/// A migrated database, the shim serving it, and its base URL. The temporary
/// directory has to outlive the test, so it comes back with the rest.
async fn shim() -> (tempfile::TempDir, Db, String) {
let dir = tempfile::tempdir().expect("tempdir");
let database = Db::connect(dir.path().join("arr.db"))
.await
.expect("connect");
database.migrate().await.expect("migrate");
let base = serve(CompatState::new(database.clone())).await;
(dir, database, base)
}
async fn serve(state: CompatState) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
tokio::spawn(async move {
axum::serve(listener, router(state)).await.expect("serve");
});
format!("http://{address}")
}
async fn get_json(url: String) -> Value {
reqwest::get(url)
.await
.expect("request")
.json()
.await
.expect("json body")
}
/// A TMDB that knows one film, wired the way `arr-meta` calls it.
async fn tmdb_with_dune() -> MockServer {
let server = MockServer::start().await;
let movie = json!({
"id": 693_134,
"imdb_id": "tt15239678",
"title": "Dune: Part Two",
"original_title": "Dune: Part Two",
"original_language": "en",
"origin_country": ["US"],
"release_date": "2024-02-27",
"runtime": 167,
"status": "Released",
"overview": "Paul Atreides unites with Chani.",
"poster_path": "/dune.jpg",
"release_dates": { "results": [] }
});
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(200).set_body_json(movie))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/3/search/movie"))
.and(query_param("query", "dune"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"results": [{
"id": 693_134,
"title": "Dune: Part Two",
"original_title": "Dune: Part Two",
"original_language": "en",
"release_date": "2024-02-27",
"overview": "Paul Atreides unites with Chani.",
"poster_path": "/dune.jpg"
}]
})))
.mount(&server)
.await;
server
}
fn client(server: &MockServer) -> Arc<TmdbClient> {
Arc::new(
TmdbClient::builder("key")
.base_url(format!("{}/3/", server.uri()))
.build()
.expect("tmdb client"),
)
}
#[tokio::test]
async fn status_names_a_radarr_that_serves_v3() {
let (_dir, _database, base) = shim().await;
let body = get_json(format!("{base}/api/v3/system/status")).await;
assert_eq!(body["appName"], "Radarr");
assert_eq!(body["version"], super::RADARR_VERSION);
assert!(
body["version"]
.as_str()
.is_some_and(|version| version.starts_with('5')),
"Jellyseerr branches on the major version: {body}"
);
assert_eq!(body["authentication"], "none");
}
#[tokio::test]
async fn root_folders_and_profiles_are_the_real_roots() {
let (_dir, _database, base) = shim().await;
let folders = get_json(format!("{base}/api/v3/rootfolder")).await;
let folders = folders.as_array().expect("array");
assert_eq!(folders.len(), 4, "§5.1: two roots per media kind");
let paths: Vec<&str> = folders
.iter()
.filter_map(|folder| folder["path"].as_str())
.collect();
assert!(paths.contains(&"/mnt/media/movies/main"));
assert!(paths.contains(&"/mnt/media/movies/kids"));
// §5.1: one fake profile per root, and the ids line up so that the two
// dropdowns in Jellyseerr cannot be set to disagree.
let profiles = get_json(format!("{base}/api/v3/qualityProfile")).await;
let profiles = profiles.as_array().expect("array");
assert_eq!(profiles.len(), folders.len());
for (profile, folder) in profiles.iter().zip(folders) {
assert_eq!(profile["id"], folder["id"]);
}
assert!(profiles
.iter()
.any(|profile| profile["name"] == "Movies — main"));
}
async fn tmdb_with_show() -> MockServer {
let server = MockServer::start().await;
let series = json!({
"id": 1396,
"name": "Breaking Bad",
"original_language": "en",
"first_air_date": "2008-01-20",
"status": "Ended",
"overview": "A chemistry teacher changes careers.",
"poster_path": "/breaking-bad.jpg",
"external_ids": {"tvdb_id": 81189},
"seasons": [
{"season_number": 0, "episode_count": 1},
{"season_number": 1, "episode_count": 2},
{"season_number": 2, "episode_count": 1}
]
});
Mock::given(method("GET"))
.and(path("/3/tv/1396"))
.respond_with(ResponseTemplate::new(200).set_body_json(series))
.mount(&server)
.await;
for (number, episodes) in [
(
0,
json!([{"episode_number": 1, "name": "Special", "air_date": "2009-01-01"}]),
),
(
1,
json!([
{"episode_number": 1, "name": "Pilot", "air_date": "2008-01-20"},
{"episode_number": 2, "name": "Cat's in the Bag...", "air_date": "2008-01-27"}
]),
),
(
2,
json!([{"episode_number": 1, "name": "Seven Thirty-Seven", "air_date": "2009-03-08"}]),
),
] {
Mock::given(method("GET"))
.and(path(format!("/3/tv/1396/season/{number}")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"season_number": number,
"episodes": episodes
})))
.mount(&server)
.await;
}
Mock::given(method("GET"))
.and(path("/3/search/tv"))
.and(query_param("query", "breaking bad"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"results": [{
"id": 1396, "name": "Breaking Bad", "original_language": "en",
"first_air_date": "2008-01-20", "overview": "A chemistry teacher changes careers.",
"poster_path": "/breaking-bad.jpg"
}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/3/find/81189"))
.and(query_param("external_source", "tvdb_id"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"tv_results": [{
"id": 1396, "name": "Breaking Bad", "original_language": "en",
"first_air_date": "2008-01-20", "overview": "A chemistry teacher changes careers.",
"poster_path": "/breaking-bad.jpg"
}]
})))
.mount(&server)
.await;
server
}
#[tokio::test]
async fn a_season_request_marks_only_that_seasons_episodes_wanted() {
let (_dir, database, _unused) = shim().await;
let tmdb = tmdb_with_show().await;
let base = serve(CompatState::new(database.clone()).with_tmdb(client(&tmdb))).await;
let lookup = get_json(format!("{base}/api/v3/series/lookup?term=tmdb:1396")).await;
assert_eq!(lookup[0]["id"], 0);
assert_eq!(lookup[0]["seasons"].as_array().map(Vec::len), Some(3));
assert_eq!(lookup[0]["tvdbId"], 81189);
let by_tvdb = get_json(format!("{base}/api/v3/series/lookup?term=tvdb:81189")).await;
assert_eq!(by_tvdb[0]["tmdbId"], 1396);
assert_eq!(by_tvdb[0]["tvdbId"], 81189);
let response = reqwest::Client::new()
.post(format!("{base}/api/v3/series"))
.json(&json!({
"tmdbId": 1396,
"title": "Breaking Bad",
"year": 2008,
"rootFolderPath": "/mnt/media/tv/main/",
"qualityProfileId": 9999,
"languageProfileId": 9999,
"monitored": false,
"seasons": [
{"seasonNumber": 0, "monitored": false},
{"seasonNumber": 1, "monitored": false},
{"seasonNumber": 2, "monitored": true}
],
"addOptions": {"searchForMissingEpisodes": true}
}))
.send()
.await
.expect("add series");
assert_eq!(response.status(), 201);
let created: Value = response.json().await.expect("json");
assert_eq!(created["rootFolderPath"], "/mnt/media/tv/main");
assert_eq!(created["monitored"], true);
let wanted = sqlx::query_as::<_, (i64, i64, bool)>(
"SELECT seasons.number, episodes.number, episodes.wanted
FROM episodes JOIN seasons ON seasons.id = episodes.season_id
ORDER BY seasons.number, episodes.number",
)
.fetch_all(database.pool())
.await
.expect("wanted set");
assert_eq!(
wanted,
vec![(0, 1, false), (1, 1, false), (1, 2, false), (2, 1, true)]
);
let listed = get_json(format!("{base}/api/v3/series?tmdbId=1396")).await;
assert_eq!(listed[0]["id"], created["id"]);
assert_eq!(listed[0]["seasons"][2]["monitored"], true);
}
#[tokio::test]
async fn tags_are_owners() {
let (_dir, database, base) = shim().await;
sqlx::query("INSERT INTO owners (name, ntfy_topic) VALUES ('miguel', 'arr-miguel')")
.execute(database.pool())
.await
.expect("owner");
let tags = get_json(format!("{base}/api/v3/tag")).await;
assert_eq!(tags[0]["label"], "miguel");
}
#[tokio::test]
async fn a_request_creates_a_wanted_movie_in_the_named_root() {
let (_dir, database, _unused) = shim().await;
let tmdb = tmdb_with_dune().await;
let base = serve(CompatState::new(database.clone()).with_tmdb(client(&tmdb))).await;
let owner_id: i64 = sqlx::query_scalar(
"INSERT INTO owners (name, ntfy_topic) VALUES ('kid', 't') RETURNING id",
)
.fetch_one(database.pool())
.await
.expect("owner");
let response = reqwest::Client::new()
.post(format!("{base}/api/v3/movie"))
.json(&json!({
"tmdbId": 693_134,
"title": "Dune: Part Two",
"year": 2024,
// Trailing separator, because Jellyseerr stores what was typed.
"rootFolderPath": "/mnt/media/movies/kids/",
"qualityProfileId": 9999,
"monitored": true,
"tags": [owner_id],
"addOptions": { "searchForMovie": true }
}))
.send()
.await
.expect("add");
assert_eq!(response.status(), 201);
let created: Value = response.json().await.expect("json");
assert!(created["id"].as_i64().is_some_and(|id| id > 0));
assert_eq!(created["monitored"], true);
assert_eq!(created["rootFolderPath"], "/mnt/media/movies/kids");
assert_eq!(
created["path"], "/mnt/media/movies/kids/Dune: Part Two (2024) [tmdbid-693134]",
"§7.4 layout, provider id included"
);
assert_eq!(created["tags"][0], owner_id);
let row = sqlx::query_as::<_, (i64, bool, String, Option<String>)>(
"SELECT m.root_id, m.wanted, r.audience, m.original_language
FROM movies m JOIN roots r ON r.id = m.root_id WHERE m.tmdb_id = 693134",
)
.fetch_one(database.pool())
.await
.expect("movie row");
assert!(row.1, "§4.1: monitored maps onto wanted");
assert_eq!(row.2, "kids", "the path decided the root, not the profile");
assert_eq!(
row.3.as_deref(),
Some("en"),
"§5.2 needs the original language"
);
// The quality profile Jellyseerr sent named nothing here and was ignored;
// what comes back is the root's own profile id (§5.1).
assert_eq!(created["qualityProfileId"], row.0);
}
#[tokio::test]
async fn an_unknown_root_is_a_radarr_validation_failure() {
let (_dir, _database, base) = shim().await;
let response = reqwest::Client::new()
.post(format!("{base}/api/v3/movie"))
.json(&json!({
"tmdbId": 1,
"title": "Somewhere Else",
"rootFolderPath": "/mnt/media/movies/nope"
}))
.send()
.await
.expect("add");
assert_eq!(response.status(), 400);
let body: Value = response.json().await.expect("json");
assert_eq!(body[0]["propertyName"], "RootFolderPath");
assert_eq!(body[0]["severity"], "error");
}
#[tokio::test]
async fn adding_the_same_film_twice_is_rejected_the_way_radarr_rejects_it() {
let (_dir, _database, base) = shim().await;
let request = json!({
"tmdbId": 693_134,
"title": "Dune: Part Two",
"year": 2024,
"rootFolderPath": "/mnt/media/movies/main"
});
let first = reqwest::Client::new()
.post(format!("{base}/api/v3/movie"))
.json(&request)
.send()
.await
.expect("first add");
assert_eq!(first.status(), 201);
let second = reqwest::Client::new()
.post(format!("{base}/api/v3/movie"))
.json(&request)
.send()
.await
.expect("second add");
assert_eq!(second.status(), 400);
let body: Value = second.json().await.expect("json");
assert_eq!(body[0]["propertyName"], "TmdbId");
}
#[tokio::test]
async fn the_library_answers_the_existence_check() {
let (_dir, _database, base) = shim().await;
reqwest::Client::new()
.post(format!("{base}/api/v3/movie"))
.json(&json!({
"tmdbId": 693_134, "title": "Dune: Part Two", "year": 2024,
"rootFolderPath": "/mnt/media/movies/main"
}))
.send()
.await
.expect("add");
let all = get_json(format!("{base}/api/v3/movie")).await;
assert_eq!(all.as_array().map(Vec::len), Some(1));
let id = all[0]["id"].as_i64().expect("id");
let filtered = get_json(format!("{base}/api/v3/movie?tmdbId=693134")).await;
assert_eq!(filtered.as_array().map(Vec::len), Some(1));
let missing = get_json(format!("{base}/api/v3/movie?tmdbId=1")).await;
assert_eq!(missing.as_array().map(Vec::len), Some(0));
let one = get_json(format!("{base}/api/v3/movie/{id}")).await;
assert_eq!(one["tmdbId"], 693_134);
let absent = reqwest::get(format!("{base}/api/v3/movie/424242"))
.await
.expect("request");
assert_eq!(absent.status(), 404);
}
#[tokio::test]
async fn lookup_stamps_the_library_id_onto_a_film_already_added() {
let (_dir, database, _unused) = shim().await;
let tmdb = tmdb_with_dune().await;
let base = serve(CompatState::new(database.clone()).with_tmdb(client(&tmdb))).await;
// Not yet in the library: id 0 is what tells Jellyseerr to go on and add.
let before = get_json(format!("{base}/api/v3/movie/lookup?term=tmdb:693134")).await;
assert_eq!(before.as_array().map(Vec::len), Some(1));
assert_eq!(before[0]["id"], 0);
assert_eq!(before[0]["tmdbId"], 693_134);
assert_eq!(before[0]["imdbId"], "tt15239678");
assert_eq!(before[0]["monitored"], false);
let created: Value = reqwest::Client::new()
.post(format!("{base}/api/v3/movie"))
.json(&json!({
"tmdbId": 693_134, "title": "Dune: Part Two", "year": 2024,
"rootFolderPath": "/mnt/media/movies/main"
}))
.send()
.await
.expect("add")
.json()
.await
.expect("json");
let after = get_json(format!("{base}/api/v3/movie/lookup?term=tmdb:693134")).await;
assert_eq!(after[0]["id"], created["id"]);
assert_eq!(after[0]["monitored"], true);
assert_eq!(after[0]["hasFile"], false);
// A free-text search takes the same overlay.
let searched = get_json(format!("{base}/api/v3/movie/lookup?term=dune")).await;
assert_eq!(searched[0]["id"], created["id"]);
}
#[tokio::test]
async fn an_unknown_tmdb_id_looks_up_empty_rather_than_failing() {
let (_dir, database, _unused) = shim().await;
let tmdb = tmdb_with_dune().await;
Mock::given(method("GET"))
.and(path("/3/movie/1"))
.respond_with(ResponseTemplate::new(404))
.mount(&tmdb)
.await;
let base = serve(CompatState::new(database).with_tmdb(client(&tmdb))).await;
let body = get_json(format!("{base}/api/v3/movie/lookup?term=tmdb:1")).await;
assert_eq!(body.as_array().map(Vec::len), Some(0));
let by_imdb = get_json(format!("{base}/api/v3/movie/lookup?term=imdb:tt15239678")).await;
assert_eq!(by_imdb.as_array().map(Vec::len), Some(0));
}
#[tokio::test]
async fn lookup_without_tmdb_fails_loudly_instead_of_answering_empty() {
let (_dir, _database, base) = shim().await;
let response = reqwest::get(format!("{base}/api/v3/movie/lookup?term=dune"))
.await
.expect("request");
assert_eq!(
response.status(),
502,
"an empty list would read as 'no such film'"
);
}
#[tokio::test]
async fn adding_survives_tmdb_being_down() {
let (_dir, database, _unused) = shim().await;
let tmdb = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/movie/693134"))
.respond_with(ResponseTemplate::new(500))
.mount(&tmdb)
.await;
let base = serve(CompatState::new(database.clone()).with_tmdb(client(&tmdb))).await;
let response = reqwest::Client::new()
.post(format!("{base}/api/v3/movie"))
.json(&json!({
"tmdbId": 693_134, "title": "Dune: Part Two", "year": 2024,
"rootFolderPath": "/mnt/media/movies/main"
}))
.send()
.await
.expect("add");
assert_eq!(response.status(), 201);
let language: Option<String> =
sqlx::query_scalar("SELECT original_language FROM movies WHERE tmdb_id = 693134")
.fetch_one(database.pool())
.await
.expect("row");
assert_eq!(language, None, "left for the metadata refresh to fill");
}