diff --git a/src/cli/readarr/edit_command_handler.rs b/src/cli/readarr/edit_command_handler.rs new file mode 100644 index 00000000..0e8c808d --- /dev/null +++ b/src/cli/readarr/edit_command_handler.rs @@ -0,0 +1,145 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::{ArgAction, ArgGroup, Subcommand}; +use tokio::sync::Mutex; + +use super::ReadarrCommand; +use crate::{ + app::App, + cli::{CliCommandHandler, Command, mutex_flags_or_option}, + models::readarr_models::{EditAuthorParams, NewItemMonitorType}, + network::{NetworkTrait, readarr_network::ReadarrEvent}, +}; + +#[cfg(test)] +#[path = "edit_command_handler_tests.rs"] +mod edit_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum ReadarrEditCommand { + #[command( + about = "Edit preferences for the specified author", + group( + ArgGroup::new("edit_author") + .args([ + "enable_monitoring", + "disable_monitoring", + "monitor_new_items", + "quality_profile_id", + "metadata_profile_id", + "root_folder_path", + "tag", + "clear_tags" + ]).required(true) + .multiple(true)) + )] + Author { + #[arg( + long, + help = "The ID of the author whose settings you want to edit", + required = true + )] + author_id: i64, + #[arg( + long, + help = "Enable monitoring of this author in Readarr so Readarr will automatically download books from this author if they are available", + conflicts_with = "disable_monitoring" + )] + enable_monitoring: bool, + #[arg( + long, + help = "Disable monitoring of this author so Readarr does not automatically download books from this author if they are available", + conflicts_with = "enable_monitoring" + )] + disable_monitoring: bool, + #[arg( + long, + help = "How Readarr should monitor new books from this author", + value_enum + )] + monitor_new_items: Option, + #[arg(long, help = "The ID of the quality profile to use for this author")] + quality_profile_id: Option, + #[arg(long, help = "The ID of the metadata profile to use for this author")] + metadata_profile_id: Option, + #[arg( + long, + help = "The root folder path where all author data and metadata should live" + )] + root_folder_path: Option, + #[arg( + long, + help = "Tag IDs to tag this author with", + value_parser, + action = ArgAction::Append, + conflicts_with = "clear_tags" + )] + tag: Option>, + #[arg(long, help = "Clear all tags on this author", conflicts_with = "tag")] + clear_tags: bool, + }, +} + +impl From for Command { + fn from(value: ReadarrEditCommand) -> Self { + Command::Readarr(ReadarrCommand::Edit(value)) + } +} + +pub(super) struct ReadarrEditCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: ReadarrEditCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrEditCommand> for ReadarrEditCommandHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: ReadarrEditCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + ReadarrEditCommandHandler { + _app: app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + ReadarrEditCommand::Author { + author_id, + enable_monitoring, + disable_monitoring, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tag, + clear_tags, + } => { + let monitored_value = mutex_flags_or_option(enable_monitoring, disable_monitoring); + let edit_author_params = EditAuthorParams { + author_id, + monitored: monitored_value, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags: tag, + tag_input_string: None, + clear_tags, + }; + + self + .network + .handle_network_event(ReadarrEvent::EditAuthor(edit_author_params).into()) + .await?; + "Author updated".to_owned() + } + }; + + Ok(result) + } +} diff --git a/src/cli/readarr/edit_command_handler_tests.rs b/src/cli/readarr/edit_command_handler_tests.rs new file mode 100644 index 00000000..80313dcb --- /dev/null +++ b/src/cli/readarr/edit_command_handler_tests.rs @@ -0,0 +1,480 @@ +#[cfg(test)] +mod tests { + use clap::{CommandFactory, Parser, error::ErrorKind}; + + use crate::{ + Cli, + cli::{ + Command, + readarr::{ReadarrCommand, edit_command_handler::ReadarrEditCommand}, + }, + }; + use pretty_assertions::assert_eq; + + #[test] + fn test_readarr_edit_command_from() { + let command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tag: None, + clear_tags: false, + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Readarr(ReadarrCommand::Edit(command))); + } + + mod cli { + use super::*; + use crate::models::readarr_models::NewItemMonitorType; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[test] + fn test_edit_author_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "readarr", "edit", "author"]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_author_with_author_id_still_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + ]); + + assert_err!(&result); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_author_author_id_requires_a_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "not_a_number", + "--enable-monitoring", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ValueValidation); + } + + #[test] + fn test_edit_author_monitoring_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--enable-monitoring", + "--disable-monitoring", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_author_tag_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--tag", + "1", + "--clear-tags", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[rstest] + fn test_edit_author_assert_argument_flags_require_args( + #[values( + "--monitor-new-items", + "--quality-profile-id", + "--metadata-profile-id", + "--root-folder-path", + "--tag" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + flag, + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_author_monitor_new_items_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--monitor-new-items", + "test", + ]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_author_only_requires_at_least_one_argument_plus_author_id() { + let expected_args = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: Some("/nfs/books".to_owned()), + tag: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--root-folder-path", + "/nfs/books", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_author_tag_argument_is_repeatable() { + let expected_args = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--tag", + "1", + "--tag", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + + #[test] + fn test_edit_author_all_arguments_defined() { + let expected_args = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: true, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "readarr", + "edit", + "author", + "--author-id", + "1", + "--enable-monitoring", + "--monitor-new-items", + "new", + "--quality-profile-id", + "1", + "--metadata-profile-id", + "1", + "--root-folder-path", + "/nfs/books", + "--tag", + "1", + "--tag", + "2", + ]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::Edit(edit_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(edit_command, expected_args); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::CliCommandHandler; + use crate::cli::readarr::edit_command_handler::{ + ReadarrEditCommand, ReadarrEditCommandHandler, + }; + use crate::models::Serdeable; + use crate::models::readarr_models::{EditAuthorParams, NewItemMonitorType, ReadarrSerdeable}; + use crate::network::readarr_network::ReadarrEvent; + use crate::{ + app::App, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_edit_author_command() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(true), + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: true, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::New), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_author_command_handles_disable_monitoring_flag_properly() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: true, + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_author_command_no_monitoring_boolean_flags_returns_none_value() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: None, + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tags: Some(vec![1, 2]), + tag_input_string: None, + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: Some(NewItemMonitorType::All), + quality_profile_id: Some(1), + metadata_profile_id: Some(1), + root_folder_path: Some("/nfs/books".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_edit_author_command_clear_tags() { + let expected_edit_author_params = EditAuthorParams { + author_id: 1, + monitored: None, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tags: None, + tag_input_string: None, + clear_tags: true, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::EditAuthor(expected_edit_author_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let edit_author_command = ReadarrEditCommand::Author { + author_id: 1, + enable_monitoring: false, + disable_monitoring: false, + monitor_new_items: None, + quality_profile_id: None, + metadata_profile_id: None, + root_folder_path: None, + tag: None, + clear_tags: true, + }; + + let result = + ReadarrEditCommandHandler::with(&app_arc, edit_author_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + } +} diff --git a/src/cli/readarr/mod.rs b/src/cli/readarr/mod.rs index 37e47506..b57cd562 100644 --- a/src/cli/readarr/mod.rs +++ b/src/cli/readarr/mod.rs @@ -4,6 +4,7 @@ use add_command_handler::{ReadarrAddCommand, ReadarrAddCommandHandler}; use anyhow::Result; use clap::Subcommand; use delete_command_handler::{ReadarrDeleteCommand, ReadarrDeleteCommandHandler}; +use edit_command_handler::{ReadarrEditCommand, ReadarrEditCommandHandler}; use get_command_handler::{ReadarrGetCommand, ReadarrGetCommandHandler}; use list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}; use tokio::sync::Mutex; @@ -15,6 +16,7 @@ use crate::{app::App, network::NetworkTrait}; mod add_command_handler; mod delete_command_handler; +mod edit_command_handler; mod get_command_handler; mod list_command_handler; @@ -34,6 +36,11 @@ pub enum ReadarrCommand { about = "Commands to delete resources from your Readarr instance" )] Delete(ReadarrDeleteCommand), + #[command( + subcommand, + about = "Commands to edit resources in your Readarr instance" + )] + Edit(ReadarrEditCommand), #[command( subcommand, about = "Commands to fetch details of the resources in your Readarr instance" @@ -102,6 +109,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrCommand> for ReadarrCliHandler<'a, .handle() .await? } + ReadarrCommand::Edit(edit_command) => { + ReadarrEditCommandHandler::with(self.app, edit_command, self.network) + .handle() + .await? + } ReadarrCommand::Get(get_command) => { ReadarrGetCommandHandler::with(self.app, get_command, self.network) .handle() diff --git a/src/models/readarr_models.rs b/src/models/readarr_models.rs index e54051bb..600571af 100644 --- a/src/models/readarr_models.rs +++ b/src/models/readarr_models.rs @@ -243,6 +243,21 @@ pub struct AddAuthorOptions { pub search_for_missing_books: bool, } +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditAuthorParams { + pub author_id: i64, + pub monitored: Option, + pub monitor_new_items: Option, + pub quality_profile_id: Option, + pub metadata_profile_id: Option, + pub root_folder_path: Option, + pub tags: Option>, + #[serde(skip_serializing, skip_deserializing)] + pub tag_input_string: Option, + pub clear_tags: bool, +} + #[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AddReadarrRootFolderBody { diff --git a/src/network/readarr_network/library/authors/mod.rs b/src/network/readarr_network/library/authors/mod.rs index 1938aa8d..5e44a6db 100644 --- a/src/network/readarr_network/library/authors/mod.rs +++ b/src/network/readarr_network/library/authors/mod.rs @@ -1,9 +1,11 @@ use anyhow::Result; -use log::info; -use serde_json::Value; +use log::{debug, info}; +use serde_json::{Value, json}; use crate::models::Route; -use crate::models::readarr_models::{AddAuthorBody, AddAuthorSearchResult, Author}; +use crate::models::readarr_models::{ + AddAuthorBody, AddAuthorSearchResult, Author, EditAuthorParams, +}; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; use crate::models::stateful_table::StatefulTable; use crate::network::readarr_network::ReadarrEvent; @@ -43,6 +45,123 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn edit_author( + &mut self, + mut edit_author_params: EditAuthorParams, + ) -> Result<()> { + info!("Editing Readarr author"); + if let Some(tag_input_str) = edit_author_params.tag_input_string.as_ref() { + let tag_ids_vec = self + .extract_and_add_readarr_tag_ids_vec(tag_input_str) + .await; + edit_author_params.tags = Some(tag_ids_vec); + } + let author_id = edit_author_params.author_id; + let detail_event = ReadarrEvent::GetAuthorDetails(author_id); + let event = ReadarrEvent::EditAuthor(EditAuthorParams::default()); + info!("Fetching author details for author with ID: {author_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{author_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_author_body, _| { + response = detailed_author_body.to_string() + }) + .await?; + + info!("Constructing edit author body"); + + let mut detailed_author_body: Value = serde_json::from_str(&response)?; + let ( + monitored, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags, + ) = { + let monitored = edit_author_params.monitored.unwrap_or( + detailed_author_body["monitored"] + .as_bool() + .expect("Unable to deserialize 'monitored'"), + ); + let monitor_new_items = edit_author_params.monitor_new_items.unwrap_or_else(|| { + serde_json::from_value(detailed_author_body["monitorNewItems"].clone()) + .expect("Unable to deserialize 'monitorNewItems'") + }); + let quality_profile_id = edit_author_params.quality_profile_id.unwrap_or_else(|| { + detailed_author_body["qualityProfileId"] + .as_i64() + .expect("Unable to deserialize 'qualityProfileId'") + }); + let metadata_profile_id = edit_author_params.metadata_profile_id.unwrap_or_else(|| { + detailed_author_body["metadataProfileId"] + .as_i64() + .expect("Unable to deserialize 'metadataProfileId'") + }); + let root_folder_path = edit_author_params.root_folder_path.unwrap_or_else(|| { + detailed_author_body["path"] + .as_str() + .expect("Unable to deserialize 'path'") + .to_owned() + }); + let tags = if edit_author_params.clear_tags { + vec![] + } else { + edit_author_params.tags.unwrap_or( + detailed_author_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + + ( + monitored, + monitor_new_items, + quality_profile_id, + metadata_profile_id, + root_folder_path, + tags, + ) + }; + + *detailed_author_body.get_mut("monitored").unwrap() = json!(monitored); + *detailed_author_body.get_mut("monitorNewItems").unwrap() = json!(monitor_new_items); + *detailed_author_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); + *detailed_author_body.get_mut("metadataProfileId").unwrap() = json!(metadata_profile_id); + *detailed_author_body.get_mut("path").unwrap() = json!(root_folder_path); + *detailed_author_body.get_mut("tags").unwrap() = json!(tags); + + debug!("Edit author body: {detailed_author_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_author_body), + Some(format!("/{author_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + pub(in crate::network::readarr_network) async fn get_author_details( &mut self, author_id: i64, diff --git a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs index 4a2b5453..b4fe8dd9 100644 --- a/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs +++ b/src/network/readarr_network/library/authors/readarr_authors_network_tests.rs @@ -1,10 +1,12 @@ #[cfg(test)] mod tests { use crate::models::readarr_models::{ - AddAuthorBody, AddAuthorSearchResult, Author, ReadarrSerdeable, + AddAuthorBody, AddAuthorSearchResult, Author, EditAuthorParams, NewItemMonitorType, + ReadarrSerdeable, }; use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock; 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::{ @@ -12,6 +14,7 @@ mod tests { stale_author, }; use bimap::BiMap; + use mockito::Matcher; use pretty_assertions::assert_eq; use serde_json::{Value, json}; @@ -387,4 +390,239 @@ mod tests { mock.assert_async().await; assert_err!(result); } + + #[tokio::test] + async fn test_handle_edit_author_event() { + let mut expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("monitorNewItems").unwrap() = json!("none"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("metadataProfileId").unwrap() = json!(2222); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1111), + metadata_profile_id: Some(2222), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tag_input_string: Some("usenet, testing".to_owned()), + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + app + .data + .readarr_data + .authors + .set_items(vec![stale_author()]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + let app = app.lock().await; + assert_eq!(app.data.readarr_data.authors.items, vec![stale_author()]); + } + + #[tokio::test] + async fn test_handle_edit_author_event_does_not_overwrite_tags_vec_when_tag_input_string_is_none() + { + let mut expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("monitorNewItems").unwrap() = json!("none"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("metadataProfileId").unwrap() = json!(2222); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + let edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + monitor_new_items: Some(NewItemMonitorType::None), + quality_profile_id: Some(1111), + metadata_profile_id: Some(2222), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tags: Some(vec![1, 2]), + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app.lock().await; + app.server_tabs.set_index(3); + app.data.readarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + } + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_author_event_defaults_to_previous_values() { + let edit_author_params = EditAuthorParams { + author_id: 1, + ..EditAuthorParams::default() + }; + let expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_author_event_returns_empty_tags_vec_when_clear_tags_is_true() { + let mut expected_body: Value = serde_json::from_str(AUTHOR_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + let edit_author_params = EditAuthorParams { + author_id: 1, + clear_tags: true, + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_ok!(result); + } + + #[tokio::test] + async fn test_handle_edit_author_event_failure() { + let edit_author_params = EditAuthorParams { + author_id: 1, + monitored: Some(false), + ..EditAuthorParams::default() + }; + let (async_details_server, app, mut server) = MockServarrApi::get() + .returns(serde_json::from_str(AUTHOR_JSON).unwrap()) + .status(404) + .path("/1") + .build_for(ReadarrEvent::GetAuthorDetails(1)) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v1{}/1", + ReadarrEvent::EditAuthor(edit_author_params.clone()).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .expect(0) + .create_async() + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::EditAuthor(edit_author_params)) + .await; + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + assert_err!(result); + } } diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index 7803adee..8c687730 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use super::{NetworkEvent, NetworkResource}; use crate::models::readarr_models::{ - AddAuthorBody, AddReadarrRootFolderBody, ReadarrSerdeable, ReadarrTaskName, + AddAuthorBody, AddReadarrRootFolderBody, EditAuthorParams, ReadarrSerdeable, ReadarrTaskName, }; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::{Network, RequestMethod}; @@ -28,6 +28,7 @@ pub enum ReadarrEvent { AddTag(String), DeleteRootFolder(i64), DeleteTag(i64), + EditAuthor(EditAuthorParams), GetAuthorDetails(i64), GetDiskSpace, GetHostConfig, @@ -51,6 +52,7 @@ impl NetworkResource for ReadarrEvent { fn resource(&self) -> &'static str { match &self { ReadarrEvent::AddAuthor(_) + | ReadarrEvent::EditAuthor(_) | ReadarrEvent::GetAuthorDetails(_) | ReadarrEvent::ListAuthors => "/author", ReadarrEvent::SearchNewAuthor(_) => "/author/lookup", @@ -98,6 +100,10 @@ impl Network<'_, '_> { .delete_readarr_tag(tag_id) .await .map(ReadarrSerdeable::from), + ReadarrEvent::EditAuthor(edit_author_params) => self + .edit_author(edit_author_params) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetAuthorDetails(author_id) => self .get_author_details(author_id) .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index adfa3dbc..30858bf7 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -1,7 +1,9 @@ #[cfg(test)] mod tests { use crate::app::App; - use crate::models::readarr_models::{AddAuthorBody, AddReadarrRootFolderBody, ReadarrSerdeable}; + use crate::models::readarr_models::{ + AddAuthorBody, AddReadarrRootFolderBody, EditAuthorParams, ReadarrSerdeable, + }; use crate::models::servarr_models::{MetadataProfile, QualityProfile, Tag}; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::{NetworkEvent, NetworkResource, readarr_network::ReadarrEvent}; @@ -14,6 +16,7 @@ mod tests { #[rstest] #[case(ReadarrEvent::AddAuthor(AddAuthorBody::default()), "/author")] + #[case(ReadarrEvent::EditAuthor(EditAuthorParams::default()), "/author")] #[case(ReadarrEvent::GetAuthorDetails(1), "/author")] #[case(ReadarrEvent::ListAuthors, "/author")] #[case(ReadarrEvent::SearchNewAuthor(String::new()), "/author/lookup")]