517 lines
18 KiB
Rust
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");
|
|
}
|