feat: add product against categories #34

Merged
realaravinth merged 10 commits from add-product into master 2024-07-15 18:21:13 +05:30
26 changed files with 1433 additions and 9 deletions

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -0,0 +1,27 @@
-- SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
--
-- 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)
);

View file

@ -17,6 +17,14 @@ impl From<SqlxError> 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 {

View file

@ -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;

View file

@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// 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<bool> {
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();
}
}

View file

@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// 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<bool> {
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;
}
}

View file

@ -0,0 +1,265 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// 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<String>,
image: Option<String>, // string = filename
product_id: Uuid,
sku_able: bool,
price_minor: i32,
price_major: i32,
price_currency: String,
category_id: Uuid,
}
impl From<ProductView> 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<Product> for ProductView {
fn update(&mut self, event: &EventEnvelope<Product>) {
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<ProductView, Product> for InventoryDBPostgresAdapter {
async fn load(&self, product_id: &str) -> Result<Option<ProductView>, 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<Option<(ProductView, ViewContext)>, 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<Product> for InventoryDBPostgresAdapter {
async fn dispatch(&self, product_id: &str, events: &[EventEnvelope<Product>]) {
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();
}
}

View file

@ -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();

View file

@ -10,7 +10,10 @@ pub type InventoryDBResult<V> = Result<V, InventoryDBError>;
#[derive(Debug, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum InventoryDBError {
DuplicateCategoryName,
DuplicateCategoryID,
DuplicateStoreName,
DuplicateStoreID,
DuplicateProductName,
DuplicateProductID,
InternalError,
}

View file

@ -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;

View file

@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// 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<bool>;
}
pub type ProductIDExistsDBPortObj = std::sync::Arc<dyn ProductIDExistsDBPort>;
#[cfg(test)]
pub mod tests {
use super::*;
use std::sync::Arc;
pub fn mock_product_id_exists_db_port_false(times: Option<usize>) -> 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<usize>) -> 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)
}
}

View file

@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// 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<bool>;
}
pub type ProductNameExistsForCategoryDBPortObj =
std::sync::Arc<dyn ProductNameExistsForCategoryDBPort>;
#[cfg(test)]
pub mod tests {
use super::*;
use std::sync::Arc;
pub fn mock_product_name_exists_for_category_db_port_false(
times: Option<usize>,
) -> 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<usize>,
) -> 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)
}
}

View file

@ -0,0 +1,169 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// 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<ProductAddedEvent>;
}
pub type AddProductServiceObj = Arc<dyn AddProductUseCase>;
#[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<ProductAddedEvent> {
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<usize>,
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)
)
}
}

View file

@ -14,6 +14,7 @@ pub type InventoryResult<V> = Result<V, InventoryError>;
pub enum InventoryError {
DuplicateCategoryName,
DuplicateStoreName,
DuplicateProductName,
InternalError,
}
@ -22,9 +23,18 @@ impl From<InventoryDBError> 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,
}

View file

@ -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()
}
}

View file

@ -0,0 +1,224 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// 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<String>,
image: Option<String>,
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<String>,
image: Option<String>,
category_id: Uuid,
sku_able: bool,
price: Price,
adding_by: Uuid,
}
impl UnvalidatedAddProductCommand {
pub fn validate(self) -> Result<AddProductCommand, AddProductCommandError> {
let description: Option<String> = 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<String> = 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))
}
}

View file

@ -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),
}

View file

@ -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()

View file

@ -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;

View file

@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// 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<String>,
image: Option<String>, // 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()
}
}

View file

@ -0,0 +1,165 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// 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<String>,
image: Option<String>, // 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<Self, Self::Err> {
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<dyn InventoryServicesInterface>;
// 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<Vec<Self::Event>, 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<Product>;
#[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
);
}
}