feat: add category service with tests and ports
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
ci/woodpecker/pull_request_closed/woodpecker Pipeline was successful

This commit is contained in:
Aravinth Manivannan 2024-07-13 21:29:43 +05:30
parent 99b615d01d
commit f98213dc2e
Signed by: realaravinth
GPG key ID: F8F50389936984FF
26 changed files with 1028 additions and 26 deletions

View file

@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n name, description, category_id, store_id\n FROM\n cqrs_inventory_category_query\n WHERE\n view_id = $1;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "description",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "category_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "store_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false,
false
]
},
"hash": "1e9dcba9a2b5a7e0bbb2b8c0d87166c24567420c5f7579aaa12b9d3d60c4e24d"
}

View file

@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_inventory_category_query\n WHERE\n name = $1\n AND\n store_id = $2\n );",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "5944442c15d28d47654afae92815ccefea89cc9aee705e0e1e54a3bf884bb194"
}

View file

@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n view_id, version\n FROM\n cqrs_inventory_category_query\n WHERE\n view_id = $1;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "view_id",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "version",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "74313c3fbf8a5985b6deae21b56469fb66ddb078d016fd6f05cb5e62ef0b23d5"
}

View file

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1\n FROM cqrs_inventory_category_query\n WHERE\n category_id = $1\n );",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "b70e7f74ae29d5e7cfdec133f1cedbd455d56875545f952efa261c99b0e7b4b4"
}

View file

@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE\n cqrs_inventory_category_query\n SET\n view_id = $1,\n version = $2,\n name = $3,\n description = $4,\n category_id = $5,\n store_id = $6;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Text",
"Text",
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "c524c7fc3281e8ffe5d3d6f237d49cae596a9f621e6a64c31270d744406271ec"
}

View file

@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO cqrs_inventory_category_query (\n view_id, version, name, description, category_id, store_id\n ) VALUES (\n $1, $2, $3, $4, $5, $6\n );",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Text",
"Text",
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "e66276ae53a3155b2a682a451bf4800f2ec7f777cc822cbe0866105def3e11af"
}

View file

@ -0,0 +1,36 @@
-- SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
--
-- SPDX-License-Identifier: AGPL-3.0-or-later
CREATE TABLE IF NOT EXISTS categoty_events
(
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
sequence bigint CHECK (sequence >= 0) NOT NULL,
event_type text NOT NULL,
event_version text NOT NULL,
payload json NOT NULL,
metadata json NOT NULL,
timestamp timestamp with time zone DEFAULT (CURRENT_TIMESTAMP),
PRIMARY KEY (aggregate_type, aggregate_id, sequence)
);
CREATE TABLE IF NOT EXISTS cqrs_inventory_category_query
(
view_id text NOT NULL,
version bigint CHECK (version >= 0) NOT NULL,
name TEXT NOT NULL,
description TEXT,
store_id UUID NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
category_id UUID NOT NULL UNIQUE,
UNIQUE(store_id, name),
PRIMARY KEY (view_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS
cqrs_inventory_store_query_category_id_index
ON
cqrs_inventory_category_query (category_id);

View file

@ -1,3 +1,7 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use async_trait::async_trait;
use cqrs_es::Aggregate;

View file

@ -1 +1,5 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod postgres;

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::*, store_id_exists::*,
};
use crate::inventory::domain::store_aggregate::*;
#[async_trait::async_trait]
impl StoreIDExistsDBPort for InventoryDBPostgresAdapter {
async fn store_id_exists(&self, s: &Store) -> InventoryDBResult<bool> {
let res = sqlx::query!(
"SELECT EXISTS (
SELECT 1
FROM cqrs_inventory_store_query
WHERE
store_id = $1
);",
s.store_id(),
)
.fetch_one(&self.pool)
.await?;
if let Some(x) = res.exists {
Ok(x)
} else {
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use uuid::Uuid;
use super::*;
#[actix_rt::test]
async fn test_postgres_store_exists() {
let store_id = Uuid::new_v4();
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 store = StoreBuilder::default().name("store_name".into()).owner("store_owner".into())
.address(Some("store_address".into()))
.store_id(store_id)
.build().unwrap();
// state doesn't exist
assert!(!db.store_id_exists(&store).await.unwrap());
sqlx::query!(
"INSERT INTO cqrs_inventory_store_query
(view_id, version, name, address, store_id, owner)
VALUES ($1, $2, $3, $4, $5, $6);",
"1",
1,
store.name(),
store.address().as_ref().unwrap(),
store.store_id(),
store.owner(),
)
.execute(&db.pool)
.await
.unwrap();
// state exists
assert!(db.store_id_exists(&store).await.unwrap());
settings.drop_db().await;
}
}

View file

@ -0,0 +1,80 @@
// 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::{category_id_exists::*, errors::*};
use crate::inventory::domain::category_aggregate::*;
#[async_trait::async_trait]
impl CategoryIDExistsDBPort for InventoryDBPostgresAdapter {
async fn category_id_exists(&self, s: &Category) -> InventoryDBResult<bool> {
let res = sqlx::query!(
"SELECT EXISTS (
SELECT 1
FROM cqrs_inventory_category_query
WHERE
category_id = $1
);",
s.category_id(),
)
.fetch_one(&self.pool)
.await?;
if let Some(x) = res.exists {
Ok(x)
} else {
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use uuid::Uuid;
use super::*;
#[actix_rt::test]
async fn test_postgres_category_exists() {
let category_id = Uuid::new_v4();
let store_id = Uuid::new_v4();
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 category = CategoryBuilder::default()
.name("category_name".into())
.description(Some("category_description".into()))
.category_id(category_id)
.store_id(store_id)
.build()
.unwrap();
// state doesn't exist
assert!(!db.category_id_exists(&category).await.unwrap());
sqlx::query!(
"INSERT INTO cqrs_inventory_category_query
(view_id, version, name, description, category_id, store_id)
VALUES ($1, $2, $3, $4, $5, $6);",
"1",
1,
category.name(),
category.description().as_ref().unwrap(),
category.category_id(),
category.store_id(),
)
.execute(&db.pool)
.await
.unwrap();
// state exists
assert!(db.category_id_exists(&category).await.unwrap());
settings.drop_db().await;
}
}

View file

@ -0,0 +1,85 @@
// 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::{
category_name_exists_for_store::*, errors::*,
};
use crate::inventory::domain::category_aggregate::*;
#[async_trait::async_trait]
impl CategoryNameExistsForStoreDBPort for InventoryDBPostgresAdapter {
async fn category_name_exists_for_store(&self, s: &Category) -> InventoryDBResult<bool> {
let res = sqlx::query!(
"SELECT EXISTS (
SELECT 1
FROM cqrs_inventory_category_query
WHERE
name = $1
AND
store_id = $2
);",
s.name(),
s.store_id(),
)
.fetch_one(&self.pool)
.await?;
if let Some(x) = res.exists {
Ok(x)
} else {
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use uuid::Uuid;
use super::*;
#[actix_rt::test]
async fn test_postgres_category_exists() {
let category_id = Uuid::new_v4();
let store_id = Uuid::new_v4();
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 category = CategoryBuilder::default()
.name("category_name".into())
.description(Some("category_description".into()))
.category_id(category_id)
.store_id(store_id)
.build()
.unwrap();
// state doesn't exist
assert!(!db.category_name_exists_for_store(&category).await.unwrap());
sqlx::query!(
"INSERT INTO cqrs_inventory_category_query
(view_id, version, name, description, category_id, store_id)
VALUES ($1, $2, $3, $4, $5, $6);",
"1",
1,
category.name(),
category.description().as_ref().unwrap(),
category.category_id(),
category.store_id(),
)
.execute(&db.pool)
.await
.unwrap();
// state exists
assert!(db.category_name_exists_for_store(&category).await.unwrap());
settings.drop_db().await;
}
}

View file

@ -0,0 +1,175 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use async_trait::async_trait;
use cqrs_es::persist::GenericQuery;
use cqrs_es::persist::{PersistenceError, ViewContext, ViewRepository};
use cqrs_es::{EventEnvelope, Query, View};
use uuid::Uuid;
use super::errors::*;
use super::InventoryDBPostgresAdapter;
use crate::inventory::domain::category_aggregate::Category;
use crate::inventory::domain::events::InventoryEvent;
use serde::{Deserialize, Serialize};
// The view for a Category 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 CategoryView {
name: String,
description: Option<String>,
category_id: Uuid,
store_id: Uuid,
}
// 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<Category> for CategoryView {
fn update(&mut self, event: &EventEnvelope<Category>) {
match &event.payload {
InventoryEvent::CategoryAdded(val) => {
self.name = val.name().into();
self.description = val.description().clone();
self.category_id = val.category_id().clone();
self.store_id = val.store_id().clone();
}
_ => (),
}
}
}
#[async_trait]
impl ViewRepository<CategoryView, Category> for InventoryDBPostgresAdapter {
async fn load(&self, view_id: &str) -> Result<Option<CategoryView>, PersistenceError> {
let res = sqlx::query_as!(
CategoryView,
"SELECT
name, description, category_id, store_id
FROM
cqrs_inventory_category_query
WHERE
view_id = $1;",
view_id
)
.fetch_one(&self.pool)
.await
.map_err(PostgresAggregateError::from)?;
Ok(Some(res))
}
async fn load_with_context(
&self,
view_id: &str,
) -> Result<Option<(CategoryView, ViewContext)>, PersistenceError> {
let res = sqlx::query_as!(
CategoryView,
"SELECT
name, description, category_id, store_id
FROM
cqrs_inventory_category_query
WHERE
view_id = $1;",
view_id
)
.fetch_one(&self.pool)
.await
.map_err(PostgresAggregateError::from)?;
struct Context {
version: i64,
view_id: String,
}
let ctx = sqlx::query_as!(
Context,
"SELECT
view_id, version
FROM
cqrs_inventory_category_query
WHERE
view_id = $1;",
view_id
)
.fetch_one(&self.pool)
.await
.map_err(PostgresAggregateError::from)?;
let view_context = ViewContext::new(ctx.view_id, ctx.version);
Ok(Some((res, view_context)))
}
async fn update_view(
&self,
view: CategoryView,
context: ViewContext,
) -> Result<(), PersistenceError> {
match context.version {
0 => {
let version = context.version + 1;
sqlx::query!(
"INSERT INTO cqrs_inventory_category_query (
view_id, version, name, description, category_id, store_id
) VALUES (
$1, $2, $3, $4, $5, $6
);",
context.view_instance_id,
version,
view.name,
view.description,
view.category_id,
view.store_id,
)
.execute(&self.pool)
.await
.map_err(PostgresAggregateError::from)?;
}
_ => {
let version = context.version + 1;
sqlx::query!(
"UPDATE
cqrs_inventory_category_query
SET
view_id = $1,
version = $2,
name = $3,
description = $4,
category_id = $5,
store_id = $6;",
context.view_instance_id,
version,
view.name,
view.description,
view.category_id,
view.store_id
)
.execute(&self.pool)
.await
.map_err(PostgresAggregateError::from)?;
}
}
Ok(())
}
}
pub struct SimpleLoggingQuery {}
// Our simplest query, this is great for debugging but absolutely useless in production.
// This query just pretty prints the events as they are processed.
#[async_trait]
impl Query<Category> for SimpleLoggingQuery {
async fn dispatch(&self, aggregate_id: &str, events: &[EventEnvelope<Category>]) {
for event in events {
let payload = serde_json::to_string_pretty(&event.payload).unwrap();
println!("{}-{}\n{}", aggregate_id, event.sequence, payload);
}
}
}
// Our second query, this one will be handled with Postgres `GenericQuery`
// which will serialize and persist our view after it is updated. It also
// provides a `load` method to deserialize the view on request.
pub type CategoryQuery = GenericQuery<InventoryDBPostgresAdapter, CategoryView, Category>;

View file

@ -1,12 +1,16 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::Arc;
use sqlx::postgres::PgPool;
use crate::db::{migrate::RunMigrations, sqlx_postgres::Postgres};
mod category_id_exists;
mod category_name_exists_for_store;
mod category_view;
mod errors;
mod store_id_exists;
mod store_view;

View file

@ -0,0 +1,56 @@
// 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::category_aggregate::Category;
use super::errors::*;
#[cfg(test)]
#[allow(unused_imports)]
pub use tests::*;
#[automock]
#[async_trait::async_trait]
pub trait CategoryIDExistsDBPort: Send + Sync {
async fn category_id_exists(&self, c: &Category) -> InventoryDBResult<bool>;
}
pub type CategoryIDExistsDBPortObj = std::sync::Arc<dyn CategoryIDExistsDBPort>;
#[cfg(test)]
pub mod tests {
use super::*;
use std::sync::Arc;
pub fn mock_category_id_exists_db_port_false(
times: Option<usize>,
) -> CategoryIDExistsDBPortObj {
let mut m = MockCategoryIDExistsDBPort::new();
if let Some(times) = times {
m.expect_category_id_exists()
.times(times)
.returning(|_| Ok(false));
} else {
m.expect_category_id_exists().returning(|_| Ok(false));
}
Arc::new(m)
}
pub fn mock_category_id_exists_db_port_true(times: Option<usize>) -> CategoryIDExistsDBPortObj {
let mut m = MockCategoryIDExistsDBPort::new();
if let Some(times) = times {
m.expect_category_id_exists()
.times(times)
.returning(|_| Ok(true));
} else {
m.expect_category_id_exists().returning(|_| Ok(true));
}
Arc::new(m)
}
}

View file

@ -0,0 +1,60 @@
// 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::category_aggregate::Category;
use super::errors::*;
#[cfg(test)]
#[allow(unused_imports)]
pub use tests::*;
#[automock]
#[async_trait::async_trait]
pub trait CategoryNameExistsForStoreDBPort: Send + Sync {
async fn category_name_exists_for_store(&self, c: &Category) -> InventoryDBResult<bool>;
}
pub type CategoryNameExistsForStoreDBPortObj = std::sync::Arc<dyn CategoryNameExistsForStoreDBPort>;
#[cfg(test)]
pub mod tests {
use super::*;
use std::sync::Arc;
pub fn mock_category_name_exists_for_store_db_port_false(
times: Option<usize>,
) -> CategoryNameExistsForStoreDBPortObj {
let mut m = MockCategoryNameExistsForStoreDBPort::new();
if let Some(times) = times {
m.expect_category_name_exists_for_store()
.times(times)
.returning(|_| Ok(false));
} else {
m.expect_category_name_exists_for_store()
.returning(|_| Ok(false));
}
Arc::new(m)
}
pub fn mock_category_name_exists_for_store_db_port_true(
times: Option<usize>,
) -> CategoryNameExistsForStoreDBPortObj {
let mut m = MockCategoryNameExistsForStoreDBPort::new();
if let Some(times) = times {
m.expect_category_name_exists_for_store()
.times(times)
.returning(|_| Ok(true));
} else {
m.expect_category_name_exists_for_store()
.returning(|_| Ok(true));
}
Arc::new(m)
}
}

View file

@ -2,6 +2,7 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
//pub mod category_exists;
pub mod category_id_exists;
pub mod category_name_exists_for_store;
pub mod errors;
pub mod store_id_exists;

View file

@ -1,9 +1,11 @@
// 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 uuid::Uuid;
use mockall::predicate::*;
use mockall::*;
use super::errors::*;
use crate::inventory::{
@ -16,12 +18,13 @@ use crate::inventory::{
};
use crate::utils::uuid::*;
#[automock]
#[async_trait::async_trait]
pub trait AddCategoryUseCase: Send + Sync {
async fn add_category(&self, cmd: AddCategoryCommand) -> InventoryResult<CategoryAddedEvent>;
}
pub type AddCategoryServiceObj = std::sync::Arc<dyn AddCategoryUseCase>;
pub type AddCategoryServiceObj = Arc<dyn AddCategoryUseCase>;
#[derive(Clone, Builder)]
pub struct AddCategoryService {
@ -83,7 +86,7 @@ impl AddCategoryUseCase for AddCategoryService {
}
#[cfg(test)]
mod tests {
pub mod tests {
use super::*;
use uuid::Uuid;
@ -91,6 +94,32 @@ mod tests {
use crate::utils::uuid::tests::UUID;
use crate::{tests::bdd::*, utils::uuid::tests::mock_get_uuid};
pub fn mock_add_category_service(
times: Option<usize>,
cmd: AddCategoryCommand,
) -> AddCategoryServiceObj {
let mut m = MockAddCategoryUseCase::new();
let res = CategoryAddedEventBuilder::default()
.name(cmd.name().into())
.description(cmd.description().as_ref().map(|s| s.to_string()))
.added_by_user(cmd.adding_by().into())
.store_id(cmd.store_id().clone())
.category_id(UUID.clone())
.build()
.unwrap();
if let Some(times) = times {
m.expect_add_category()
.times(times)
.returning(move |_| Ok(res.clone()));
} else {
m.expect_add_category().returning(move |_| Ok(res.clone()));
}
Arc::new(m)
}
#[actix_rt::test]
async fn test_service_category_doesnt_exist() {
let name = "foo";

View file

@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use derive_builder::Builder;
use mockall::predicate::*;
use mockall::*;
@ -8,26 +9,26 @@ use mockall::*;
pub mod errors;
// services
//pub mod add_category_service;
pub mod add_category_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_category(&self) -> add_category_service::AddCategoryServiceObj;
}
#[derive(Clone, Builder)]
pub struct InventoryServices {
add_store: add_store_service::AddStoreServiceObj,
// add_category: add_category_service::AddCategoryServiceObj,
add_category: add_category_service::AddCategoryServiceObj,
}
impl InventoryServicesInterface for InventoryServices {
fn add_store(&self) -> add_store_service::AddStoreServiceObj {
self.add_store.clone()
}
// fn add_category(&self) -> add_category_service::AddCategoryServiceObj {
// self.add_category.clone()
// }
fn add_category(&self) -> add_category_service::AddCategoryServiceObj {
self.add_category.clone()
}
}

View file

@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use derive_getters::Getters;
use derive_more::{Display, Error};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Error, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum AddCategoryCommandError {
NameIsEmpty,
}
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)]
pub struct AddCategoryCommand {
name: String,
description: Option<String>,
store_id: Uuid,
adding_by: String,
}
impl AddCategoryCommand {
pub fn new(
name: String,
description: Option<String>,
store_id: Uuid,
adding_by: String,
) -> Result<Self, AddCategoryCommandError> {
let description: Option<String> = if let Some(description) = description {
let description = description.trim();
if description.is_empty() {
None
} else {
Some(description.to_owned())
}
} else {
None
};
let name = name.trim().to_owned();
if name.is_empty() {
return Err(AddCategoryCommandError::NameIsEmpty);
}
Ok(Self {
name,
store_id,
description,
adding_by,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cmd() {
let name = "foo";
let description = "bar";
let username = "baz";
let store_id = Uuid::new_v4();
// description = None
let cmd =
AddCategoryCommand::new(name.into(), None, store_id.clone(), username.into()).unwrap();
assert_eq!(cmd.name(), name);
assert_eq!(cmd.description(), &None);
assert_eq!(cmd.adding_by(), username);
assert_eq!(cmd.store_id(), &store_id);
// description = Some
let cmd = AddCategoryCommand::new(
name.into(),
Some(description.into()),
store_id.clone(),
username.into(),
)
.unwrap();
assert_eq!(cmd.name(), name);
assert_eq!(cmd.description(), &Some(description.to_owned()));
assert_eq!(cmd.adding_by(), username);
assert_eq!(cmd.store_id(), &store_id);
// AddCategoryCommandError::NameIsEmpty
assert_eq!(
AddCategoryCommand::new(
"".into(),
Some(description.into()),
store_id.clone(),
username.into()
),
Err(AddCategoryCommandError::NameIsEmpty)
)
}
}

View file

@ -0,0 +1,19 @@
// 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;
#[derive(
Clone, Debug, Builder, Serialize, Deserialize, Getters, Eq, PartialEq, Ord, PartialOrd,
)]
pub struct CategoryAddedEvent {
name: String,
description: Option<String>,
added_by_user: String,
category_id: Uuid,
store_id: Uuid,
}

View file

@ -0,0 +1,128 @@
// SPDX-FileCopyrightText: 2024 Aravinth Manivannan <realaravinth@batsense.net>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
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 crate::inventory::application::services::errors::*;
use crate::inventory::application::services::InventoryServicesInterface;
use super::{commands::InventoryCommand, events::InventoryEvent};
#[derive(
Clone, Debug, Serialize, Default, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder,
)]
pub struct Category {
name: String,
description: Option<String>,
store_id: Uuid,
category_id: Uuid,
}
#[async_trait]
impl Aggregate for Category {
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.category".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::AddCategory(cmd) => {
let res = services.add_category().add_category(cmd).await?;
Ok(vec![InventoryEvent::CategoryAdded(res)])
}
_ => Ok(Vec::default()),
}
}
fn apply(&mut self, event: Self::Event) {
match event {
InventoryEvent::CategoryAdded(e) => {
*self = CategoryBuilder::default()
.name(e.name().into())
.category_id(e.category_id().clone())
.description(e.description().clone())
.store_id(e.store_id().clone())
.build()
.unwrap();
}
_ => (),
}
}
}
#[cfg(test)]
mod aggregate_tests {
use std::sync::Arc;
use cqrs_es::test::TestFramework;
use uuid::Uuid;
use super::*;
use crate::inventory::{
application::services::{add_category_service::tests::*, *},
domain::{
add_category_command::*, category_added_event::*, commands::InventoryCommand,
events::InventoryEvent,
},
};
use crate::tests::bdd::*;
use crate::utils::uuid::tests::*;
type CategoryTestFramework = TestFramework<Category>;
#[test]
fn test_create_store() {
let name = "category_name";
let description = Some("category_description".to_string());
let adding_by = "store_owner";
let store_id = Uuid::new_v4();
let category_id = UUID.clone();
let cmd = AddCategoryCommand::new(
name.into(),
description.clone(),
store_id.clone(),
adding_by.into(),
)
.unwrap();
let expected = CategoryAddedEventBuilder::default()
.name(cmd.name().into())
.description(cmd.description().as_ref().map(|s| s.to_string()))
.added_by_user(cmd.adding_by().into())
.store_id(cmd.store_id().clone())
.category_id(category_id.clone())
.build()
.unwrap();
let expected = InventoryEvent::CategoryAdded(expected);
let mut services = MockInventoryServicesInterface::new();
services
.expect_add_category()
.times(IS_CALLED_ONLY_ONCE.unwrap())
.return_const(mock_add_category_service(IS_CALLED_ONLY_ONCE, cmd.clone()));
CategoryTestFramework::with(Arc::new(services))
.given_no_previous_events()
.when(InventoryCommand::AddCategory(cmd))
.then_expect_events(vec![expected]);
}
}

View file

@ -5,13 +5,10 @@
use mockall::predicate::*;
use serde::{Deserialize, Serialize};
use super::{
// add_category_command::AddCategoryCommand,
add_store_command::AddStoreCommand,
};
use super::{add_category_command::AddCategoryCommand, add_store_command::AddStoreCommand};
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)]
pub enum InventoryCommand {
//AddCategory(AddCategoryCommand),
AddCategory(AddCategoryCommand),
AddStore(AddStoreCommand),
}

View file

@ -5,14 +5,11 @@
use cqrs_es::DomainEvent;
use serde::{Deserialize, Serialize};
use super::{
// category_added_event::*,
store_added_event::StoreAddedEvent,
};
use super::{category_added_event::*, store_added_event::StoreAddedEvent};
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)]
pub enum InventoryEvent {
// CategoryAdded(CategoryAddedEvent),
CategoryAdded(CategoryAddedEvent),
StoreAdded(StoreAddedEvent),
}
@ -25,7 +22,7 @@ impl DomainEvent for InventoryEvent {
fn event_type(&self) -> String {
let e: &str = match self {
// InventoryEvent::CategoryAdded { .. } => "InventoryCategoryAdded",
InventoryEvent::CategoryAdded { .. } => "InventoryCategoryAdded",
InventoryEvent::StoreAdded { .. } => "InventoryStoredded",
};

View file

@ -5,15 +5,16 @@
// aggregates
//pub mod money_aggregate;
//pub mod product_aggregate;
pub mod category_aggregate;
//pub mod stock_aggregate;
pub mod store_aggregate;
// commands
//pub mod add_category_command;
pub mod add_category_command;
pub mod add_store_command;
pub mod commands;
// events
//pub mod category_added_event;
pub mod category_added_event;
pub mod events;
pub mod store_added_event;

View file

@ -72,9 +72,7 @@ impl Aggregate for Store {
}
}
}
// The aggregate tests are the most important part of a CQRS system.
// The simplicity and flexibility of these tests are a good part of what
// makes an event sourced system so friendly to changing business requirements.
#[cfg(test)]
mod aggregate_tests {
use std::sync::Arc;