init: scaffold from k-template with postgres + worker
This commit is contained in:
2
crates/application/src/lib.rs
Normal file
2
crates/application/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod testing;
|
||||
pub mod use_cases;
|
||||
79
crates/application/src/testing.rs
Normal file
79
crates/application/src/testing.rs
Normal file
@@ -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<HashMap<String, User>>,
|
||||
}
|
||||
|
||||
impl InMemoryUserRepository {
|
||||
pub fn new() -> Self {
|
||||
Self { users: Mutex::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
pub async fn all(&self) -> Vec<User> {
|
||||
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<Option<User>, DomainError> {
|
||||
Ok(self.users.lock().await.get(&id.to_string()).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, 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<PasswordHash, DomainError> {
|
||||
Ok(PasswordHash::from_hash(format!("hashed:{password}")))
|
||||
}
|
||||
async fn verify(&self, password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
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<String, DomainError> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
40
crates/application/src/use_cases/get_profile.rs
Normal file
40
crates/application/src/use_cases/get_profile.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::sync::Arc;
|
||||
use domain::{entities::User, errors::DomainError, ports::UserRepository, value_objects::UserId};
|
||||
|
||||
pub struct GetProfile {
|
||||
repo: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl GetProfile {
|
||||
pub fn new(repo: Arc<dyn UserRepository>) -> Self { Self { repo } }
|
||||
|
||||
pub async fn execute(&self, user_id: &UserId) -> Result<User, DomainError> {
|
||||
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(_))));
|
||||
}
|
||||
}
|
||||
74
crates/application/src/use_cases/login.rs
Normal file
74
crates/application/src/use_cases/login.rs
Normal file
@@ -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<dyn UserRepository>,
|
||||
hasher: Arc<dyn PasswordHasher>,
|
||||
issuer: Arc<dyn TokenIssuer>,
|
||||
}
|
||||
|
||||
impl LoginUser {
|
||||
pub fn new(
|
||||
repo: Arc<dyn UserRepository>,
|
||||
hasher: Arc<dyn PasswordHasher>,
|
||||
issuer: Arc<dyn TokenIssuer>,
|
||||
) -> 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<InMemoryUserRepository> {
|
||||
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(_))));
|
||||
}
|
||||
}
|
||||
7
crates/application/src/use_cases/mod.rs
Normal file
7
crates/application/src/use_cases/mod.rs
Normal file
@@ -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;
|
||||
72
crates/application/src/use_cases/register.rs
Normal file
72
crates/application/src/use_cases/register.rs
Normal file
@@ -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<dyn UserRepository>,
|
||||
hasher: Arc<dyn PasswordHasher>,
|
||||
}
|
||||
|
||||
impl RegisterUser {
|
||||
pub fn new(repo: Arc<dyn UserRepository>, hasher: Arc<dyn PasswordHasher>) -> Self {
|
||||
Self { repo, hasher }
|
||||
}
|
||||
|
||||
pub async fn execute(&self, email: &str, password: &str) -> Result<User, DomainError> {
|
||||
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(_))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user