From cd5ef8e093344512ce6d6c4a896d3e4cfcbd496b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 2 Sep 2026 13:46:36 -0600 Subject: [PATCH] feat(readarr): test all indexers and expose the command on the CLI Results come back keyed only by indexer id, so each one is joined against the cached indexer list to recover a display name, and its validation failures are flattened into a single line for the results table. The endpoint reports failures with a non-2xx status, so the request ignores the status code and parses the body either way. When the request itself fails the results table is replaced with an empty one rather than being left showing the previous run. --- src/cli/readarr/mod.rs | 10 +++ src/cli/readarr/readarr_command_tests.rs | 31 ++++++- src/network/readarr_network/indexers/mod.rs | 65 ++++++++++++++- .../readarr_indexers_network_tests.rs | 80 ++++++++++++++++++- src/network/readarr_network/mod.rs | 6 ++ .../readarr_network_test_utils.rs | 58 ++++++++++++++ .../readarr_network/readarr_network_tests.rs | 1 + 7 files changed, 245 insertions(+), 6 deletions(-) diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 566a3197..76eb484f 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -110,6 +110,8 @@ pub enum ReadarrCommand { )] task_name: ReadarrTaskName, }, + #[command(about = "Test all Readarr indexers")] + TestAllIndexers, #[command( about = "Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'" )] @@ -247,6 +249,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .await?; serde_json::to_string_pretty(&resp)? } + ReadarrCommand::TestAllIndexers => { + println!("Testing all Readarr indexers. This may take a minute..."); + let resp = self + .network + .handle_network_event(ReadarrEvent::TestAllIndexers.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } ReadarrCommand::TestIndexer { indexer_id } => { let resp = self .network diff --git a/src/cli/readarr/readarr_command_tests.rs b/src/cli/readarr/readarr_command_tests.rs index e7ad9cf7..21a25396 100644 --- a/src/cli/readarr/readarr_command_tests.rs +++ b/src/cli/readarr/readarr_command_tests.rs @@ -4,6 +4,7 @@ mod tests { use clap::CommandFactory; use clap::error::ErrorKind; use pretty_assertions::assert_eq; + use rstest::rstest; use crate::Cli; @@ -212,9 +213,11 @@ mod tests { ); } - #[test] - fn test_clear_blocklist_has_no_arg_requirements() { - let result = Cli::command().try_get_matches_from(["managarr", "readarr", "clear-blocklist"]); + #[rstest] + fn test_commands_that_have_no_arg_requirements( + #[values("clear-blocklist", "test-all-indexers")] subcommand: &str, + ) { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", subcommand]); assert_ok!(&result); } @@ -395,6 +398,28 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_test_all_indexers_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::TestAllIndexers.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let test_all_indexers_command = ReadarrCommand::TestAllIndexers; + + let result = ReadarrCliHandler::with(&app_arc, test_all_indexers_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 9f6ff7e4..98afff73 100644 --- a/src/network/readarr_network/indexers/mod.rs +++ b/src/network/readarr_network/indexers/mod.rs @@ -1,4 +1,6 @@ -use crate::models::servarr_models::{EditIndexerParams, Indexer}; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; +use crate::models::servarr_models::{EditIndexerParams, Indexer, IndexerTestResult}; +use crate::models::stateful_table::StatefulTable; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::{Context, Result}; @@ -315,4 +317,65 @@ impl Network<'_, '_> { }) .await } + + pub(in crate::network::readarr_network) async fn test_all_readarr_indexers( + &mut self, + ) -> Result> { + info!("Testing all Readarr indexers"); + let event = ReadarrEvent::TestAllIndexers; + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, None, None, None) + .await; + request_props.ignore_status_code = true; + + let result = self + .handle_request::<(), Vec>(request_props, |test_results, mut app| { + let mut test_all_indexer_results = StatefulTable::default(); + let indexers = app.data.readarr_data.indexers.items.clone(); + let modal_test_results = test_results + .iter() + .map(|result| { + let name = indexers + .iter() + .filter(|&indexer| indexer.id == result.id) + .map(|indexer| indexer.name.clone()) + .nth(0) + .unwrap_or_default(); + let validation_failures = result + .validation_failures + .iter() + .map(|failure| { + format!( + "Failure for field '{}': {}", + failure.property_name, failure.error_message + ) + }) + .collect::>() + .join(", "); + + IndexerTestResultModalItem { + name: name.unwrap_or_default(), + is_valid: result.is_valid, + validation_failures: validation_failures.into(), + } + }) + .collect(); + test_all_indexer_results.set_items(modal_test_results); + app.data.readarr_data.indexer_test_all_results = Some(test_all_indexer_results); + }) + .await; + + if result.is_err() { + self + .app + .lock() + .await + .data + .readarr_data + .indexer_test_all_results = Some(StatefulTable::default()); + } + + result + } } 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 999da1d8..153c4ac0 100644 --- a/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs +++ b/src/network/readarr_network/indexers/readarr_indexers_network_tests.rs @@ -1,12 +1,14 @@ #[cfg(test)] mod tests { use crate::models::readarr_models::ReadarrSerdeable; - use crate::models::servarr_models::{EditIndexerParams, Indexer}; + use crate::models::servarr_models::{EditIndexerParams, Indexer, IndexerTestResult}; + use crate::models::stateful_table::StatefulTable; use crate::network::NetworkResource; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; use crate::network::readarr_network::readarr_network_test_utils::test_utils::{ - INDEXER_JSON, indexer, stale_indexer, + INDEXER_JSON, INDEXER_TEST_RESULTS_JSON, indexer, indexer_test_results, stale_indexer, + tested_indexers, }; use bimap::BiMap; use mockito::Matcher; @@ -570,4 +572,78 @@ mod tests { &"stale error".to_owned() ); } + + #[tokio::test] + async fn test_handle_test_all_readarr_indexers_event() { + let response_json: Value = serde_json::from_str(INDEXER_TEST_RESULTS_JSON).unwrap(); + let expected_results: Vec = + serde_json::from_str(INDEXER_TEST_RESULTS_JSON).unwrap(); + let (async_server, app, _server) = MockServarrApi::post() + .returns(response_json) + .status(400) + .build_for(ReadarrEvent::TestAllIndexers) + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexers.set_items(tested_indexers()); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestAllIndexers) + .await; + + async_server.assert_async().await; + let ReadarrSerdeable::IndexerTestResults(results) = assert_ok!(result) else { + panic!("Expected IndexerTestResults") + }; + assert_eq!(results, expected_results); + let app = app.lock().await; + assert_eq!( + app + .data + .readarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .items, + indexer_test_results() + ); + } + + #[tokio::test] + async fn test_handle_test_all_readarr_indexers_event_failure() { + let (async_server, app, _server) = MockServarrApi::post() + .returns(json!({ "message": "Internal Server Error" })) + .status(500) + .build_for(ReadarrEvent::TestAllIndexers) + .await; + { + let mut stale_test_all_results = StatefulTable::default(); + stale_test_all_results.set_items(indexer_test_results()); + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.indexers.set_items(tested_indexers()); + app.data.readarr_data.indexer_test_all_results = Some(stale_test_all_results); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::TestAllIndexers) + .await; + + async_server.assert_async().await; + assert_err!(result); + let app = app.lock().await; + assert_is_empty!( + app + .data + .readarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .items + ); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 5e4ee3f5..a16ff907 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), + TestAllIndexers, TestIndexer(i64), ToggleAuthorMonitoring(i64), ToggleBookMonitoring(i64), @@ -121,6 +122,7 @@ impl NetworkResource for ReadarrEvent { "/indexer" } ReadarrEvent::TestIndexer(_) => "/indexer/test", + ReadarrEvent::TestAllIndexers => "/indexer/testall", ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetMetadataProfiles => "/metadataprofile", ReadarrEvent::GetQualityProfiles => "/qualityprofile", @@ -308,6 +310,10 @@ impl Network<'_, '_> { .start_readarr_task(task_name) .await .map(ReadarrSerdeable::from), + ReadarrEvent::TestAllIndexers => self + .test_all_readarr_indexers() + .await + .map(ReadarrSerdeable::from), ReadarrEvent::TestIndexer(indexer_id) => self .test_readarr_indexer(indexer_id) .await diff --git a/src/network/readarr_network/readarr_network_test_utils.rs b/src/network/readarr_network/readarr_network_test_utils.rs index 620d4abd..23537302 100644 --- a/src/network/readarr_network/readarr_network_test_utils.rs +++ b/src/network/readarr_network/readarr_network_test_utils.rs @@ -1,12 +1,14 @@ #[cfg(test)] #[allow(dead_code)] pub mod test_utils { + use crate::models::HorizontallyScrollableText; use crate::models::readarr_models::{ AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, BlocklistItem, Book, BookFile, DownloadRecord, DownloadStatus, DownloadsResponse, Edition, MonitorType, NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrRelease, }; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_models::{Indexer, IndexerField, Quality, QualityWrapper, RootFolder}; use chrono::DateTime; use serde_json::{Number, json}; @@ -463,4 +465,60 @@ pub mod test_utils { tags: vec![Number::from(1)], } } + + pub const INDEXER_TEST_RESULTS_JSON: &str = r#"[ + { + "id": 8, + "isValid": true, + "validationFailures": [] + }, + { + "id": 14, + "isValid": false, + "validationFailures": [ + { + "propertyName": "baseUrl", + "errorMessage": "Unable to connect to indexer", + "severity": "error" + }, + { + "propertyName": "apiKey", + "errorMessage": "Invalid API key", + "severity": "error" + } + ] + } + ]"#; + + pub fn tested_indexers() -> Vec { + vec![ + Indexer { + id: 8, + name: Some("Test Indexer".to_owned()), + ..Indexer::default() + }, + Indexer { + id: 14, + name: Some("Other Indexer".to_owned()), + ..Indexer::default() + }, + ] + } + + pub fn indexer_test_results() -> Vec { + vec![ + IndexerTestResultModalItem { + name: "Test Indexer".to_owned(), + is_valid: true, + validation_failures: HorizontallyScrollableText::default(), + }, + IndexerTestResultModalItem { + name: "Other Indexer".to_owned(), + is_valid: false, + validation_failures: + "Failure for field 'baseUrl': Unable to connect to indexer, Failure for field 'apiKey': Invalid API key" + .into(), + }, + ] + } } diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 3959166e..31db1641 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -53,6 +53,7 @@ mod tests { #[case(ReadarrEvent::EditIndexer(EditIndexerParams::default()), "/indexer")] #[case(ReadarrEvent::GetIndexers, "/indexer")] #[case(ReadarrEvent::TestIndexer(8), "/indexer/test")] + #[case(ReadarrEvent::TestAllIndexers, "/indexer/testall")] #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetMetadataProfiles, "/metadataprofile")] #[case(ReadarrEvent::GetQualityProfiles, "/qualityprofile")]