From 531b8f6eaed2158612da95317d203ac578a51930 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 17 May 2026 23:58:02 +0200 Subject: [PATCH] feat(application, api-types): use cases with tests and DTOs --- crates/api-types/Cargo.toml | 11 +++ crates/api-types/src/lib.rs | 2 + crates/api-types/src/requests.rs | 11 +++ crates/api-types/src/responses.rs | 27 +++++++ crates/application/Cargo.toml | 12 +++ crates/application/src/lib.rs | 2 + crates/application/src/testing.rs | 79 +++++++++++++++++++ .../application/src/use_cases/get_profile.rs | 40 ++++++++++ crates/application/src/use_cases/login.rs | 74 +++++++++++++++++ crates/application/src/use_cases/mod.rs | 7 ++ crates/application/src/use_cases/register.rs | 72 +++++++++++++++++ 11 files changed, 337 insertions(+) create mode 100644 crates/api-types/Cargo.toml create mode 100644 crates/api-types/src/lib.rs create mode 100644 crates/api-types/src/requests.rs create mode 100644 crates/api-types/src/responses.rs create mode 100644 crates/application/Cargo.toml create mode 100644 crates/application/src/lib.rs create mode 100644 crates/application/src/testing.rs create mode 100644 crates/application/src/use_cases/get_profile.rs create mode 100644 crates/application/src/use_cases/login.rs create mode 100644 crates/application/src/use_cases/mod.rs create mode 100644 crates/application/src/use_cases/register.rs diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml new file mode 100644 index 0000000..a88a94c --- /dev/null +++ b/crates/api-types/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "api-types" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +serde = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +utoipa = { workspace = true } diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs new file mode 100644 index 0000000..116da0f --- /dev/null +++ b/crates/api-types/src/lib.rs @@ -0,0 +1,2 @@ +pub mod requests; +pub mod responses; diff --git a/crates/api-types/src/requests.rs b/crates/api-types/src/requests.rs new file mode 100644 index 0000000..5e67edf --- /dev/null +++ b/crates/api-types/src/requests.rs @@ -0,0 +1,11 @@ +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct RegisterRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs new file mode 100644 index 0000000..0a9f7ee --- /dev/null +++ b/crates/api-types/src/responses.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct UserResponse { + pub id: Uuid, + pub email: String, + pub role: String, + pub created_at: DateTime, +} + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct AuthResponse { + pub token: String, + pub user: UserResponse, +} + +impl UserResponse { + pub fn from_domain(user: &domain::entities::User) -> Self { + Self { + id: *user.id.as_uuid(), + email: user.email.to_string(), + role: user.role.to_string(), + created_at: user.created_at, + } + } +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml new file mode 100644 index 0000000..1fb2d49 --- /dev/null +++ b/crates/application/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "application" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +tokio = { workspace = true } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs new file mode 100644 index 0000000..0435007 --- /dev/null +++ b/crates/application/src/lib.rs @@ -0,0 +1,2 @@ +pub mod testing; +pub mod use_cases; diff --git a/crates/application/src/testing.rs b/crates/application/src/testing.rs new file mode 100644 index 0000000..1d27ddd --- /dev/null +++ b/crates/application/src/testing.rs @@ -0,0 +1,79 @@ +use std::collections::HashMap; +use async_trait::async_trait; +use tokio::sync::Mutex; +use domain::{ + entities::User, + errors::DomainError, + ports::{PasswordHasher, TokenIssuer, UserRepository}, + value_objects::{Email, PasswordHash, Role, UserId}, +}; + +pub struct InMemoryUserRepository { + users: Mutex>, +} + +impl InMemoryUserRepository { + pub fn new() -> Self { + Self { users: Mutex::new(HashMap::new()) } + } + + pub async fn all(&self) -> Vec { + self.users.lock().await.values().cloned().collect() + } +} + +impl Default for InMemoryUserRepository { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl UserRepository for InMemoryUserRepository { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + Ok(self.users.lock().await.get(&id.to_string()).cloned()) + } + + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + Ok(self.users.lock().await.values() + .find(|u| u.email.as_str() == email.as_str()) + .cloned()) + } + + async fn save(&self, user: &User) -> Result<(), DomainError> { + self.users.lock().await.insert(user.id.to_string(), user.clone()); + Ok(()) + } + + async fn delete(&self, id: &UserId) -> Result<(), DomainError> { + self.users.lock().await.remove(&id.to_string()); + Ok(()) + } +} + +pub struct StubPasswordHasher; + +#[async_trait] +impl PasswordHasher for StubPasswordHasher { + async fn hash(&self, password: &str) -> Result { + Ok(PasswordHash::from_hash(format!("hashed:{password}"))) + } + async fn verify(&self, password: &str, hash: &PasswordHash) -> Result { + Ok(hash.as_str() == format!("hashed:{password}")) + } +} + +pub struct StubTokenIssuer; + +#[async_trait] +impl TokenIssuer for StubTokenIssuer { + async fn issue(&self, user_id: &UserId, _role: &Role) -> Result { + Ok(format!("token:{user_id}")) + } + async fn verify(&self, token: &str) -> Result<(UserId, Role), DomainError> { + let id_str = token.strip_prefix("token:").ok_or_else(|| { + DomainError::Unauthorized("Invalid stub token".to_string()) + })?; + let uuid = uuid::Uuid::parse_str(id_str) + .map_err(|_| DomainError::Unauthorized("Bad UUID in stub token".to_string()))?; + Ok((UserId::from_uuid(uuid), Role::User)) + } +} diff --git a/crates/application/src/use_cases/get_profile.rs b/crates/application/src/use_cases/get_profile.rs new file mode 100644 index 0000000..1d7c687 --- /dev/null +++ b/crates/application/src/use_cases/get_profile.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; +use domain::{entities::User, errors::DomainError, ports::UserRepository, value_objects::UserId}; + +pub struct GetProfile { + repo: Arc, +} + +impl GetProfile { + pub fn new(repo: Arc) -> Self { Self { repo } } + + pub async fn execute(&self, user_id: &UserId) -> Result { + self.repo.find_by_id(user_id).await? + .ok_or_else(|| DomainError::NotFound(format!("User {user_id} not found"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{InMemoryUserRepository, StubPasswordHasher}; + use crate::use_cases::register::RegisterUser; + + #[tokio::test] + async fn get_profile_returns_existing_user() { + let repo = Arc::new(InMemoryUserRepository::new()); + let r = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher)); + let user = r.execute("user@example.com", "password123").await.unwrap(); + let uc = GetProfile::new(repo); + let found = uc.execute(&user.id).await.unwrap(); + assert_eq!(found.id, user.id); + } + + #[tokio::test] + async fn get_profile_returns_not_found() { + let repo = Arc::new(InMemoryUserRepository::new()); + let uc = GetProfile::new(repo); + let result = uc.execute(&UserId::new()).await; + assert!(matches!(result, Err(DomainError::NotFound(_)))); + } +} diff --git a/crates/application/src/use_cases/login.rs b/crates/application/src/use_cases/login.rs new file mode 100644 index 0000000..fb3355f --- /dev/null +++ b/crates/application/src/use_cases/login.rs @@ -0,0 +1,74 @@ +use std::sync::Arc; +use domain::{ + entities::User, + errors::DomainError, + ports::{PasswordHasher, TokenIssuer, UserRepository}, + value_objects::Email, +}; + +pub struct LoginUser { + repo: Arc, + hasher: Arc, + issuer: Arc, +} + +impl LoginUser { + pub fn new( + repo: Arc, + hasher: Arc, + issuer: Arc, + ) -> Self { + Self { repo, hasher, issuer } + } + + pub async fn execute(&self, email: &str, password: &str) -> Result<(User, String), DomainError> { + let email = Email::new(email)?; + let user = self.repo.find_by_email(&email).await? + .ok_or_else(|| DomainError::Unauthorized("Invalid credentials".to_string()))?; + let valid = self.hasher.verify(password, &user.password_hash).await?; + if !valid { + return Err(DomainError::Unauthorized("Invalid credentials".to_string())); + } + let token = self.issuer.issue(&user.id, &user.role).await?; + Ok((user, token)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{InMemoryUserRepository, StubPasswordHasher, StubTokenIssuer}; + use crate::use_cases::register::RegisterUser; + + async fn seeded_repo() -> Arc { + let repo = Arc::new(InMemoryUserRepository::new()); + let r = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher)); + r.execute("user@example.com", "password123").await.unwrap(); + repo + } + + #[tokio::test] + async fn login_returns_user_and_token() { + let repo = seeded_repo().await; + let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer)); + let (user, token) = uc.execute("user@example.com", "password123").await.unwrap(); + assert_eq!(user.email.as_str(), "user@example.com"); + assert!(token.starts_with("token:")); + } + + #[tokio::test] + async fn login_rejects_wrong_password() { + let repo = seeded_repo().await; + let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer)); + let result = uc.execute("user@example.com", "wrongpassword").await; + assert!(matches!(result, Err(DomainError::Unauthorized(_)))); + } + + #[tokio::test] + async fn login_rejects_unknown_email() { + let repo = seeded_repo().await; + let uc = LoginUser::new(repo, Arc::new(StubPasswordHasher), Arc::new(StubTokenIssuer)); + let result = uc.execute("nobody@example.com", "password123").await; + assert!(matches!(result, Err(DomainError::Unauthorized(_)))); + } +} diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs new file mode 100644 index 0000000..0427641 --- /dev/null +++ b/crates/application/src/use_cases/mod.rs @@ -0,0 +1,7 @@ +pub mod get_profile; +pub mod login; +pub mod register; + +pub use get_profile::GetProfile; +pub use login::LoginUser; +pub use register::RegisterUser; diff --git a/crates/application/src/use_cases/register.rs b/crates/application/src/use_cases/register.rs new file mode 100644 index 0000000..341b40a --- /dev/null +++ b/crates/application/src/use_cases/register.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; +use domain::{ + entities::User, + errors::DomainError, + ports::{PasswordHasher, UserRepository}, + value_objects::{Email, UserId}, +}; + +pub struct RegisterUser { + repo: Arc, + hasher: Arc, +} + +impl RegisterUser { + pub fn new(repo: Arc, hasher: Arc) -> Self { + Self { repo, hasher } + } + + pub async fn execute(&self, email: &str, password: &str) -> Result { + if password.len() < 8 { + return Err(DomainError::Validation("Password must be at least 8 characters".to_string())); + } + let email = Email::new(email)?; + if self.repo.find_by_email(&email).await?.is_some() { + return Err(DomainError::Conflict(format!("Email {} is already registered", email.as_str()))); + } + let hash = self.hasher.hash(password).await?; + let user = User::new(UserId::new(), email, hash); + self.repo.save(&user).await?; + Ok(user) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{InMemoryUserRepository, StubPasswordHasher}; + + #[tokio::test] + async fn register_creates_user() { + let repo = Arc::new(InMemoryUserRepository::new()); + let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher)); + let user = uc.execute("test@example.com", "password123").await.unwrap(); + assert_eq!(user.email.as_str(), "test@example.com"); + assert_eq!(repo.all().await.len(), 1); + } + + #[tokio::test] + async fn register_rejects_duplicate_email() { + let repo = Arc::new(InMemoryUserRepository::new()); + let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher)); + uc.execute("test@example.com", "password123").await.unwrap(); + let result = uc.execute("test@example.com", "different1").await; + assert!(matches!(result, Err(DomainError::Conflict(_)))); + } + + #[tokio::test] + async fn register_rejects_short_password() { + let repo = Arc::new(InMemoryUserRepository::new()); + let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher)); + let result = uc.execute("test@example.com", "short").await; + assert!(matches!(result, Err(DomainError::Validation(_)))); + } + + #[tokio::test] + async fn register_rejects_invalid_email() { + let repo = Arc::new(InMemoryUserRepository::new()); + let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher)); + let result = uc.execute("notanemail", "password123").await; + assert!(matches!(result, Err(DomainError::Validation(_)))); + } +}