use domain::{ errors::DomainError, events::DomainEvent, models::user::User, ports::{AuthService, EventPublisher, PasswordHasher, UserRepository}, value_objects::{Email, UserId, Username}, }; pub struct RegisterInput { pub username: String, pub email: String, pub password: String, } #[derive(Debug)] pub struct RegisterOutput { pub user: User, pub token: String, } pub async fn register( users: &dyn UserRepository, hasher: &dyn PasswordHasher, auth: &dyn AuthService, events: &dyn EventPublisher, input: RegisterInput, ) -> Result { let username = Username::new(input.username)?; let email = Email::new(input.email)?; if users.find_by_username(&username).await?.is_some() { return Err(DomainError::Conflict("username taken".into())); } if users.find_by_email(&email).await?.is_some() { return Err(DomainError::Conflict("email taken".into())); } let hash = hasher.hash(&input.password).await?; let user = User::new_local(UserId::new(), username, email, hash); users.save(&user).await?; events .publish(&DomainEvent::UserRegistered { user_id: user.id.clone(), }) .await?; let token = auth.generate_token(&user.id)?; Ok(RegisterOutput { user, token: token.token, }) } pub struct LoginInput { pub email: String, pub password: String, } #[derive(Debug)] pub struct LoginOutput { pub user: User, pub token: String, } pub async fn login( users: &dyn UserRepository, hasher: &dyn PasswordHasher, auth: &dyn AuthService, input: LoginInput, ) -> Result { let email = Email::new(input.email)?; let user = users .find_by_email(&email) .await? .ok_or(DomainError::Unauthorized)?; if !hasher.verify(&input.password, &user.password_hash).await? { return Err(DomainError::Unauthorized); } let token = auth.generate_token(&user.id)?; Ok(LoginOutput { user, token: token.token, }) } #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use domain::{ errors::DomainError, events::DomainEvent, ports::{AuthService, GeneratedToken, PasswordHasher}, testing::{NoOpEventPublisher, TestStore}, value_objects::{PasswordHash, UserId}, }; struct FakeHasher; #[async_trait] impl PasswordHasher for FakeHasher { async fn hash(&self, plain: &str) -> Result { Ok(PasswordHash(plain.to_string())) } async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result { Ok(plain == hash.0) } } struct FakeAuth; impl AuthService for FakeAuth { fn generate_token(&self, uid: &UserId) -> Result { Ok(GeneratedToken { token: uid.to_string(), user_id: uid.clone(), }) } fn validate_token(&self, token: &str) -> Result { Ok(UserId::from_uuid( uuid::Uuid::parse_str(token).map_err(|_| DomainError::Unauthorized)?, )) } } fn input() -> RegisterInput { RegisterInput { username: "alice".into(), email: "alice@ex.com".into(), password: "pw".into(), } } #[tokio::test] async fn register_creates_user() { let store = TestStore::default(); let out = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) .await .unwrap(); assert_eq!(out.user.username.as_str(), "alice"); assert!(!out.token.is_empty()); } #[tokio::test] async fn register_rejects_duplicate_username() { let store = TestStore::default(); register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) .await .unwrap(); let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) .await .unwrap_err(); assert!(matches!(err, DomainError::Conflict(_))); } #[tokio::test] async fn login_succeeds_with_correct_password() { let store = TestStore::default(); register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) .await .unwrap(); let out = login( &store, &FakeHasher, &FakeAuth, LoginInput { email: "alice@ex.com".into(), password: "pw".into(), }, ) .await .unwrap(); assert!(!out.token.is_empty()); } #[tokio::test] async fn login_fails_wrong_password() { let store = TestStore::default(); register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) .await .unwrap(); let err = login( &store, &FakeHasher, &FakeAuth, LoginInput { email: "alice@ex.com".into(), password: "wrong".into(), }, ) .await .unwrap_err(); assert!(matches!(err, DomainError::Unauthorized)); } #[tokio::test] async fn register_publishes_user_registered_event() { let store = TestStore::default(); register(&store, &FakeHasher, &FakeAuth, &store, input()) .await .unwrap(); let events = store.events.lock().unwrap(); assert_eq!(events.len(), 1); assert!(matches!(events[0], DomainEvent::UserRegistered { .. })); } #[tokio::test] async fn login_fails_for_nonexistent_user() { let store = TestStore::default(); let err = login( &store, &FakeHasher, &FakeAuth, LoginInput { email: "ghost@ex.com".into(), password: "pass".into(), }, ) .await .unwrap_err(); assert!(matches!(err, DomainError::Unauthorized)); } #[tokio::test] async fn register_rejects_duplicate_email() { let store = TestStore::default(); register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) .await .unwrap(); let err = register( &store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, RegisterInput { username: "alice2".into(), email: "alice@ex.com".into(), password: "pass2".into(), }, ) .await .unwrap_err(); assert!(matches!(err, DomainError::Conflict(_))); } }