diff --git a/.sqlx/query-3c82b22884858afc80ab013abddc072c816f5ea94eac454406f5eaa3129dd180.json b/.sqlx/query-3c82b22884858afc80ab013abddc072c816f5ea94eac454406f5eaa3129dd180.json new file mode 100644 index 0000000..ed65490 --- /dev/null +++ b/.sqlx/query-3c82b22884858afc80ab013abddc072c816f5ea94eac454406f5eaa3129dd180.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO cqrs_inventory_product_query (\n version,\n name,\n description,\n image,\n product_id,\n category_id,\n price_major,\n price_minor,\n price_currency,\n sku_able\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n );", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Uuid", + "Uuid", + "Int4", + "Int4", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "3c82b22884858afc80ab013abddc072c816f5ea94eac454406f5eaa3129dd180" +} diff --git a/.sqlx/query-4389b997a21aa5184102aaf7c90fc527776e7a753b12bf7035904dedde904aae.json b/.sqlx/query-4389b997a21aa5184102aaf7c90fc527776e7a753b12bf7035904dedde904aae.json new file mode 100644 index 0000000..923f7f5 --- /dev/null +++ b/.sqlx/query-4389b997a21aa5184102aaf7c90fc527776e7a753b12bf7035904dedde904aae.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n name,\n description,\n image,\n product_id,\n category_id,\n price_major,\n price_minor,\n price_currency,\n sku_able\n FROM\n cqrs_inventory_product_query\n WHERE\n product_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "image", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "product_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "category_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "price_major", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "price_minor", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "price_currency", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "sku_able", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + true, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "4389b997a21aa5184102aaf7c90fc527776e7a753b12bf7035904dedde904aae" +} diff --git a/.sqlx/query-4d3524bb1742ba55267ab53322cf6c6942d6fc4057b65327f8c6d79ef21cc064.json b/.sqlx/query-4d3524bb1742ba55267ab53322cf6c6942d6fc4057b65327f8c6d79ef21cc064.json new file mode 100644 index 0000000..cc910f8 --- /dev/null +++ b/.sqlx/query-4d3524bb1742ba55267ab53322cf6c6942d6fc4057b65327f8c6d79ef21cc064.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE\n cqrs_inventory_product_query\n SET\n version = $1,\n name = $2,\n description = $3,\n image = $4,\n product_id = $5,\n category_id = $6,\n price_major = $7,\n price_minor = $8,\n price_currency = $9,\n sku_able = $10;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Uuid", + "Uuid", + "Int4", + "Int4", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "4d3524bb1742ba55267ab53322cf6c6942d6fc4057b65327f8c6d79ef21cc064" +} diff --git a/.sqlx/query-53a5e7e87387ffc14013067a24c80b9d14c9864875177af5320c926d68cfb4ae.json b/.sqlx/query-53a5e7e87387ffc14013067a24c80b9d14c9864875177af5320c926d68cfb4ae.json new file mode 100644 index 0000000..5050200 --- /dev/null +++ b/.sqlx/query-53a5e7e87387ffc14013067a24c80b9d14c9864875177af5320c926d68cfb4ae.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_inventory_product_query\n WHERE\n name = $1\n AND\n category_id = $2\n );", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "53a5e7e87387ffc14013067a24c80b9d14c9864875177af5320c926d68cfb4ae" +} diff --git a/.sqlx/query-e20284d3953f5cba62c63b3a9482cea0739c072a7d83ce29ded99342d203766f.json b/.sqlx/query-e20284d3953f5cba62c63b3a9482cea0739c072a7d83ce29ded99342d203766f.json new file mode 100644 index 0000000..7ad7bd5 --- /dev/null +++ b/.sqlx/query-e20284d3953f5cba62c63b3a9482cea0739c072a7d83ce29ded99342d203766f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_inventory_product_query\n WHERE\n product_id = $1\n );", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e20284d3953f5cba62c63b3a9482cea0739c072a7d83ce29ded99342d203766f" +} diff --git a/.sqlx/query-fbd8fa5a39f6c5351e7e366ad385c02cfe11dbee292fc0cb80a01228a3cceb93.json b/.sqlx/query-fbd8fa5a39f6c5351e7e366ad385c02cfe11dbee292fc0cb80a01228a3cceb93.json new file mode 100644 index 0000000..cf43695 --- /dev/null +++ b/.sqlx/query-fbd8fa5a39f6c5351e7e366ad385c02cfe11dbee292fc0cb80a01228a3cceb93.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT \n product_id, version\n FROM\n cqrs_inventory_product_query\n WHERE\n product_id = $1;", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "product_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "version", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "fbd8fa5a39f6c5351e7e366ad385c02cfe11dbee292fc0cb80a01228a3cceb93" +} diff --git a/migrations/20240715113708_cqrs_inventory_product_query.sql b/migrations/20240715113708_cqrs_inventory_product_query.sql new file mode 100644 index 0000000..0036ba6 --- /dev/null +++ b/migrations/20240715113708_cqrs_inventory_product_query.sql @@ -0,0 +1,27 @@ +-- SPDX-FileCopyrightText: 2024 Aravinth Manivannan +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +CREATE TABLE IF NOT EXISTS cqrs_inventory_product_query +( + version bigint CHECK (version >= 0) NOT NULL, + + name TEXT NOT NULL, + description TEXT, + image TEXT, + sku_able BOOLEAN NOT NULL DEFAULT FALSE, + product_id UUID NOT NULL UNIQUE, + + + price_minor INTEGER NOT NULL, + price_major INTEGER NOT NULL, + price_currency TEXT NOT NULL, + + + category_id UUID NOT NULL, + + deleted BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE(category_id, name), + + PRIMARY KEY (product_id) +); diff --git a/src/inventory/adapters/output/db/postgres/errors.rs b/src/inventory/adapters/output/db/postgres/errors.rs index e4e9378..a3ac3f6 100644 --- a/src/inventory/adapters/output/db/postgres/errors.rs +++ b/src/inventory/adapters/output/db/postgres/errors.rs @@ -17,6 +17,14 @@ impl From for InventoryDBError { let msg = err.message(); if msg.contains("cqrs_inventory_store_query_store_id_key") { return Self::DuplicateStoreID; + } else if msg.contains("cqrs_inventory_store_query_product_id_key") { + return Self::DuplicateProductID; + } else if msg.contains("cqrs_inventory_store_query_category_id_key") { + return Self::DuplicateCategoryID; + } else if msg.contains("cqrs_inventory_product_query_name_key") { + return Self::DuplicateProductName; + } else if msg.contains("cqrs_inventory_category_query_name_key") { + return Self::DuplicateProductName; } else if msg.contains("cqrs_inventory_store_query_name_key") { return Self::DuplicateStoreName; } else { diff --git a/src/inventory/adapters/output/db/postgres/mod.rs b/src/inventory/adapters/output/db/postgres/mod.rs index 7baaec0..9c106f3 100644 --- a/src/inventory/adapters/output/db/postgres/mod.rs +++ b/src/inventory/adapters/output/db/postgres/mod.rs @@ -12,6 +12,9 @@ mod category_id_exists; mod category_name_exists_for_store; mod category_view; mod errors; +mod product_id_exists; +mod product_name_exists_for_category; +mod product_view; mod store_id_exists; mod store_name_exists; mod store_view; diff --git a/src/inventory/adapters/output/db/postgres/product_id_exists.rs b/src/inventory/adapters/output/db/postgres/product_id_exists.rs new file mode 100644 index 0000000..9d8b076 --- /dev/null +++ b/src/inventory/adapters/output/db/postgres/product_id_exists.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later +use uuid::Uuid; + +use super::InventoryDBPostgresAdapter; +use crate::inventory::application::port::output::db::{errors::*, product_id_exists::*}; + +#[async_trait::async_trait] +impl ProductIDExistsDBPort for InventoryDBPostgresAdapter { + async fn product_id_exists(&self, product_id: &Uuid) -> InventoryDBResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_inventory_product_query + WHERE + product_id = $1 + );", + product_id + ) + .fetch_one(&self.pool) + .await?; + if let Some(x) = res.exists { + Ok(x) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +pub mod tests { + + use super::*; + use crate::inventory::domain::{add_product_command::tests::get_command, product_aggregate::*}; + use crate::utils::uuid::tests::UUID; + + #[actix_rt::test] + async fn test_postgres_product_exists() { + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::InventoryDBPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let cmd = get_command(); + + let product = ProductBuilder::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()) + .build() + .unwrap(); + + // state doesn't exist + assert!(!db.product_id_exists(product.product_id()).await.unwrap()); + + create_dummy_product_record(&product, &db).await; + + // state exists + assert!(db.product_id_exists(product.product_id()).await.unwrap()); + + settings.drop_db().await; + } + + pub async fn create_dummy_product_record(p: &Product, db: &InventoryDBPostgresAdapter) { + sqlx::query!( + "INSERT INTO cqrs_inventory_product_query ( + version, + name, + description, + image, + product_id, + category_id, + price_major, + price_minor, + price_currency, + sku_able + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + );", + 1, + p.name(), + p.description().as_ref().unwrap(), + p.image().as_ref().unwrap(), + p.product_id(), + p.category_id(), + p.price().major().clone() as i32, + p.price().minor().clone() as i32, + p.price().currency().to_string(), + p.sku_able().clone() + ) + .execute(&db.pool) + .await + .unwrap(); + } +} diff --git a/src/inventory/adapters/output/db/postgres/product_name_exists_for_category.rs b/src/inventory/adapters/output/db/postgres/product_name_exists_for_category.rs new file mode 100644 index 0000000..c2f1f45 --- /dev/null +++ b/src/inventory/adapters/output/db/postgres/product_name_exists_for_category.rs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::InventoryDBPostgresAdapter; +use crate::inventory::application::port::output::db::{ + errors::*, product_name_exists_for_category::*, +}; +use crate::inventory::domain::product_aggregate::*; + +#[async_trait::async_trait] +impl ProductNameExistsForCategoryDBPort for InventoryDBPostgresAdapter { + async fn product_name_exists_for_category(&self, s: &Product) -> InventoryDBResult { + let res = sqlx::query!( + "SELECT EXISTS ( + SELECT 1 + FROM cqrs_inventory_product_query + WHERE + name = $1 + AND + category_id = $2 + );", + s.name(), + s.category_id(), + ) + .fetch_one(&self.pool) + .await?; + if let Some(x) = res.exists { + Ok(x) + } else { + Ok(false) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inventory::adapters::output::db::postgres::product_id_exists::tests::create_dummy_product_record; + use crate::inventory::domain::add_product_command::tests::get_command; + use crate::utils::uuid::tests::UUID; + + #[actix_rt::test] + async fn test_postgres_product_exists() { + let product_name = "foo_product"; + + let settings = crate::settings::tests::get_settings().await; + settings.create_db().await; + let db = super::InventoryDBPostgresAdapter::new( + sqlx::postgres::PgPool::connect(&settings.database.url) + .await + .unwrap(), + ); + + let cmd = get_command(); + + let product = ProductBuilder::default() + .name(product_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()) + .build() + .unwrap(); + + // state doesn't exist + assert!(!db.product_name_exists_for_category(&product).await.unwrap()); + + create_dummy_product_record(&product, &db).await; + + // state exists + assert!(db.product_name_exists_for_category(&product).await.unwrap()); + + settings.drop_db().await; + } +} diff --git a/src/inventory/adapters/output/db/postgres/product_view.rs b/src/inventory/adapters/output/db/postgres/product_view.rs new file mode 100644 index 0000000..be06416 --- /dev/null +++ b/src/inventory/adapters/output/db/postgres/product_view.rs @@ -0,0 +1,265 @@ +// 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::persist::{PersistenceError, ViewContext, ViewRepository}; +use cqrs_es::{EventEnvelope, Query, View}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::errors::*; +use super::InventoryDBPostgresAdapter; +use crate::inventory::domain::events::InventoryEvent; +use crate::inventory::domain::product_aggregate::{ + Currency, PriceBuilder, Product, ProductBuilder, +}; +use crate::utils::parse_aggregate_id::parse_aggregate_id; + +pub const NEW_PRODUCT_NON_UUID: &str = "new_product_non_uuid-asdfa"; + +// The view for a Product query, for a standard http application this should +// be designed to reflect the response dto that will be returned to a user. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ProductView { + name: String, + description: Option, + image: Option, // string = filename + product_id: Uuid, + sku_able: bool, + + price_minor: i32, + price_major: i32, + price_currency: String, + + category_id: Uuid, +} + +impl From for Product { + fn from(v: ProductView) -> Self { + let price = PriceBuilder::default() + .minor(v.price_minor as usize) + .major(v.price_major as usize) + .currency(Currency::from_str(&v.price_currency).unwrap()) + .build() + .unwrap(); + + ProductBuilder::default() + .name(v.name) + .description(v.description) + .image(v.image) + .sku_able(v.sku_able) + .price(price) + .category_id(v.category_id) + .product_id(v.product_id) + .build() + .unwrap() + } +} + +// This updates the view with events as they are committed. +// The logic should be minimal here, e.g., don't calculate the account balance, +// design the events to carry the balance information instead. +impl View for ProductView { + fn update(&mut self, event: &EventEnvelope) { + match &event.payload { + InventoryEvent::ProductAdded(val) => { + self.name = val.name().into(); + self.description = val.description().clone(); + self.image = val.image().clone(); + self.product_id = val.product_id().clone(); + self.category_id = val.category_id().clone(); + + self.sku_able = val.sku_able().clone(); + + self.price_minor = val.price().minor().clone() as i32; + self.price_major = val.price().major().clone() as i32; + self.price_currency = val.price().currency().to_string(); + } + _ => (), + } + } +} + +#[async_trait] +impl ViewRepository for InventoryDBPostgresAdapter { + async fn load(&self, product_id: &str) -> Result, PersistenceError> { + let product_id = match parse_aggregate_id(product_id, NEW_PRODUCT_NON_UUID)? { + Some((val, _)) => return Ok(Some(val)), + None => Uuid::parse_str(product_id).unwrap(), + }; + + let res = sqlx::query_as!( + ProductView, + "SELECT + name, + description, + image, + product_id, + category_id, + price_major, + price_minor, + price_currency, + sku_able + FROM + cqrs_inventory_product_query + WHERE + product_id = $1;", + product_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + Ok(Some(res)) + } + + async fn load_with_context( + &self, + product_id: &str, + ) -> Result, PersistenceError> { + let product_id = match parse_aggregate_id(product_id, NEW_PRODUCT_NON_UUID)? { + Some(val) => return Ok(Some(val)), + None => Uuid::parse_str(product_id).unwrap(), + }; + + let res = sqlx::query_as!( + ProductView, + "SELECT + name, + description, + image, + product_id, + category_id, + price_major, + price_minor, + price_currency, + sku_able + FROM + cqrs_inventory_product_query + WHERE + product_id = $1;", + product_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + struct Context { + version: i64, + product_id: Uuid, + } + + let ctx = sqlx::query_as!( + Context, + "SELECT + product_id, version + FROM + cqrs_inventory_product_query + WHERE + product_id = $1;", + product_id + ) + .fetch_one(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + + let view_context = ViewContext::new(ctx.product_id.to_string(), ctx.version); + Ok(Some((res, view_context))) + } + + async fn update_view( + &self, + view: ProductView, + context: ViewContext, + ) -> Result<(), PersistenceError> { + match context.version { + 0 => { + let version = context.version + 1; + sqlx::query!( + "INSERT INTO cqrs_inventory_product_query ( + version, + name, + description, + image, + product_id, + category_id, + price_major, + price_minor, + price_currency, + sku_able + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + );", + version, + view.name, + view.description, + view.image, + view.product_id, + view.category_id, + view.price_major, + view.price_minor, + view.price_currency, + view.sku_able + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + _ => { + let version = context.version + 1; + sqlx::query!( + "UPDATE + cqrs_inventory_product_query + SET + version = $1, + name = $2, + description = $3, + image = $4, + product_id = $5, + category_id = $6, + price_major = $7, + price_minor = $8, + price_currency = $9, + sku_able = $10;", + version, + view.name, + view.description, + view.image, + view.product_id, + view.category_id, + view.price_major, + view.price_minor, + view.price_currency, + view.sku_able + ) + .execute(&self.pool) + .await + .map_err(PostgresAggregateError::from)?; + } + } + + Ok(()) + } +} + +#[async_trait] +impl Query for InventoryDBPostgresAdapter { + async fn dispatch(&self, product_id: &str, events: &[EventEnvelope]) { + let res = self + .load_with_context(&product_id) + .await + .unwrap_or_else(|_| { + Some(( + ProductView::default(), + ViewContext::new(product_id.into(), 0), + )) + }); + let (mut view, view_context): (ProductView, ViewContext) = res.unwrap(); + for event in events { + view.update(event); + } + self.update_view(view, view_context).await.unwrap(); + } +} diff --git a/src/inventory/adapters/output/db/postgres/store_view.rs b/src/inventory/adapters/output/db/postgres/store_view.rs index 473f24a..3743734 100644 --- a/src/inventory/adapters/output/db/postgres/store_view.rs +++ b/src/inventory/adapters/output/db/postgres/store_view.rs @@ -210,11 +210,12 @@ mod tests { inventory::{ application::services::{ add_category_service::tests::mock_add_category_service, + add_product_service::tests::mock_add_product_service, add_store_service::AddStoreServiceBuilder, InventoryServicesBuilder, }, domain::{ - add_category_command::AddCategoryCommand, add_store_command::AddStoreCommand, - commands::InventoryCommand, + add_category_command::AddCategoryCommand, add_product_command::tests::get_command, + add_store_command::AddStoreCommand, commands::InventoryCommand, }, }, tests::bdd::IS_NEVER_CALLED, @@ -250,6 +251,7 @@ mod tests { IS_NEVER_CALLED, AddCategoryCommand::new("foo".into(), None, UUID.clone(), UUID.clone()).unwrap(), )) + .add_product(mock_add_product_service(IS_NEVER_CALLED, get_command())) .build() .unwrap(); diff --git a/src/inventory/application/port/output/db/errors.rs b/src/inventory/application/port/output/db/errors.rs index 5066b15..66a7560 100644 --- a/src/inventory/application/port/output/db/errors.rs +++ b/src/inventory/application/port/output/db/errors.rs @@ -10,7 +10,10 @@ pub type InventoryDBResult = Result; #[derive(Debug, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub enum InventoryDBError { DuplicateCategoryName, + DuplicateCategoryID, DuplicateStoreName, DuplicateStoreID, + DuplicateProductName, + DuplicateProductID, InternalError, } diff --git a/src/inventory/application/port/output/db/mod.rs b/src/inventory/application/port/output/db/mod.rs index d5741ec..599269b 100644 --- a/src/inventory/application/port/output/db/mod.rs +++ b/src/inventory/application/port/output/db/mod.rs @@ -5,5 +5,7 @@ pub mod category_id_exists; pub mod category_name_exists_for_store; pub mod errors; +pub mod product_id_exists; +pub mod product_name_exists_for_category; pub mod store_id_exists; pub mod store_name_exists; diff --git a/src/inventory/application/port/output/db/product_id_exists.rs b/src/inventory/application/port/output/db/product_id_exists.rs new file mode 100644 index 0000000..ba3675f --- /dev/null +++ b/src/inventory/application/port/output/db/product_id_exists.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; +use uuid::Uuid; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait ProductIDExistsDBPort: Send + Sync { + async fn product_id_exists(&self, c: &Uuid) -> InventoryDBResult; +} + +pub type ProductIDExistsDBPortObj = std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_product_id_exists_db_port_false(times: Option) -> ProductIDExistsDBPortObj { + let mut m = MockProductIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_product_id_exists() + .times(times) + .returning(|_| Ok(false)); + } else { + m.expect_product_id_exists().returning(|_| Ok(false)); + } + + Arc::new(m) + } + + pub fn mock_product_id_exists_db_port_true(times: Option) -> ProductIDExistsDBPortObj { + let mut m = MockProductIDExistsDBPort::new(); + if let Some(times) = times { + m.expect_product_id_exists() + .times(times) + .returning(|_| Ok(true)); + } else { + m.expect_product_id_exists().returning(|_| Ok(true)); + } + + Arc::new(m) + } +} diff --git a/src/inventory/application/port/output/db/product_name_exists_for_category.rs b/src/inventory/application/port/output/db/product_name_exists_for_category.rs new file mode 100644 index 0000000..222faf7 --- /dev/null +++ b/src/inventory/application/port/output/db/product_name_exists_for_category.rs @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2024 Aravinth Manivannan +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use mockall::predicate::*; +use mockall::*; + +use crate::inventory::domain::product_aggregate::Product; + +use super::errors::*; +#[cfg(test)] +#[allow(unused_imports)] +pub use tests::*; + +#[automock] +#[async_trait::async_trait] +pub trait ProductNameExistsForCategoryDBPort: Send + Sync { + async fn product_name_exists_for_category(&self, c: &Product) -> InventoryDBResult; +} + +pub type ProductNameExistsForCategoryDBPortObj = + std::sync::Arc; + +#[cfg(test)] +pub mod tests { + use super::*; + + use std::sync::Arc; + + pub fn mock_product_name_exists_for_category_db_port_false( + times: Option, + ) -> ProductNameExistsForCategoryDBPortObj { + let mut m = MockProductNameExistsForCategoryDBPort::new(); + if let Some(times) = times { + m.expect_product_name_exists_for_category() + .times(times) + .returning(|_| Ok(false)); + } else { + m.expect_product_name_exists_for_category() + .returning(|_| Ok(false)); + } + + Arc::new(m) + } + + pub fn mock_product_name_exists_for_category_db_port_true( + times: Option, + ) -> ProductNameExistsForCategoryDBPortObj { + let mut m = MockProductNameExistsForCategoryDBPort::new(); + if let Some(times) = times { + m.expect_product_name_exists_for_category() + .times(times) + .returning(|_| Ok(true)); + } else { + m.expect_product_name_exists_for_category() + .returning(|_| Ok(true)); + } + + Arc::new(m) + } +} diff --git a/src/inventory/application/services/add_product_service.rs b/src/inventory/application/services/add_product_service.rs new file mode 100644 index 0000000..de5fe7f --- /dev/null +++ b/src/inventory/application/services/add_product_service.rs @@ -0,0 +1,169 @@ +// 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::{product_id_exists::*, product_name_exists_for_category::*}, + domain::{ + add_product_command::AddProductCommand, + product_added_event::{ProductAddedEvent, ProductAddedEventBuilder}, + product_aggregate::*, + }, +}; +use crate::utils::uuid::*; + +#[automock] +#[async_trait::async_trait] +pub trait AddProductUseCase: Send + Sync { + async fn add_product(&self, cmd: AddProductCommand) -> InventoryResult; +} + +pub type AddProductServiceObj = Arc; + +#[derive(Clone, Builder)] +pub struct AddProductService { + db_product_name_exists_for_category: ProductNameExistsForCategoryDBPortObj, + db_product_id_exists: ProductIDExistsDBPortObj, + get_uuid: GetUUIDInterfaceObj, +} + +#[async_trait::async_trait] +impl AddProductUseCase for AddProductService { + async fn add_product(&self, cmd: AddProductCommand) -> InventoryResult { + let mut product_id = self.get_uuid.get_uuid(); + + loop { + if self + .db_product_id_exists + .product_id_exists(&product_id) + .await? + { + product_id = self.get_uuid.get_uuid(); + continue; + } else { + break; + } + } + + let product = ProductBuilder::default() + .name(cmd.name().into()) + .description(cmd.description().as_ref().map(|s| s.to_string())) + .image(cmd.image().clone()) + .sku_able(cmd.sku_able().clone()) + .price(cmd.price().clone()) + .category_id(cmd.category_id().clone()) + .product_id(product_id) + .build() + .unwrap(); + + if self + .db_product_name_exists_for_category + .product_name_exists_for_category(&product) + .await? + { + return Err(InventoryError::DuplicateProductName); + } + + Ok(ProductAddedEventBuilder::default() + .added_by_user(cmd.adding_by().clone()) + .name(product.name().into()) + .description(product.description().as_ref().map(|s| s.to_string())) + .image(product.image().clone()) + .sku_able(product.sku_able().clone()) + .price(product.price().clone()) + .category_id(product.category_id().clone()) + .product_id(product.product_id().clone()) + .build() + .unwrap()) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + + use uuid::Uuid; + + use crate::inventory::domain::add_product_command::tests::get_command; + use crate::utils::uuid::tests::UUID; + use crate::{tests::bdd::*, utils::uuid::tests::mock_get_uuid}; + + pub fn mock_add_product_service( + times: Option, + cmd: AddProductCommand, + ) -> AddProductServiceObj { + let mut m = MockAddProductUseCase::new(); + + let res = 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(); + + if let Some(times) = times { + m.expect_add_product() + .times(times) + .returning(move |_| Ok(res.clone())); + } else { + m.expect_add_product().returning(move |_| Ok(res.clone())); + } + + Arc::new(m) + } + + #[actix_rt::test] + async fn test_service_product_doesnt_exist() { + let cmd = get_command(); + + let s = AddProductServiceBuilder::default() + .db_product_name_exists_for_category( + mock_product_name_exists_for_category_db_port_false(IS_CALLED_ONLY_ONCE), + ) + .db_product_id_exists(mock_product_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .get_uuid(mock_get_uuid(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + let res = s.add_product(cmd.clone()).await.unwrap(); + assert_eq!(res.name(), cmd.name()); + assert_eq!(res.description(), cmd.description()); + assert_eq!(res.image(), cmd.image()); + assert_eq!(res.sku_able(), cmd.sku_able()); + assert_eq!(res.price(), cmd.price()); + assert_eq!(res.added_by_user(), cmd.adding_by()); + assert_eq!(res.category_id(), cmd.category_id()); + assert_eq!(res.product_id(), &UUID); + } + + #[actix_rt::test] + async fn test_service_product_name_exists_for_store() { + let cmd = get_command(); + + let s = AddProductServiceBuilder::default() + .db_product_name_exists_for_category( + mock_product_name_exists_for_category_db_port_true(IS_CALLED_ONLY_ONCE), + ) + .get_uuid(mock_get_uuid(IS_CALLED_ONLY_ONCE)) + .db_product_id_exists(mock_product_id_exists_db_port_false(IS_CALLED_ONLY_ONCE)) + .build() + .unwrap(); + + assert_eq!( + s.add_product(cmd.clone()).await, + Err(InventoryError::DuplicateProductName) + ) + } +} diff --git a/src/inventory/application/services/errors.rs b/src/inventory/application/services/errors.rs index 7bb9fb2..e1a5a70 100644 --- a/src/inventory/application/services/errors.rs +++ b/src/inventory/application/services/errors.rs @@ -14,6 +14,7 @@ pub type InventoryResult = Result; pub enum InventoryError { DuplicateCategoryName, DuplicateStoreName, + DuplicateProductName, InternalError, } @@ -22,9 +23,18 @@ impl From for InventoryError { match value { InventoryDBError::DuplicateCategoryName => Self::DuplicateCategoryName, InventoryDBError::DuplicateStoreName => Self::DuplicateStoreName, + InventoryDBError::DuplicateProductName => Self::DuplicateProductName, InventoryDBError::DuplicateStoreID => { error!("DuplicateStoreID"); Self::InternalError + }, + InventoryDBError::DuplicateProductID => { + error!("DuplicateProductID"); + Self::InternalError + }, + InventoryDBError::DuplicateCategoryID => { + error!("DuplicateCategoryID"); + Self::InternalError } InventoryDBError::InternalError => Self::InternalError, } diff --git a/src/inventory/application/services/mod.rs b/src/inventory/application/services/mod.rs index 4a95814..62788bf 100644 --- a/src/inventory/application/services/mod.rs +++ b/src/inventory/application/services/mod.rs @@ -10,18 +10,21 @@ pub mod errors; // services pub mod add_category_service; +pub mod add_product_service; pub mod add_store_service; #[automock] pub trait InventoryServicesInterface: Send + Sync { fn add_store(&self) -> add_store_service::AddStoreServiceObj; fn add_category(&self) -> add_category_service::AddCategoryServiceObj; + fn add_product(&self) -> add_product_service::AddProductServiceObj; } #[derive(Clone, Builder)] pub struct InventoryServices { add_store: add_store_service::AddStoreServiceObj, add_category: add_category_service::AddCategoryServiceObj, + add_product: add_product_service::AddProductServiceObj, } impl InventoryServicesInterface for InventoryServices { @@ -31,4 +34,7 @@ impl InventoryServicesInterface for InventoryServices { fn add_category(&self) -> add_category_service::AddCategoryServiceObj { self.add_category.clone() } + fn add_product(&self) -> add_product_service::AddProductServiceObj { + self.add_product.clone() + } } 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 + ); + } +}