Merge pull request 'fix: replace username with first and last name and use user_id UUID for primary keys' (#32) from user-di into master
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Reviewed-on: #32
This commit is contained in:
Aravinth Manivannan 2024-07-14 22:53:49 +05:30
commit caf580c6df
68 changed files with 737 additions and 879 deletions

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1\n FROM user_query\n WHERE\n username = $1\n );",
"query": "SELECT EXISTS (\n SELECT 1\n FROM user_query\n WHERE\n user_id = $1\n );",
"describe": {
"columns": [
{
@ -11,12 +11,12 @@
],
"parameters": {
"Left": [
"Text"
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "ed4bd44b2a0595cd80b36ce70b30ab55af1af12c1f22a2d4a2baf8af4569cf73"
"hash": "004d12b7ccb1b21c39ef6de716953bf039bdba5096ae139be7656170ff45613f"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO verification_otp (secret, created_at, purpose, username)\n VALUES ($1, $2, $3, $4);",
"query": "INSERT INTO verification_otp (secret, created_at, purpose, user_id)\n VALUES ($1, $2, $3, $4);",
"describe": {
"columns": [],
"parameters": {
@ -8,10 +8,10 @@
"Varchar",
"Timestamptz",
"Text",
"Text"
"Uuid"
]
},
"nullable": []
},
"hash": "487deedfaaf10c4ab02fc223b8fec4bf359b082331adfa788096742673168be3"
"hash": "0fbaa8084440adce8a6162d67e3e57b6062cc17bbbbeb5ea3e1e58db17ac8240"
}

View file

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE\n user_query\n SET\n user_id = $1, version = $2, first_name = $3, email = $4,\n hashed_password = $5, is_admin = $6, is_verified = $7, deleted = $8,\n last_name=$9;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Int8",
"Text",
"Text",
"Text",
"Bool",
"Bool",
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "14e8d7a1c8f80701b76b2bac69b1ecd99f7694d620f1945ad5c4ae474a17be1b"
}

View file

@ -9,7 +9,7 @@
"Text",
"Text",
"Uuid",
"Text"
"Uuid"
]
},
"nullable": []

View file

@ -21,7 +21,7 @@
{
"ordinal": 3,
"name": "owner",
"type_info": "Text"
"type_info": "Uuid"
}
],
"parameters": {

View file

@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n view_id, version\n FROM\n user_query\n WHERE\n view_id = $1;",
"query": "SELECT \n user_id, version\n FROM\n user_query\n WHERE\n user_id = $1;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "view_id",
"type_info": "Text"
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
@ -16,7 +16,7 @@
],
"parameters": {
"Left": [
"Text"
"Uuid"
]
},
"nullable": [
@ -24,5 +24,5 @@
false
]
},
"hash": "fa1c65d51e3e0521d6a20998f4b80b615c9e0ffe9ceda82e0bce410c9aca39a0"
"hash": "6523c83a859d7ca283d209133a10d4ac74b6b0358bdf1e17f1a54e2cc02e305b"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1\n FROM verification_otp\n WHERE\n username = $1\n AND\n purpose = $2\n AND\n secret = $3\n );",
"query": "SELECT EXISTS (\n SELECT 1\n FROM verification_otp\n WHERE\n user_id = $1\n AND\n purpose = $2\n AND\n secret = $3\n );",
"describe": {
"columns": [
{
@ -11,7 +11,7 @@
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text",
"Text"
]
@ -20,5 +20,5 @@
null
]
},
"hash": "3edf94a78114819085b573ff51702faee1c444c24bbf6d33ec1b4b245dd9f675"
"hash": "70e6216e30f90175d4c3bad51ff51f5fa2b6f965d868b15c53264cb8cd6b4053"
}

View file

@ -1,21 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE\n user_query\n SET\n view_id = $1, version = $2, username = $3, email = $4,\n hashed_password = $5, is_admin = $6, is_verified = $7, deleted = $8;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Text",
"Text",
"Text",
"Bool",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "750f96296e6d2c0b6d79c6f21e5b665369d5062a42c2c405f43d56a614a1e0ea"
}

View file

@ -1,21 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO user_query (\n view_id, version, username, email,\n hashed_password, is_admin, is_verified, deleted\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n );",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Text",
"Text",
"Text",
"Bool",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "7a401e98fff59eb8c0fe1744ccdea68842eaf86ba312c2eb324854041beb9de7"
}

View file

@ -1,42 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n username, email, hashed_password, is_admin, is_verified, deleted\n FROM\n user_query\n WHERE\n view_id = $1;",
"query": "SELECT \n first_name, last_name, user_id, email, hashed_password, is_admin, is_verified, deleted\n FROM\n user_query\n WHERE\n user_id = $1;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"name": "first_name",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "email",
"name": "last_name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "hashed_password",
"type_info": "Text"
},
{
"ordinal": 3,
"ordinal": 5,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 4,
"ordinal": 6,
"name": "is_verified",
"type_info": "Bool"
},
{
"ordinal": 5,
"ordinal": 7,
"name": "deleted",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
"Uuid"
]
},
"nullable": [
@ -45,8 +55,10 @@
false,
false,
false,
false,
false,
false
]
},
"hash": "803f868ee4a79fefdd85c5d0a034a5925c3bb7de87828095971cb19bc1fc7550"
"hash": "7a59c989d043c249cd04fe24544cf9ea55e1329ce4b53889947478c2e766ea1a"
}

View file

@ -9,7 +9,7 @@
"Text",
"Text",
"Uuid",
"Text"
"Uuid"
]
},
"nullable": []

View file

@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM\n verification_otp\n WHERE\n username = $1\n AND\n purpose = $2\n AND\n secret = $3;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "c54d1b89ee39ceb11326e82962eef3d8f588f6252ba4bbac99fb73cdbbcd2204"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n secret\n FROM\n verification_otp\n WHERE\n username = $1\n AND\n purpose = $2;",
"query": "SELECT\n secret\n FROM\n verification_otp\n WHERE\n user_id = $1\n AND\n purpose = $2;",
"describe": {
"columns": [
{
@ -11,7 +11,7 @@
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text"
]
},
@ -19,5 +19,5 @@
false
]
},
"hash": "7c48c7569b16e9600aa31fbf14b9ceeb195da7cde0082be861369ef4f997c534"
"hash": "ec80b5dc41697e7df7112962aba8185040fc0a101edea4b7e0c23f6468300196"
}

View file

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO user_query (\n version, first_name, last_name, email,\n hashed_password, is_admin, is_verified, deleted, user_id\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9\n );",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Text",
"Text",
"Text",
"Bool",
"Bool",
"Bool",
"Uuid"
]
},
"nullable": []
},
"hash": "f1b7a6581e20b9f1c0dabeb7a2479d70ab005cf787a0ed13260e65f7ea949136"
}

View file

@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM\n verification_otp\n WHERE\n user_id = $1\n AND\n purpose = $2\n AND\n secret = $3;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "ffb4315a9b95ed39964c2a0a5a9dc45e4ab7941ced043931568023238d5127f7"
}

View file

@ -17,17 +17,16 @@ CREATE TABLE IF NOT EXISTS events
CREATE TABLE IF NOT EXISTS user_query
(
view_id text NOT NULL,
version bigint CHECK (version >= 0) NOT NULL,
username TEXT NOT NULL UNIQUE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
user_id UUID NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
hashed_password TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (view_id)
PRIMARY KEY (user_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS user_username_index ON user_query (username);

View file

@ -6,6 +6,6 @@ CREATE TABLE IF NOT EXISTS verification_otp (
secret VARCHAR(32) NOT NULL UNIQUE,
created_at timestamp with time zone DEFAULT (CURRENT_TIMESTAMP),
purpose TEXT NOT NULL,
username TEXT NOT NULL,
user_id UUID NOT NULL,
ID SERIAL PRIMARY KEY NOT NULL
);

View file

@ -15,8 +15,3 @@ CREATE TABLE IF NOT EXISTS cqrs_inventory_category_query
PRIMARY KEY (category_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS
cqrs_inventory_store_query_category_id_index
ON
cqrs_inventory_category_query (category_id);

View file

@ -8,11 +8,9 @@ CREATE TABLE IF NOT EXISTS cqrs_inventory_store_query
name TEXT NOT NULL,
address TEXT,
owner TEXT NOT NULL,
owner UUID NOT NULL,
store_id UUID NOT NULL UNIQUE,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (store_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS store_store_id_index ON cqrs_inventory_store_query (store_id);

View file

@ -11,12 +11,12 @@ use crate::identity::application::port::output::db::{create_verification_secret:
impl CreateVerificationSecretOutDBPort for DBOutPostgresAdapter {
async fn create_verification_secret(&self, msg: CreateSecretMsg) -> OutDBPortResult<()> {
sqlx::query!(
"INSERT INTO verification_otp (secret, created_at, purpose, username)
"INSERT INTO verification_otp (secret, created_at, purpose, user_id)
VALUES ($1, $2, $3, $4);",
&msg.secret,
OffsetDateTime::now_utc(),
REGISTRATION_SECRET_PURPOSE,
&msg.username,
&msg.user_id,
)
.execute(&self.pool)
.await?;
@ -26,6 +26,8 @@ impl CreateVerificationSecretOutDBPort for DBOutPostgresAdapter {
#[cfg(test)]
mod tests {
use crate::utils::uuid::tests::UUID;
use super::*;
#[actix_rt::test]
@ -40,7 +42,7 @@ mod tests {
let msg = CreateSecretMsgBuilder::default()
.secret("secret".into())
.username("username".into())
.user_id(UUID.clone())
.build()
.unwrap();

View file

@ -12,12 +12,12 @@ impl DeleteVerificationSecretOutDBPort for DBOutPostgresAdapter {
"DELETE FROM
verification_otp
WHERE
username = $1
user_id = $1
AND
purpose = $2
AND
secret = $3;",
msg.username,
msg.user_id,
REGISTRATION_SECRET_PURPOSE,
msg.secret,
)
@ -30,13 +30,16 @@ impl DeleteVerificationSecretOutDBPort for DBOutPostgresAdapter {
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::application::port::output::db::{
use crate::{
identity::application::port::output::db::{
create_verification_secret::*, verification_secret_exists::*,
},
utils::uuid::tests::UUID,
};
#[actix_rt::test]
async fn test_postgres_delete_verification_secret() {
let username = "batman";
let user_id = UUID;
let secret = "bsdasdf";
let settings = crate::settings::tests::get_settings().await;
settings.create_db().await;
@ -46,7 +49,7 @@ mod tests {
.unwrap(),
);
let msg = VerifySecretExistsMsgBuilder::default()
.username(username.into())
.user_id(user_id.clone())
.secret(secret.into())
.build()
.unwrap();
@ -56,7 +59,7 @@ mod tests {
let create_msg = CreateSecretMsgBuilder::default()
.secret(secret.into())
.username(username.into())
.user_id(user_id.clone())
.build()
.unwrap();
db.create_verification_secret(create_msg).await.unwrap();

View file

@ -33,6 +33,8 @@ impl EmailExistsOutDBPort for DBOutPostgresAdapter {
mod tests {
use super::*;
use crate::utils::uuid::tests::*;
#[actix_rt::test]
async fn test_postgres_email_exists() {
let email = "foo@exmaple.com";
@ -49,12 +51,13 @@ mod tests {
sqlx::query!(
"INSERT INTO user_query
(view_id, version, username, email, hashed_password)
VALUES ($1, $2, $3, $4, $5);",
"1",
(user_id, version, first_name, email, hashed_password, last_name)
VALUES ($1, $2, $3, $4, $5, $6);",
&UUID,
1,
"foo",
email,
"passwd",
"passwd"
)
.execute(&db.pool)

View file

@ -2,13 +2,15 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use uuid::Uuid;
use super::errors::*;
use super::DBOutPostgresAdapter;
use crate::identity::application::port::output::db::{errors::*, get_verification_secret::*};
#[async_trait::async_trait]
impl GetVerificationSecretOutDBPort for DBOutPostgresAdapter {
async fn get_verification_secret(&self, username: &str) -> OutDBPortResult<String> {
async fn get_verification_secret(&self, user_id: &Uuid) -> OutDBPortResult<String> {
struct Secret {
secret: String,
}
@ -19,10 +21,10 @@ impl GetVerificationSecretOutDBPort for DBOutPostgresAdapter {
FROM
verification_otp
WHERE
username = $1
user_id = $1
AND
purpose = $2;",
username,
user_id,
REGISTRATION_SECRET_PURPOSE,
)
.fetch_one(&self.pool)
@ -35,11 +37,14 @@ impl GetVerificationSecretOutDBPort for DBOutPostgresAdapter {
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::application::port::output::db::create_verification_secret::*;
use crate::{
identity::application::port::output::db::create_verification_secret::*,
utils::uuid::tests::UUID,
};
#[actix_rt::test]
async fn test_postgres_get_verification_secret() {
let username = "batman";
let user_id = UUID;
let secret = "bsdasdf";
let settings = crate::settings::tests::get_settings().await;
settings.create_db().await;
@ -49,19 +54,19 @@ mod tests {
.unwrap(),
);
assert_eq!(
db.get_verification_secret(username).await.err(),
db.get_verification_secret(&user_id).await.err(),
Some(OutDBPortError::VerificationOTPSecretNotFound)
);
let create_msg = CreateSecretMsgBuilder::default()
.secret(secret.into())
.username(username.into())
.user_id(user_id.clone())
.build()
.unwrap();
db.create_verification_secret(create_msg).await.unwrap();
assert_eq!(db.get_verification_secret(username).await.unwrap(), secret);
assert_eq!(db.get_verification_secret(&user_id).await.unwrap(), secret);
settings.drop_db().await;
}

View file

@ -14,8 +14,8 @@ pub mod delete_verification_secret;
pub mod email_exists;
mod errors;
pub mod get_verification_secret;
pub mod user_id_exists;
pub mod user_view;
pub mod username_exists;
pub mod verification_secret_exists;
#[derive(Clone)]

View file

@ -2,22 +2,24 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use uuid::Uuid;
use super::DBOutPostgresAdapter;
use crate::identity::application::port::output::db::{
errors::*, username_exists::UsernameExistsOutDBPort,
errors::*, user_id_exists::UserIDExistsOutDBPort,
};
#[async_trait::async_trait]
impl UsernameExistsOutDBPort for DBOutPostgresAdapter {
async fn username_exists(&self, username: &str) -> OutDBPortResult<bool> {
impl UserIDExistsOutDBPort for DBOutPostgresAdapter {
async fn user_id_exists(&self, user_id: &Uuid) -> OutDBPortResult<bool> {
let res = sqlx::query!(
"SELECT EXISTS (
SELECT 1
FROM user_query
WHERE
username = $1
user_id = $1
);",
username
user_id,
)
.fetch_one(&self.pool)
.await?;
@ -33,9 +35,12 @@ impl UsernameExistsOutDBPort for DBOutPostgresAdapter {
mod tests {
use super::*;
use crate::utils::uuid::tests::UUID;
use crate::identity::domain::aggregate::*;
#[actix_rt::test]
async fn test_postgres_username_exists() {
let username = "foo@exmaple.com";
async fn test_postgres_user_id_exists() {
let settings = crate::settings::tests::get_settings().await;
settings.create_db().await;
let db = super::DBOutPostgresAdapter::new(
@ -44,25 +49,28 @@ mod tests {
.unwrap(),
);
let user = User::default();
// state doesn't exist
assert!(!db.username_exists(username).await.unwrap());
assert!(!db.user_id_exists(&UUID).await.unwrap());
sqlx::query!(
"INSERT INTO user_query
(view_id, version, username, email, hashed_password)
VALUES ($1, $2, $3, $4, $5);",
"1",
(version, user_id, email, hashed_password, first_name, last_name)
VALUES ($1, $2, $3, $4, $5, $6);",
1,
username,
"foo",
"passwd"
UUID,
user.email(),
user.hashed_password(),
user.first_name(),
user.last_name(),
)
.execute(&db.pool)
.await
.unwrap();
// state exists
assert!(db.username_exists(username).await.unwrap());
assert!(db.user_id_exists(&UUID).await.unwrap());
settings.drop_db().await;
}

View file

@ -6,18 +6,24 @@ use async_trait::async_trait;
use cqrs_es::persist::GenericQuery;
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::DBOutPostgresAdapter;
use crate::identity::application::services::events::UserEvent;
use crate::identity::domain::aggregate::User;
use serde::{Deserialize, Serialize};
use crate::utils::parse_aggregate_id::parse_aggregate_id;
pub const NEW_USER_NON_UUID: &str = "new_user_non_uuid-asdfa";
// The view for a User 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 UserView {
username: String,
first_name: String,
last_name: String,
user_id: Uuid,
email: String,
hashed_password: String,
is_admin: bool,
@ -32,12 +38,14 @@ impl View<User> for UserView {
fn update(&mut self, event: &EventEnvelope<User>) {
match &event.payload {
UserEvent::UserRegistered(val) => {
self.username = val.username().into();
self.email = val.email().into();
self.hashed_password = val.hashed_password().into();
self.is_admin = val.is_admin().to_owned();
self.is_verified = val.is_verified().to_owned();
self.deleted = false;
self.first_name = val.first_name().into();
self.last_name = val.last_name().into();
self.user_id = val.user_id().clone();
}
UserEvent::UserDeleted => self.deleted = true,
UserEvent::Loggedin(_) => (),
@ -58,16 +66,21 @@ impl View<User> for UserView {
#[async_trait]
impl ViewRepository<UserView, User> for DBOutPostgresAdapter {
async fn load(&self, view_id: &str) -> Result<Option<UserView>, PersistenceError> {
async fn load(&self, user_id: &str) -> Result<Option<UserView>, PersistenceError> {
let user_id = match parse_aggregate_id(user_id, NEW_USER_NON_UUID)? {
Some((val, _)) => return Ok(Some(val)),
None => Uuid::parse_str(user_id).unwrap(),
};
let res = sqlx::query_as!(
UserView,
"SELECT
username, email, hashed_password, is_admin, is_verified, deleted
first_name, last_name, user_id, email, hashed_password, is_admin, is_verified, deleted
FROM
user_query
WHERE
view_id = $1;",
view_id
user_id = $1;",
user_id
)
.fetch_one(&self.pool)
.await
@ -77,17 +90,22 @@ impl ViewRepository<UserView, User> for DBOutPostgresAdapter {
async fn load_with_context(
&self,
view_id: &str,
user_id: &str,
) -> Result<Option<(UserView, ViewContext)>, PersistenceError> {
let user_id = match parse_aggregate_id(user_id, NEW_USER_NON_UUID)? {
Some(val) => return Ok(Some(val)),
None => Uuid::parse_str(user_id).unwrap(),
};
let res = sqlx::query_as!(
UserView,
"SELECT
username, email, hashed_password, is_admin, is_verified, deleted
first_name, last_name, user_id, email, hashed_password, is_admin, is_verified, deleted
FROM
user_query
WHERE
view_id = $1;",
view_id
user_id = $1;",
user_id
)
.fetch_one(&self.pool)
.await
@ -95,24 +113,24 @@ impl ViewRepository<UserView, User> for DBOutPostgresAdapter {
struct Context {
version: i64,
view_id: String,
user_id: String,
}
let ctx = sqlx::query_as!(
Context,
"SELECT
view_id, version
user_id, version
FROM
user_query
WHERE
view_id = $1;",
view_id
user_id = $1;",
user_id
)
.fetch_one(&self.pool)
.await
.map_err(PostgresAggregateError::from)?;
let view_context = ViewContext::new(ctx.view_id, ctx.version);
let view_context = ViewContext::new(ctx.user_id.to_string(), ctx.version);
Ok(Some((res, view_context)))
}
@ -126,19 +144,20 @@ impl ViewRepository<UserView, User> for DBOutPostgresAdapter {
let version = context.version + 1;
sqlx::query!(
"INSERT INTO user_query (
view_id, version, username, email,
hashed_password, is_admin, is_verified, deleted
version, first_name, last_name, email,
hashed_password, is_admin, is_verified, deleted, user_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
$1, $2, $3, $4, $5, $6, $7, $8, $9
);",
context.view_instance_id,
version,
view.username,
view.first_name,
view.last_name,
view.email,
view.hashed_password,
view.is_admin,
view.is_verified,
view.deleted,
view.user_id,
)
.execute(&self.pool)
.await
@ -150,16 +169,18 @@ impl ViewRepository<UserView, User> for DBOutPostgresAdapter {
"UPDATE
user_query
SET
view_id = $1, version = $2, username = $3, email = $4,
hashed_password = $5, is_admin = $6, is_verified = $7, deleted = $8;",
context.view_instance_id,
user_id = $1, version = $2, first_name = $3, email = $4,
hashed_password = $5, is_admin = $6, is_verified = $7, deleted = $8,
last_name=$9;",
view.user_id,
version,
view.username,
view.first_name,
view.email,
view.hashed_password,
view.is_admin,
view.is_verified,
view.deleted,
view.last_name,
)
.execute(&self.pool)
.await

View file

@ -16,13 +16,13 @@ impl VerificationSecretExistsOutDBPort for DBOutPostgresAdapter {
SELECT 1
FROM verification_otp
WHERE
username = $1
user_id = $1
AND
purpose = $2
AND
secret = $3
);",
msg.username,
msg.user_id,
REGISTRATION_SECRET_PURPOSE,
msg.secret,
)
@ -39,11 +39,14 @@ impl VerificationSecretExistsOutDBPort for DBOutPostgresAdapter {
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::application::port::output::db::create_verification_secret::*;
use crate::{
identity::application::port::output::db::create_verification_secret::*,
utils::uuid::tests::UUID,
};
#[actix_rt::test]
async fn test_postgres_verification_secret_exists() {
let username = "batman";
let user_id = UUID;
let secret = "bsdasdf";
let settings = crate::settings::tests::get_settings().await;
settings.create_db().await;
@ -53,7 +56,7 @@ mod tests {
.unwrap(),
);
let msg = VerifySecretExistsMsgBuilder::default()
.username(username.into())
.user_id(user_id.clone())
.secret(secret.into())
.build()
.unwrap();
@ -63,7 +66,7 @@ mod tests {
let create_msg = CreateSecretMsgBuilder::default()
.secret(secret.into())
.username(username.into())
.user_id(user_id.clone())
.build()
.unwrap();
db.create_verification_secret(create_msg).await.unwrap();

View file

@ -12,17 +12,17 @@ impl AccountValidationLinkOutMailerPort for LettreMailer {
async fn account_validation_link(
&self,
to: &str,
username: &str,
first_name: &str,
validation_secret: &str,
) -> OutMailerPortResult<()> {
let email = Message::builder()
.from(self.from.parse().unwrap())
.reply_to(self.reply_to.parse().unwrap())
.to(format!("{username} <{to}>").parse().unwrap())
.to(format!("{first_name} <{to}>").parse().unwrap())
.subject("Please verify your account on Vanikam") // TODO: use better title
.header(ContentType::TEXT_PLAIN)
.body(format!(
r#"Hello {username},
r#"Hello {first_name},
Please click here to verify your Vanikam account: {validation_secret}
Warm regards,
Vanikam Admin

View file

@ -1,325 +0,0 @@
// 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 crate::identity::application::services::errors::*;
use crate::identity::application::services::events::UserEvent;
use crate::identity::application::services::UserCommand;
use crate::identity::application::services::UserServicesInterface;
use crate::identity::domain::aggregate::User;
use crate::identity::domain::aggregate::UserBuilder;
#[async_trait]
impl Aggregate for User {
type Command = UserCommand;
type Event = UserEvent;
type Error = IdentityError;
type Services = std::sync::Arc<dyn UserServicesInterface>;
// This identifier should be unique to the system.
fn aggregate_type() -> String {
"account".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 {
UserCommand::RegisterUser(cmd) => {
let res = services.register_user().register_user(cmd).await?;
Ok(vec![UserEvent::UserRegistered(res)])
}
UserCommand::DeleteUser(cmd) => {
services.delete_user().delete_user(cmd).await;
Ok(vec![UserEvent::UserDeleted])
}
UserCommand::Login(cmd) => {
let res = services.login().login(cmd).await;
Ok(vec![UserEvent::Loggedin(res)])
}
UserCommand::UpdatePassword(cmd) => {
let res = services.update_password().update_password(cmd).await;
Ok(vec![UserEvent::PasswordUpdated(res)])
}
UserCommand::UpdateEmail(cmd) => {
let res = services.update_email().update_email(cmd).await?;
Ok(vec![UserEvent::EmailUpdated(res)])
}
UserCommand::MarkUserVerified(cmd) => {
services
.mark_user_verified()
.mark_user_verified(cmd)
.await?;
Ok(vec![UserEvent::UserVerified])
}
UserCommand::SetAdmin(cmd) => {
let res = services.set_user_admin().set_user_admin(cmd).await;
Ok(vec![UserEvent::UserPromotedToAdmin(res)])
}
UserCommand::ResendVerificationEmail(cmd) => {
services
.resend_verification_email()
.resend_verification_email(cmd)
.await?;
Ok(vec![UserEvent::VerificationEmailResent])
}
}
}
fn apply(&mut self, event: Self::Event) {
match event {
UserEvent::UserRegistered(e) => {
*self = UserBuilder::default()
.username(e.username().into())
.email(e.email().into())
.hashed_password(e.hashed_password().into())
.is_admin(e.is_admin().to_owned())
.email_verified(e.email_verified().to_owned())
.is_verified(e.is_verified().to_owned())
.deleted(false)
.build()
.unwrap();
}
UserEvent::UserDeleted => {
self.set_deleted(true);
}
UserEvent::Loggedin(_) => (),
UserEvent::PasswordUpdated(_) => (),
UserEvent::EmailUpdated(e) => {
self.set_email(e.new_email().into());
self.set_email_verified(false);
}
UserEvent::UserVerified => {
self.set_is_verified(true);
self.set_email_verified(true);
}
UserEvent::UserPromotedToAdmin(_) => {
self.set_is_admin(true);
}
UserEvent::VerificationEmailResent => (),
}
}
}
//// 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 async_trait::async_trait;
// use std::sync::Mutex;
//
// use cqrs_es::test::TestFramework;
//
// use crate::domain::aggregate::User;
// use crate::domain::commands::UserCommand;
// use crate::domain::events::UserEvent;
// use crate::services::{AtmError, UserApi, UserServices, CheckingError};
//
// // A test framework that will apply our events and command
// // and verify that the logic works as expected.
// type AccountTestFramework = TestFramework<User>;
//
// #[test]
// fn test_deposit_money() {
// let expected = UserEvent::CustomerDepositedMoney {
// amount: 200.0,
// balance: 200.0,
// };
// let command = UserCommand::DepositMoney { amount: 200.0 };
// let services = UserServices::new(Box::new(MockUserServices::default()));
// // Obtain a new test framework
// AccountTestFramework::with(services)
// // In a test case with no previous events
// .given_no_previous_events()
// // Wnen we fire this command
// .when(command)
// // then we expect these results
// .then_expect_events(vec![expected]);
// }
//
// #[test]
// fn test_deposit_money_with_balance() {
// let previous = UserEvent::CustomerDepositedMoney {
// amount: 200.0,
// balance: 200.0,
// };
// let expected = UserEvent::CustomerDepositedMoney {
// amount: 200.0,
// balance: 400.0,
// };
// let command = UserCommand::DepositMoney { amount: 200.0 };
// let services = UserServices::new(Box::new(MockUserServices::default()));
//
// AccountTestFramework::with(services)
// // Given this previously applied event
// .given(vec![previous])
// // When we fire this command
// .when(command)
// // Then we expect this resultant event
// .then_expect_events(vec![expected]);
// }
//
// #[test]
// fn test_withdraw_money() {
// let previous = UserEvent::CustomerDepositedMoney {
// amount: 200.0,
// balance: 200.0,
// };
// let expected = UserEvent::CustomerWithdrewCash {
// amount: 100.0,
// balance: 100.0,
// };
// let services = MockUserServices::default();
// services.set_atm_withdrawal_response(Ok(()));
// let command = UserCommand::WithdrawMoney {
// amount: 100.0,
// atm_id: "ATM34f1ba3c".to_string(),
// };
//
// AccountTestFramework::with(UserServices::new(Box::new(services)))
// .given(vec![previous])
// .when(command)
// .then_expect_events(vec![expected]);
// }
//
// #[test]
// fn test_withdraw_money_client_error() {
// let previous = UserEvent::CustomerDepositedMoney {
// amount: 200.0,
// balance: 200.0,
// };
// let services = MockUserServices::default();
// services.set_atm_withdrawal_response(Err(AtmError));
// let command = UserCommand::WithdrawMoney {
// amount: 100.0,
// atm_id: "ATM34f1ba3c".to_string(),
// };
//
// let services = UserServices::new(Box::new(services));
// AccountTestFramework::with(services)
// .given(vec![previous])
// .when(command)
// .then_expect_error_message("atm rule violation");
// }
//
// #[test]
// fn test_withdraw_money_funds_not_available() {
// let command = UserCommand::WithdrawMoney {
// amount: 200.0,
// atm_id: "ATM34f1ba3c".to_string(),
// };
//
// let services = UserServices::new(Box::new(MockUserServices::default()));
// AccountTestFramework::with(services)
// .given_no_previous_events()
// .when(command)
// // Here we expect an error rather than any events
// .then_expect_error_message("funds not available")
// }
//
// #[test]
// fn test_wrote_check() {
// let previous = UserEvent::CustomerDepositedMoney {
// amount: 200.0,
// balance: 200.0,
// };
// let expected = UserEvent::CustomerWroteCheck {
// check_number: "1170".to_string(),
// amount: 100.0,
// balance: 100.0,
// };
// let services = MockUserServices::default();
// services.set_validate_check_response(Ok(()));
// let services = UserServices::new(Box::new(services));
// let command = UserCommand::WriteCheck {
// check_number: "1170".to_string(),
// amount: 100.0,
// };
//
// AccountTestFramework::with(services)
// .given(vec![previous])
// .when(command)
// .then_expect_events(vec![expected]);
// }
//
// #[test]
// fn test_wrote_check_bad_check() {
// let previous = UserEvent::CustomerDepositedMoney {
// amount: 200.0,
// balance: 200.0,
// };
// let services = MockUserServices::default();
// services.set_validate_check_response(Err(CheckingError));
// let services = UserServices::new(Box::new(services));
// let command = UserCommand::WriteCheck {
// check_number: "1170".to_string(),
// amount: 100.0,
// };
//
// AccountTestFramework::with(services)
// .given(vec![previous])
// .when(command)
// .then_expect_error_message("check invalid");
// }
//
// #[test]
// fn test_wrote_check_funds_not_available() {
// let command = UserCommand::WriteCheck {
// check_number: "1170".to_string(),
// amount: 100.0,
// };
//
// let services = UserServices::new(Box::new(MockUserServices::default()));
// AccountTestFramework::with(services)
// .given_no_previous_events()
// .when(command)
// .then_expect_error_message("funds not available")
// }
//
// pub struct MockUserServices {
// atm_withdrawal_response: Mutex<Option<Result<(), AtmError>>>,
// validate_check_response: Mutex<Option<Result<(), CheckingError>>>,
// }
//
// impl Default for MockUserServices {
// fn default() -> Self {
// Self {
// atm_withdrawal_response: Mutex::new(None),
// validate_check_response: Mutex::new(None),
// }
// }
// }
//
// impl MockUserServices {
// fn set_atm_withdrawal_response(&self, response: Result<(), AtmError>) {
// *self.atm_withdrawal_response.lock().unwrap() = Some(response);
// }
// fn set_validate_check_response(&self, response: Result<(), CheckingError>) {
// *self.validate_check_response.lock().unwrap() = Some(response);
// }
// }
//
// #[async_trait]
// impl UserApi for MockUserServices {
// async fn atm_withdrawal(&self, _atm_id: &str, _amount: f64) -> Result<(), AtmError> {
// self.atm_withdrawal_response.lock().unwrap().take().unwrap()
// }
//
// async fn validate_check(
// &self,
// _account_id: &str,
// _check_number: &str,
// ) -> Result<(), CheckingError> {
// self.validate_check_response.lock().unwrap().take().unwrap()
// }
// }
//}
//

View file

@ -2,6 +2,5 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod aggregate;
pub mod port;
pub mod services;

View file

@ -6,6 +6,7 @@ use derive_builder::Builder;
use mockall::predicate::*;
use mockall::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::errors::*;
#[cfg(test)]
@ -15,7 +16,7 @@ pub use tests::*;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Builder)]
pub struct CreateSecretMsg {
pub secret: String,
pub username: String,
pub user_id: Uuid,
}
pub const REGISTRATION_SECRET_PURPOSE: &str = "account_validation";

View file

@ -6,6 +6,7 @@ use derive_builder::Builder;
use mockall::predicate::*;
use mockall::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub use super::create_verification_secret::REGISTRATION_SECRET_PURPOSE;
use super::errors::*;
@ -16,14 +17,14 @@ pub use tests::*;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Builder)]
pub struct DeleteSecretMsg {
pub secret: String,
pub username: String,
pub user_id: Uuid,
}
impl From<super::verification_secret_exists::VerifySecretExistsMsg> for DeleteSecretMsg {
fn from(value: super::verification_secret_exists::VerifySecretExistsMsg) -> Self {
Self {
secret: value.secret,
username: value.username,
user_id: value.user_id,
}
}
}

View file

@ -4,6 +4,7 @@
use mockall::predicate::*;
use mockall::*;
use uuid::Uuid;
pub use super::create_verification_secret::REGISTRATION_SECRET_PURPOSE;
use super::errors::*;
@ -14,7 +15,7 @@ pub use tests::*;
#[automock]
#[async_trait::async_trait]
pub trait GetVerificationSecretOutDBPort: Send + Sync {
async fn get_verification_secret(&self, username: &str) -> OutDBPortResult<String>;
async fn get_verification_secret(&self, user_id: &Uuid) -> OutDBPortResult<String>;
}
pub type GetVerificationSecretOutDBPortObj = std::sync::Arc<dyn GetVerificationSecretOutDBPort>;

View file

@ -7,5 +7,5 @@ pub mod delete_verification_secret;
pub mod email_exists;
pub mod errors;
pub mod get_verification_secret;
pub mod username_exists;
pub mod user_id_exists;
pub mod verification_secret_exists;

View file

@ -4,6 +4,7 @@
use mockall::predicate::*;
use mockall::*;
use uuid::Uuid;
use super::errors::*;
#[cfg(test)]
@ -12,11 +13,11 @@ pub use tests::*;
#[automock]
#[async_trait::async_trait]
pub trait UsernameExistsOutDBPort: Send + Sync {
async fn username_exists(&self, username: &str) -> OutDBPortResult<bool>;
pub trait UserIDExistsOutDBPort: Send + Sync {
async fn user_id_exists(&self, user_id: &Uuid) -> OutDBPortResult<bool>;
}
pub type UsernameExistsOutDBPortObj = std::sync::Arc<dyn UsernameExistsOutDBPort>;
pub type UserIDExistsOutDBPortObj = std::sync::Arc<dyn UserIDExistsOutDBPort>;
#[cfg(test)]
pub mod tests {
@ -24,17 +25,17 @@ pub mod tests {
use std::sync::Arc;
pub fn mock_username_exists_db_port(
pub fn mock_user_id_exists_db_port(
times: Option<usize>,
returning: bool,
) -> UsernameExistsOutDBPortObj {
let mut m = MockUsernameExistsOutDBPort::new();
) -> UserIDExistsOutDBPortObj {
let mut m = MockUserIDExistsOutDBPort::new();
if let Some(times) = times {
m.expect_username_exists()
m.expect_user_id_exists()
.times(times)
.returning(move |_| Ok(returning));
} else {
m.expect_username_exists().returning(move |_| Ok(returning));
m.expect_user_id_exists().returning(move |_| Ok(returning));
}
Arc::new(m)

View file

@ -6,6 +6,7 @@ use derive_builder::Builder;
use mockall::predicate::*;
use mockall::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub use super::create_verification_secret::REGISTRATION_SECRET_PURPOSE;
use super::errors::*;
@ -16,7 +17,7 @@ pub use tests::*;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Builder)]
pub struct VerifySecretExistsMsg {
pub secret: String,
pub username: String,
pub user_id: Uuid,
}
#[automock]

View file

@ -16,7 +16,7 @@ pub trait AccountValidationLinkOutMailerPort: Send + Sync {
async fn account_validation_link(
&self,
to: &str,
username: &str,
first_name: &str,
validation_secret: &str,
) -> OutMailerPortResult<()>;
}

View file

@ -30,13 +30,10 @@ pub enum IdentityCommandError {
impl From<CredsError> for IdentityCommandError {
fn from(v: CredsError) -> Self {
match v {
CredsError::ProfainityError => Self::BadUsername(v.to_string()),
CredsError::UsernameCaseMappedError => Self::BadUsername(v.to_string()),
CredsError::BlacklistError => Self::BadUsername(v.to_string()),
CredsError::NotAnEmail => Self::BadEmail,
CredsError::PasswordTooShort => Self::BadPassowrd(v.to_string()),
CredsError::PasswordTooLong => Self::BadPassowrd(v.to_string()),
CredsError::Argon2Error(e) => Self::BadUsername(e.to_string()),
_ => Self::BadUsername(v.to_string()),
}
}
}

View file

@ -5,29 +5,32 @@
use super::*;
use derive_getters::Getters;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)]
pub struct MarkUserVerifiedCommand {
username: String,
user_id: Uuid,
secret: String,
}
impl MarkUserVerifiedCommand {
pub fn new(username: String, secret: String) -> IdentityCommandResult<Self> {
Ok(Self { username, secret })
pub fn new(user_id: Uuid, secret: String) -> IdentityCommandResult<Self> {
Ok(Self { user_id, secret })
}
}
#[cfg(test)]
mod tests {
use crate::utils::uuid::tests::UUID;
use super::*;
#[test]
fn test_cmd() {
let username = "realaravinth";
let user_id = UUID;
let secret = "asdfasdf";
let cmd = MarkUserVerifiedCommand::new(username.into(), secret.into()).unwrap();
assert_eq!(cmd.username(), username);
let cmd = MarkUserVerifiedCommand::new(user_id.clone(), secret.into()).unwrap();
assert_eq!(cmd.user_id(), &user_id);
assert_eq!(cmd.secret(), secret);
}
}

View file

@ -22,7 +22,7 @@ impl MarkUserVerifiedUseCase for MarkUserVerifiedService {
cmd: command::MarkUserVerifiedCommand,
) -> IdentityResult<()> {
let msg = VerifySecretExistsMsgBuilder::default()
.username(cmd.username().into())
.user_id(cmd.user_id().clone())
.secret(cmd.secret().into())
.build()
.unwrap();
@ -48,13 +48,13 @@ impl MarkUserVerifiedUseCase for MarkUserVerifiedService {
mod tests {
use super::*;
use crate::tests::bdd::*;
use crate::{tests::bdd::*, utils::uuid::tests::UUID};
#[actix_rt::test]
async fn test_service() {
let username = "realaravinth";
let user_id = UUID;
let secret = "password";
let cmd = command::MarkUserVerifiedCommand::new(username.into(), secret.into()).unwrap();
let cmd = command::MarkUserVerifiedCommand::new(user_id.clone(), secret.into()).unwrap();
// happy case
{

View file

@ -3,35 +3,45 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::*;
use derive_builder::Builder;
use derive_getters::Getters;
use serde::{Deserialize, Serialize};
#[derive(
Clone, Debug, Serialize, Deserialize, Builder, Eq, PartialEq, Ord, PartialOrd, Getters,
)]
pub struct UnvalidatedRegisterUserCommand {
first_name: String,
last_name: String,
email: String,
password: String,
confirm_password: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)]
pub struct RegisterUserCommand {
username: String,
first_name: String,
last_name: String,
email: String,
hashed_password: String,
}
impl RegisterUserCommand {
pub fn new(
username: String,
email: String,
password: String,
confirm_password: String,
impl UnvalidatedRegisterUserCommand {
pub fn validate(
self,
config: &argon2_creds::Config,
) -> IdentityCommandResult<Self> {
let username = config.username(&username)?;
config.email(&email)?;
) -> IdentityCommandResult<RegisterUserCommand> {
config.email(&self.email)?;
if password != confirm_password {
if self.password != self.confirm_password {
return Err(IdentityCommandError::PasswordsDontMatch);
}
let hashed_password: String = config.password(&password)?;
let hashed_password: String = config.password(&self.password)?;
Ok(Self {
username,
email,
Ok(RegisterUserCommand {
first_name: self.first_name,
last_name: self.last_name,
email: self.email,
hashed_password,
})
}
@ -44,49 +54,47 @@ mod tests {
#[test]
fn test_cmd() {
let config = argon2_creds::Config::default();
RegisterUserCommand::new(
"realaravinth".into(),
"realaravinth@example.com".into(),
"asdfasdfasdfasdf".into(),
"asdfasdfasdfasdf".into(),
&config,
)
let first_name = "John";
let last_name = "Doe";
let email = "john@example.com";
let password = "sadfasdfasdf";
let wrong_password = "sadfasdfasdf--wrong";
UnvalidatedRegisterUserCommandBuilder::default()
.first_name(first_name.into())
.last_name(last_name.into())
.email(email.into())
.password(password.into())
.confirm_password(password.into())
.build()
.unwrap()
.validate(&config)
.unwrap();
assert_eq!(
RegisterUserCommand::new(
"realaravinth".into(),
"username".into(),
"password".into(),
"password".into(),
&config,
)
.err(),
Some(IdentityCommandError::BadEmail)
UnvalidatedRegisterUserCommandBuilder::default()
.first_name(first_name.into())
.last_name(last_name.into())
.email(first_name.into())
.password(password.into())
.confirm_password(password.into())
.build()
.unwrap()
.validate(&config),
Err(IdentityCommandError::BadEmail)
);
assert!(matches!(
RegisterUserCommand::new(
"username".into(),
"username@example.com".into(),
"password".into(),
"password".into(),
&config,
)
.err(),
Some(IdentityCommandError::BadUsername(_))
));
assert_eq!(
RegisterUserCommand::new(
"realaravinth".into(),
"realaravinth@example.com".into(),
"password".into(),
"mismatch_password".into(),
&config,
)
.err(),
Some(IdentityCommandError::PasswordsDontMatch)
UnvalidatedRegisterUserCommandBuilder::default()
.first_name(first_name.into())
.last_name(last_name.into())
.email(email.into())
.password(password.into())
.confirm_password(wrong_password.into())
.build()
.unwrap()
.validate(&config),
Err(IdentityCommandError::PasswordsDontMatch)
);
}
}

View file

@ -5,12 +5,15 @@
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 UserRegisteredEvent {
username: String,
first_name: String,
last_name: String,
user_id: Uuid,
email: String,
hashed_password: String,
is_verified: bool,

View file

@ -7,10 +7,10 @@ use derive_builder::Builder;
use super::*;
use crate::identity::application::port::output::{
db::{create_verification_secret::*, email_exists::*, username_exists::*},
db::{create_verification_secret::*, email_exists::*, user_id_exists::*},
mailer::account_validation_link::*,
};
use crate::utils::random_string::*;
use crate::utils::{random_string::*, uuid::*};
pub const SECRET_LEN: usize = 20;
pub const REGISTRATION_SECRET_PURPOSE: &str = "account_validation";
@ -18,9 +18,10 @@ pub const REGISTRATION_SECRET_PURPOSE: &str = "account_validation";
#[derive(Builder)]
pub struct RegisterUserService {
db_email_exists_adapter: EmailExistsOutDBPortObj,
db_username_exists_adapter: UsernameExistsOutDBPortObj,
db_user_id_exists_adapter: UserIDExistsOutDBPortObj,
db_create_verification_secret_adapter: CreateVerificationSecretOutDBPortObj,
mailer_account_validation_link_adapter: AccountValidationLinkOutMailerPortObj,
get_uuid: GetUUIDInterfaceObj,
random_string_adapter: GenerateRandomStringInterfaceObj,
}
@ -30,15 +31,6 @@ impl RegisterUserUseCase for RegisterUserService {
&self,
cmd: command::RegisterUserCommand,
) -> IdentityResult<events::UserRegisteredEvent> {
if self
.db_username_exists_adapter
.username_exists(cmd.username())
.await
.unwrap()
{
return Err(IdentityError::DuplicateUsername);
}
if self
.db_email_exists_adapter
.email_exists(cmd.email())
@ -48,13 +40,27 @@ impl RegisterUserUseCase for RegisterUserService {
return Err(IdentityError::DuplicateEmail);
}
let mut user_id = self.get_uuid.get_uuid();
loop {
if self
.db_user_id_exists_adapter
.user_id_exists(&user_id)
.await
.unwrap()
{
user_id = self.get_uuid.get_uuid();
} else {
break;
}
}
let secret = self.random_string_adapter.get_random(SECRET_LEN);
self.db_create_verification_secret_adapter
.create_verification_secret(
CreateSecretMsgBuilder::default()
.secret(secret.clone())
.username(cmd.username().into())
.user_id(user_id.clone())
.build()
.unwrap(),
)
@ -62,12 +68,14 @@ impl RegisterUserUseCase for RegisterUserService {
.unwrap();
self.mailer_account_validation_link_adapter
.account_validation_link(cmd.email(), cmd.username(), &secret)
.account_validation_link(cmd.email(), cmd.first_name(), &secret)
.await
.unwrap();
Ok(events::UserRegisteredEventBuilder::default()
.username(cmd.username().into())
.first_name(cmd.first_name().into())
.last_name(cmd.last_name().into())
.user_id(user_id)
.email(cmd.email().into())
.hashed_password(cmd.hashed_password().into())
.is_verified(false)
@ -84,6 +92,7 @@ mod tests {
use crate::tests::bdd::*;
use crate::utils::random_string::tests::*;
use crate::utils::uuid::tests::*;
#[actix_rt::test]
async fn test_service() {
@ -91,19 +100,19 @@ mod tests {
let email = format!("{username}@example.com");
let password = "password";
let config = argon2_creds::Config::default();
let cmd = command::RegisterUserCommand::new(
username.into(),
email.clone(),
password.into(),
password.into(),
&config,
)
let cmd = command::UnvalidatedRegisterUserCommandBuilder::default()
.first_name(username.into())
.last_name(username.into())
.email(email.into())
.password(password.into())
.confirm_password(password.into())
.build()
.unwrap()
.validate(&config)
.unwrap();
// happy case
{
let s = RegisterUserServiceBuilder::default()
.db_username_exists_adapter(mock_username_exists_db_port(
.db_user_id_exists_adapter(mock_user_id_exists_db_port(
IS_CALLED_ONLY_ONCE,
RETURNS_FALSE,
))
@ -121,57 +130,44 @@ mod tests {
.mailer_account_validation_link_adapter(mock_account_validation_link_db_port(
IS_CALLED_ONLY_ONCE,
))
.get_uuid(mock_get_uuid(IS_CALLED_ONLY_ONCE))
.build()
.unwrap();
let res = s.register_user(cmd.clone()).await.unwrap();
assert_eq!(res.username(), cmd.username());
assert_eq!(res.first_name(), cmd.first_name());
assert_eq!(res.last_name(), cmd.last_name());
assert_eq!(res.user_id(), &UUID);
assert_eq!(res.email(), cmd.email());
assert!(!res.is_admin());
assert!(argon2_creds::Config::verify(res.hashed_password(), password).unwrap())
}
// username exists
{
let s = RegisterUserServiceBuilder::default()
.db_username_exists_adapter(mock_username_exists_db_port(
IS_CALLED_ONLY_ONCE,
RETURNS_TRUE,
))
.db_email_exists_adapter(mock_email_exists_db_port(IS_NEVER_CALLED, RETURNS_FALSE))
.db_create_verification_secret_adapter(mock_create_verification_secret_db_port(
IS_NEVER_CALLED,
))
.random_string_adapter(mock_generate_random_string(
IS_NEVER_CALLED,
RETURNS_RANDOM_STRING.into(),
))
.mailer_account_validation_link_adapter(mock_account_validation_link_db_port(
IS_NEVER_CALLED,
))
#[actix_rt::test]
async fn test_service_email_exists() {
let username = "realaravinth";
let email = format!("{username}@example.com");
let password = "password";
let config = argon2_creds::Config::default();
let cmd = command::UnvalidatedRegisterUserCommandBuilder::default()
.first_name(username.into())
.last_name(username.into())
.email(email.into())
.password(password.into())
.confirm_password(password.into())
.build()
.unwrap()
.validate(&config)
.unwrap();
assert_eq!(
s.register_user(cmd.clone()).await.err(),
Some(IdentityError::DuplicateUsername)
);
}
// email exists
{
let s = RegisterUserServiceBuilder::default()
.db_username_exists_adapter(mock_username_exists_db_port(
IS_CALLED_ONLY_ONCE,
.db_user_id_exists_adapter(mock_user_id_exists_db_port(
IGNORE_CALL_COUNT,
RETURNS_FALSE,
))
.db_create_verification_secret_adapter(mock_create_verification_secret_db_port(
IS_NEVER_CALLED,
))
.db_email_exists_adapter(mock_email_exists_db_port(
IS_CALLED_ONLY_ONCE,
RETURNS_TRUE,
))
.db_email_exists_adapter(mock_email_exists_db_port(IS_CALLED_ONLY_ONCE, RETURNS_TRUE))
.random_string_adapter(mock_generate_random_string(
IS_NEVER_CALLED,
RETURNS_RANDOM_STRING.into(),
@ -179,6 +175,7 @@ mod tests {
.mailer_account_validation_link_adapter(mock_account_validation_link_db_port(
IS_NEVER_CALLED,
))
.get_uuid(mock_get_uuid(IS_NEVER_CALLED))
.build()
.unwrap();
@ -188,4 +185,3 @@ mod tests {
);
}
}
}

View file

@ -5,54 +5,58 @@
use super::*;
use derive_getters::Getters;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)]
pub struct ResendVerificationEmailCommand {
username: String,
user_id: Uuid,
first_name: String,
email: String,
}
impl ResendVerificationEmailCommand {
pub fn new(
username: String,
user_id: Uuid,
first_name: String,
email: String,
config: &argon2_creds::Config,
) -> IdentityCommandResult<Self> {
let username = config.username(&username)?;
config.email(&email)?;
Ok(Self { username, email })
Ok(Self {
user_id,
first_name,
email,
})
}
}
#[cfg(test)]
mod tests {
use crate::utils::uuid::tests::UUID;
use super::*;
#[test]
fn test_cmd() {
let config = argon2_creds::Config::default();
ResendVerificationEmailCommand::new(
"realaravinth".into(),
UUID.clone(),
"john".into(),
"realaravinth@example.com".into(),
&config,
)
.unwrap();
assert_eq!(
ResendVerificationEmailCommand::new("realaravinth".into(), "username".into(), &config,)
ResendVerificationEmailCommand::new(
UUID.clone(),
"john".into(),
"john".into(),
&config
)
.err(),
Some(IdentityCommandError::BadEmail)
);
assert!(matches!(
ResendVerificationEmailCommand::new(
"username".into(),
"username@example.com".into(),
&config,
)
.err(),
Some(IdentityCommandError::BadUsername(_))
));
}
}

View file

@ -6,14 +6,13 @@ use derive_builder::Builder;
use super::*;
use crate::identity::application::port::output::{
db::{email_exists::*, get_verification_secret::*, username_exists::*},
db::{email_exists::*, get_verification_secret::*},
mailer::account_validation_link::*,
};
#[derive(Builder)]
pub struct ResendVerificationEmailService {
db_email_exists_adapter: EmailExistsOutDBPortObj,
db_username_exists_adapter: UsernameExistsOutDBPortObj,
db_get_verification_secret_adapter: GetVerificationSecretOutDBPortObj,
mailer_account_validation_link_adapter: AccountValidationLinkOutMailerPortObj,
}
@ -24,15 +23,6 @@ impl ResendVerificationEmailUseCase for ResendVerificationEmailService {
&self,
cmd: command::ResendVerificationEmailCommand,
) -> IdentityResult<()> {
if self
.db_username_exists_adapter
.username_exists(cmd.username())
.await
.unwrap()
{
return Err(IdentityError::DuplicateUsername);
}
if self
.db_email_exists_adapter
.email_exists(cmd.email())
@ -44,12 +34,12 @@ impl ResendVerificationEmailUseCase for ResendVerificationEmailService {
let secret = self
.db_get_verification_secret_adapter
.get_verification_secret(cmd.username())
.get_verification_secret(cmd.user_id())
.await
.unwrap();
self.mailer_account_validation_link_adapter
.account_validation_link(cmd.email(), cmd.username(), &secret)
.account_validation_link(cmd.email(), cmd.first_name(), &secret)
.await
.unwrap();
@ -62,25 +52,23 @@ mod tests {
use super::*;
use crate::tests::bdd::*;
use crate::utils::random_string::tests::*;
use crate::utils::uuid::tests::UUID;
#[actix_rt::test]
async fn test_service() {
let username = "realaravinth";
let email = format!("{username}@example.com");
let user_id = UUID;
let email = format!("john@example.com");
let secret = "asdfasdf";
let config = argon2_creds::Config::default();
let cmd =
command::ResendVerificationEmailCommand::new(username.into(), email.clone(), &config)
let cmd = command::ResendVerificationEmailCommand::new(
UUID.clone(),
"john".into(),
email.clone(),
&config,
)
.unwrap();
// happy case
{
let s = ResendVerificationEmailServiceBuilder::default()
.db_username_exists_adapter(mock_username_exists_db_port(
IS_CALLED_ONLY_ONCE,
RETURNS_FALSE,
))
.db_get_verification_secret_adapter(mock_get_verification_secret_db_port(
IS_CALLED_ONLY_ONCE,
secret.into(),
@ -97,46 +85,26 @@ mod tests {
s.resend_verification_email(cmd.clone()).await.unwrap();
}
// username exists
{
let s = ResendVerificationEmailServiceBuilder::default()
.db_username_exists_adapter(mock_username_exists_db_port(
IS_CALLED_ONLY_ONCE,
RETURNS_TRUE,
))
.db_email_exists_adapter(mock_email_exists_db_port(IS_NEVER_CALLED, RETURNS_FALSE))
.db_get_verification_secret_adapter(mock_get_verification_secret_db_port(
IS_NEVER_CALLED,
secret.into(),
))
.mailer_account_validation_link_adapter(mock_account_validation_link_db_port(
IS_NEVER_CALLED,
))
.build()
#[actix_rt::test]
async fn test_service_email_exists() {
let user_id = UUID;
let email = format!("john@example.com");
let secret = "asdfasdf";
let config = argon2_creds::Config::default();
let cmd = command::ResendVerificationEmailCommand::new(
UUID.clone(),
"john".into(),
email.clone(),
&config,
)
.unwrap();
assert_eq!(
s.resend_verification_email(cmd.clone()).await.err(),
Some(IdentityError::DuplicateUsername)
);
}
// email exists
{
let s = ResendVerificationEmailServiceBuilder::default()
.db_username_exists_adapter(mock_username_exists_db_port(
IS_CALLED_ONLY_ONCE,
RETURNS_FALSE,
))
.db_get_verification_secret_adapter(mock_get_verification_secret_db_port(
IS_NEVER_CALLED,
secret.into(),
))
.db_email_exists_adapter(mock_email_exists_db_port(
IS_CALLED_ONLY_ONCE,
RETURNS_TRUE,
))
.db_email_exists_adapter(mock_email_exists_db_port(IS_CALLED_ONLY_ONCE, RETURNS_TRUE))
.mailer_account_validation_link_adapter(mock_account_validation_link_db_port(
IS_NEVER_CALLED,
))
@ -149,4 +117,3 @@ mod tests {
);
}
}
}

View file

@ -26,6 +26,7 @@ impl SetAdminCommand {
#[cfg(test)]
mod tests {
use crate::identity::domain::aggregate::UserBuilder;
use crate::utils::uuid::tests::UUID;
use super::*;
@ -35,13 +36,15 @@ mod tests {
SetAdminCommand::new(
UserBuilder::default()
.username(username.into())
.first_name(username.into())
.last_name(username.into())
.email(username.into())
.hashed_password(username.into())
.is_verified(true)
.email_verified(false)
.is_admin(true)
.deleted(false)
.user_id(UUID.clone())
.build()
.unwrap(),
)
@ -50,13 +53,15 @@ mod tests {
assert_eq!(
SetAdminCommand::new(
UserBuilder::default()
.username(username.into())
.first_name(username.into())
.last_name(username.into())
.email(username.into())
.hashed_password(username.into())
.is_verified(true)
.is_admin(false)
.email_verified(false)
.deleted(false)
.user_id(UUID.clone())
.build()
.unwrap(),
)

View file

@ -20,6 +20,7 @@ impl SetUserAdminUseCase for SetUserAdminService {
#[cfg(test)]
mod tests {
use crate::identity::domain::aggregate::UserBuilder;
use crate::utils::uuid::tests::UUID;
use super::*;
@ -29,13 +30,15 @@ mod tests {
let s = SetUserAdminService;
let u = UserBuilder::default()
.username(username.into())
.first_name(username.into())
.last_name(username.into())
.email(username.into())
.hashed_password(username.into())
.is_verified(true)
.email_verified(false)
.is_admin(true)
.deleted(false)
.user_id(UUID.clone())
.build()
.unwrap();

View file

@ -4,19 +4,22 @@
use derive_getters::Getters;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::*;
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters)]
pub struct UpdateEmailCommand {
new_email: String,
username: String,
user_id: Uuid,
first_name: String,
}
impl UpdateEmailCommand {
pub fn new(
username: String,
new_email: String,
user_id: Uuid,
first_name: String,
supplied_password: String,
actual_password_hash: &str,
config: &argon2_creds::Config,
@ -27,27 +30,32 @@ impl UpdateEmailCommand {
config.email(&new_email)?;
Ok(Self {
username,
user_id,
new_email,
first_name,
})
}
}
#[cfg(test)]
mod tests {
use crate::utils::uuid::tests::UUID;
use super::*;
#[test]
fn test_cmd() {
let config = argon2_creds::Config::default();
let password = "adsfasdfasd";
let username = "realaravinth";
let first_name = "john";
let user_id = UUID;
let new_email = format!("newemail@example.com");
let hashed_password = config.password(password).unwrap();
assert_eq!(
UpdateEmailCommand::new(
username.into(),
new_email.clone(),
user_id.clone(),
first_name.into(),
password.into(),
&hashed_password,
&config
@ -60,8 +68,9 @@ mod tests {
// email is not valid email
assert_eq!(
UpdateEmailCommand::new(
username.into(),
username.into(),
user_id.to_string(),
user_id.clone(),
first_name.into(),
password.into(),
&hashed_password,
&config
@ -73,9 +82,10 @@ mod tests {
// wrong password
assert_eq!(
UpdateEmailCommand::new(
username.into(),
username.into(),
username.into(),
new_email.to_string(),
user_id.clone(),
first_name.into(),
first_name.into(),
&hashed_password,
&config
)

View file

@ -44,7 +44,7 @@ impl UpdateEmailUseCase for UpdateEmailService {
.create_verification_secret(
CreateSecretMsgBuilder::default()
.secret(secret.clone())
.username(cmd.username().into())
.user_id(cmd.user_id().clone())
.build()
.unwrap(),
)
@ -52,7 +52,7 @@ impl UpdateEmailUseCase for UpdateEmailService {
.unwrap();
self.mailer_account_validation_link_adapter
.account_validation_link(cmd.new_email(), cmd.username(), &secret)
.account_validation_link(cmd.new_email(), cmd.first_name(), &secret)
.await
.unwrap();
@ -66,18 +66,20 @@ mod tests {
use crate::utils::random_string::tests::*;
use crate::tests::bdd::*;
use crate::utils::uuid::tests::UUID;
#[actix_rt::test]
async fn test_service() {
let username = "realaravinth";
let new_email = format!("{username}@example.com");
let user_id = UUID;
let new_email = format!("john@example.com");
let password = "password";
let config = argon2_creds::Config::default();
let hashed_password = config.password(password).unwrap();
let cmd = command::UpdateEmailCommand::new(
username.into(),
new_email.clone(),
user_id.clone(),
"john".into(),
password.into(),
&hashed_password,
&config,

View file

@ -2,15 +2,25 @@
//
// 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::identity::application::services::errors::*;
use crate::identity::application::services::events::UserEvent;
use crate::identity::application::services::UserCommand;
use crate::identity::application::services::UserServicesInterface;
#[derive(
Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd, Getters, Builder,
)]
pub struct User {
username: String,
first_name: String,
last_name: String,
user_id: Uuid,
email: String,
hashed_password: String,
is_verified: bool,
@ -22,13 +32,15 @@ pub struct User {
impl Default for User {
fn default() -> Self {
User {
username: "".to_string(),
first_name: "".to_string(),
last_name: "".to_string(),
email: "".to_string(),
hashed_password: "".to_string(),
is_verified: false,
is_admin: false,
email_verified: false,
deleted: false,
user_id: Uuid::new_v4(),
}
}
}
@ -64,6 +76,101 @@ impl User {
}
}
#[async_trait]
impl Aggregate for User {
type Command = UserCommand;
type Event = UserEvent;
type Error = IdentityError;
type Services = std::sync::Arc<dyn UserServicesInterface>;
// This identifier should be unique to the system.
fn aggregate_type() -> String {
"account".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 {
UserCommand::RegisterUser(cmd) => {
let res = services.register_user().register_user(cmd).await?;
Ok(vec![UserEvent::UserRegistered(res)])
}
UserCommand::DeleteUser(cmd) => {
services.delete_user().delete_user(cmd).await;
Ok(vec![UserEvent::UserDeleted])
}
UserCommand::Login(cmd) => {
let res = services.login().login(cmd).await;
Ok(vec![UserEvent::Loggedin(res)])
}
UserCommand::UpdatePassword(cmd) => {
let res = services.update_password().update_password(cmd).await;
Ok(vec![UserEvent::PasswordUpdated(res)])
}
UserCommand::UpdateEmail(cmd) => {
let res = services.update_email().update_email(cmd).await?;
Ok(vec![UserEvent::EmailUpdated(res)])
}
UserCommand::MarkUserVerified(cmd) => {
services
.mark_user_verified()
.mark_user_verified(cmd)
.await?;
Ok(vec![UserEvent::UserVerified])
}
UserCommand::SetAdmin(cmd) => {
let res = services.set_user_admin().set_user_admin(cmd).await;
Ok(vec![UserEvent::UserPromotedToAdmin(res)])
}
UserCommand::ResendVerificationEmail(cmd) => {
services
.resend_verification_email()
.resend_verification_email(cmd)
.await?;
Ok(vec![UserEvent::VerificationEmailResent])
}
}
}
fn apply(&mut self, event: Self::Event) {
match event {
UserEvent::UserRegistered(e) => {
self.first_name = e.first_name().into();
self.last_name = e.last_name().into();
self.user_id = e.user_id().clone();
self.email = e.email().into();
self.hashed_password = e.hashed_password().into();
self.is_admin = e.is_admin().clone();
self.email_verified = e.email_verified().clone();
self.is_verified = e.is_verified().clone();
self.deleted = false;
}
UserEvent::UserDeleted => {
self.set_deleted(true);
}
UserEvent::Loggedin(_) => (),
UserEvent::PasswordUpdated(_) => (),
UserEvent::EmailUpdated(e) => {
self.set_email(e.new_email().into());
self.set_email_verified(false);
}
UserEvent::UserVerified => {
self.set_is_verified(true);
self.set_email_verified(true);
}
UserEvent::UserPromotedToAdmin(_) => {
self.set_is_admin(true);
}
UserEvent::VerificationEmailResent => (),
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -6,13 +6,14 @@ use async_trait::async_trait;
use cqrs_es::persist::GenericQuery;
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::category_aggregate::Category;
use crate::inventory::domain::events::InventoryEvent;
use serde::{Deserialize, Serialize};
use crate::utils::parse_aggregate_id::parse_aggregate_id;
pub const NEW_CATEGORY_NON_UUID: &str = "new_category_non_uuid-asdfa";
@ -46,8 +47,7 @@ impl View<Category> for CategoryView {
#[async_trait]
impl ViewRepository<CategoryView, Category> for InventoryDBPostgresAdapter {
async fn load(&self, category_id: &str) -> Result<Option<CategoryView>, PersistenceError> {
let category_id =
match super::utils::parse_aggregate_id(category_id, NEW_CATEGORY_NON_UUID)? {
let category_id = match parse_aggregate_id(category_id, NEW_CATEGORY_NON_UUID)? {
Some((val, _)) => return Ok(Some(val)),
None => Uuid::parse_str(category_id).unwrap(),
};
@ -72,8 +72,7 @@ impl ViewRepository<CategoryView, Category> for InventoryDBPostgresAdapter {
&self,
category_id: &str,
) -> Result<Option<(CategoryView, ViewContext)>, PersistenceError> {
let category_id =
match super::utils::parse_aggregate_id(category_id, NEW_CATEGORY_NON_UUID)? {
let category_id = match parse_aggregate_id(category_id, NEW_CATEGORY_NON_UUID)? {
Some(val) => return Ok(Some(val)),
None => Uuid::parse_str(category_id).unwrap(),
};

View file

@ -15,7 +15,6 @@ mod errors;
mod store_id_exists;
mod store_name_exists;
mod store_view;
mod utils;
#[derive(Clone)]
pub struct InventoryDBPostgresAdapter {

View file

@ -32,6 +32,8 @@ impl StoreIDExistsDBPort for InventoryDBPostgresAdapter {
mod tests {
use uuid::Uuid;
use crate::utils::uuid::tests::UUID;
use super::*;
#[actix_rt::test]
@ -47,7 +49,7 @@ mod tests {
let store = StoreBuilder::default()
.name("store_name".into())
.owner("store_owner".into())
.owner(UUID.clone())
.address(Some("store_address".into()))
.store_id(store_id)
.build()

View file

@ -32,6 +32,8 @@ impl StoreNameExistsDBPort for InventoryDBPostgresAdapter {
mod tests {
use uuid::Uuid;
use crate::utils::uuid::tests::UUID;
use super::*;
#[actix_rt::test]
@ -47,7 +49,7 @@ mod tests {
let store = StoreBuilder::default()
.name("store_name".into())
.owner("store_owner".into())
.owner(UUID.clone())
.address(Some("store_address".into()))
.store_id(store_id)
.build()

View file

@ -12,6 +12,7 @@ use super::errors::*;
use super::InventoryDBPostgresAdapter;
use crate::inventory::domain::events::InventoryEvent;
use crate::inventory::domain::store_aggregate::Store;
use crate::utils::parse_aggregate_id::parse_aggregate_id;
pub const NEW_STORE_NON_UUID: &str = "new_store_non_uuid-asdfa";
@ -22,7 +23,7 @@ pub struct StoreView {
name: String,
address: Option<String>,
store_id: Uuid,
owner: String,
owner: Uuid,
}
// This updates the view with events as they are committed.
@ -45,7 +46,7 @@ impl View<Store> for StoreView {
#[async_trait]
impl ViewRepository<StoreView, Store> for InventoryDBPostgresAdapter {
async fn load(&self, store_id: &str) -> Result<Option<StoreView>, PersistenceError> {
let store_id = match super::utils::parse_aggregate_id(store_id, NEW_STORE_NON_UUID)? {
let store_id = match parse_aggregate_id(store_id, NEW_STORE_NON_UUID)? {
Some((val, _)) => return Ok(Some(val)),
None => Uuid::parse_str(store_id).unwrap(),
};
@ -70,7 +71,7 @@ impl ViewRepository<StoreView, Store> for InventoryDBPostgresAdapter {
&self,
store_id: &str,
) -> Result<Option<(StoreView, ViewContext)>, PersistenceError> {
let store_id = match super::utils::parse_aggregate_id(store_id, NEW_STORE_NON_UUID)? {
let store_id = match parse_aggregate_id(store_id, NEW_STORE_NON_UUID)? {
Some(val) => return Ok(Some(val)),
None => Uuid::parse_str(store_id).unwrap(),
};
@ -247,7 +248,7 @@ mod tests {
))
.add_category(mock_add_category_service(
IS_NEVER_CALLED,
AddCategoryCommand::new("foo".into(), None, UUID.clone(), "bar".into()).unwrap(),
AddCategoryCommand::new("foo".into(), None, UUID.clone(), UUID.clone()).unwrap(),
))
.build()
.unwrap();
@ -265,7 +266,7 @@ mod tests {
);
let rand = crate::utils::random_string::GenerateRandomString {};
let cmd = AddStoreCommand::new(rand.get_random(10), None, "me".into()).unwrap();
let cmd = AddStoreCommand::new(rand.get_random(10), None, UUID.clone()).unwrap();
cqrs.execute("", InventoryCommand::AddStore(cmd.clone()))
.await
.unwrap();

View file

@ -78,7 +78,7 @@ impl AddCategoryUseCase for AddCategoryService {
Ok(CategoryAddedEventBuilder::default()
.name(category.name().into())
.description(category.description().as_ref().map(|s| s.to_string()))
.added_by_user(cmd.adding_by().into())
.added_by_user(cmd.adding_by().clone())
.store_id(category.store_id().clone())
.category_id(category.category_id().clone())
.build()
@ -104,7 +104,7 @@ pub mod tests {
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())
.added_by_user(cmd.adding_by().clone())
.store_id(cmd.store_id().clone())
.category_id(UUID.clone())
.build()
@ -125,7 +125,7 @@ pub mod tests {
async fn test_service_category_doesnt_exist() {
let name = "foo";
let description = "bar";
let username = "baz";
let user_id = UUID;
let store_id = Uuid::new_v4();
// description = None
@ -133,7 +133,7 @@ pub mod tests {
name.into(),
Some(description.into()),
store_id.clone(),
username.into(),
user_id.clone(),
)
.unwrap();
@ -158,7 +158,7 @@ pub mod tests {
async fn test_service_category_name_exists_for_store() {
let name = "foo";
let description = "bar";
let username = "baz";
let user_id = UUID;
let store_id = Uuid::new_v4();
// description = None
@ -166,7 +166,7 @@ pub mod tests {
name.into(),
Some(description.into()),
store_id.clone(),
username.into(),
user_id.clone(),
)
.unwrap();
@ -175,7 +175,7 @@ pub mod tests {
IS_CALLED_ONLY_ONCE,
))
.db_category_id_exists(mock_category_id_exists_db_port_false(IS_NEVER_CALLED))
.get_uuid(mock_get_uuid(IS_NEVER_CALLED))
.get_uuid(mock_get_uuid(IS_CALLED_ONLY_ONCE))
.build()
.unwrap();

View file

@ -43,7 +43,7 @@ impl AddStoreUseCase for AddStoreService {
let mut store = StoreBuilder::default()
.name(cmd.name().into())
.address(cmd.address().as_ref().map(|s| s.to_string()))
.owner(cmd.owner().into())
.owner(cmd.owner().clone())
.store_id(store_id.clone())
.build()
.unwrap();
@ -58,6 +58,7 @@ impl AddStoreUseCase for AddStoreService {
store = StoreBuilder::default()
.name(cmd.name().into())
.address(cmd.address().as_ref().map(|s| s.to_string()))
.owner(cmd.owner().clone())
.store_id(store_id.clone())
.build()
.unwrap();
@ -70,7 +71,7 @@ impl AddStoreUseCase for AddStoreService {
Ok(StoreAddedEventBuilder::default()
.name(store.name().into())
.address(store.address().as_ref().map(|s| s.to_string()))
.owner(cmd.owner().into())
.owner(cmd.owner().clone())
.store_id(store_id.clone())
.build()
.unwrap())
@ -93,7 +94,7 @@ pub mod tests {
let res = StoreAddedEventBuilder::default()
.name(cmd.name().into())
.address(cmd.address().as_ref().map(|s| s.to_string()))
.owner(cmd.owner().into())
.owner(cmd.owner().clone())
.store_id(UUID.clone())
.build()
.unwrap();
@ -113,10 +114,10 @@ pub mod tests {
async fn test_service_store_id_doesnt_exist() {
let name = "foo";
let address = "bar";
let username = "baz";
let owner = UUID;
// address = None
let cmd = AddStoreCommand::new(name.into(), Some(address.into()), username.into()).unwrap();
let cmd = AddStoreCommand::new(name.into(), Some(address.into()), owner.clone()).unwrap();
let s = AddStoreServiceBuilder::default()
.db_store_id_exists(mock_store_id_exists_db_port_false(IS_CALLED_ONLY_ONCE))
@ -136,10 +137,10 @@ pub mod tests {
async fn test_service_store_name_exists() {
let name = "foo";
let address = "bar";
let username = "baz";
let owner = UUID;
// address = None
let cmd = AddStoreCommand::new(name.into(), Some(address.into()), username.into()).unwrap();
let cmd = AddStoreCommand::new(name.into(), Some(address.into()), owner.clone()).unwrap();
let s = AddStoreServiceBuilder::default()
.db_store_id_exists(mock_store_id_exists_db_port_false(IS_NEVER_CALLED))

View file

@ -17,7 +17,7 @@ pub struct AddCategoryCommand {
name: String,
description: Option<String>,
store_id: Uuid,
adding_by: String,
adding_by: Uuid,
}
impl AddCategoryCommand {
@ -25,7 +25,7 @@ impl AddCategoryCommand {
name: String,
description: Option<String>,
store_id: Uuid,
adding_by: String,
adding_by: Uuid,
) -> Result<Self, AddCategoryCommandError> {
let description: Option<String> = if let Some(description) = description {
let description = description.trim();
@ -56,19 +56,21 @@ impl AddCategoryCommand {
mod tests {
use super::*;
use crate::utils::uuid::tests::UUID;
#[test]
fn test_cmd() {
let name = "foo";
let description = "bar";
let username = "baz";
let adding_by = UUID;
let store_id = Uuid::new_v4();
// description = None
let cmd =
AddCategoryCommand::new(name.into(), None, store_id.clone(), username.into()).unwrap();
let cmd = AddCategoryCommand::new(name.into(), None, store_id.clone(), adding_by.clone())
.unwrap();
assert_eq!(cmd.name(), name);
assert_eq!(cmd.description(), &None);
assert_eq!(cmd.adding_by(), username);
assert_eq!(cmd.adding_by(), &adding_by);
assert_eq!(cmd.store_id(), &store_id);
// description = Some
@ -76,12 +78,12 @@ mod tests {
name.into(),
Some(description.into()),
store_id.clone(),
username.into(),
adding_by.clone(),
)
.unwrap();
assert_eq!(cmd.name(), name);
assert_eq!(cmd.description(), &Some(description.to_owned()));
assert_eq!(cmd.adding_by(), username);
assert_eq!(cmd.adding_by(), &adding_by);
assert_eq!(cmd.store_id(), &store_id);
// AddCategoryCommandError::NameIsEmpty
@ -90,7 +92,7 @@ mod tests {
"".into(),
Some(description.into()),
store_id.clone(),
username.into()
adding_by.clone(),
),
Err(AddCategoryCommandError::NameIsEmpty)
)

View file

@ -5,6 +5,7 @@
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 AddStoreCommandError {
@ -15,14 +16,14 @@ pub enum AddStoreCommandError {
pub struct AddStoreCommand {
name: String,
address: Option<String>,
owner: String,
owner: Uuid,
}
impl AddStoreCommand {
pub fn new(
name: String,
address: Option<String>,
owner: String,
owner: Uuid,
) -> Result<Self, AddStoreCommandError> {
let address: Option<String> = if let Some(address) = address {
let address = address.trim();
@ -50,29 +51,31 @@ impl AddStoreCommand {
#[cfg(test)]
mod tests {
use crate::utils::uuid::tests::UUID;
use super::*;
#[test]
fn test_cmd() {
let name = "foo";
let address = "bar";
let username = "baz";
let owner = UUID.clone();
// address = None
let cmd = AddStoreCommand::new(name.into(), None, username.into()).unwrap();
let cmd = AddStoreCommand::new(name.into(), None, owner.clone()).unwrap();
assert_eq!(cmd.name(), name);
assert_eq!(cmd.address(), &None);
assert_eq!(cmd.owner(), username);
assert_eq!(cmd.owner(), &owner);
// address = Some
let cmd = AddStoreCommand::new(name.into(), Some(address.into()), username.into()).unwrap();
let cmd = AddStoreCommand::new(name.into(), Some(address.into()), owner.clone()).unwrap();
assert_eq!(cmd.name(), name);
assert_eq!(cmd.address(), &Some(address.to_owned()));
assert_eq!(cmd.owner(), username);
assert_eq!(cmd.owner(), &owner);
// AddStoreCommandError::NameIsEmpty
assert_eq!(
AddStoreCommand::new("".into(), Some(address.into()), username.into()),
AddStoreCommand::new("".into(), Some(address.into()), owner.clone()),
Err(AddStoreCommandError::NameIsEmpty)
)
}

View file

@ -13,7 +13,7 @@ use uuid::Uuid;
pub struct CategoryAddedEvent {
name: String,
description: Option<String>,
added_by_user: String,
added_by_user: Uuid,
category_id: Uuid,
store_id: Uuid,
}

View file

@ -92,7 +92,7 @@ mod aggregate_tests {
fn test_create_store() {
let name = "category_name";
let description = Some("category_description".to_string());
let adding_by = "store_owner";
let adding_by = UUID;
let store_id = Uuid::new_v4();
let category_id = UUID.clone();
@ -100,14 +100,14 @@ mod aggregate_tests {
name.into(),
description.clone(),
store_id.clone(),
adding_by.into(),
adding_by.clone(),
)
.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())
.added_by_user(cmd.adding_by().clone())
.store_id(cmd.store_id().clone())
.category_id(category_id.clone())
.build()

View file

@ -13,6 +13,6 @@ use uuid::Uuid;
pub struct StoreAddedEvent {
name: String,
address: Option<String>,
owner: String,
owner: Uuid,
store_id: Uuid,
}

View file

@ -20,7 +20,7 @@ use super::{commands::InventoryCommand, events::InventoryEvent};
pub struct Store {
name: String,
address: Option<String>,
owner: String,
owner: Uuid,
store_id: Uuid,
}
@ -62,7 +62,7 @@ impl Aggregate for Store {
InventoryEvent::StoreAdded(e) => {
self.name = e.name().into();
self.address = e.address().as_ref().map(|s| s.to_string());
self.owner = e.owner().into();
self.owner = e.owner().clone();
self.store_id = e.store_id().clone();
}
_ => (),
@ -95,7 +95,7 @@ mod tests {
fn test_create_store() {
let name = "store_name";
let address = Some("store_address".to_string());
let owner = "store_owner";
let owner = UUID;
let store_id = UUID;
let expected = StoreAddedEventBuilder::default()
@ -107,7 +107,7 @@ mod tests {
.unwrap();
let expected = InventoryEvent::StoreAdded(expected);
let cmd = AddStoreCommand::new(name.into(), address.clone(), owner.into()).unwrap();
let cmd = AddStoreCommand::new(name.into(), address.clone(), owner.clone()).unwrap();
let mut services = MockInventoryServicesInterface::new();
services

View file

@ -68,12 +68,19 @@ mod tests {
#[test]
fn test_db_env_override() {
let init_settings = crate::settings::Settings::new().unwrap();
std::thread::spawn(move || {
env_helper!(
init_settings,
"DATABASE_URL",
"postgres://test_db_env_override",
database.url
);
assert!(true);
})
.join()
.unwrap();
env_helper!(init_settings, "VANIKAM_database_POOL", 99, database.pool);
}
}

View file

@ -130,6 +130,7 @@ pub mod tests {
macro_rules! env_helper {
($init_settings:ident, $env:expr, $val:expr, $val_typed:expr, $($param:ident).+) => {
println!("Setting env var {} to {} for test", $env, $val);
let current = env::var($env);
env::set_var($env, $val);
{
let new_settings = $crate::settings::Settings::new().unwrap();
@ -137,6 +138,9 @@ pub mod tests {
assert_ne!(new_settings.$($param).+, $init_settings.$($param).+);
}
env::remove_var($env);
if let Ok(current) = current {
env::set_var($env, current);
}
};
@ -150,6 +154,7 @@ pub mod tests {
let mut db_url = Url::parse(&settings.database.url).unwrap();
db_url.set_path(&GenerateRandomString.get_random(12));
settings.database.url = db_url.to_string();
settings.database.pool = 1;
settings
}

View file

@ -3,9 +3,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const IS_CALLED_ONLY_ONCE: Option<usize> = Some(1);
pub const IS_NEVER_CALLED: Option<usize> = None;
pub const IS_NEVER_CALLED: Option<usize> = Some(0);
pub const IS_CALLED_ONLY_TWICE: Option<usize> = Some(2);
pub const RETURNS_RANDOM_STRING: &str = "test_random_string";
pub const IGNORE_CALL_COUNT: Option<usize> = None;
pub const RETURNS_TRUE: bool = true;
pub const RETURNS_FALSE: bool = false;

View file

@ -2,5 +2,6 @@
//
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod parse_aggregate_id;
pub mod random_string;
pub mod uuid;