From f4fd8516a6636509f84b844c9d03ef4e9ef5d3f9 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 13:39:29 -0600 Subject: [PATCH] feat(readarr): test an indexer and expose the command on the CLI Fetch the indexer's full details, then post them back verbatim to the test endpoint. The payload round-trips through serde_json::Value so the fields Indexer does not model, such as infoLink and the per-field label and type metadata, survive the round trip intact. A successful test returns an empty JSON object and clears indexer_test_errors; a failure returns an array whose first entry carries the error message. The test endpoint reports failures with a non-2xx status, so the request ignores the status code and parses the body either way. --- src/cli/readarr/mod.rs | 14 ++ src/cli/readarr/readarr_command_tests.rs | 49 ++++++ src/network/readarr_network/indexers/mod.rs | 52 +++++++ .../readarr_indexers_network_tests.rs | 145 ++++++++++++++++++ src/network/readarr_network/mod.rs | 6 + .../readarr_network/readarr_network_tests.rs | 1 + 6 files changed, 267 insertions(+) diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 774dc62c..566a3197 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -110,6 +110,13 @@ pub enum ReadarrCommand { )] task_name: ReadarrTaskName, }, + #[command( + about = "Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'" + )] + TestIndexer { + #[arg(long, help = "The ID of the indexer to test", required = true)] + indexer_id: i64, + }, #[command( about = "Toggle monitoring for the specified author corresponding to the given author ID" )] @@ -240,6 +247,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .await?; serde_json::to_string_pretty(&resp)? } + ReadarrCommand::TestIndexer { indexer_id } => { + let resp = self + .network + .handle_network_event(ReadarrEvent::TestIndexer(indexer_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrCommand::ToggleAuthorMonitoring { author_id } => { let resp = self .network diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index c61d1cd2..e7ad9cf7 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -151,6 +151,30 @@ mod tests { assert_ok!(&result); } + #[test] + fn test_test_indexer_requires_indexer_id() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "test-indexer"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_test_indexer_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "test-indexer", + "--indexer-id", + "8", + ]); + + assert_ok!(&result); + } + #[test] fn test_toggle_author_monitoring_requires_author_id() { let result = @@ -346,6 +370,31 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_test_indexer_command() { + let expected_indexer_id = 8; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::TestIndexer(expected_indexer_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let test_indexer_command = ReadarrCommand::TestIndexer { indexer_id: 8 }; + + let result = ReadarrCliHandler::with(&app_arc, test_indexer_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + #[tokio::test] async fn test_toggle_author_monitoring_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/indexers/mod.rs b/src/network/readarr_network/indexers/mod.rs index 165d8525..9f6ff7e4 100644 --- a/src/network/readarr_network/indexers/mod.rs +++ b/src/network/readarr_network/indexers/mod.rs @@ -263,4 +263,56 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn test_readarr_indexer( + &mut self, + indexer_id: i64, + ) -> Result { + let detail_event = ReadarrEvent::GetIndexers; + let event = ReadarrEvent::TestIndexer(indexer_id); + info!("Testing Readarr indexer with ID: {indexer_id}"); + + info!("Fetching indexer details for indexer with ID: {indexer_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{indexer_id}")), + None, + ) + .await; + + let mut test_body: Value = Value::default(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + test_body = detailed_indexer_body; + }) + .await?; + + info!("Testing indexer"); + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, Some(test_body), None, None) + .await; + request_props.ignore_status_code = true; + + self + .handle_request::(request_props, |test_results, mut app| { + if test_results.as_object().is_none() { + let error_message = test_results + .as_array() + .and_then(|arr| arr.first()) + .and_then(|item| item.get("errorMessage")) + .map(|msg| msg.to_string()) + .unwrap_or_else(|| "Unknown indexer test error".to_string()); + app.data.readarr_data.indexer_test_errors = Some(error_message); + } else { + app.data.readarr_data.indexer_test_errors = Some(String::new()); + }; + }) + .await + } } diff --git a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs index 80bcacdf..999da1d8 100644 --- a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -425,4 +425,149 @@ mod tests { async_edit_server.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v1{}", ReadarrEvent::TestIndexer(8).resource()).as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .with_body("{}") + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_ok!(&result); + let app = app.lock().await; + assert_some_eq_x!(&app.data.readarr_data.indexer_test_errors, &String::new()); + } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event_captures_validation_errors() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v1{}", ReadarrEvent::TestIndexer(8).resource()).as_str(), + ) + .with_status(400) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .with_body(json!([{ "errorMessage": "Unable to connect to indexer" }]).to_string()) + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_ok!(&result); + let app = app.lock().await; + assert_some_eq_x!( + &app.data.readarr_data.indexer_test_errors, + &"\"Unable to connect to indexer\"".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event_falls_back_to_a_generic_error_message() { + let expected_body: Value = serde_json::from_str(INDEXER_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .build_for(ReadarrEvent::GetIndexers) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v1{}", ReadarrEvent::TestIndexer(8).resource()).as_str(), + ) + .with_status(400) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .with_body("[]") + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_ok!(&result); + let app = app.lock().await; + assert_some_eq_x!( + &app.data.readarr_data.indexer_test_errors, + &"Unknown indexer test error".to_owned() + ); + } + + #[tokio::test] + async fn test_handle_test_readarr_indexer_event_failure() { + let (async_details_server, app, _server) = MockServarrApi::get() + .returns(serde_json::from_str(INDEXER_JSON).unwrap()) + .path("/8") + .status(500) + .build_for(ReadarrEvent::GetIndexers) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexer_test_errors = Some("stale error".to_owned()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestIndexer(8)) + .await; + + async_details_server.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_some_eq_x!( + &app.data.readarr_data.indexer_test_errors, + &"stale error".to_owned() + ); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 06cac640..5e4ee3f5 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -75,6 +75,7 @@ pub enum ReadarrEvent { MarkHistoryItemAsFailed(i64), SearchNewAuthor(String), StartTask(ReadarrTaskName), + TestIndexer(i64), ToggleAuthorMonitoring(i64), ToggleBookMonitoring(i64), TriggerAutomaticAuthorSearch(i64), @@ -119,6 +120,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::DeleteIndexer(_) | ReadarrEvent::EditIndexer(_) | ReadarrEvent::GetIndexers => { "/indexer" } + ReadarrEvent::TestIndexer(_) => "/indexer/test", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", @@ -306,6 +308,10 @@ impl Network<'_, '_> { .start_readarr_task(task_name) .await .map(ReadarrSerdeable::from), + ReadarrEvent::TestIndexer(indexer_id) => self + .test_readarr_indexer(indexer_id) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::ToggleAuthorMonitoring(author_id) => self .toggle_author_monitoring(author_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 555f9ca1..3959166e 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -52,6 +52,7 @@ mod tests { #[case(ReadarrEvent::DeleteIndexer(1), "/indexer")] #[case(ReadarrEvent::EditIndexer(EditIndexerParams::default()), "/indexer")] #[case(ReadarrEvent::GetIndexers, "/indexer")] + #[case(ReadarrEvent::TestIndexer(8), "/indexer/test")] #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")]