diff --git a/src/inventory/adapters/output/db/postgres/store_view.rs b/src/inventory/adapters/output/db/postgres/store_view.rs index bf609af..4198bee 100644 --- a/src/inventory/adapters/output/db/postgres/store_view.rs +++ b/src/inventory/adapters/output/db/postgres/store_view.rs @@ -215,6 +215,7 @@ mod tests { add_customization_service::tests::mock_add_customization_service, add_product_service::tests::mock_add_product_service, add_store_service::AddStoreServiceBuilder, + update_category_service::tests::mock_update_category_service, update_customization_service::tests::mock_update_customization_service, update_product_service::tests::mock_update_product_service, InventoryServicesBuilder, @@ -223,6 +224,7 @@ mod tests { add_category_command::AddCategoryCommand, add_customization_command, add_product_command::tests::get_command, add_store_command::AddStoreCommand, commands::InventoryCommand, + update_category_command::tests::get_update_category_command, update_customization_command::tests::get_update_customization_command, update_product_command, }, @@ -273,6 +275,10 @@ mod tests { IS_NEVER_CALLED, get_update_customization_command(), )) + .update_category(mock_update_category_service( + IS_NEVER_CALLED, + get_update_category_command(), + )) .build() .unwrap(); diff --git a/src/inventory/application/services/mod.rs b/src/inventory/application/services/mod.rs index b689067..b2b5f50 100644 --- a/src/inventory/application/services/mod.rs +++ b/src/inventory/application/services/mod.rs @@ -13,6 +13,7 @@ pub mod add_category_service; pub mod add_customization_service; pub mod add_product_service; pub mod add_store_service; +pub mod update_category_service; pub mod update_customization_service; pub mod update_product_service; @@ -24,6 +25,7 @@ pub trait InventoryServicesInterface: Send + Sync { fn add_customization(&self) -> add_customization_service::AddCustomizationServiceObj; fn update_product(&self) -> update_product_service::UpdateProductServiceObj; fn update_customization(&self) -> update_customization_service::UpdateCustomizationServiceObj; + fn update_category(&self) -> update_category_service::UpdateCategoryServiceObj; } #[derive(Clone, Builder)] @@ -34,6 +36,7 @@ pub struct InventoryServices { add_customization: add_customization_service::AddCustomizationServiceObj, update_product: update_product_service::UpdateProductServiceObj, update_customization: update_customization_service::UpdateCustomizationServiceObj, + update_category: update_category_service::UpdateCategoryServiceObj, } impl InventoryServicesInterface for InventoryServices { @@ -58,4 +61,8 @@ impl InventoryServicesInterface for InventoryServices { fn update_customization(&self) -> update_customization_service::UpdateCustomizationServiceObj { self.update_customization.clone() } + + fn update_category(&self) -> update_category_service::UpdateCategoryServiceObj { + self.update_category.clone() + } } diff --git a/src/inventory/application/services/update_category_service.rs b/src/inventory/application/services/update_category_service.rs index 56f60de..f0297d5 100644 --- a/src/inventory/application/services/update_category_service.rs +++ b/src/inventory/application/services/update_category_service.rs @@ -1,3 +1,197 @@ -// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +/// SPDX-FileCopyrightText: 2024 Aravinth Manivannan // // SPDX-License-Identifier: AGPL-3.0-or-later +use std::sync::Arc; + +use derive_builder::Builder; +use mockall::predicate::*; +use mockall::*; + +use super::errors::*; +use crate::inventory::{ + application::port::output::db::{ + category_id_exists::*, category_name_exists_for_store::*, store_id_exists::*, + }, + domain::{category_aggregate::*, category_updated_event::*, update_category_command::*}, +}; +use crate::utils::uuid::*; + +#[automock] +#[async_trait::async_trait] +pub trait UpdateCategoryUseCase: Send + Sync { + async fn update_category( + &self, + cmd: UpdateCategoryCommand, + ) -> InventoryResult; +} + +pub type UpdateCategoryServiceObj = Arc; + +#[derive(Clone, Builder)] +pub struct UpdateCategoryService { + // TODO: check if store ID exists + db_category_name_exists_for_store: CategoryNameExistsForStoreDBPortObj, + db_category_id_exists: CategoryIDExistsDBPortObj, + db_store_id_exists: StoreIDExistsDBPortObj, +} + +#[async_trait::async_trait] +impl UpdateCategoryUseCase for UpdateCategoryService { + async fn update_category( + &self, + cmd: UpdateCategoryCommand, + ) -> InventoryResult { + if !self + .db_category_id_exists + .category_id_exists(cmd.old_category().category_id()) + .await? + { + return Err(InventoryError::CategoryIDNotFound); + } + + if !self + .db_store_id_exists + .store_id_exists(cmd.old_category().store_id()) + .await? + { + return Err(InventoryError::StoreIDNotFound); + } + + let updated_category = CategoryBuilder::default() + .name(cmd.name().into()) + .description(cmd.description().as_ref().map(|s| s.to_string())) + .category_id(*cmd.old_category().category_id()) + .store_id(*cmd.old_category().store_id()) + .build() + .unwrap(); + + if updated_category.name() != cmd.old_category().name() { + if self + .db_category_name_exists_for_store + .category_name_exists_for_store(&updated_category) + .await? + { + return Err(InventoryError::DuplicateCategoryName); + } + } + + Ok(CategoryUpdatedEventBuilder::default() + .added_by_user(*cmd.adding_by()) + .old_category(cmd.old_category().clone()) + .new_category(updated_category) + .build() + .unwrap()) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + use crate::inventory::domain::category_updated_event; + use crate::inventory::domain::update_category_command::tests::get_update_category_command; + use crate::utils::uuid::tests::UUID; + use crate::{tests::bdd::*, utils::uuid::tests::mock_get_uuid}; + + pub fn mock_update_category_service( + times: Option, + cmd: UpdateCategoryCommand, + ) -> UpdateCategoryServiceObj { + let mut m = MockUpdateCategoryUseCase::new(); + + let res = category_updated_event::tests::get_category_updated_event_from_command(&cmd); + + if let Some(times) = times { + m.expect_update_category() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_update_category() + .returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + + #[actix_rt::test] + async fn test_service() { + let cmd = get_update_category_command(); + + let s = UpdateCategoryServiceBuilder::default() + .db_store_id_exists(mock_store_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_category_name_exists_for_store(mock_category_name_exists_for_store_db_port_false( + IS_CALLED_ONLY_ONCE, + )) + .db_category_id_exists(mock_category_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + let res = s.update_category(cmd.clone()).await.unwrap(); + assert_eq!(res.new_category().name(), cmd.name()); + assert_eq!(res.new_category().description(), cmd.description()); + assert_eq!(res.new_category().store_id(), cmd.old_category().store_id()); + assert_eq!( + res.new_category().category_id(), + cmd.old_category().category_id() + ); + assert_eq!(res.old_category(), cmd.old_category()); + assert_eq!(res.added_by_user(), cmd.adding_by()); + } + + #[actix_rt::test] + async fn test_service_store_doesnt_exist() { + let cmd = get_update_category_command(); + + let s = UpdateCategoryServiceBuilder::default() + .db_store_id_exists(mock_store_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .db_category_name_exists_for_store(mock_category_name_exists_for_store_db_port_false( + IS_NEVER_CALLED, + )) + .db_category_id_exists(mock_category_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + assert_eq!( + s.update_category(cmd.clone()).await, + Err(InventoryError::StoreIDNotFound) + ); + } + + #[actix_rt::test] + async fn test_category_id_not_found() { + let cmd = get_update_category_command(); + + let s = UpdateCategoryServiceBuilder::default() + .db_store_id_exists(mock_store_id_exists_db_port_true(IS_NEVER_CALLED)) + .db_category_name_exists_for_store(mock_category_name_exists_for_store_db_port_false( + IS_NEVER_CALLED, + )) + .db_category_id_exists(mock_category_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + assert_eq!( + s.update_category(cmd.clone()).await, + Err(InventoryError::CategoryIDNotFound) + ); + } + + #[actix_rt::test] + async fn test_duplicate_new_name() { + let cmd = get_update_category_command(); + + let s = UpdateCategoryServiceBuilder::default() + .db_store_id_exists(mock_store_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .db_category_name_exists_for_store(mock_category_name_exists_for_store_db_port_true( + IS_CALLED_ONLY_ONCE, + )) + .db_category_id_exists(mock_category_id_exists_db_port_true(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + assert_eq!( + s.update_category(cmd.clone()).await, + Err(InventoryError::DuplicateCategoryName) + ); + } +} diff --git a/src/inventory/domain/category_aggregate.rs b/src/inventory/domain/category_aggregate.rs index df286bd..faeabb7 100644 --- a/src/inventory/domain/category_aggregate.rs +++ b/src/inventory/domain/category_aggregate.rs @@ -50,19 +50,28 @@ impl Aggregate for Category { let res = services.add_category().add_category(cmd).await?; Ok(vec![InventoryEvent::CategoryAdded(res)]) } + InventoryCommand::UpdateCategory(cmd) => { + let res = services.update_category().update_category(cmd).await?; + Ok(vec![InventoryEvent::CategoryUpdated(res)]) + } _ => Ok(Vec::default()), } } fn apply(&mut self, event: Self::Event) { - if let InventoryEvent::CategoryAdded(e) = event { - *self = CategoryBuilder::default() - .name(e.name().into()) - .category_id(*e.category_id()) - .description(e.description().clone()) - .store_id(*e.store_id()) - .build() - .unwrap(); + match event { + InventoryEvent::CategoryAdded(e) => { + *self = CategoryBuilder::default() + .name(e.name().into()) + .category_id(*e.category_id()) + .description(e.description().clone()) + .store_id(*e.store_id()) + .build() + .unwrap() + } + + InventoryEvent::CategoryUpdated(e) => *self = e.new_category().clone(), + _ => (), } } } @@ -72,14 +81,17 @@ mod aggregate_tests { use std::sync::Arc; use cqrs_es::test::TestFramework; + use update_category_service::tests::mock_update_category_service; use uuid::Uuid; use super::*; use crate::inventory::{ application::services::{add_category_service::tests::*, *}, domain::{ - add_category_command::*, category_added_event::*, commands::InventoryCommand, - events::InventoryEvent, + add_category_command::*, category_added_event::*, + category_updated_event::tests::get_category_updated_event_from_command, + commands::InventoryCommand, events::InventoryEvent, + update_category_command::tests::get_update_category_command, }, }; use crate::tests::bdd::*; @@ -119,4 +131,26 @@ mod aggregate_tests { .when(InventoryCommand::AddCategory(cmd)) .then_expect_events(vec![expected]); } + + #[test] + fn test_update_category() { + let cmd = get_update_category_command(); + + let expected = get_category_updated_event_from_command(&cmd); + let expected = InventoryEvent::CategoryUpdated(expected); + + let mut services = MockInventoryServicesInterface::new(); + services + .expect_update_category() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_update_category_service( + IS_CALLED_ONLY_ONCE, + cmd.clone(), + )); + + CategoryTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(InventoryCommand::UpdateCategory(cmd)) + .then_expect_events(vec![expected]); + } } diff --git a/src/inventory/domain/category_updated_event.rs b/src/inventory/domain/category_updated_event.rs new file mode 100644 index 0000000..83e0de4 --- /dev/null +++ b/src/inventory/domain/category_updated_event.rs @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::category_aggregate::*; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct CategoryUpdatedEvent { + added_by_user: Uuid, + new_category: Category, + old_category: Category, +} + +#[cfg(test)] +pub mod tests { + use crate::inventory::domain::update_category_command::UpdateCategoryCommand; + + use super::*; + + pub fn get_category_updated_event_from_command( + cmd: &UpdateCategoryCommand, + ) -> CategoryUpdatedEvent { + let category = CategoryBuilder::default() + .name(cmd.name().into()) + .description(cmd.description().as_ref().map(|s| s.to_string())) + .category_id(*cmd.old_category().category_id()) + .store_id(*cmd.old_category().store_id()) + .build() + .unwrap(); + + CategoryUpdatedEventBuilder::default() + .new_category(category) + .old_category(cmd.old_category().clone()) + .added_by_user(*cmd.adding_by()) + .build() + .unwrap() + } +} diff --git a/src/inventory/domain/commands.rs b/src/inventory/domain/commands.rs index f87ccb2..8779580 100644 --- a/src/inventory/domain/commands.rs +++ b/src/inventory/domain/commands.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use super::{ add_category_command::AddCategoryCommand, add_customization_command::AddCustomizationCommand, add_product_command::AddProductCommand, add_store_command::AddStoreCommand, + update_category_command::UpdateCategoryCommand, update_customization_command::UpdateCustomizationCommand, update_product_command::UpdateProductCommand, }; @@ -20,4 +21,5 @@ pub enum InventoryCommand { AddCustomization(AddCustomizationCommand), UpdateProduct(UpdateProductCommand), UpdateCustomization(UpdateCustomizationCommand), + UpdateCategory(UpdateCategoryCommand), } diff --git a/src/inventory/domain/customization_updated_event.rs b/src/inventory/domain/customization_updated_event.rs index 8839c6b..ba3b25a 100644 --- a/src/inventory/domain/customization_updated_event.rs +++ b/src/inventory/domain/customization_updated_event.rs @@ -28,9 +28,6 @@ pub mod tests { use super::*; - #[test] - fn test_name() {} - pub fn get_customization_updated_event_from_command( cmd: &UpdateCustomizationCommand, ) -> CustomizationUpdatedEvent { diff --git a/src/inventory/domain/events.rs b/src/inventory/domain/events.rs index 269ac78..cb5906c 100644 --- a/src/inventory/domain/events.rs +++ b/src/inventory/domain/events.rs @@ -6,7 +6,8 @@ use cqrs_es::DomainEvent; use serde::{Deserialize, Serialize}; use super::{ - category_added_event::*, customization_added_event::CustomizationAddedEvent, + category_added_event::*, category_updated_event::CategoryUpdatedEvent, + customization_added_event::CustomizationAddedEvent, customization_updated_event::CustomizationUpdatedEvent, product_added_event::ProductAddedEvent, product_updated_event::ProductUpdatedEvent, store_added_event::StoreAddedEvent, }; @@ -19,6 +20,7 @@ pub enum InventoryEvent { CustomizationAdded(CustomizationAddedEvent), ProductUpdated(ProductUpdatedEvent), CustomizationUpdated(CustomizationUpdatedEvent), + CategoryUpdated(CategoryUpdatedEvent), } impl DomainEvent for InventoryEvent { @@ -34,6 +36,7 @@ impl DomainEvent for InventoryEvent { InventoryEvent::CustomizationAdded { .. } => "InventoryCustomizationAdded", InventoryEvent::ProductUpdated { .. } => "InventoryProductUpdated", InventoryEvent::CustomizationUpdated { .. } => "InventoryCustomizationUpdated", + InventoryEvent::CategoryUpdated { .. } => "InventoryCategoryUpdated", }; e.to_string() diff --git a/src/inventory/domain/mod.rs b/src/inventory/domain/mod.rs index 6e13df7..3f9de8c 100644 --- a/src/inventory/domain/mod.rs +++ b/src/inventory/domain/mod.rs @@ -15,11 +15,13 @@ pub mod add_customization_command; pub mod add_product_command; pub mod add_store_command; pub mod commands; +pub mod update_category_command; pub mod update_customization_command; pub mod update_product_command; // events pub mod category_added_event; +pub mod category_updated_event; pub mod customization_added_event; pub mod customization_updated_event; pub mod events; diff --git a/src/inventory/domain/update_category_command.rs b/src/inventory/domain/update_category_command.rs new file mode 100644 index 0000000..b5e9785 --- /dev/null +++ b/src/inventory/domain/update_category_command.rs @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_getters::Getters; +use derive_more::{Display, Error}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::category_aggregate::*; + +#[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum UpdateCategoryCommandError { + NameIsEmpty, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)] +pub struct UpdateCategoryCommand { + name: String, + description: Option, + adding_by: Uuid, + + old_category: Category, +} + +impl UpdateCategoryCommand { + pub fn new( + name: String, + description: Option, + old_category: Category, + adding_by: Uuid, + ) -> Result { + let description: Option = if let Some(description) = description { + let description = description.trim(); + if description.is_empty() { + None + } else { + Some(description.to_owned()) + } + } else { + None + }; + + let name = name.trim().to_owned(); + if name.is_empty() { + return Err(UpdateCategoryCommandError::NameIsEmpty); + } + + Ok(Self { + name, + description, + old_category, + adding_by, + }) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + use crate::utils::uuid::tests::UUID; + + pub fn get_update_category_command() -> UpdateCategoryCommand { + let name = "foo"; + let description = "bar"; + let adding_by = UUID; + let old_category = Category::default(); + UpdateCategoryCommand::new( + name.into(), + Some(description.into()), + old_category.clone(), + adding_by, + ) + .unwrap() + } + + #[test] + fn test_cmd() { + let name = "foo"; + let description = "bar"; + let adding_by = UUID; + let old_category = Category::default(); + + // description = None + let cmd = + UpdateCategoryCommand::new(name.into(), None, old_category.clone(), adding_by).unwrap(); + assert_eq!(cmd.name(), name); + assert_eq!(cmd.description(), &None); + assert_eq!(cmd.adding_by(), &adding_by); + assert_eq!(cmd.old_category(), &old_category); + + // description = Some + let cmd = UpdateCategoryCommand::new( + name.into(), + Some(description.into()), + old_category.clone(), + adding_by, + ) + .unwrap(); + assert_eq!(cmd.name(), name); + assert_eq!(cmd.description(), &Some(description.to_owned())); + assert_eq!(cmd.adding_by(), &adding_by); + assert_eq!(cmd.old_category(), &old_category); + + // UpdateCategoryCommandError::NameIsEmpty + assert_eq!( + UpdateCategoryCommand::new( + "".into(), + Some(description.into()), + old_category, + adding_by, + ), + Err(UpdateCategoryCommandError::NameIsEmpty) + ) + } +}