From d1394ce7bbc8da9da901bc6ea35182b1c46fd102 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 05:17:51 +0200 Subject: [PATCH] app: add organization + sharing commands/queries --- Cargo.lock | 1 + crates/application/Cargo.toml | 3 + .../commands/manage_album_entries.rs | 43 +++++++++ .../src/organization/commands/mod.rs | 4 + .../src/organization/commands/tag_asset.rs | 44 +++++++++ crates/application/src/organization/mod.rs | 4 + .../src/organization/queries/get_album.rs | 27 ++++++ .../src/organization/queries/mod.rs | 1 + .../sharing/commands/generate_share_link.rs | 40 ++++++++ .../application/src/sharing/commands/mod.rs | 7 ++ .../src/sharing/commands/revoke_share.rs | 39 ++++++++ .../src/sharing/commands/share_resource.rs | 51 ++++++++++ crates/application/src/sharing/mod.rs | 8 +- .../sharing/queries/access_shared_resource.rs | 38 ++++++++ crates/application/src/sharing/queries/mod.rs | 1 + crates/application/tests/app_tests.rs | 1 + .../commands/manage_album_entries.rs | 92 +++++++++++++++++++ .../tests/organization/commands/mod.rs | 2 + .../tests/organization/commands/tag_asset.rs | 81 ++++++++++++++++ crates/application/tests/organization/mod.rs | 1 + .../tests/organization/queries/get_album.rs | 38 ++++++++ .../tests/organization/queries/mod.rs | 1 + .../sharing/commands/generate_share_link.rs | 46 ++++++++++ .../application/tests/sharing/commands/mod.rs | 3 + .../tests/sharing/commands/revoke_share.rs | 48 ++++++++++ .../tests/sharing/commands/share_resource.rs | 47 ++++++++++ crates/application/tests/sharing/mod.rs | 2 + .../sharing/queries/access_shared_resource.rs | 67 ++++++++++++++ .../application/tests/sharing/queries/mod.rs | 1 + 29 files changed, 740 insertions(+), 1 deletion(-) create mode 100644 crates/application/src/organization/commands/manage_album_entries.rs create mode 100644 crates/application/src/organization/commands/tag_asset.rs create mode 100644 crates/application/src/organization/queries/get_album.rs create mode 100644 crates/application/src/organization/queries/mod.rs create mode 100644 crates/application/src/sharing/commands/generate_share_link.rs create mode 100644 crates/application/src/sharing/commands/mod.rs create mode 100644 crates/application/src/sharing/commands/revoke_share.rs create mode 100644 crates/application/src/sharing/commands/share_resource.rs create mode 100644 crates/application/src/sharing/queries/access_shared_resource.rs create mode 100644 crates/application/src/sharing/queries/mod.rs create mode 100644 crates/application/tests/organization/commands/manage_album_entries.rs create mode 100644 crates/application/tests/organization/commands/tag_asset.rs create mode 100644 crates/application/tests/organization/queries/get_album.rs create mode 100644 crates/application/tests/organization/queries/mod.rs create mode 100644 crates/application/tests/sharing/commands/generate_share_link.rs create mode 100644 crates/application/tests/sharing/commands/mod.rs create mode 100644 crates/application/tests/sharing/commands/revoke_share.rs create mode 100644 crates/application/tests/sharing/commands/share_resource.rs create mode 100644 crates/application/tests/sharing/mod.rs create mode 100644 crates/application/tests/sharing/queries/access_shared_resource.rs create mode 100644 crates/application/tests/sharing/queries/mod.rs diff --git a/Cargo.lock b/Cargo.lock index b0dd278..3dbd97d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,7 @@ dependencies = [ "anyhow", "async-trait", "bytes", + "chrono", "domain", "serde", "thiserror", diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index d0784d3..b5f9132 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -12,3 +12,6 @@ uuid = { workspace = true } tokio = { workspace = true } bytes = { workspace = true } serde = { workspace = true } + +[dev-dependencies] +chrono = { workspace = true } diff --git a/crates/application/src/organization/commands/manage_album_entries.rs b/crates/application/src/organization/commands/manage_album_entries.rs new file mode 100644 index 0000000..a585fce --- /dev/null +++ b/crates/application/src/organization/commands/manage_album_entries.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; +use domain::{ + entities::Album, + errors::DomainError, + ports::AlbumRepository, + value_objects::SystemId, +}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum AlbumAction { + Add { asset_id: SystemId }, + Remove { asset_id: SystemId }, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ManageAlbumEntriesCommand { + pub album_id: SystemId, + pub action: AlbumAction, + pub user_id: SystemId, +} + +pub struct ManageAlbumEntriesHandler { + album_repo: Arc, +} + +impl ManageAlbumEntriesHandler { + pub fn new(album_repo: Arc) -> Self { + Self { album_repo } + } + + pub async fn execute(&self, cmd: ManageAlbumEntriesCommand) -> Result { + let mut album = self.album_repo.find_by_id(&cmd.album_id).await? + .ok_or_else(|| DomainError::NotFound(format!("Album {} not found", cmd.album_id)))?; + + match cmd.action { + AlbumAction::Add { asset_id } => album.add_asset(asset_id, cmd.user_id)?, + AlbumAction::Remove { asset_id } => album.remove_asset(&asset_id)?, + } + + self.album_repo.save(&album).await?; + Ok(album) + } +} diff --git a/crates/application/src/organization/commands/mod.rs b/crates/application/src/organization/commands/mod.rs index f576691..bb3b324 100644 --- a/crates/application/src/organization/commands/mod.rs +++ b/crates/application/src/organization/commands/mod.rs @@ -1,3 +1,7 @@ pub mod create_album; +pub mod manage_album_entries; +pub mod tag_asset; pub use create_album::{CreateAlbumCommand, CreateAlbumHandler}; +pub use manage_album_entries::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler}; +pub use tag_asset::{TagAssetCommand, TagAssetHandler}; diff --git a/crates/application/src/organization/commands/tag_asset.rs b/crates/application/src/organization/commands/tag_asset.rs new file mode 100644 index 0000000..c282042 --- /dev/null +++ b/crates/application/src/organization/commands/tag_asset.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; +use domain::{ + entities::{AssetTag, Tag}, + errors::DomainError, + ports::{AssetRepository, TagRepository}, + value_objects::SystemId, +}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TagAssetCommand { + pub asset_id: SystemId, + pub tag_name: String, + pub user_id: SystemId, +} + +pub struct TagAssetHandler { + asset_repo: Arc, + tag_repo: Arc, +} + +impl TagAssetHandler { + pub fn new(asset_repo: Arc, tag_repo: Arc) -> Self { + Self { asset_repo, tag_repo } + } + + pub async fn execute(&self, cmd: TagAssetCommand) -> Result<(Tag, AssetTag), DomainError> { + self.asset_repo.find_by_id(&cmd.asset_id).await? + .ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", cmd.asset_id)))?; + + let tag = match self.tag_repo.find_by_name(&cmd.tag_name).await? { + Some(existing) => existing, + None => { + let new_tag = Tag::new_manual(&cmd.tag_name); + self.tag_repo.save_tag(&new_tag).await?; + new_tag + } + }; + + let asset_tag = AssetTag::new_manual(cmd.asset_id, tag.tag_id, cmd.user_id); + self.tag_repo.save_asset_tag(&asset_tag).await?; + + Ok((tag, asset_tag)) + } +} diff --git a/crates/application/src/organization/mod.rs b/crates/application/src/organization/mod.rs index 9520a46..463b68b 100644 --- a/crates/application/src/organization/mod.rs +++ b/crates/application/src/organization/mod.rs @@ -1,3 +1,7 @@ pub mod commands; +pub mod queries; pub use commands::{CreateAlbumCommand, CreateAlbumHandler}; +pub use commands::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler}; +pub use commands::{TagAssetCommand, TagAssetHandler}; +pub use queries::get_album::{GetAlbumQuery, GetAlbumHandler}; diff --git a/crates/application/src/organization/queries/get_album.rs b/crates/application/src/organization/queries/get_album.rs new file mode 100644 index 0000000..7d0a6c2 --- /dev/null +++ b/crates/application/src/organization/queries/get_album.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; +use domain::{ + entities::Album, + errors::DomainError, + ports::AlbumRepository, + value_objects::SystemId, +}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GetAlbumQuery { + pub album_id: SystemId, +} + +pub struct GetAlbumHandler { + album_repo: Arc, +} + +impl GetAlbumHandler { + pub fn new(album_repo: Arc) -> Self { + Self { album_repo } + } + + pub async fn execute(&self, query: GetAlbumQuery) -> Result { + self.album_repo.find_by_id(&query.album_id).await? + .ok_or_else(|| DomainError::NotFound(format!("Album {} not found", query.album_id))) + } +} diff --git a/crates/application/src/organization/queries/mod.rs b/crates/application/src/organization/queries/mod.rs new file mode 100644 index 0000000..43722a8 --- /dev/null +++ b/crates/application/src/organization/queries/mod.rs @@ -0,0 +1 @@ +pub mod get_album; diff --git a/crates/application/src/sharing/commands/generate_share_link.rs b/crates/application/src/sharing/commands/generate_share_link.rs new file mode 100644 index 0000000..01741a7 --- /dev/null +++ b/crates/application/src/sharing/commands/generate_share_link.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; +use domain::{ + entities::{LinkAccessLevel, ScopeType, ShareLink, ShareScope, ShareableType}, + errors::DomainError, + ports::ShareRepository, + value_objects::{DateTimeStamp, SystemId}, +}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GenerateShareLinkCommand { + pub shareable_type: ShareableType, + pub shareable_id: SystemId, + pub access_level: LinkAccessLevel, + pub created_by: SystemId, + pub expires_at: Option, + pub max_uses: Option, +} + +pub struct GenerateShareLinkHandler { + share_repo: Arc, +} + +impl GenerateShareLinkHandler { + pub fn new(share_repo: Arc) -> Self { + Self { share_repo } + } + + pub async fn execute(&self, cmd: GenerateShareLinkCommand) -> Result<(ShareScope, ShareLink), DomainError> { + let scope = ShareScope::new(ScopeType::Link, cmd.shareable_type, cmd.shareable_id, cmd.created_by); + let token = uuid::Uuid::new_v4().to_string(); + let mut link = ShareLink::new(scope.scope_id, token, cmd.access_level); + link.expires_at = cmd.expires_at; + link.max_uses = cmd.max_uses; + + self.share_repo.save_scope(&scope).await?; + self.share_repo.save_link(&link).await?; + + Ok((scope, link)) + } +} diff --git a/crates/application/src/sharing/commands/mod.rs b/crates/application/src/sharing/commands/mod.rs new file mode 100644 index 0000000..5f0428e --- /dev/null +++ b/crates/application/src/sharing/commands/mod.rs @@ -0,0 +1,7 @@ +pub mod share_resource; +pub mod generate_share_link; +pub mod revoke_share; + +pub use share_resource::{ShareResourceCommand, ShareResourceHandler}; +pub use generate_share_link::{GenerateShareLinkCommand, GenerateShareLinkHandler}; +pub use revoke_share::{RevokeShareCommand, RevokeShareHandler}; diff --git a/crates/application/src/sharing/commands/revoke_share.rs b/crates/application/src/sharing/commands/revoke_share.rs new file mode 100644 index 0000000..85c991e --- /dev/null +++ b/crates/application/src/sharing/commands/revoke_share.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::{EventPublisher, ShareRepository}, + value_objects::{DateTimeStamp, SystemId}, +}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RevokeShareCommand { + pub scope_id: SystemId, + pub revoked_by: SystemId, +} + +pub struct RevokeShareHandler { + share_repo: Arc, + event_pub: Arc, +} + +impl RevokeShareHandler { + pub fn new(share_repo: Arc, event_pub: Arc) -> Self { + Self { share_repo, event_pub } + } + + pub async fn execute(&self, cmd: RevokeShareCommand) -> Result<(), DomainError> { + self.share_repo.find_scope_by_id(&cmd.scope_id).await? + .ok_or_else(|| DomainError::NotFound(format!("Share scope {} not found", cmd.scope_id)))?; + + self.share_repo.delete_scope(&cmd.scope_id).await?; + + self.event_pub.publish(DomainEvent::ShareRevoked { + scope_id: cmd.scope_id, + revoked_by: cmd.revoked_by, + timestamp: DateTimeStamp::now(), + }).await?; + + Ok(()) + } +} diff --git a/crates/application/src/sharing/commands/share_resource.rs b/crates/application/src/sharing/commands/share_resource.rs new file mode 100644 index 0000000..56d6f47 --- /dev/null +++ b/crates/application/src/sharing/commands/share_resource.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; +use domain::{ + entities::{ScopeType, ShareScope, ShareTarget, ShareableType, TargetType}, + errors::DomainError, + events::DomainEvent, + ports::{EventPublisher, ShareRepository}, + value_objects::{DateTimeStamp, SystemId}, +}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ShareResourceCommand { + pub shareable_type: ShareableType, + pub shareable_id: SystemId, + pub target_type: TargetType, + pub target_id: SystemId, + pub role_id: SystemId, + pub created_by: SystemId, +} + +pub struct ShareResourceHandler { + share_repo: Arc, + event_pub: Arc, +} + +impl ShareResourceHandler { + pub fn new(share_repo: Arc, event_pub: Arc) -> Self { + Self { share_repo, event_pub } + } + + pub async fn execute(&self, cmd: ShareResourceCommand) -> Result<(ShareScope, ShareTarget), DomainError> { + let scope_type = match cmd.target_type { + TargetType::User => ScopeType::User, + TargetType::Group => ScopeType::Group, + }; + + let scope = ShareScope::new(scope_type, cmd.shareable_type, cmd.shareable_id, cmd.created_by); + let target = ShareTarget::new(scope.scope_id, cmd.target_type, cmd.target_id, cmd.role_id); + + self.share_repo.save_scope(&scope).await?; + self.share_repo.save_target(&target).await?; + + self.event_pub.publish(DomainEvent::ShareCreated { + scope_id: scope.scope_id, + shareable_id: cmd.shareable_id, + created_by: cmd.created_by, + timestamp: DateTimeStamp::now(), + }).await?; + + Ok((scope, target)) + } +} diff --git a/crates/application/src/sharing/mod.rs b/crates/application/src/sharing/mod.rs index e115d4f..b619155 100644 --- a/crates/application/src/sharing/mod.rs +++ b/crates/application/src/sharing/mod.rs @@ -1 +1,7 @@ -// Sharing commands/queries (future: CreateShareLink, ManageAccess, etc.) +pub mod commands; +pub mod queries; + +pub use commands::{ShareResourceCommand, ShareResourceHandler}; +pub use commands::{GenerateShareLinkCommand, GenerateShareLinkHandler}; +pub use commands::{RevokeShareCommand, RevokeShareHandler}; +pub use queries::access_shared_resource::{AccessSharedResourceQuery, AccessSharedResourceHandler}; diff --git a/crates/application/src/sharing/queries/access_shared_resource.rs b/crates/application/src/sharing/queries/access_shared_resource.rs new file mode 100644 index 0000000..d7cb64c --- /dev/null +++ b/crates/application/src/sharing/queries/access_shared_resource.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; +use domain::{ + entities::{LinkAccessLevel, ShareScope}, + errors::DomainError, + ports::ShareRepository, +}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AccessSharedResourceQuery { + pub token: String, +} + +pub struct AccessSharedResourceHandler { + share_repo: Arc, +} + +impl AccessSharedResourceHandler { + pub fn new(share_repo: Arc) -> Self { + Self { share_repo } + } + + pub async fn execute(&self, query: AccessSharedResourceQuery) -> Result<(ShareScope, LinkAccessLevel), DomainError> { + let mut link = self.share_repo.find_link_by_token(&query.token).await? + .ok_or_else(|| DomainError::NotFound("Share link not found".to_string()))?; + + if !link.is_valid() { + return Err(DomainError::Forbidden("Link expired or exhausted".to_string())); + } + + link.record_use(); + self.share_repo.save_link(&link).await?; + + let scope = self.share_repo.find_scope_by_id(&link.scope_id).await? + .ok_or_else(|| DomainError::NotFound("Share scope not found".to_string()))?; + + Ok((scope, link.access_level)) + } +} diff --git a/crates/application/src/sharing/queries/mod.rs b/crates/application/src/sharing/queries/mod.rs new file mode 100644 index 0000000..6c76d2f --- /dev/null +++ b/crates/application/src/sharing/queries/mod.rs @@ -0,0 +1 @@ +pub mod access_shared_resource; diff --git a/crates/application/tests/app_tests.rs b/crates/application/tests/app_tests.rs index 06b39a9..bbbcaef 100644 --- a/crates/application/tests/app_tests.rs +++ b/crates/application/tests/app_tests.rs @@ -2,3 +2,4 @@ mod identity; mod organization; mod storage; mod catalog; +mod sharing; diff --git a/crates/application/tests/organization/commands/manage_album_entries.rs b/crates/application/tests/organization/commands/manage_album_entries.rs new file mode 100644 index 0000000..41ca869 --- /dev/null +++ b/crates/application/tests/organization/commands/manage_album_entries.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; +use application::testing::InMemoryAlbumRepository; +use application::organization::{ + AlbumAction, CreateAlbumCommand, CreateAlbumHandler, + ManageAlbumEntriesCommand, ManageAlbumEntriesHandler, +}; +use domain::errors::DomainError; +use domain::value_objects::SystemId; + +async fn setup_album(repo: &Arc, creator: SystemId) -> SystemId { + let handler = CreateAlbumHandler::new(repo.clone()); + let album = handler.execute(CreateAlbumCommand { + title: "Test Album".into(), + creator_id: creator, + }).await.unwrap(); + album.album_id +} + +#[tokio::test] +async fn adds_asset_to_album() { + let repo = Arc::new(InMemoryAlbumRepository::new()); + let user = SystemId::new(); + let album_id = setup_album(&repo, user).await; + let asset_id = SystemId::new(); + + let handler = ManageAlbumEntriesHandler::new(repo.clone()); + let album = handler.execute(ManageAlbumEntriesCommand { + album_id, + action: AlbumAction::Add { asset_id }, + user_id: user, + }).await.unwrap(); + + assert_eq!(album.asset_count(), 1); + assert_eq!(album.entries[0].asset_id, asset_id); +} + +#[tokio::test] +async fn removes_asset_from_album() { + let repo = Arc::new(InMemoryAlbumRepository::new()); + let user = SystemId::new(); + let album_id = setup_album(&repo, user).await; + let asset_id = SystemId::new(); + + let handler = ManageAlbumEntriesHandler::new(repo.clone()); + handler.execute(ManageAlbumEntriesCommand { + album_id, + action: AlbumAction::Add { asset_id }, + user_id: user, + }).await.unwrap(); + + let album = handler.execute(ManageAlbumEntriesCommand { + album_id, + action: AlbumAction::Remove { asset_id }, + user_id: user, + }).await.unwrap(); + + assert_eq!(album.asset_count(), 0); +} + +#[tokio::test] +async fn rejects_nonexistent_album() { + let repo = Arc::new(InMemoryAlbumRepository::new()); + let handler = ManageAlbumEntriesHandler::new(repo); + let result = handler.execute(ManageAlbumEntriesCommand { + album_id: SystemId::new(), + action: AlbumAction::Add { asset_id: SystemId::new() }, + user_id: SystemId::new(), + }).await; + assert!(matches!(result, Err(DomainError::NotFound(_)))); +} + +#[tokio::test] +async fn rejects_duplicate_add() { + let repo = Arc::new(InMemoryAlbumRepository::new()); + let user = SystemId::new(); + let album_id = setup_album(&repo, user).await; + let asset_id = SystemId::new(); + + let handler = ManageAlbumEntriesHandler::new(repo.clone()); + handler.execute(ManageAlbumEntriesCommand { + album_id, + action: AlbumAction::Add { asset_id }, + user_id: user, + }).await.unwrap(); + + let result = handler.execute(ManageAlbumEntriesCommand { + album_id, + action: AlbumAction::Add { asset_id }, + user_id: user, + }).await; + assert!(matches!(result, Err(DomainError::Conflict(_)))); +} diff --git a/crates/application/tests/organization/commands/mod.rs b/crates/application/tests/organization/commands/mod.rs index 68e6536..0d74aae 100644 --- a/crates/application/tests/organization/commands/mod.rs +++ b/crates/application/tests/organization/commands/mod.rs @@ -1 +1,3 @@ mod create_album; +mod manage_album_entries; +mod tag_asset; diff --git a/crates/application/tests/organization/commands/tag_asset.rs b/crates/application/tests/organization/commands/tag_asset.rs new file mode 100644 index 0000000..63e8ba7 --- /dev/null +++ b/crates/application/tests/organization/commands/tag_asset.rs @@ -0,0 +1,81 @@ +use std::sync::Arc; +use application::testing::{InMemoryAssetRepository, InMemoryTagRepository}; +use application::organization::{TagAssetCommand, TagAssetHandler}; +use domain::entities::{Asset, AssetType, SourceReference}; +use domain::errors::DomainError; +use domain::ports::{AssetRepository, TagRepository}; +use domain::value_objects::{Checksum, SystemId}; + +async fn seed_asset(repo: &Arc) -> SystemId { + let owner = SystemId::new(); + let asset = Asset::new( + SourceReference { + volume_id: SystemId::new(), + relative_path: "photos/test.jpg".into(), + checksum: Checksum::new("a".repeat(64)).unwrap(), + }, + AssetType::Image, + "image/jpeg", + 1024, + owner, + ); + let id = asset.asset_id; + repo.save(&asset).await.unwrap(); + id +} + +#[tokio::test] +async fn tags_asset_creates_new_tag() { + let asset_repo = Arc::new(InMemoryAssetRepository::new()); + let tag_repo = Arc::new(InMemoryTagRepository::new()); + let asset_id = seed_asset(&asset_repo).await; + let user = SystemId::new(); + + let handler = TagAssetHandler::new(asset_repo, tag_repo); + let (tag, asset_tag) = handler.execute(TagAssetCommand { + asset_id, + tag_name: "sunset".into(), + user_id: user, + }).await.unwrap(); + + assert_eq!(tag.name, "sunset"); + assert_eq!(asset_tag.asset_id, asset_id); + assert_eq!(asset_tag.tag_id, tag.tag_id); + assert_eq!(asset_tag.tagged_by_user_id, Some(user)); +} + +#[tokio::test] +async fn reuses_existing_tag() { + let asset_repo = Arc::new(InMemoryAssetRepository::new()); + let tag_repo = Arc::new(InMemoryTagRepository::new()); + let asset_id = seed_asset(&asset_repo).await; + let user = SystemId::new(); + + // Pre-create tag + let existing = domain::entities::Tag::new_manual("sunset"); + let existing_id = existing.tag_id; + tag_repo.save_tag(&existing).await.unwrap(); + + let handler = TagAssetHandler::new(asset_repo, tag_repo); + let (tag, _) = handler.execute(TagAssetCommand { + asset_id, + tag_name: "sunset".into(), + user_id: user, + }).await.unwrap(); + + assert_eq!(tag.tag_id, existing_id); +} + +#[tokio::test] +async fn rejects_nonexistent_asset() { + let asset_repo = Arc::new(InMemoryAssetRepository::new()); + let tag_repo = Arc::new(InMemoryTagRepository::new()); + + let handler = TagAssetHandler::new(asset_repo, tag_repo); + let result = handler.execute(TagAssetCommand { + asset_id: SystemId::new(), + tag_name: "sunset".into(), + user_id: SystemId::new(), + }).await; + assert!(matches!(result, Err(DomainError::NotFound(_)))); +} diff --git a/crates/application/tests/organization/mod.rs b/crates/application/tests/organization/mod.rs index f3d4468..2406e7d 100644 --- a/crates/application/tests/organization/mod.rs +++ b/crates/application/tests/organization/mod.rs @@ -1 +1,2 @@ mod commands; +mod queries; diff --git a/crates/application/tests/organization/queries/get_album.rs b/crates/application/tests/organization/queries/get_album.rs new file mode 100644 index 0000000..d2029b0 --- /dev/null +++ b/crates/application/tests/organization/queries/get_album.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; +use application::testing::InMemoryAlbumRepository; +use application::organization::{ + CreateAlbumCommand, CreateAlbumHandler, + GetAlbumQuery, GetAlbumHandler, +}; +use domain::errors::DomainError; +use domain::value_objects::SystemId; + +#[tokio::test] +async fn returns_album() { + let repo = Arc::new(InMemoryAlbumRepository::new()); + let creator = SystemId::new(); + + let create_handler = CreateAlbumHandler::new(repo.clone()); + let album = create_handler.execute(CreateAlbumCommand { + title: "My Album".into(), + creator_id: creator, + }).await.unwrap(); + + let query_handler = GetAlbumHandler::new(repo); + let found = query_handler.execute(GetAlbumQuery { + album_id: album.album_id, + }).await.unwrap(); + + assert_eq!(found.album_id, album.album_id); + assert_eq!(found.title, "My Album"); +} + +#[tokio::test] +async fn rejects_nonexistent() { + let repo = Arc::new(InMemoryAlbumRepository::new()); + let handler = GetAlbumHandler::new(repo); + let result = handler.execute(GetAlbumQuery { + album_id: SystemId::new(), + }).await; + assert!(matches!(result, Err(DomainError::NotFound(_)))); +} diff --git a/crates/application/tests/organization/queries/mod.rs b/crates/application/tests/organization/queries/mod.rs new file mode 100644 index 0000000..7b9d021 --- /dev/null +++ b/crates/application/tests/organization/queries/mod.rs @@ -0,0 +1 @@ +mod get_album; diff --git a/crates/application/tests/sharing/commands/generate_share_link.rs b/crates/application/tests/sharing/commands/generate_share_link.rs new file mode 100644 index 0000000..c6390ac --- /dev/null +++ b/crates/application/tests/sharing/commands/generate_share_link.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; +use application::testing::InMemoryShareRepository; +use application::sharing::{GenerateShareLinkCommand, GenerateShareLinkHandler}; +use domain::entities::{LinkAccessLevel, ScopeType, ShareableType}; +use domain::value_objects::{DateTimeStamp, SystemId}; + +#[tokio::test] +async fn generates_link() { + let share_repo = Arc::new(InMemoryShareRepository::new()); + let handler = GenerateShareLinkHandler::new(share_repo); + + let (scope, link) = handler.execute(GenerateShareLinkCommand { + shareable_type: ShareableType::Album, + shareable_id: SystemId::new(), + access_level: LinkAccessLevel::ViewOnly, + created_by: SystemId::new(), + expires_at: None, + max_uses: None, + }).await.unwrap(); + + assert_eq!(scope.scope_type, ScopeType::Link); + assert!(!link.token.is_empty()); + assert_eq!(link.access_level, LinkAccessLevel::ViewOnly); + assert!(link.expires_at.is_none()); + assert!(link.max_uses.is_none()); +} + +#[tokio::test] +async fn generates_link_with_expiry_and_max_uses() { + let share_repo = Arc::new(InMemoryShareRepository::new()); + let handler = GenerateShareLinkHandler::new(share_repo); + + let expiry = DateTimeStamp::now(); + let (_, link) = handler.execute(GenerateShareLinkCommand { + shareable_type: ShareableType::Collection, + shareable_id: SystemId::new(), + access_level: LinkAccessLevel::LimitedSearch, + created_by: SystemId::new(), + expires_at: Some(expiry), + max_uses: Some(10), + }).await.unwrap(); + + assert!(link.expires_at.is_some()); + assert_eq!(link.max_uses, Some(10)); + assert_eq!(link.access_level, LinkAccessLevel::LimitedSearch); +} diff --git a/crates/application/tests/sharing/commands/mod.rs b/crates/application/tests/sharing/commands/mod.rs new file mode 100644 index 0000000..7f54552 --- /dev/null +++ b/crates/application/tests/sharing/commands/mod.rs @@ -0,0 +1,3 @@ +mod share_resource; +mod generate_share_link; +mod revoke_share; diff --git a/crates/application/tests/sharing/commands/revoke_share.rs b/crates/application/tests/sharing/commands/revoke_share.rs new file mode 100644 index 0000000..dc11f8e --- /dev/null +++ b/crates/application/tests/sharing/commands/revoke_share.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; +use application::testing::{InMemoryShareRepository, StubEventPublisher}; +use application::sharing::{ + GenerateShareLinkCommand, GenerateShareLinkHandler, + RevokeShareCommand, RevokeShareHandler, +}; +use domain::entities::{LinkAccessLevel, ShareableType}; +use domain::errors::DomainError; +use domain::value_objects::SystemId; + +#[tokio::test] +async fn revokes_share() { + let share_repo = Arc::new(InMemoryShareRepository::new()); + let event_pub = Arc::new(StubEventPublisher::new()); + + // Create a scope first via generate_share_link + let gen_handler = GenerateShareLinkHandler::new(share_repo.clone()); + let (scope, _) = gen_handler.execute(GenerateShareLinkCommand { + shareable_type: ShareableType::Album, + shareable_id: SystemId::new(), + access_level: LinkAccessLevel::ViewOnly, + created_by: SystemId::new(), + expires_at: None, + max_uses: None, + }).await.unwrap(); + + let handler = RevokeShareHandler::new(share_repo, event_pub.clone()); + handler.execute(RevokeShareCommand { + scope_id: scope.scope_id, + revoked_by: SystemId::new(), + }).await.unwrap(); + + let events = event_pub.published().await; + assert_eq!(events.len(), 1); +} + +#[tokio::test] +async fn rejects_nonexistent_scope() { + let share_repo = Arc::new(InMemoryShareRepository::new()); + let event_pub = Arc::new(StubEventPublisher::new()); + let handler = RevokeShareHandler::new(share_repo, event_pub); + + let result = handler.execute(RevokeShareCommand { + scope_id: SystemId::new(), + revoked_by: SystemId::new(), + }).await; + assert!(matches!(result, Err(DomainError::NotFound(_)))); +} diff --git a/crates/application/tests/sharing/commands/share_resource.rs b/crates/application/tests/sharing/commands/share_resource.rs new file mode 100644 index 0000000..dbb3e0f --- /dev/null +++ b/crates/application/tests/sharing/commands/share_resource.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; +use application::testing::{InMemoryShareRepository, StubEventPublisher}; +use application::sharing::{ShareResourceCommand, ShareResourceHandler}; +use domain::entities::{ScopeType, ShareableType, TargetType}; +use domain::value_objects::SystemId; + +#[tokio::test] +async fn shares_with_user() { + let share_repo = Arc::new(InMemoryShareRepository::new()); + let event_pub = Arc::new(StubEventPublisher::new()); + + let handler = ShareResourceHandler::new(share_repo, event_pub.clone()); + let (scope, target) = handler.execute(ShareResourceCommand { + shareable_type: ShareableType::Album, + shareable_id: SystemId::new(), + target_type: TargetType::User, + target_id: SystemId::new(), + role_id: SystemId::new(), + created_by: SystemId::new(), + }).await.unwrap(); + + assert_eq!(scope.scope_type, ScopeType::User); + assert_eq!(target.target_type, TargetType::User); + assert_eq!(target.scope_id, scope.scope_id); + + let events = event_pub.published().await; + assert_eq!(events.len(), 1); +} + +#[tokio::test] +async fn shares_with_group() { + let share_repo = Arc::new(InMemoryShareRepository::new()); + let event_pub = Arc::new(StubEventPublisher::new()); + + let handler = ShareResourceHandler::new(share_repo, event_pub.clone()); + let (scope, target) = handler.execute(ShareResourceCommand { + shareable_type: ShareableType::Asset, + shareable_id: SystemId::new(), + target_type: TargetType::Group, + target_id: SystemId::new(), + role_id: SystemId::new(), + created_by: SystemId::new(), + }).await.unwrap(); + + assert_eq!(scope.scope_type, ScopeType::Group); + assert_eq!(target.target_type, TargetType::Group); +} diff --git a/crates/application/tests/sharing/mod.rs b/crates/application/tests/sharing/mod.rs new file mode 100644 index 0000000..2406e7d --- /dev/null +++ b/crates/application/tests/sharing/mod.rs @@ -0,0 +1,2 @@ +mod commands; +mod queries; diff --git a/crates/application/tests/sharing/queries/access_shared_resource.rs b/crates/application/tests/sharing/queries/access_shared_resource.rs new file mode 100644 index 0000000..5d49c5b --- /dev/null +++ b/crates/application/tests/sharing/queries/access_shared_resource.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; +use application::testing::InMemoryShareRepository; +use application::sharing::{ + AccessSharedResourceQuery, AccessSharedResourceHandler, + GenerateShareLinkCommand, GenerateShareLinkHandler, +}; +use chrono::{DateTime, Utc}; +use domain::entities::{LinkAccessLevel, ShareableType}; +use domain::errors::DomainError; +use domain::value_objects::{DateTimeStamp, SystemId}; + +async fn create_link( + repo: &Arc, + expires_at: Option, + max_uses: Option, +) -> String { + let handler = GenerateShareLinkHandler::new(repo.clone()); + let (_, link) = handler.execute(GenerateShareLinkCommand { + shareable_type: ShareableType::Album, + shareable_id: SystemId::new(), + access_level: LinkAccessLevel::ViewOnly, + created_by: SystemId::new(), + expires_at, + max_uses, + }).await.unwrap(); + link.token +} + +#[tokio::test] +async fn valid_link_returns_scope() { + let repo = Arc::new(InMemoryShareRepository::new()); + let token = create_link(&repo, None, None).await; + + let handler = AccessSharedResourceHandler::new(repo); + let (scope, access_level) = handler.execute(AccessSharedResourceQuery { + token, + }).await.unwrap(); + + assert_eq!(access_level, LinkAccessLevel::ViewOnly); + assert_eq!(scope.shareable_type, ShareableType::Album); +} + +#[tokio::test] +async fn expired_link_rejected() { + let repo = Arc::new(InMemoryShareRepository::new()); + // Create link with past expiry + let past = DateTimeStamp::from_datetime(DateTime::::from_timestamp(0, 0).unwrap()); + let token = create_link(&repo, Some(past), None).await; + + let handler = AccessSharedResourceHandler::new(repo); + let result = handler.execute(AccessSharedResourceQuery { token }).await; + assert!(matches!(result, Err(DomainError::Forbidden(_)))); +} + +#[tokio::test] +async fn exhausted_link_rejected() { + let repo = Arc::new(InMemoryShareRepository::new()); + let token = create_link(&repo, None, Some(1)).await; + + // Use it once + let handler = AccessSharedResourceHandler::new(repo.clone()); + handler.execute(AccessSharedResourceQuery { token: token.clone() }).await.unwrap(); + + // Second use should fail + let result = handler.execute(AccessSharedResourceQuery { token }).await; + assert!(matches!(result, Err(DomainError::Forbidden(_)))); +} diff --git a/crates/application/tests/sharing/queries/mod.rs b/crates/application/tests/sharing/queries/mod.rs new file mode 100644 index 0000000..e94cbe3 --- /dev/null +++ b/crates/application/tests/sharing/queries/mod.rs @@ -0,0 +1 @@ +mod access_shared_resource;