diff --git a/Cargo.lock b/Cargo.lock index 2ce068c..168fa25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bytes", "domain", "thiserror", "tokio", diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 1fb2d49..4fb737f 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -10,3 +10,4 @@ anyhow = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } tokio = { workspace = true } +bytes = { workspace = true } diff --git a/crates/application/src/testing.rs b/crates/application/src/testing.rs deleted file mode 100644 index 3c66fcc..0000000 --- a/crates/application/src/testing.rs +++ /dev/null @@ -1,85 +0,0 @@ -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, SystemId}, -}; - -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: &SystemId) -> 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 find_by_username(&self, username: &str) -> Result, DomainError> { - Ok(self.users.lock().await.values() - .find(|u| u.username == username) - .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: &SystemId) -> 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: &SystemId, _role: &str) -> Result { - Ok(format!("token:{user_id}")) - } - async fn verify(&self, token: &str) -> Result<(SystemId, String), 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((SystemId::from_uuid(uuid), "user".to_string())) - } -} diff --git a/crates/application/src/testing/fakes.rs b/crates/application/src/testing/fakes.rs new file mode 100644 index 0000000..57bd33f --- /dev/null +++ b/crates/application/src/testing/fakes.rs @@ -0,0 +1,143 @@ +use std::collections::HashMap; +use async_trait::async_trait; +use bytes::Bytes; +use tokio::sync::Mutex; +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::{EventPublisher, FileStoragePort, FileEntry, PasswordHasher, TokenIssuer, SidecarWriterPort}, + value_objects::{PasswordHash, StructuredData, SystemId}, +}; + +// --- StubEventPublisher --- + +pub struct StubEventPublisher { + events: Mutex>, +} + +impl StubEventPublisher { + pub fn new() -> Self { + Self { events: Mutex::new(Vec::new()) } + } + + pub async fn published(&self) -> Vec { + self.events.lock().await.clone() + } +} + +impl Default for StubEventPublisher { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl EventPublisher for StubEventPublisher { + async fn publish(&self, event: DomainEvent) -> Result<(), DomainError> { + self.events.lock().await.push(event); + Ok(()) + } +} + +// --- InMemoryFileStorage --- + +pub struct InMemoryFileStorage { + files: Mutex>, +} + +impl InMemoryFileStorage { + pub fn new() -> Self { + Self { files: Mutex::new(HashMap::new()) } + } +} + +impl Default for InMemoryFileStorage { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl FileStoragePort for InMemoryFileStorage { + async fn store_file(&self, path: &str, data: Bytes) -> Result<(), DomainError> { + self.files.lock().await.insert(path.to_string(), data); + Ok(()) + } + + async fn read_file(&self, path: &str) -> Result { + self.files.lock().await.get(path).cloned() + .ok_or_else(|| DomainError::NotFound(format!("File not found: {path}"))) + } + + async fn delete_file(&self, path: &str) -> Result<(), DomainError> { + self.files.lock().await.remove(path); + Ok(()) + } + + async fn list_directory(&self, path: &str) -> Result, DomainError> { + let files = self.files.lock().await; + let prefix = if path.ends_with('/') { path.to_string() } else { format!("{path}/") }; + Ok(files.keys() + .filter(|k| k.starts_with(&prefix)) + .map(|k| FileEntry { + path: k.clone(), + size_bytes: files.get(k).map(|b| b.len() as u64).unwrap_or(0), + is_directory: false, + }) + .collect()) + } + + async fn file_exists(&self, path: &str) -> Result { + Ok(self.files.lock().await.contains_key(path)) + } + + async fn available_space(&self) -> Result { + Ok(u64::MAX) + } +} + +// --- StubSidecarWriter --- + +pub struct StubSidecarWriter; + +#[async_trait] +impl SidecarWriterPort for StubSidecarWriter { + fn format_name(&self) -> &str { "stub" } + + async fn write_sidecar(&self, _data: &StructuredData, _path: &str) -> Result<(), DomainError> { + Ok(()) + } + + async fn read_sidecar(&self, _path: &str) -> Result { + Ok(StructuredData::new()) + } +} + +// --- StubPasswordHasher --- + +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}")) + } +} + +// --- StubTokenIssuer --- + +pub struct StubTokenIssuer; + +#[async_trait] +impl TokenIssuer for StubTokenIssuer { + async fn issue(&self, user_id: &SystemId, _role: &str) -> Result { + Ok(format!("token:{user_id}")) + } + async fn verify(&self, token: &str) -> Result<(SystemId, String), 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((SystemId::from_uuid(uuid), "user".to_string())) + } +} diff --git a/crates/application/src/testing/mod.rs b/crates/application/src/testing/mod.rs new file mode 100644 index 0000000..a846338 --- /dev/null +++ b/crates/application/src/testing/mod.rs @@ -0,0 +1,5 @@ +pub mod fakes; +pub mod repositories; + +pub use fakes::*; +pub use repositories::*; diff --git a/crates/application/src/testing/repositories.rs b/crates/application/src/testing/repositories.rs new file mode 100644 index 0000000..ea59c8e --- /dev/null +++ b/crates/application/src/testing/repositories.rs @@ -0,0 +1,278 @@ +use std::collections::HashMap; +use async_trait::async_trait; +use tokio::sync::Mutex; +use domain::{ + entities::{Album, Asset, Group, Job, JobStatus, Role, User}, + errors::DomainError, + ports::{ + AlbumRepository, AssetRepository, GroupRepository, + JobRepository, RoleRepository, UserRepository, + }, + value_objects::{Checksum, Email, SystemId}, +}; + +// --- InMemoryUserRepository --- + +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: &SystemId) -> 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 find_by_username(&self, username: &str) -> Result, DomainError> { + Ok(self.users.lock().await.values() + .find(|u| u.username == username) + .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: &SystemId) -> Result<(), DomainError> { + self.users.lock().await.remove(&id.to_string()); + Ok(()) + } +} + +// --- InMemoryAssetRepository --- + +pub struct InMemoryAssetRepository { + data: Mutex>, +} + +impl InMemoryAssetRepository { + pub fn new() -> Self { + Self { data: Mutex::new(HashMap::new()) } + } +} + +impl Default for InMemoryAssetRepository { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl AssetRepository for InMemoryAssetRepository { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError> { + Ok(self.data.lock().await.get(&id.to_string()).cloned()) + } + + async fn find_by_checksum(&self, checksum: &Checksum) -> Result, DomainError> { + Ok(self.data.lock().await.values() + .filter(|a| &a.source_reference.checksum == checksum) + .cloned() + .collect()) + } + + async fn find_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result, DomainError> { + let all: Vec = self.data.lock().await.values() + .filter(|a| &a.owner_user_id == owner_id) + .cloned() + .collect(); + Ok(all.into_iter().skip(offset as usize).take(limit as usize).collect()) + } + + async fn save(&self, asset: &Asset) -> Result<(), DomainError> { + self.data.lock().await.insert(asset.asset_id.to_string(), asset.clone()); + Ok(()) + } + + async fn delete(&self, id: &SystemId) -> Result<(), DomainError> { + self.data.lock().await.remove(&id.to_string()); + Ok(()) + } +} + +// --- InMemoryAlbumRepository --- + +pub struct InMemoryAlbumRepository { + data: Mutex>, +} + +impl InMemoryAlbumRepository { + pub fn new() -> Self { + Self { data: Mutex::new(HashMap::new()) } + } +} + +impl Default for InMemoryAlbumRepository { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl AlbumRepository for InMemoryAlbumRepository { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError> { + Ok(self.data.lock().await.get(&id.to_string()).cloned()) + } + + async fn find_by_creator(&self, creator_id: &SystemId) -> Result, DomainError> { + Ok(self.data.lock().await.values() + .filter(|a| &a.creator_user_id == creator_id) + .cloned() + .collect()) + } + + async fn save(&self, album: &Album) -> Result<(), DomainError> { + self.data.lock().await.insert(album.album_id.to_string(), album.clone()); + Ok(()) + } + + async fn delete(&self, id: &SystemId) -> Result<(), DomainError> { + self.data.lock().await.remove(&id.to_string()); + Ok(()) + } +} + +// --- InMemoryJobRepository --- + +pub struct InMemoryJobRepository { + data: Mutex>, +} + +impl InMemoryJobRepository { + pub fn new() -> Self { + Self { data: Mutex::new(HashMap::new()) } + } +} + +impl Default for InMemoryJobRepository { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl JobRepository for InMemoryJobRepository { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError> { + Ok(self.data.lock().await.get(&id.to_string()).cloned()) + } + + async fn find_next_queued(&self) -> Result, DomainError> { + let data = self.data.lock().await; + Ok(data.values() + .filter(|j| j.status == JobStatus::Queued) + .max_by_key(|j| j.priority) + .cloned()) + } + + async fn find_by_batch(&self, batch_id: &SystemId) -> Result, DomainError> { + Ok(self.data.lock().await.values() + .filter(|j| j.batch_id.as_ref() == Some(batch_id)) + .cloned() + .collect()) + } + + async fn save(&self, job: &Job) -> Result<(), DomainError> { + self.data.lock().await.insert(job.job_id.to_string(), job.clone()); + Ok(()) + } +} + +// --- InMemoryRoleRepository --- + +pub struct InMemoryRoleRepository { + data: Mutex>, +} + +impl InMemoryRoleRepository { + pub fn new() -> Self { + Self { data: Mutex::new(HashMap::new()) } + } +} + +impl Default for InMemoryRoleRepository { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl RoleRepository for InMemoryRoleRepository { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError> { + Ok(self.data.lock().await.get(&id.to_string()).cloned()) + } + + async fn find_by_name(&self, name: &str) -> Result, DomainError> { + Ok(self.data.lock().await.values() + .find(|r| r.name == name) + .cloned()) + } + + async fn find_defaults(&self) -> Result, DomainError> { + Ok(self.data.lock().await.values() + .filter(|r| r.is_system_default) + .cloned() + .collect()) + } + + async fn save(&self, role: &Role) -> Result<(), DomainError> { + self.data.lock().await.insert(role.role_id.to_string(), role.clone()); + Ok(()) + } + + async fn delete(&self, id: &SystemId) -> Result<(), DomainError> { + self.data.lock().await.remove(&id.to_string()); + Ok(()) + } +} + +// --- InMemoryGroupRepository --- + +pub struct InMemoryGroupRepository { + data: Mutex>, +} + +impl InMemoryGroupRepository { + pub fn new() -> Self { + Self { data: Mutex::new(HashMap::new()) } + } +} + +impl Default for InMemoryGroupRepository { + fn default() -> Self { Self::new() } +} + +#[async_trait] +impl GroupRepository for InMemoryGroupRepository { + async fn find_by_id(&self, id: &SystemId) -> Result, DomainError> { + Ok(self.data.lock().await.get(&id.to_string()).cloned()) + } + + async fn find_by_user(&self, user_id: &SystemId) -> Result, DomainError> { + Ok(self.data.lock().await.values() + .filter(|g| g.is_member(user_id)) + .cloned() + .collect()) + } + + async fn save(&self, group: &Group) -> Result<(), DomainError> { + self.data.lock().await.insert(group.group_id.to_string(), group.clone()); + Ok(()) + } + + async fn delete(&self, id: &SystemId) -> Result<(), DomainError> { + self.data.lock().await.remove(&id.to_string()); + Ok(()) + } +}