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.
This commit is contained in:
Dark-Alex-17 committed 2026-09-02 13:46:36 -06:00
1 parent f4fd8516a6
commit cd5ef8e093
7 files changed
+245 -6

No files matched your search

+64 -1
View File
@@ -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<Vec<IndexerTestResult>> {
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<IndexerTestResult>>(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::<Vec<String>>()
.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
}
}
@@ -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<IndexerTestResult> =
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
);
}
}
+6
View File
@@ -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
@@ -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<Indexer> {
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<IndexerTestResultModalItem> {
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(),
},
]
}
}
@@ -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")]