app: add core use cases (RegisterUser, CreateAlbum) with TDD

This commit is contained in:
2026-05-31 04:25:47 +02:00
parent 294626db72
commit 51fa62fdef
19 changed files with 168 additions and 154 deletions

View File

@@ -0,0 +1 @@
// Catalog use cases (future: SearchAssets, UpdateMetadata, etc.)

View File

@@ -13,28 +13,3 @@ impl GetProfile {
.ok_or_else(|| DomainError::NotFound(format!("User {user_id} not found"))) .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("testuser", "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(&SystemId::new()).await;
assert!(matches!(result, Err(DomainError::NotFound(_))));
}
}

View File

@@ -0,0 +1,3 @@
pub mod register_user;
pub use register_user::RegisterUser;

View File

@@ -0,0 +1,38 @@
use std::sync::Arc;
use domain::{
entities::User,
errors::DomainError,
ports::{PasswordHasher, UserRepository},
value_objects::Email,
};
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, username: &str, email: &str, password: &str) -> Result<User, DomainError> {
if username.is_empty() {
return Err(DomainError::Validation("Username must not be empty".to_string()));
}
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())));
}
if self.repo.find_by_username(username).await?.is_some() {
return Err(DomainError::Conflict(format!("Username {username} is already taken")));
}
let hash = self.hasher.hash(password).await?;
let user = User::new(username, email, hash);
self.repo.save(&user).await?;
Ok(user)
}
}

View File

@@ -33,42 +33,3 @@ impl LoginUser {
Ok((user, token)) 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("testuser", "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(_))));
}
}

View File

@@ -1,7 +1,18 @@
pub mod get_profile; // Bounded context use case modules
pub mod login; pub mod identity;
pub mod register; pub mod organization;
pub mod storage;
pub mod catalog;
pub mod sharing;
pub mod sidecar;
pub mod processing;
pub use get_profile::GetProfile; // Legacy top-level use cases (kept for backward compat)
pub mod login;
pub mod get_profile;
// Re-exports
pub use identity::RegisterUser;
pub use login::LoginUser; pub use login::LoginUser;
pub use register::RegisterUser; pub use get_profile::GetProfile;
pub use organization::CreateAlbum;

View File

@@ -0,0 +1,26 @@
use std::sync::Arc;
use domain::{
entities::Album,
errors::DomainError,
ports::AlbumRepository,
value_objects::SystemId,
};
pub struct CreateAlbum {
album_repo: Arc<dyn AlbumRepository>,
}
impl CreateAlbum {
pub fn new(album_repo: Arc<dyn AlbumRepository>) -> Self {
Self { album_repo }
}
pub async fn execute(&self, title: &str, creator_id: SystemId) -> Result<Album, DomainError> {
if title.is_empty() {
return Err(DomainError::Validation("Album title must not be empty".to_string()));
}
let album = Album::new(title, creator_id);
self.album_repo.save(&album).await?;
Ok(album)
}
}

View File

@@ -0,0 +1,3 @@
pub mod create_album;
pub use create_album::CreateAlbum;

View File

@@ -0,0 +1 @@
// Processing use cases (future: EnqueueJob, ProcessBatch, etc.)

View File

@@ -1,85 +0,0 @@
use std::sync::Arc;
use domain::{
entities::User,
errors::DomainError,
ports::{PasswordHasher, UserRepository},
value_objects::Email,
};
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, username: &str, 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())));
}
if self.repo.find_by_username(username).await?.is_some() {
return Err(DomainError::Conflict(format!("Username {username} is already taken")));
}
let hash = self.hasher.hash(password).await?;
let user = User::new(username, 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("testuser", "test@example.com", "password123").await.unwrap();
assert_eq!(user.email.as_str(), "test@example.com");
assert_eq!(user.username, "testuser");
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("user1", "test@example.com", "password123").await.unwrap();
let result = uc.execute("user2", "test@example.com", "different1").await;
assert!(matches!(result, Err(DomainError::Conflict(_))));
}
#[tokio::test]
async fn register_rejects_duplicate_username() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
uc.execute("sameuser", "a@example.com", "password123").await.unwrap();
let result = uc.execute("sameuser", "b@example.com", "password123").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("user", "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("user", "notanemail", "password123").await;
assert!(matches!(result, Err(DomainError::Validation(_))));
}
}

View File

@@ -0,0 +1 @@
// Sharing use cases (future: CreateShareLink, ManageAccess, etc.)

View File

@@ -0,0 +1 @@
// Sidecar Sync use cases (future: SyncSidecar, ExportMetadata, etc.)

View File

@@ -0,0 +1 @@
// Storage use cases (future: IngestAsset, ManageVolume, etc.)

View File

@@ -0,0 +1 @@
mod use_cases;

View File

@@ -0,0 +1 @@
mod register_user;

View File

@@ -0,0 +1,48 @@
use std::sync::Arc;
use application::testing::{InMemoryUserRepository, StubPasswordHasher};
use application::use_cases::RegisterUser;
use domain::errors::DomainError;
#[tokio::test]
async fn registers_new_user() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
let user = uc.execute("testuser", "test@example.com", "password123").await.unwrap();
assert_eq!(user.username, "testuser");
assert_eq!(user.email.as_str(), "test@example.com");
assert_eq!(repo.all().await.len(), 1);
}
#[tokio::test]
async fn rejects_duplicate_email() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
uc.execute("user1", "test@example.com", "password123").await.unwrap();
let result = uc.execute("user2", "test@example.com", "different1").await;
assert!(matches!(result, Err(DomainError::Conflict(_))));
}
#[tokio::test]
async fn rejects_duplicate_username() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo.clone(), Arc::new(StubPasswordHasher));
uc.execute("sameuser", "a@example.com", "password123").await.unwrap();
let result = uc.execute("sameuser", "b@example.com", "password123").await;
assert!(matches!(result, Err(DomainError::Conflict(_))));
}
#[tokio::test]
async fn rejects_short_password() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher));
let result = uc.execute("user", "test@example.com", "short").await;
assert!(matches!(result, Err(DomainError::Validation(_))));
}
#[tokio::test]
async fn rejects_empty_username() {
let repo = Arc::new(InMemoryUserRepository::new());
let uc = RegisterUser::new(repo, Arc::new(StubPasswordHasher));
let result = uc.execute("", "test@example.com", "password123").await;
assert!(matches!(result, Err(DomainError::Validation(_))));
}

View File

@@ -0,0 +1,2 @@
mod identity;
mod organization;

View File

@@ -0,0 +1,24 @@
use std::sync::Arc;
use application::testing::InMemoryAlbumRepository;
use application::use_cases::CreateAlbum;
use domain::errors::DomainError;
use domain::value_objects::SystemId;
#[tokio::test]
async fn creates_album() {
let repo = Arc::new(InMemoryAlbumRepository::new());
let uc = CreateAlbum::new(repo);
let creator = SystemId::new();
let album = uc.execute("Vacation 2024", creator).await.unwrap();
assert_eq!(album.title, "Vacation 2024");
assert_eq!(album.creator_user_id, creator);
assert_eq!(album.asset_count(), 0);
}
#[tokio::test]
async fn rejects_empty_title() {
let repo = Arc::new(InMemoryAlbumRepository::new());
let uc = CreateAlbum::new(repo);
let result = uc.execute("", SystemId::new()).await;
assert!(matches!(result, Err(DomainError::Validation(_))));
}

View File

@@ -0,0 +1 @@
mod create_album;