diff --git a/src/inventory/domain/add_product_command.rs b/src/inventory/domain/add_product_command.rs new file mode 100644 index 0000000..045a808 --- /dev/null +++ b/src/inventory/domain/add_product_command.rs @@ -0,0 +1,224 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use derive_builder::Builder; +use derive_getters::Getters; +use derive_more::{Display, Error}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::product_aggregate::Price; + +#[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum AddProductCommandError { + NameIsEmpty, +} + +#[derive( + Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct UnvalidatedAddProductCommand { + name: String, + description: Option, + image: Option, + category_id: Uuid, + sku_able: bool, + price: Price, + adding_by: Uuid, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)] +pub struct AddProductCommand { + name: String, + description: Option, + image: Option, + category_id: Uuid, + sku_able: bool, + price: Price, + adding_by: Uuid, +} + +impl UnvalidatedAddProductCommand { + pub fn validate(self) -> Result { + let description: Option = if let Some(description) = self.description { + let description = description.trim(); + if description.is_empty() { + None + } else { + Some(description.to_owned()) + } + } else { + None + }; + + let image: Option = if let Some(image) = self.image { + let image = image.trim(); + if image.is_empty() { + None + } else { + Some(image.to_owned()) + } + } else { + None + }; + + let name = self.name.trim().to_owned(); + if name.is_empty() { + return Err(AddProductCommandError::NameIsEmpty); + } + + Ok(AddProductCommand { + name, + description, + image, + category_id: self.category_id, + sku_able: self.sku_able, + price: self.price, + adding_by: self.adding_by, + }) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + use crate::{ + inventory::domain::product_aggregate::{Currency, PriceBuilder}, + utils::uuid::tests::UUID, + }; + + pub fn get_command() -> AddProductCommand { + let name = "foo"; + let adding_by = UUID; + let category_id = Uuid::new_v4(); + let sku_able = false; + let image = Some("image".to_string()); + let description = Some("description".to_string()); + + let price = PriceBuilder::default() + .minor(0) + .major(100) + .currency(Currency::INR) + .build() + .unwrap(); + + let cmd = UnvalidatedAddProductCommandBuilder::default() + .name(name.into()) + .description(description.clone()) + .image(image.clone()) + .category_id(category_id.clone()) + .adding_by(adding_by.clone()) + .sku_able(sku_able) + .price(price.clone()) + .build() + .unwrap(); + + cmd.validate().unwrap() + } + + #[test] + fn test_description_and_image_none() { + let name = "foo"; + let adding_by = UUID; + let category_id = Uuid::new_v4(); + let sku_able = false; + + let price = PriceBuilder::default() + .minor(0) + .major(100) + .currency(Currency::INR) + .build() + .unwrap(); + + // description = None + let cmd = UnvalidatedAddProductCommandBuilder::default() + .name(name.into()) + .description(None) + .image(None) + .category_id(category_id.clone()) + .adding_by(adding_by.clone()) + .sku_able(sku_able) + .price(price.clone()) + .build() + .unwrap(); + + let cmd = cmd.validate().unwrap(); + + assert_eq!(cmd.name(), name); + assert_eq!(cmd.description(), &None); + assert_eq!(cmd.adding_by(), &adding_by); + assert_eq!(cmd.category_id(), &category_id); + assert_eq!(cmd.image(), &None); + assert_eq!(cmd.sku_able(), &sku_able); + assert_eq!(cmd.price(), &price); + } + #[test] + fn test_description_some() { + let name = "foo"; + let adding_by = UUID; + let category_id = Uuid::new_v4(); + let sku_able = false; + let image = Some("image".to_string()); + let description = Some("description".to_string()); + + let price = PriceBuilder::default() + .minor(0) + .major(100) + .currency(Currency::INR) + .build() + .unwrap(); + + let cmd = UnvalidatedAddProductCommandBuilder::default() + .name(name.into()) + .description(description.clone()) + .image(image.clone()) + .category_id(category_id.clone()) + .adding_by(adding_by.clone()) + .sku_able(sku_able) + .price(price.clone()) + .build() + .unwrap(); + + let cmd = cmd.validate().unwrap(); + + assert_eq!(cmd.name(), name); + assert_eq!(cmd.description(), &description); + assert_eq!(cmd.adding_by(), &adding_by); + assert_eq!(cmd.category_id(), &category_id); + assert_eq!(cmd.image(), &image); + assert_eq!(cmd.sku_able(), &sku_able); + assert_eq!(cmd.price(), &price); + } + + #[test] + fn test_name_is_empty() { + let adding_by = UUID; + let category_id = Uuid::new_v4(); + let sku_able = false; + let image = Some("image".to_string()); + let description = Some("description".to_string()); + + let price = PriceBuilder::default() + .minor(0) + .major(100) + .currency(Currency::INR) + .build() + .unwrap(); + + let cmd = UnvalidatedAddProductCommandBuilder::default() + .name("".into()) + .description(description.clone()) + .image(image.clone()) + .category_id(category_id.clone()) + .adding_by(adding_by.clone()) + .sku_able(sku_able) + .price(price.clone()) + .build() + .unwrap(); + + // AddProductCommandError::NameIsEmpty + assert_eq!(cmd.validate(), Err(AddProductCommandError::NameIsEmpty)) + } +} diff --git a/src/inventory/domain/commands.rs b/src/inventory/domain/commands.rs index f87fdf9..63ce9c5 100644 --- a/src/inventory/domain/commands.rs +++ b/src/inventory/domain/commands.rs @@ -5,10 +5,14 @@ use mockall::predicate::*; use serde::{Deserialize, Serialize}; -use super::{add_category_command::AddCategoryCommand, add_store_command::AddStoreCommand}; +use super::{ + add_category_command::AddCategoryCommand, add_product_command::AddProductCommand, + add_store_command::AddStoreCommand, +}; #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] pub enum InventoryCommand { AddCategory(AddCategoryCommand), AddStore(AddStoreCommand), + AddProduct(AddProductCommand), } diff --git a/src/inventory/domain/events.rs b/src/inventory/domain/events.rs index f487a8a..03c64eb 100644 --- a/src/inventory/domain/events.rs +++ b/src/inventory/domain/events.rs @@ -5,16 +5,18 @@ use cqrs_es::DomainEvent; use serde::{Deserialize, Serialize}; -use super::{category_added_event::*, store_added_event::StoreAddedEvent}; +use super::{ + category_added_event::*, product_added_event::ProductAddedEvent, + store_added_event::StoreAddedEvent, +}; #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] pub enum InventoryEvent { CategoryAdded(CategoryAddedEvent), StoreAdded(StoreAddedEvent), + ProductAdded(ProductAddedEvent), } -//TODO: define password type that takes string and converts to hash - impl DomainEvent for InventoryEvent { fn event_version(&self) -> String { "1.0".to_string() @@ -23,7 +25,8 @@ impl DomainEvent for InventoryEvent { fn event_type(&self) -> String { let e: &str = match self { InventoryEvent::CategoryAdded { .. } => "InventoryCategoryAdded", - InventoryEvent::StoreAdded { .. } => "InventoryStoredded", + InventoryEvent::StoreAdded { .. } => "InventoryStoreAdded", + InventoryEvent::ProductAdded { .. } => "InventoryProductAdded", }; e.to_string() diff --git a/src/inventory/domain/mod.rs b/src/inventory/domain/mod.rs index bce356e..89649c4 100644 --- a/src/inventory/domain/mod.rs +++ b/src/inventory/domain/mod.rs @@ -3,18 +3,19 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // aggregates -//pub mod money_aggregate; -//pub mod product_aggregate; pub mod category_aggregate; +pub mod product_aggregate; //pub mod stock_aggregate; pub mod store_aggregate; // commands pub mod add_category_command; +pub mod add_product_command; pub mod add_store_command; pub mod commands; // events pub mod category_added_event; pub mod events; +pub mod product_added_event; pub mod store_added_event; diff --git a/src/inventory/domain/product_added_event.rs b/src/inventory/domain/product_added_event.rs new file mode 100644 index 0000000..48f7637 --- /dev/null +++ b/src/inventory/domain/product_added_event.rs @@ -0,0 +1,48 @@ +// 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::product_aggregate::Price; + +#[derive( + Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd, +)] +pub struct ProductAddedEvent { + added_by_user: Uuid, + + name: String, + description: Option, + image: Option, // string = file_name + price: Price, + category_id: Uuid, + sku_able: bool, + product_id: Uuid, +} + +#[cfg(test)] +pub mod tests { + use crate::inventory::domain::add_product_command::AddProductCommand; + + use super::*; + + use crate::utils::uuid::tests::UUID; + + pub fn get_event_from_command(cmd: &AddProductCommand) -> ProductAddedEvent { + ProductAddedEventBuilder::default() + .name(cmd.name().into()) + .description(cmd.description().as_ref().map(|s| s.to_string())) + .image(cmd.image().as_ref().map(|s| s.to_string())) + .sku_able(cmd.sku_able().clone()) + .category_id(cmd.category_id().clone()) + .product_id(UUID.clone()) + .price(cmd.price().clone()) + .added_by_user(cmd.adding_by().clone()) + .build() + .unwrap() + } +} diff --git a/src/inventory/domain/product_aggregate.rs b/src/inventory/domain/product_aggregate.rs new file mode 100644 index 0000000..dff8b0b --- /dev/null +++ b/src/inventory/domain/product_aggregate.rs @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::str::FromStr; + +use async_trait::async_trait; +use cqrs_es::Aggregate; +use derive_builder::Builder; +use derive_getters::Getters; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{commands::InventoryCommand, events::InventoryEvent}; +use crate::inventory::application::services::errors::*; +use crate::inventory::application::services::InventoryServicesInterface; + +#[derive( + Clone, Debug, Default, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct Product { + name: String, + description: Option, + image: Option, // string = file_name + price: Price, + category_id: Uuid, + sku_able: bool, + product_id: Uuid, +} + +#[derive( + Clone, Default, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder, +)] +pub struct Price { + major: usize, + minor: usize, + currency: Currency, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] +pub enum Currency { + INR, +} + +impl ToString for Currency { + fn to_string(&self) -> String { + match self { + Self::INR => "INR".into(), + } + } +} + +impl FromStr for Currency { + type Err = String; + fn from_str(s: &str) -> Result { + let s = s.trim(); + let inr = Self::INR.to_string(); + match s { + inr => Ok(Self::INR), + _ => Err("Currency unsupported".into()), + } + } +} + +impl Default for Currency { + fn default() -> Self { + Self::INR + } +} + +#[async_trait] +impl Aggregate for Product { + type Command = InventoryCommand; + type Event = InventoryEvent; + type Error = InventoryError; + type Services = std::sync::Arc; + + // This identifier should be unique to the system. + fn aggregate_type() -> String { + "inventory.product".to_string() + } + + // The aggregate logic goes here. Note that this will be the _bulk_ of a CQRS system + // so expect to use helper functions elsewhere to keep the code clean. + async fn handle( + &self, + command: Self::Command, + services: &Self::Services, + ) -> Result, Self::Error> { + match command { + InventoryCommand::AddProduct(cmd) => { + let res = services.add_product().add_product(cmd).await?; + Ok(vec![InventoryEvent::ProductAdded(res)]) + } + _ => Ok(Vec::default()), + } + } + + fn apply(&mut self, event: Self::Event) { + match event { + InventoryEvent::ProductAdded(e) => { + *self = ProductBuilder::default() + .name(e.name().into()) + .description(e.description().clone()) + .image(e.image().clone()) + .price(e.price().clone()) + .category_id(e.category_id().clone()) + .sku_able(e.sku_able().clone()) + .product_id(e.product_id().clone()) + .build() + .unwrap(); + } + _ => (), + } + } +} + +#[cfg(test)] +mod aggregate_tests { + use std::sync::Arc; + + use cqrs_es::test::TestFramework; + + use super::*; + use crate::inventory::{ + application::services::{add_product_service::tests::*, *}, + domain::{ + add_product_command::tests::get_command, commands::InventoryCommand, + events::InventoryEvent, product_added_event::tests::get_event_from_command, + }, + }; + use crate::tests::bdd::*; + + type ProductTestFramework = TestFramework; + + #[test] + fn test_create_product() { + let cmd = get_command(); + let expected = get_event_from_command(&cmd); + let expected = InventoryEvent::ProductAdded(expected); + + let mut services = MockInventoryServicesInterface::new(); + services + .expect_add_product() + .times(IS_CALLED_ONLY_ONCE.unwrap()) + .return_const(mock_add_product_service(IS_CALLED_ONLY_ONCE, cmd.clone())); + + ProductTestFramework::with(Arc::new(services)) + .given_no_previous_events() + .when(InventoryCommand::AddProduct(cmd)) + .then_expect_events(vec![expected]); + } + + #[test] + fn currency_to_string_from_str() { + assert_eq!(Currency::INR.to_string(), "INR".to_string()); + + assert_eq!(Currency::from_str("INR").unwrap(), Currency::INR); + + assert_eq!( + Currency::from_str(Currency::INR.to_string().as_str()).unwrap(), + Currency::INR + ); + } +}