app: add core use cases (RegisterUser, CreateAlbum) with TDD
This commit is contained in:
1
crates/application/src/use_cases/catalog/mod.rs
Normal file
1
crates/application/src/use_cases/catalog/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Catalog use cases (future: SearchAssets, UpdateMetadata, etc.)
|
||||||
@@ -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(_))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
3
crates/application/src/use_cases/identity/mod.rs
Normal file
3
crates/application/src/use_cases/identity/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod register_user;
|
||||||
|
|
||||||
|
pub use register_user::RegisterUser;
|
||||||
38
crates/application/src/use_cases/identity/register_user.rs
Normal file
38
crates/application/src/use_cases/identity/register_user.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(_))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
crates/application/src/use_cases/organization/mod.rs
Normal file
3
crates/application/src/use_cases/organization/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod create_album;
|
||||||
|
|
||||||
|
pub use create_album::CreateAlbum;
|
||||||
1
crates/application/src/use_cases/processing/mod.rs
Normal file
1
crates/application/src/use_cases/processing/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Processing use cases (future: EnqueueJob, ProcessBatch, etc.)
|
||||||
@@ -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(_))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
crates/application/src/use_cases/sharing/mod.rs
Normal file
1
crates/application/src/use_cases/sharing/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Sharing use cases (future: CreateShareLink, ManageAccess, etc.)
|
||||||
1
crates/application/src/use_cases/sidecar/mod.rs
Normal file
1
crates/application/src/use_cases/sidecar/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Sidecar Sync use cases (future: SyncSidecar, ExportMetadata, etc.)
|
||||||
1
crates/application/src/use_cases/storage/mod.rs
Normal file
1
crates/application/src/use_cases/storage/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Storage use cases (future: IngestAsset, ManageVolume, etc.)
|
||||||
1
crates/application/tests/app_tests.rs
Normal file
1
crates/application/tests/app_tests.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mod use_cases;
|
||||||
1
crates/application/tests/use_cases/identity/mod.rs
Normal file
1
crates/application/tests/use_cases/identity/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mod register_user;
|
||||||
48
crates/application/tests/use_cases/identity/register_user.rs
Normal file
48
crates/application/tests/use_cases/identity/register_user.rs
Normal 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(_))));
|
||||||
|
}
|
||||||
2
crates/application/tests/use_cases/mod.rs
Normal file
2
crates/application/tests/use_cases/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
mod identity;
|
||||||
|
mod organization;
|
||||||
@@ -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(_))));
|
||||||
|
}
|
||||||
1
crates/application/tests/use_cases/organization/mod.rs
Normal file
1
crates/application/tests/use_cases/organization/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mod create_album;
|
||||||
Reference in New Issue
Block a user