arc-web-cli 0.8.2

Create self-contained Arc applications
use super::{commands::UserCommand, events::*};
use arc_core::{
    aggregate::Aggregate,
    event::{Event, NewEvent},
};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum UserError {
    #[error("user already exists")]
    AlreadyExists,
    #[error("active user not found")]
    NotFound,
    #[error("name cannot be empty")]
    EmptyName,
    #[error("invalid email address")]
    InvalidEmail,
    #[error("password hash cannot be empty")]
    EmptyPasswordHash,
}

#[derive(Default, Serialize, Deserialize)]
pub struct UserAggregate {
    id: Option<String>,
    name: Option<String>,
    email: Option<String>,
    password_hash: Option<String>,
    version: i64,
    active: bool,
}

fn valid_name(value: &str) -> Result<(), UserError> {
    if value.trim().is_empty() {
        Err(UserError::EmptyName)
    } else {
        Ok(())
    }
}
fn normalized_email(value: &str) -> Result<String, UserError> {
    let value = value.trim().to_ascii_lowercase();
    if value
        .split_once('@')
        .is_some_and(|(left, right)| !left.is_empty() && right.contains('.'))
    {
        Ok(value)
    } else {
        Err(UserError::InvalidEmail)
    }
}
fn valid_hash(value: &str) -> Result<(), UserError> {
    if value.trim().is_empty() {
        Err(UserError::EmptyPasswordHash)
    } else {
        Ok(())
    }
}

#[async_trait]
impl Aggregate for UserAggregate {
    type Command = UserCommand;
    type Event = ();
    type Error = UserError;
    fn aggregate_type() -> &'static str {
        "User"
    }
    fn version(&self) -> i64 {
        self.version
    }
    async fn handle(&self, command: UserCommand) -> Result<Vec<Event>, UserError> {
        let (id, event_type, payload) = match command {
            UserCommand::Register {
                id,
                name,
                email,
                password_hash,
            } => {
                if self.version > 0 {
                    return Err(UserError::AlreadyExists);
                }
                valid_name(&name)?;
                let email = normalized_email(&email)?;
                valid_hash(&password_hash)?;
                let payload = serde_json::json!({"id":id,"name":name.trim(),"email":email,"password_hash":password_hash});
                (id, REGISTERED, payload)
            }
            UserCommand::UpdateProfile { id, name } => {
                if !self.active {
                    return Err(UserError::NotFound);
                }
                valid_name(&name)?;
                let payload = serde_json::json!({"name":name.trim()});
                (id, PROFILE_UPDATED, payload)
            }
            UserCommand::ChangeEmail { id, email } => {
                if !self.active {
                    return Err(UserError::NotFound);
                }
                let email = normalized_email(&email)?;
                (id, EMAIL_CHANGED, serde_json::json!({"email":email}))
            }
            UserCommand::ChangePassword { id, password_hash } => {
                if !self.active {
                    return Err(UserError::NotFound);
                }
                valid_hash(&password_hash)?;
                (
                    id,
                    PASSWORD_CHANGED,
                    serde_json::json!({"password_hash":password_hash}),
                )
            }
            UserCommand::Deactivate { id } => {
                if !self.active {
                    return Err(UserError::NotFound);
                }
                (id, DEACTIVATED, serde_json::json!({}))
            }
        };
        Ok(vec![Event::new(NewEvent {
            aggregate_type: Self::aggregate_type(),
            aggregate_id: &id,
            sequence: self.version + 1,
            event_type,
            payload,
        })])
    }
    fn apply(&mut self, event: &Event) {
        self.version = event.sequence;
        match event.event_type.as_str() {
            REGISTERED => {
                self.id = Some(event.aggregate_id.clone());
                self.name = event.payload["name"].as_str().map(str::to_owned);
                self.email = event.payload["email"].as_str().map(str::to_owned);
                self.password_hash = event.payload["password_hash"].as_str().map(str::to_owned);
                self.active = true;
            }
            PROFILE_UPDATED => self.name = event.payload["name"].as_str().map(str::to_owned),
            EMAIL_CHANGED => self.email = event.payload["email"].as_str().map(str::to_owned),
            PASSWORD_CHANGED => {
                self.password_hash = event.payload["password_hash"].as_str().map(str::to_owned)
            }
            DEACTIVATED => self.active = false,
            _ => {}
        }
    }
    fn to_snapshot(&self) -> Option<serde_json::Value> {
        serde_json::to_value(self).ok()
    }
    fn from_snapshot(value: serde_json::Value) -> Option<Self> {
        serde_json::from_value(value).ok()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[tokio::test]
    async fn registration_normalizes_email_and_does_not_redact_persisted_hash() {
        let events = UserAggregate::default()
            .handle(UserCommand::Register {
                id: "u1".into(),
                name: "Admin".into(),
                email: " ADMIN@Example.COM ".into(),
                password_hash: "$argon2id$hash".into(),
            })
            .await
            .unwrap();
        assert_eq!(events[0].payload["email"], "admin@example.com");
        assert_eq!(events[0].payload["password_hash"], "$argon2id$hash");
    }
}