From 0177eb7a0ebdaf82cead75f1bc2eeb957dc91995 Mon Sep 17 00:00:00 2001 From: turtton Date: Fri, 4 Sep 2026 01:25:17 +0900 Subject: [PATCH 1/4] :sparkles: profile-transfer: add ProfileTransferRequest aggregate, events, read model, and storage port --- kernel/src/entity.rs | 2 + kernel/src/entity/profile.rs | 35 +++ kernel/src/entity/profile_transfer_request.rs | 205 ++++++++++++++++++ .../src/entity/profile_transfer_request/id.rs | 14 ++ kernel/src/event_store.rs | 2 + .../event_store/profile_transfer_request.rs | 50 +++++ kernel/src/lib.rs | 21 ++ kernel/src/read_model.rs | 2 + kernel/src/read_model/profile.rs | 22 ++ .../read_model/profile_transfer_request.rs | 159 ++++++++++++++ .../profile_transfer_request/query.rs | 90 ++++++++ kernel/src/repository/aggregate.rs | 15 +- kernel/src/storage.rs | 21 ++ 13 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 kernel/src/entity/profile_transfer_request.rs create mode 100644 kernel/src/entity/profile_transfer_request/id.rs create mode 100644 kernel/src/event_store/profile_transfer_request.rs create mode 100644 kernel/src/read_model/profile_transfer_request.rs create mode 100644 kernel/src/read_model/profile_transfer_request/query.rs diff --git a/kernel/src/entity.rs b/kernel/src/entity.rs index 5dec569..9c84417 100644 --- a/kernel/src/entity.rs +++ b/kernel/src/entity.rs @@ -12,6 +12,7 @@ mod metadata; mod mute; mod organization_membership; mod profile; +mod profile_transfer_request; mod remote_account; mod signing_key; @@ -29,5 +30,6 @@ pub use self::metadata::*; pub use self::mute::*; pub use self::organization_membership::*; pub use self::profile::*; +pub use self::profile_transfer_request::*; pub use self::remote_account::*; pub use self::signing_key::*; diff --git a/kernel/src/entity/profile.rs b/kernel/src/entity/profile.rs index b9ee54a..2b49b1e 100644 --- a/kernel/src/entity/profile.rs +++ b/kernel/src/entity/profile.rs @@ -54,6 +54,10 @@ pub enum ProfileEvent { #[serde(default, skip_serializing_if = "FieldAction::is_unchanged")] banner: FieldAction, }, + AccountTransferred { + from_account_id: AccountId, + to_account_id: AccountId, + }, } impl Profile { @@ -121,6 +125,18 @@ impl Profile { }; CommandEnvelope::new(EventId::from(id), event.name(), event, None) } + + pub fn transfer_account( + id: ProfileId, + from: AccountId, + to: AccountId, + ) -> CommandEnvelope { + let event = ProfileEvent::AccountTransferred { + from_account_id: from, + to_account_id: to, + }; + CommandEnvelope::new(EventId::from(id), event.name(), event, None) + } } impl EventApplier for Profile { @@ -188,6 +204,25 @@ impl EventApplier for Profile { .attach_printable(Self::not_exists(event.id.as_ref()))); } } + ProfileEvent::AccountTransferred { + from_account_id, + to_account_id, + } => { + if let Some(profile) = entity { + if profile.account_id != from_account_id { + return Err(Report::new(KernelError::Rejected).attach_printable(format!( + "Profile {} is not owned by account {}", + event.id.as_ref(), + from_account_id.as_ref() + ))); + } + profile.account_id = to_account_id; + profile.version = event.version; + } else { + return Err(Report::new(KernelError::Internal) + .attach_printable(Self::not_exists(event.id.as_ref()))); + } + } } Ok(()) } diff --git a/kernel/src/entity/profile_transfer_request.rs b/kernel/src/entity/profile_transfer_request.rs new file mode 100644 index 0000000..8caede7 --- /dev/null +++ b/kernel/src/entity/profile_transfer_request.rs @@ -0,0 +1,205 @@ +mod id; + +pub use self::id::*; + +use super::{ + AccountId, CommandEnvelope, EventEnvelope, EventId, EventVersion, ExpectedVersion, Nanoid, + ProfileId, +}; +use crate::event::EventApplier; +use crate::KernelError; +use destructure::Destructure; +use error_stack::Report; +use serde::{Deserialize, Serialize}; +use vodca::{Nameln, Newln, References}; + +#[derive( + Debug, Clone, Hash, Eq, PartialEq, References, Newln, Destructure, Serialize, Deserialize, +)] +pub struct ProfileTransferRequest { + id: ProfileTransferRequestId, + profile_id: ProfileId, + from_account_id: AccountId, + to_org_account_id: AccountId, + status: ProfileTransferStatus, + version: EventVersion, + nanoid: Nanoid, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ProfileTransferStatus { + Pending, + Accepted, + Rejected, + Cancelled, +} + +impl ProfileTransferStatus { + pub fn is_pending(&self) -> bool { + matches!(self, Self::Pending) + } +} + +#[derive(Debug, Clone, Eq, PartialEq, Nameln, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[vodca(prefix = "profile_transfer_request", snake_case)] +pub enum ProfileTransferRequestEvent { + Requested { + profile_id: ProfileId, + from_account_id: AccountId, + to_org_account_id: AccountId, + nanoid: Nanoid, + }, + Accepted, + Rejected, + Cancelled, +} + +impl ProfileTransferRequest { + pub fn request( + id: ProfileTransferRequestId, + profile_id: ProfileId, + from_account_id: AccountId, + to_org_account_id: AccountId, + nanoid: Nanoid, + ) -> CommandEnvelope { + let event = ProfileTransferRequestEvent::Requested { + profile_id, + from_account_id, + to_org_account_id, + nanoid, + }; + CommandEnvelope::new( + EventId::from(id), + event.name(), + event, + Some(ExpectedVersion::Nothing), + ) + } + + pub fn accept( + id: ProfileTransferRequestId, + current_version: EventVersion, + ) -> CommandEnvelope { + let event = ProfileTransferRequestEvent::Accepted; + CommandEnvelope::new( + EventId::from(id), + event.name(), + event, + Some(ExpectedVersion::At(current_version)), + ) + } + + pub fn reject( + id: ProfileTransferRequestId, + current_version: EventVersion, + ) -> CommandEnvelope { + let event = ProfileTransferRequestEvent::Rejected; + CommandEnvelope::new( + EventId::from(id), + event.name(), + event, + Some(ExpectedVersion::At(current_version)), + ) + } + + pub fn cancel( + id: ProfileTransferRequestId, + current_version: EventVersion, + ) -> CommandEnvelope { + let event = ProfileTransferRequestEvent::Cancelled; + CommandEnvelope::new( + EventId::from(id), + event.name(), + event, + Some(ExpectedVersion::At(current_version)), + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn reconstitute( + id: ProfileTransferRequestId, + profile_id: ProfileId, + from_account_id: AccountId, + to_org_account_id: AccountId, + status: ProfileTransferStatus, + version: EventVersion, + nanoid: Nanoid, + ) -> Self { + Self { + id, + profile_id, + from_account_id, + to_org_account_id, + status, + version, + nanoid, + } + } +} + +impl EventApplier for ProfileTransferRequest { + type Event = ProfileTransferRequestEvent; + const ENTITY_NAME: &'static str = "ProfileTransferRequest"; + + fn apply( + entity: &mut Option, + event: EventEnvelope, + ) -> error_stack::Result<(), KernelError> { + match event.event { + ProfileTransferRequestEvent::Requested { + profile_id, + from_account_id, + to_org_account_id, + nanoid, + } => { + if let Some(entity) = entity { + return Err(Report::new(KernelError::Internal) + .attach_printable(Self::already_exists(entity))); + } + *entity = Some(ProfileTransferRequest { + id: ProfileTransferRequestId::new(event.id), + profile_id, + from_account_id, + to_org_account_id, + status: ProfileTransferStatus::Pending, + version: event.version, + nanoid, + }); + } + ProfileTransferRequestEvent::Accepted + | ProfileTransferRequestEvent::Rejected + | ProfileTransferRequestEvent::Cancelled => { + if let Some(request) = entity { + if !request.status.is_pending() { + return Err(Report::new(KernelError::Rejected).attach_printable(format!( + "Profile transfer request is already {}", + status_name(&request.status) + ))); + } + request.status = match event.event { + ProfileTransferRequestEvent::Accepted => ProfileTransferStatus::Accepted, + ProfileTransferRequestEvent::Rejected => ProfileTransferStatus::Rejected, + ProfileTransferRequestEvent::Cancelled => ProfileTransferStatus::Cancelled, + ProfileTransferRequestEvent::Requested { .. } => unreachable!(), + }; + request.version = event.version; + } else { + return Err(Report::new(KernelError::Internal) + .attach_printable(Self::not_exists(event.id.as_ref()))); + } + } + } + Ok(()) + } +} + +fn status_name(status: &ProfileTransferStatus) -> &'static str { + match status { + ProfileTransferStatus::Pending => "pending", + ProfileTransferStatus::Accepted => "accepted", + ProfileTransferStatus::Rejected => "rejected", + ProfileTransferStatus::Cancelled => "cancelled", + } +} diff --git a/kernel/src/entity/profile_transfer_request/id.rs b/kernel/src/entity/profile_transfer_request/id.rs new file mode 100644 index 0000000..fec56b1 --- /dev/null +++ b/kernel/src/entity/profile_transfer_request/id.rs @@ -0,0 +1,14 @@ +use crate::entity::{EventId, ProfileTransferRequest, ProfileTransferRequestEvent}; +use serde::{Deserialize, Serialize}; +use vodca::{AsRefln, Fromln, Newln}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Fromln, AsRefln, Newln, Serialize, Deserialize)] +pub struct ProfileTransferRequestId(i64); + +impl From + for EventId +{ + fn from(profile_transfer_request_id: ProfileTransferRequestId) -> Self { + EventId::new(profile_transfer_request_id.0) + } +} diff --git a/kernel/src/event_store.rs b/kernel/src/event_store.rs index a38e478..684a096 100644 --- a/kernel/src/event_store.rs +++ b/kernel/src/event_store.rs @@ -2,8 +2,10 @@ mod account; mod account_report; mod metadata; mod profile; +mod profile_transfer_request; pub use self::account::*; pub use self::account_report::*; pub use self::metadata::*; pub use self::profile::*; +pub use self::profile_transfer_request::*; diff --git a/kernel/src/event_store/profile_transfer_request.rs b/kernel/src/event_store/profile_transfer_request.rs new file mode 100644 index 0000000..d19c658 --- /dev/null +++ b/kernel/src/event_store/profile_transfer_request.rs @@ -0,0 +1,50 @@ +use crate::database::{Connection, DatabaseConnection, DependOnDatabaseConnection}; +use crate::entity::{ + CommandEnvelope, EventEnvelope, EventId, EventVersion, ProfileTransferRequest, + ProfileTransferRequestEvent, +}; +use crate::KernelError; +use std::future::Future; + +pub trait ProfileTransferRequestEventStore: Sync + Send + 'static { + type Connection: Connection; + + fn persist( + &self, + executor: &mut Self::Connection, + command: &CommandEnvelope, + ) -> impl Future> + Send; + + fn persist_and_transform( + &self, + executor: &mut Self::Connection, + command: CommandEnvelope, + ) -> impl Future< + Output = error_stack::Result< + EventEnvelope, + KernelError, + >, + > + Send; + + fn find_by_id( + &self, + executor: &mut Self::Connection, + id: &EventId, + since: Option<&EventVersion>, + ) -> impl Future< + Output = error_stack::Result< + Vec>, + KernelError, + >, + > + Send; +} + +pub trait DependOnProfileTransferRequestEventStore: + Sync + Send + DependOnDatabaseConnection +{ + type ProfileTransferRequestEventStore: ProfileTransferRequestEventStore< + Connection = ::Connection, + >; + + fn profile_transfer_request_event_store(&self) -> &Self::ProfileTransferRequestEventStore; +} diff --git a/kernel/src/lib.rs b/kernel/src/lib.rs index be4ad21..70c879c 100644 --- a/kernel/src/lib.rs +++ b/kernel/src/lib.rs @@ -176,6 +176,20 @@ macro_rules! impl_database_delegation { } } + impl $crate::interfaces::read_model::DependOnProfileTransferRequestReadModel for $impl_type { + type ProfileTransferRequestReadModel = <$db_type as $crate::interfaces::read_model::DependOnProfileTransferRequestReadModel>::ProfileTransferRequestReadModel; + fn profile_transfer_request_read_model(&self) -> &Self::ProfileTransferRequestReadModel { + $crate::interfaces::read_model::DependOnProfileTransferRequestReadModel::profile_transfer_request_read_model(&self.$field) + } + } + + impl $crate::interfaces::event_store::DependOnProfileTransferRequestEventStore for $impl_type { + type ProfileTransferRequestEventStore = <$db_type as $crate::interfaces::event_store::DependOnProfileTransferRequestEventStore>::ProfileTransferRequestEventStore; + fn profile_transfer_request_event_store(&self) -> &Self::ProfileTransferRequestEventStore { + $crate::interfaces::event_store::DependOnProfileTransferRequestEventStore::profile_transfer_request_event_store(&self.$field) + } + } + impl $crate::interfaces::repository::DependOnAuthHostRepository for $impl_type { type AuthHostRepository = <$db_type as $crate::interfaces::repository::DependOnAuthHostRepository>::AuthHostRepository; fn auth_host_repository(&self) -> &Self::AuthHostRepository { @@ -246,6 +260,13 @@ macro_rules! impl_database_delegation { } } + impl $crate::interfaces::repository::DependOnProfileTransferRequestRepository for $impl_type { + type ProfileTransferRequestRepository = <$db_type as $crate::interfaces::repository::DependOnProfileTransferRequestRepository>::ProfileTransferRequestRepository; + fn profile_transfer_request_repository(&self) -> &Self::ProfileTransferRequestRepository { + $crate::interfaces::repository::DependOnProfileTransferRequestRepository::profile_transfer_request_repository(&self.$field) + } + } + impl $crate::interfaces::projection::DependOnProfileEventLog for $impl_type { type ProfileEventLog = <$db_type as $crate::interfaces::projection::DependOnProfileEventLog>::ProfileEventLog; fn profile_event_log(&self) -> &Self::ProfileEventLog { diff --git a/kernel/src/read_model.rs b/kernel/src/read_model.rs index 37168c8..4133ad0 100644 --- a/kernel/src/read_model.rs +++ b/kernel/src/read_model.rs @@ -3,9 +3,11 @@ mod account_report; mod metadata; mod organization_membership; mod profile; +mod profile_transfer_request; pub use self::account::*; pub use self::account_report::*; pub use self::metadata::*; pub use self::organization_membership::*; pub use self::profile::*; +pub use self::profile_transfer_request::*; diff --git a/kernel/src/read_model/profile.rs b/kernel/src/read_model/profile.rs index e8c97ba..6866e8e 100644 --- a/kernel/src/read_model/profile.rs +++ b/kernel/src/read_model/profile.rs @@ -134,6 +134,12 @@ pub trait ProfileReadModel: Sync + Send + 'static { account_ids: &[AccountId], ) -> impl Future, KernelError>> + Send; + fn find_by_nanoid( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> impl Future, KernelError>> + Send; + fn create( &self, executor: &mut Self::Connection, @@ -185,6 +191,12 @@ pub trait ProfileQuery: Send + Sync + 'static { executor: &mut Self::Connection, account_ids: &[AccountId], ) -> impl Future, KernelError>> + Send; + + fn find_by_nanoid( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> impl Future, KernelError>> + Send; } impl ProfileQuery for T @@ -221,6 +233,16 @@ where .find_by_account_ids(executor, account_ids) .await } + + async fn find_by_nanoid( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + self.profile_read_model() + .find_by_nanoid(executor, nanoid) + .await + } } pub trait DependOnProfileQuery: DependOnDatabaseConnection + Send + Sync { diff --git a/kernel/src/read_model/profile_transfer_request.rs b/kernel/src/read_model/profile_transfer_request.rs new file mode 100644 index 0000000..37cb14c --- /dev/null +++ b/kernel/src/read_model/profile_transfer_request.rs @@ -0,0 +1,159 @@ +mod query; + +pub use self::query::*; + +use crate::database::{Connection, DatabaseConnection, DependOnDatabaseConnection}; +use crate::entity::{ + AccountId, EventVersion, Nanoid, ProfileId, ProfileTransferRequest, ProfileTransferRequestId, + ProfileTransferStatus, +}; +use crate::KernelError; +use std::future::Future; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ProfileTransferRequestProjection { + id: ProfileTransferRequestId, + profile_id: ProfileId, + from_account_id: AccountId, + to_org_account_id: AccountId, + status: ProfileTransferStatus, + version: EventVersion, + nanoid: Nanoid, +} + +impl ProfileTransferRequestProjection { + pub fn new( + id: ProfileTransferRequestId, + profile_id: ProfileId, + from_account_id: AccountId, + to_org_account_id: AccountId, + status: ProfileTransferStatus, + version: EventVersion, + nanoid: Nanoid, + ) -> Self { + Self { + id, + profile_id, + from_account_id, + to_org_account_id, + status, + version, + nanoid, + } + } + + pub fn id(&self) -> &ProfileTransferRequestId { + &self.id + } + + pub fn profile_id(&self) -> &ProfileId { + &self.profile_id + } + + pub fn from_account_id(&self) -> &AccountId { + &self.from_account_id + } + + pub fn to_org_account_id(&self) -> &AccountId { + &self.to_org_account_id + } + + pub fn status(&self) -> &ProfileTransferStatus { + &self.status + } + + pub fn version(&self) -> &EventVersion { + &self.version + } + + pub fn nanoid(&self) -> &Nanoid { + &self.nanoid + } +} + +impl From for ProfileTransferRequestProjection { + fn from(value: ProfileTransferRequest) -> Self { + let destruct = value.into_destruct(); + Self::new( + destruct.id, + destruct.profile_id, + destruct.from_account_id, + destruct.to_org_account_id, + destruct.status, + destruct.version, + destruct.nanoid, + ) + } +} + +impl From for ProfileTransferRequest { + fn from(value: ProfileTransferRequestProjection) -> Self { + ProfileTransferRequest::reconstitute( + value.id().clone(), + value.profile_id().clone(), + value.from_account_id().clone(), + value.to_org_account_id().clone(), + value.status().clone(), + value.version().clone(), + value.nanoid().clone(), + ) + } +} + +pub trait ProfileTransferRequestReadModel: Sync + Send + 'static { + type Connection: Connection; + + fn find_by_id( + &self, + executor: &mut Self::Connection, + id: &ProfileTransferRequestId, + ) -> impl Future< + Output = error_stack::Result, KernelError>, + > + Send; + + fn find_by_id_unfiltered( + &self, + executor: &mut Self::Connection, + id: &ProfileTransferRequestId, + ) -> impl Future< + Output = error_stack::Result, KernelError>, + > + Send; + + fn find_by_nanoid( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> impl Future< + Output = error_stack::Result, KernelError>, + > + Send; + + fn find_pending_by_profile_id( + &self, + executor: &mut Self::Connection, + profile_id: &ProfileId, + ) -> impl Future< + Output = error_stack::Result, KernelError>, + > + Send; + + fn create( + &self, + executor: &mut Self::Connection, + request: &ProfileTransferRequest, + ) -> impl Future> + Send; + + fn update( + &self, + executor: &mut Self::Connection, + request: &ProfileTransferRequest, + ) -> impl Future> + Send; +} + +pub trait DependOnProfileTransferRequestReadModel: + Sync + Send + DependOnDatabaseConnection +{ + type ProfileTransferRequestReadModel: ProfileTransferRequestReadModel< + Connection = ::Connection, + >; + + fn profile_transfer_request_read_model(&self) -> &Self::ProfileTransferRequestReadModel; +} diff --git a/kernel/src/read_model/profile_transfer_request/query.rs b/kernel/src/read_model/profile_transfer_request/query.rs new file mode 100644 index 0000000..e3bba96 --- /dev/null +++ b/kernel/src/read_model/profile_transfer_request/query.rs @@ -0,0 +1,90 @@ +use super::{ + DependOnProfileTransferRequestReadModel, ProfileTransferRequestProjection, + ProfileTransferRequestReadModel, +}; +use crate::database::{Connection, DatabaseConnection, DependOnDatabaseConnection}; +use crate::entity::{Nanoid, ProfileId, ProfileTransferRequest, ProfileTransferRequestId}; +use crate::KernelError; +use std::future::Future; + +pub trait ProfileTransferRequestQuery: Send + Sync + 'static { + type Connection: Connection; + + fn find_by_id( + &self, + executor: &mut Self::Connection, + id: &ProfileTransferRequestId, + ) -> impl Future< + Output = error_stack::Result, KernelError>, + > + Send; + + fn find_by_nanoid( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> impl Future< + Output = error_stack::Result, KernelError>, + > + Send; + + fn find_pending_by_profile_id( + &self, + executor: &mut Self::Connection, + profile_id: &ProfileId, + ) -> impl Future< + Output = error_stack::Result, KernelError>, + > + Send; +} + +impl ProfileTransferRequestQuery for T +where + T: DependOnProfileTransferRequestReadModel + Send + Sync + 'static, +{ + type Connection = <::ProfileTransferRequestReadModel as ProfileTransferRequestReadModel>::Connection; + + async fn find_by_id( + &self, + executor: &mut Self::Connection, + id: &ProfileTransferRequestId, + ) -> error_stack::Result, KernelError> { + self.profile_transfer_request_read_model() + .find_by_id(executor, id) + .await + } + + async fn find_by_nanoid( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + self.profile_transfer_request_read_model() + .find_by_nanoid(executor, nanoid) + .await + } + + async fn find_pending_by_profile_id( + &self, + executor: &mut Self::Connection, + profile_id: &ProfileId, + ) -> error_stack::Result, KernelError> { + self.profile_transfer_request_read_model() + .find_pending_by_profile_id(executor, profile_id) + .await + } +} + +pub trait DependOnProfileTransferRequestQuery: DependOnDatabaseConnection + Send + Sync { + type ProfileTransferRequestQuery: ProfileTransferRequestQuery< + Connection = <::DatabaseConnection as DatabaseConnection>::Connection, + >; + fn profile_transfer_request_query(&self) -> &Self::ProfileTransferRequestQuery; +} + +impl DependOnProfileTransferRequestQuery for T +where + T: DependOnProfileTransferRequestReadModel + DependOnDatabaseConnection + Send + Sync + 'static, +{ + type ProfileTransferRequestQuery = Self; + fn profile_transfer_request_query(&self) -> &Self::ProfileTransferRequestQuery { + self + } +} diff --git a/kernel/src/repository/aggregate.rs b/kernel/src/repository/aggregate.rs index 5f6afbd..687dad8 100644 --- a/kernel/src/repository/aggregate.rs +++ b/kernel/src/repository/aggregate.rs @@ -1,7 +1,8 @@ use crate::database::{Connection, DatabaseConnection, DependOnDatabaseConnection}; use crate::entity::{ Account, AccountId, AccountReport, AccountReportId, CommandEnvelope, EventEnvelope, - EventVersion, Metadata, MetadataId, Profile, ProfileId, + EventVersion, Metadata, MetadataId, Profile, ProfileId, ProfileTransferRequest, + ProfileTransferRequestId, }; use crate::event::EventApplier; use crate::KernelError; @@ -146,6 +147,18 @@ pub trait DependOnAccountReportRepository: Sync + Send + DependOnDatabaseConnect fn account_report_repository(&self) -> &Self::AccountReportRepository; } +pub trait DependOnProfileTransferRequestRepository: + Sync + Send + DependOnDatabaseConnection +{ + type ProfileTransferRequestRepository: AggregateRepository< + ProfileTransferRequest, + Id = ProfileTransferRequestId, + Connection = ::Connection, + >; + + fn profile_transfer_request_repository(&self) -> &Self::ProfileTransferRequestRepository; +} + #[cfg(test)] mod test { use super::Rehydrated; diff --git a/kernel/src/storage.rs b/kernel/src/storage.rs index 74887be..5c6629c 100644 --- a/kernel/src/storage.rs +++ b/kernel/src/storage.rs @@ -1,3 +1,4 @@ +use crate::entity::{AccountId, ImageId}; use crate::KernelError; use std::future::Future; @@ -7,6 +8,13 @@ pub struct StoredObject { pub url: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProfileMediaCopyRequest { + pub from_account_id: AccountId, + pub to_account_id: AccountId, + pub image_ids: Vec, +} + pub trait ImageStorage: Send + Sync + 'static { fn put( &self, @@ -21,3 +29,16 @@ pub trait DependOnImageStorage: Send + Sync { fn image_storage(&self) -> &Self::ImageStorage; } + +pub trait ProfileMediaCopyGateway: Send + Sync + 'static { + fn request_copy( + &self, + request: ProfileMediaCopyRequest, + ) -> impl Future> + Send; +} + +pub trait DependOnProfileMediaCopyGateway: Send + Sync { + type ProfileMediaCopyGateway: ProfileMediaCopyGateway; + + fn profile_media_copy_gateway(&self) -> &Self::ProfileMediaCopyGateway; +} From 4b7e0a8501db35406929b4466045fbb9e2c623a8 Mon Sep 17 00:00:00 2001 From: turtton Date: Fri, 4 Sep 2026 01:26:37 +0900 Subject: [PATCH 2/4] :sparkles: profile-transfer: add persistence, migration, and use cases (request/accept/reject/cancel) --- application/src/dto.rs | 1 + application/src/dto/profile_transfer.rs | 25 ++ application/src/service.rs | 1 + .../src/service/characterization_tests.rs | 3 + application/src/service/profile.rs | 27 +- .../src/service/profile_transfer/cancel.rs | 99 +++++ .../src/service/profile_transfer/mod.rs | 116 +++++ .../src/service/profile_transfer/request.rs | 139 ++++++ .../src/service/profile_transfer/respond.rs | 256 +++++++++++ driver/src/database/postgres.rs | 3 + driver/src/database/postgres/profile.rs | 34 +- .../postgres/profile_transfer_request.rs | 401 ++++++++++++++++++ .../profile_transfer_request_event_store.rs | 274 ++++++++++++ .../profile_transfer_request_repository.rs | 56 +++ driver/src/database/postgres/projection.rs | 5 +- driver/src/storage.rs | 20 +- .../20260904000001_profile_transfer.sql | 36 ++ 17 files changed, 1466 insertions(+), 30 deletions(-) create mode 100644 application/src/dto/profile_transfer.rs create mode 100644 application/src/service/profile_transfer/cancel.rs create mode 100644 application/src/service/profile_transfer/mod.rs create mode 100644 application/src/service/profile_transfer/request.rs create mode 100644 application/src/service/profile_transfer/respond.rs create mode 100644 driver/src/database/postgres/profile_transfer_request.rs create mode 100644 driver/src/database/postgres/profile_transfer_request_event_store.rs create mode 100644 driver/src/database/postgres/profile_transfer_request_repository.rs create mode 100644 migrations/20260904000001_profile_transfer.sql diff --git a/application/src/dto.rs b/application/src/dto.rs index 54ba7cd..5766b90 100644 --- a/application/src/dto.rs +++ b/application/src/dto.rs @@ -6,3 +6,4 @@ pub mod metadata; pub mod organization; pub mod pagination; pub mod profile; +pub mod profile_transfer; diff --git a/application/src/dto/profile_transfer.rs b/application/src/dto/profile_transfer.rs new file mode 100644 index 0000000..a0fafe7 --- /dev/null +++ b/application/src/dto/profile_transfer.rs @@ -0,0 +1,25 @@ +use kernel::interfaces::read_model::ProfileTransferRequestProjection; + +#[derive(Debug)] +pub struct ProfileTransferRequestDto { + pub nanoid: String, + pub profile_nanoid: String, + pub org_account_nanoid: String, + pub status: String, +} + +impl ProfileTransferRequestDto { + pub fn new( + projection: ProfileTransferRequestProjection, + profile_nanoid: String, + org_account_nanoid: String, + status: String, + ) -> Self { + Self { + nanoid: projection.nanoid().as_ref().to_string(), + profile_nanoid, + org_account_nanoid, + status, + } + } +} diff --git a/application/src/service.rs b/application/src/service.rs index d4a73af..74621a0 100644 --- a/application/src/service.rs +++ b/application/src/service.rs @@ -7,6 +7,7 @@ pub mod media; pub mod mute; pub mod organization; pub mod profile; +pub mod profile_transfer; pub mod report; pub mod session_context; diff --git a/application/src/service/characterization_tests.rs b/application/src/service/characterization_tests.rs index 4519bf5..ac68955 100644 --- a/application/src/service/characterization_tests.rs +++ b/application/src/service/characterization_tests.rs @@ -744,14 +744,17 @@ delegate_database_dependence! { kernel::interfaces::read_model::DependOnAccountReadModel { AccountReadModel, account_read_model }, kernel::interfaces::read_model::DependOnAccountReportReadModel { AccountReportReadModel, account_report_read_model }, kernel::interfaces::read_model::DependOnProfileReadModel { ProfileReadModel, profile_read_model }, + kernel::interfaces::read_model::DependOnProfileTransferRequestReadModel { ProfileTransferRequestReadModel, profile_transfer_request_read_model }, kernel::interfaces::read_model::DependOnMetadataReadModel { MetadataReadModel, metadata_read_model }, kernel::interfaces::event_store::DependOnAccountEventStore { AccountEventStore, account_event_store }, kernel::interfaces::event_store::DependOnAccountReportEventStore { AccountReportEventStore, account_report_event_store }, kernel::interfaces::event_store::DependOnProfileEventStore { ProfileEventStore, profile_event_store }, + kernel::interfaces::event_store::DependOnProfileTransferRequestEventStore { ProfileTransferRequestEventStore, profile_transfer_request_event_store }, kernel::interfaces::event_store::DependOnMetadataEventStore { MetadataEventStore, metadata_event_store }, kernel::interfaces::repository::DependOnAccountRepository { AccountRepository, account_repository }, kernel::interfaces::repository::DependOnAccountReportRepository { AccountReportRepository, account_report_repository }, kernel::interfaces::repository::DependOnAuthAccountRepository { AuthAccountRepository, auth_account_repository }, + kernel::interfaces::repository::DependOnProfileTransferRequestRepository { ProfileTransferRequestRepository, profile_transfer_request_repository }, kernel::interfaces::repository::DependOnAuthHostRepository { AuthHostRepository, auth_host_repository }, kernel::interfaces::repository::DependOnBlockRepository { BlockRepository, block_repository }, kernel::interfaces::repository::DependOnFollowRepository { FollowRepository, follow_repository }, diff --git a/application/src/service/profile.rs b/application/src/service/profile.rs index 51dc7a7..4c1613f 100644 --- a/application/src/service/profile.rs +++ b/application/src/service/profile.rs @@ -1,25 +1,16 @@ use crate::dto::profile::{CreateProfileDto, ProfileDto}; use crate::service::session_context::OrganizationContext; use error_stack::Report; -use kernel::interfaces::database::{ - DatabaseConnection, DependOnTransactionManager, TransactionManager, -}; +use kernel::interfaces::database::{DependOnTransactionManager, TransactionManager}; use kernel::interfaces::event::EventApplier; -use kernel::interfaces::read_model::{ - DependOnProfileQuery, DependOnProfileReadModel, ProfileQuery, ProfileReadModel, -}; +use kernel::interfaces::read_model::{DependOnProfileReadModel, ProfileReadModel}; use kernel::interfaces::repository::{AggregateRepository, DependOnProfileRepository}; use kernel::prelude::entity::{Nanoid, Profile, ProfileDisplayName, ProfileId, ProfileSummary}; use kernel::KernelError; use std::future::Future; pub trait CreateOrganizationProfileUseCase: - 'static - + Clone - + DependOnProfileQuery - + DependOnProfileRepository - + DependOnProfileReadModel - + DependOnTransactionManager + 'static + Clone + DependOnProfileRepository + DependOnProfileReadModel + DependOnTransactionManager { fn create_organization_profile( &self, @@ -36,17 +27,6 @@ pub trait CreateOrganizationProfileUseCase: summary.validate()?; } - let mut connection = self.database_connection().connection().await?; - if self - .profile_query() - .find_by_account_id(&mut connection, &org_context.org_account_id) - .await? - .is_some() - { - return Err(Report::new(KernelError::Rejected) - .attach_printable("Organization profile already exists")); - } - let account_id = org_context.org_account_id; let account_nanoid = org_context.org_account_nanoid; let deps = self.clone(); @@ -83,7 +63,6 @@ pub trait CreateOrganizationProfileUseCase: impl CreateOrganizationProfileUseCase for T where T: 'static + Clone - + DependOnProfileQuery + DependOnProfileRepository + DependOnProfileReadModel + DependOnTransactionManager diff --git a/application/src/service/profile_transfer/cancel.rs b/application/src/service/profile_transfer/cancel.rs new file mode 100644 index 0000000..6738236 --- /dev/null +++ b/application/src/service/profile_transfer/cancel.rs @@ -0,0 +1,99 @@ +use super::resolve_personal_actor_account_among; +use error_stack::Report; +use kernel::interfaces::database::{ + DatabaseConnection, DependOnDatabaseConnection, TransactionManager, +}; +use kernel::interfaces::event::EventApplier; +use kernel::interfaces::read_model::{ + DependOnAccountQuery, DependOnProfileTransferRequestQuery, + DependOnProfileTransferRequestReadModel, ProfileTransferRequestQuery, + ProfileTransferRequestReadModel, +}; +use kernel::interfaces::repository::{ + AggregateRepository, DependOnProfileTransferRequestRepository, +}; +use kernel::prelude::entity::{AuthAccountId, Nanoid, ProfileTransferRequest}; +use kernel::KernelError; +use std::future::Future; + +pub trait CancelProfileTransferRequestUseCase: 'static + Sync + Send + Clone { + fn cancel_profile_transfer_request<'a>( + &'a self, + auth_id: &'a AuthAccountId, + request_nanoid: String, + ) -> impl Future> + Send + 'a; +} + +impl CancelProfileTransferRequestUseCase for T +where + T: 'static + + Sync + + Send + + Clone + + DependOnAccountQuery + + DependOnProfileTransferRequestQuery + + DependOnProfileTransferRequestReadModel + + DependOnProfileTransferRequestRepository + + DependOnDatabaseConnection + + kernel::interfaces::database::DependOnTransactionManager, +{ + fn cancel_profile_transfer_request<'a>( + &'a self, + auth_id: &'a AuthAccountId, + request_nanoid: String, + ) -> impl Future> + Send + 'a { + async move { + let mut connection = self.database_connection().connection().await?; + let request_nanoid = Nanoid::::new(request_nanoid); + let projection = self + .profile_transfer_request_query() + .find_by_nanoid(&mut connection, &request_nanoid) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Profile transfer request not found: {}", + request_nanoid.as_ref() + )) + })?; + + let request_id = projection.id().clone(); + let from_account_id = projection.from_account_id().clone(); + + resolve_personal_actor_account_among(self, auth_id, &from_account_id).await?; + + let deps = self.clone(); + self.transaction_manager() + .transaction(move |executor| { + Box::pin(async move { + let (request, current_version) = deps + .profile_transfer_request_repository() + .load(executor, &request_id) + .await? + .into_parts(); + if !request.status().is_pending() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("Profile transfer request is not pending")); + } + let event = deps + .profile_transfer_request_repository() + .save( + executor, + ProfileTransferRequest::cancel(request_id, current_version), + ) + .await?; + let mut updated_request = Some(request); + ProfileTransferRequest::apply(&mut updated_request, event)?; + let updated_request = updated_request.ok_or_else(|| { + Report::new(KernelError::Internal) + .attach_printable("Failed to construct cancelled transfer request") + })?; + deps.profile_transfer_request_read_model() + .update(executor, &updated_request) + .await?; + Ok(()) + }) + }) + .await + } + } +} diff --git a/application/src/service/profile_transfer/mod.rs b/application/src/service/profile_transfer/mod.rs new file mode 100644 index 0000000..153577b --- /dev/null +++ b/application/src/service/profile_transfer/mod.rs @@ -0,0 +1,116 @@ +#![allow(clippy::manual_async_fn)] + +mod cancel; +mod request; +mod respond; + +pub use cancel::CancelProfileTransferRequestUseCase; +pub use request::RequestProfileTransferUseCase; +pub use respond::{AcceptProfileTransferRequestUseCase, RejectProfileTransferRequestUseCase}; + +use error_stack::Report; +use kernel::interfaces::database::{DatabaseConnection, DependOnDatabaseConnection}; +use kernel::interfaces::read_model::{AccountQuery, DependOnAccountQuery}; +use kernel::interfaces::repository::{ + DependOnOrganizationMembershipRepository, OrganizationMembershipRepository, +}; +use kernel::prelude::entity::{ + Account, AccountId, AccountKind, AuthAccountId, OrganizationMembershipStatus, +}; +use kernel::KernelError; + +async fn resolve_personal_actor_account( + deps: &T, + auth_id: &AuthAccountId, +) -> error_stack::Result +where + T: DependOnAccountQuery + DependOnDatabaseConnection, +{ + let mut connection = deps.database_connection().connection().await?; + deps.account_query() + .find_by_auth_id(&mut connection, auth_id) + .await? + .into_iter() + .find(|account| account.kind() == &AccountKind::Personal && account.deleted_at().is_none()) + .ok_or_else(|| { + Report::new(KernelError::NotFound) + .attach_printable("No personal account belongs to the authenticated user") + }) +} + +async fn resolve_personal_actor_account_among( + deps: &T, + auth_id: &AuthAccountId, + account_id: &AccountId, +) -> error_stack::Result +where + T: DependOnAccountQuery + DependOnDatabaseConnection, +{ + let mut connection = deps.database_connection().connection().await?; + deps.account_query() + .find_by_auth_id(&mut connection, auth_id) + .await? + .into_iter() + .find(|account| { + account.kind() == &AccountKind::Personal + && account.deleted_at().is_none() + && account.id() == account_id + }) + .ok_or_else(|| { + Report::new(KernelError::PermissionDenied) + .attach_printable("Authenticated user does not control the required account") + }) +} + +async fn require_active_membership( + deps: &T, + org_account_id: &AccountId, + member_account_id: &AccountId, +) -> error_stack::Result<(), KernelError> +where + T: DependOnOrganizationMembershipRepository + DependOnDatabaseConnection, +{ + let mut connection = deps.database_connection().connection().await?; + match deps + .organization_membership_repository() + .find(&mut connection, org_account_id, member_account_id) + .await? + { + Some(membership) + if membership.status() == &OrganizationMembershipStatus::Active + && (membership.role() == &kernel::prelude::entity::OrgRole::Owner + || membership.role() == &kernel::prelude::entity::OrgRole::Admin + || membership.role() == &kernel::prelude::entity::OrgRole::Member) => + { + Ok(()) + } + _ => Err(Report::new(KernelError::PermissionDenied) + .attach_printable("Actor is not an active member of the organization")), + } +} + +async fn require_active_owner_or_admin_membership( + deps: &T, + org_account_id: &AccountId, + member_account_id: &AccountId, +) -> error_stack::Result<(), KernelError> +where + T: DependOnOrganizationMembershipRepository + DependOnDatabaseConnection, +{ + let mut connection = deps.database_connection().connection().await?; + match deps + .organization_membership_repository() + .find(&mut connection, org_account_id, member_account_id) + .await? + { + Some(membership) + if membership.status() == &OrganizationMembershipStatus::Active + && (membership.role() == &kernel::prelude::entity::OrgRole::Owner + || membership.role() == &kernel::prelude::entity::OrgRole::Admin) => + { + Ok(()) + } + _ => Err(Report::new(KernelError::PermissionDenied) + .attach_printable("Actor is not an active owner or admin of the organization")), + } +} diff --git a/application/src/service/profile_transfer/request.rs b/application/src/service/profile_transfer/request.rs new file mode 100644 index 0000000..090848c --- /dev/null +++ b/application/src/service/profile_transfer/request.rs @@ -0,0 +1,139 @@ +use super::{require_active_membership, resolve_personal_actor_account}; +use crate::dto::profile_transfer::ProfileTransferRequestDto; +use error_stack::Report; +use kernel::interfaces::database::{ + DatabaseConnection, DependOnDatabaseConnection, TransactionManager, +}; +use kernel::interfaces::event::EventApplier; +use kernel::interfaces::read_model::{ + AccountQuery, DependOnAccountQuery, DependOnProfileQuery, DependOnProfileTransferRequestQuery, + DependOnProfileTransferRequestReadModel, ProfileQuery, ProfileTransferRequestQuery, + ProfileTransferRequestReadModel, +}; +use kernel::interfaces::repository::{ + AggregateRepository, DependOnOrganizationMembershipRepository, + DependOnProfileTransferRequestRepository, +}; +use kernel::prelude::entity::{ + Account, AccountKind, AuthAccountId, Nanoid, Profile, ProfileTransferRequest, + ProfileTransferRequestId, +}; +use kernel::KernelError; +use std::future::Future; + +pub trait RequestProfileTransferUseCase: 'static + Sync + Send + Clone { + fn request_profile_transfer<'a>( + &'a self, + auth_id: &'a AuthAccountId, + profile_nanoid: String, + org_account_nanoid: String, + ) -> impl Future> + Send + 'a; +} + +impl RequestProfileTransferUseCase for T +where + T: 'static + + Sync + + Send + + Clone + + DependOnAccountQuery + + DependOnProfileQuery + + DependOnProfileTransferRequestQuery + + DependOnProfileTransferRequestReadModel + + DependOnProfileTransferRequestRepository + + DependOnOrganizationMembershipRepository + + DependOnDatabaseConnection + + kernel::interfaces::database::DependOnTransactionManager, +{ + fn request_profile_transfer<'a>( + &'a self, + auth_id: &'a AuthAccountId, + profile_nanoid: String, + org_account_nanoid: String, + ) -> impl Future> + Send + 'a + { + async move { + let mut connection = self.database_connection().connection().await?; + let profile_nanoid = Nanoid::::new(profile_nanoid); + let profile = self + .profile_query() + .find_by_nanoid(&mut connection, &profile_nanoid) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound) + .attach_printable(format!("Profile not found: {}", profile_nanoid.as_ref())) + })?; + let org_account = { + let org_nanoid = Nanoid::::new(org_account_nanoid); + self.account_query() + .find_by_nanoid(&mut connection, &org_nanoid) + .await? + .filter(|account| account.kind() == &AccountKind::Organization) + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Organization account not found: {}", + org_nanoid.as_ref() + )) + })? + }; + + let actor_account = resolve_personal_actor_account(self, auth_id).await?; + if actor_account.id() != profile.account_id() { + return Err(Report::new(KernelError::PermissionDenied) + .attach_printable("Authenticated user does not own the profile")); + } + require_active_membership(self, org_account.id(), actor_account.id()).await?; + + if self + .profile_transfer_request_query() + .find_pending_by_profile_id(&mut connection, profile.id()) + .await? + .is_some() + { + return Err(Report::new(KernelError::Rejected) + .attach_printable("A pending profile transfer request already exists")); + } + + let profile_id = profile.id().clone(); + let from_account_id = profile.account_id().clone(); + let to_org_account_id = org_account.id().clone(); + let profile_nanoid = profile.nanoid().as_ref().to_string(); + let org_account_nanoid = org_account.nanoid().as_ref().to_string(); + let deps = self.clone(); + let request = self + .transaction_manager() + .transaction(move |executor| { + Box::pin(async move { + let command = ProfileTransferRequest::request( + ProfileTransferRequestId::new(kernel::generate_id()), + profile_id, + from_account_id, + to_org_account_id, + Nanoid::default(), + ); + let event = deps + .profile_transfer_request_repository() + .save(executor, command) + .await?; + let mut request = None; + ProfileTransferRequest::apply(&mut request, event)?; + let request = request.ok_or_else(|| { + Report::new(KernelError::Internal) + .attach_printable("Failed to construct profile transfer request") + })?; + deps.profile_transfer_request_read_model() + .create(executor, &request) + .await?; + Ok(request) + }) + }) + .await?; + Ok(ProfileTransferRequestDto::new( + request.into(), + profile_nanoid, + org_account_nanoid, + "pending".to_string(), + )) + } + } +} diff --git a/application/src/service/profile_transfer/respond.rs b/application/src/service/profile_transfer/respond.rs new file mode 100644 index 0000000..3bccb2f --- /dev/null +++ b/application/src/service/profile_transfer/respond.rs @@ -0,0 +1,256 @@ +use super::{require_active_owner_or_admin_membership, resolve_personal_actor_account_among}; +use error_stack::Report; +use kernel::interfaces::database::{ + DatabaseConnection, DependOnDatabaseConnection, TransactionManager, +}; +use kernel::interfaces::event::EventApplier; +use kernel::interfaces::read_model::{ + DependOnAccountQuery, DependOnProfileQuery, DependOnProfileReadModel, + DependOnProfileTransferRequestQuery, DependOnProfileTransferRequestReadModel, ProfileReadModel, + ProfileTransferRequestQuery, ProfileTransferRequestReadModel, +}; +use kernel::interfaces::repository::{ + AggregateRepository, DependOnOrganizationMembershipRepository, DependOnProfileRepository, + DependOnProfileTransferRequestRepository, +}; +use kernel::interfaces::storage::{ + DependOnProfileMediaCopyGateway, ProfileMediaCopyGateway, ProfileMediaCopyRequest, +}; +use kernel::prelude::entity::{AuthAccountId, Nanoid, Profile, ProfileTransferRequest}; +use kernel::KernelError; +use std::future::Future; + +pub trait AcceptProfileTransferRequestUseCase: 'static + Sync + Send + Clone { + fn accept_profile_transfer_request<'a>( + &'a self, + auth_id: &'a AuthAccountId, + request_nanoid: String, + ) -> impl Future> + Send + 'a; +} + +impl AcceptProfileTransferRequestUseCase for T +where + T: 'static + + Sync + + Send + + Clone + + DependOnAccountQuery + + DependOnProfileQuery + + DependOnProfileReadModel + + DependOnProfileRepository + + DependOnProfileTransferRequestQuery + + DependOnProfileTransferRequestReadModel + + DependOnProfileTransferRequestRepository + + DependOnOrganizationMembershipRepository + + DependOnProfileMediaCopyGateway + + DependOnDatabaseConnection + + kernel::interfaces::database::DependOnTransactionManager, +{ + fn accept_profile_transfer_request<'a>( + &'a self, + auth_id: &'a AuthAccountId, + request_nanoid: String, + ) -> impl Future> + Send + 'a { + async move { + let mut connection = self.database_connection().connection().await?; + let request_nanoid = Nanoid::::new(request_nanoid); + let projection = self + .profile_transfer_request_query() + .find_by_nanoid(&mut connection, &request_nanoid) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Profile transfer request not found: {}", + request_nanoid.as_ref() + )) + })?; + + let request_id = projection.id().clone(); + let profile_id = projection.profile_id().clone(); + let from_account_id = projection.from_account_id().clone(); + let to_org_account_id = projection.to_org_account_id().clone(); + + let actor_account = + resolve_personal_actor_account_among(self, auth_id, &to_org_account_id).await?; + require_active_owner_or_admin_membership(self, &to_org_account_id, actor_account.id()) + .await?; + + let deps = self.clone(); + let copy_from_account_id = from_account_id.clone(); + let copy_to_account_id = to_org_account_id.clone(); + let transferred_profile = self + .transaction_manager() + .transaction(move |executor| { + Box::pin(async move { + let (request, current_version) = deps + .profile_transfer_request_repository() + .load(executor, &request_id) + .await? + .into_parts(); + if !request.status().is_pending() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("Profile transfer request is not pending")); + } + let event = deps + .profile_transfer_request_repository() + .save( + executor, + ProfileTransferRequest::accept( + request_id.clone(), + current_version.clone(), + ), + ) + .await?; + let mut updated_request = Some(request); + ProfileTransferRequest::apply(&mut updated_request, event)?; + let updated_request = updated_request.ok_or_else(|| { + Report::new(KernelError::Internal) + .attach_printable("Failed to construct accepted transfer request") + })?; + deps.profile_transfer_request_read_model() + .update(executor, &updated_request) + .await?; + + let (profile, _profile_version) = deps + .profile_repository() + .load(executor, &profile_id) + .await? + .into_parts(); + if profile.account_id() != &from_account_id { + return Err(Report::new(KernelError::Rejected) + .attach_printable("profile owner changed")); + } + let transfer_command = Profile::transfer_account( + profile_id.clone(), + from_account_id.clone(), + to_org_account_id.clone(), + ); + let transfer_event = deps + .profile_repository() + .save(executor, transfer_command) + .await?; + let mut transferred_profile = Some(profile); + Profile::apply(&mut transferred_profile, transfer_event)?; + let transferred_profile = transferred_profile.ok_or_else(|| { + Report::new(KernelError::Internal) + .attach_printable("Failed to construct transferred profile") + })?; + deps.profile_read_model() + .update(executor, &transferred_profile) + .await?; + Ok(transferred_profile) + }) + }) + .await?; + + let image_ids: Vec = [ + transferred_profile.icon().clone(), + transferred_profile.banner().clone(), + ] + .into_iter() + .flatten() + .collect(); + if !image_ids.is_empty() { + if let Err(error) = self + .profile_media_copy_gateway() + .request_copy(ProfileMediaCopyRequest { + from_account_id: copy_from_account_id, + to_account_id: copy_to_account_id, + image_ids, + }) + .await + { + tracing::warn!(error = %error, "Profile media copy failed after transfer"); + } + } + + Ok(()) + } + } +} + +pub trait RejectProfileTransferRequestUseCase: 'static + Sync + Send + Clone { + fn reject_profile_transfer_request<'a>( + &'a self, + auth_id: &'a AuthAccountId, + request_nanoid: String, + ) -> impl Future> + Send + 'a; +} + +impl RejectProfileTransferRequestUseCase for T +where + T: 'static + + Sync + + Send + + Clone + + DependOnAccountQuery + + DependOnProfileTransferRequestQuery + + DependOnProfileTransferRequestReadModel + + DependOnProfileTransferRequestRepository + + DependOnOrganizationMembershipRepository + + DependOnDatabaseConnection + + kernel::interfaces::database::DependOnTransactionManager, +{ + fn reject_profile_transfer_request<'a>( + &'a self, + auth_id: &'a AuthAccountId, + request_nanoid: String, + ) -> impl Future> + Send + 'a { + async move { + let mut connection = self.database_connection().connection().await?; + let request_nanoid = Nanoid::::new(request_nanoid); + let projection = self + .profile_transfer_request_query() + .find_by_nanoid(&mut connection, &request_nanoid) + .await? + .ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "Profile transfer request not found: {}", + request_nanoid.as_ref() + )) + })?; + + let request_id = projection.id().clone(); + let to_org_account_id = projection.to_org_account_id().clone(); + + let actor_account = + resolve_personal_actor_account_among(self, auth_id, &to_org_account_id).await?; + require_active_owner_or_admin_membership(self, &to_org_account_id, actor_account.id()) + .await?; + + let deps = self.clone(); + self.transaction_manager() + .transaction(move |executor| { + Box::pin(async move { + let (request, current_version) = deps + .profile_transfer_request_repository() + .load(executor, &request_id) + .await? + .into_parts(); + if !request.status().is_pending() { + return Err(Report::new(KernelError::Rejected) + .attach_printable("Profile transfer request is not pending")); + } + let event = deps + .profile_transfer_request_repository() + .save( + executor, + ProfileTransferRequest::reject(request_id, current_version), + ) + .await?; + let mut updated_request = Some(request); + ProfileTransferRequest::apply(&mut updated_request, event)?; + let updated_request = updated_request.ok_or_else(|| { + Report::new(KernelError::Internal) + .attach_printable("Failed to construct rejected transfer request") + })?; + deps.profile_transfer_request_read_model() + .update(executor, &updated_request) + .await?; + Ok(()) + }) + }) + .await + } + } +} diff --git a/driver/src/database/postgres.rs b/driver/src/database/postgres.rs index 2e66567..b035d78 100644 --- a/driver/src/database/postgres.rs +++ b/driver/src/database/postgres.rs @@ -18,6 +18,9 @@ mod outbox_activity; mod profile; mod profile_event_store; mod profile_repository; +mod profile_transfer_request; +mod profile_transfer_request_event_store; +mod profile_transfer_request_repository; mod projection; mod remote_account; mod signing_key; diff --git a/driver/src/database/postgres/profile.rs b/driver/src/database/postgres/profile.rs index 7f6f192..7b9d53a 100644 --- a/driver/src/database/postgres/profile.rs +++ b/driver/src/database/postgres/profile.rs @@ -114,6 +114,26 @@ impl ProfileReadModel for PostgresProfileReadModel { .map(|rows| rows.into_iter().map(ProfileProjection::from).collect()) } + async fn find_by_nanoid( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + let con: &mut PgConnection = executor; + sqlx::query_as::<_, ProfileRow>( + //language=postgresql + r#" + SELECT id, account_id, display, summary, icon_id, banner_id, version, nanoid + FROM profiles WHERE nanoid = $1 + "#, + ) + .bind(nanoid.as_ref()) + .fetch_optional(con) + .await + .convert_error() + .map(|option| option.map(ProfileProjection::from)) + } + async fn create( &self, executor: &mut Self::Connection, @@ -123,8 +143,8 @@ impl ProfileReadModel for PostgresProfileReadModel { sqlx::query( //language=postgresql r#" - INSERT INTO profiles (id, account_id, display, summary, icon_id, banner_id, version, nanoid) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO profiles (id, account_id, owner_kind, display, summary, icon_id, banner_id, version, nanoid) + VALUES ($1, $2, (SELECT kind FROM accounts WHERE id = $2), $3, $4, $5, $6, $7, $8) "#, ) .bind(profile.id().as_ref()) @@ -155,11 +175,19 @@ impl ProfileReadModel for PostgresProfileReadModel { let result = sqlx::query( //language=postgresql r#" - UPDATE profiles SET display = $2, summary = $3, icon_id = $4, banner_id = $5, version = $6 + UPDATE profiles + SET account_id = $2, + owner_kind = (SELECT kind FROM accounts WHERE id = $2), + display = $3, + summary = $4, + icon_id = $5, + banner_id = $6, + version = $7 WHERE id = $1 "#, ) .bind(profile.id().as_ref()) + .bind(profile.account_id().as_ref()) .bind( profile .display_name() diff --git a/driver/src/database/postgres/profile_transfer_request.rs b/driver/src/database/postgres/profile_transfer_request.rs new file mode 100644 index 0000000..d2a617f --- /dev/null +++ b/driver/src/database/postgres/profile_transfer_request.rs @@ -0,0 +1,401 @@ +use crate::database::{PostgresConnection, PostgresDatabase}; +use crate::ConvertError; +use error_stack::Report; +use kernel::interfaces::read_model::{ + DependOnProfileTransferRequestReadModel, ProfileTransferRequestProjection, + ProfileTransferRequestReadModel, +}; +use kernel::prelude::entity::{ + AccountId, EventVersion, Nanoid, ProfileId, ProfileTransferRequest, ProfileTransferRequestId, + ProfileTransferStatus, +}; +use kernel::KernelError; +use sqlx::PgConnection; + +#[derive(sqlx::FromRow)] +struct ProfileTransferRequestRow { + id: i64, + profile_id: i64, + from_account_id: i64, + to_org_account_id: i64, + status: String, + version: i64, + nanoid: String, +} + +impl TryFrom for ProfileTransferRequestProjection { + type Error = Report; + + fn try_from(value: ProfileTransferRequestRow) -> Result { + let status = match value.status.as_str() { + "pending" => ProfileTransferStatus::Pending, + "accepted" => ProfileTransferStatus::Accepted, + "rejected" => ProfileTransferStatus::Rejected, + "cancelled" => ProfileTransferStatus::Cancelled, + status => { + return Err(Report::new(KernelError::Internal).attach_printable(format!( + "Unknown profile transfer request status: {status}" + ))) + } + }; + Ok(ProfileTransferRequestProjection::new( + ProfileTransferRequestId::new(value.id), + ProfileId::new(value.profile_id), + AccountId::new(value.from_account_id), + AccountId::new(value.to_org_account_id), + status, + EventVersion::new(value.version), + Nanoid::new(value.nanoid), + )) + } +} + +fn status_value(status: &ProfileTransferStatus) -> &'static str { + match status { + ProfileTransferStatus::Pending => "pending", + ProfileTransferStatus::Accepted => "accepted", + ProfileTransferStatus::Rejected => "rejected", + ProfileTransferStatus::Cancelled => "cancelled", + } +} + +pub struct PostgresProfileTransferRequestReadModel; + +impl ProfileTransferRequestReadModel for PostgresProfileTransferRequestReadModel { + type Connection = PostgresConnection; + + async fn find_by_id( + &self, + executor: &mut Self::Connection, + id: &ProfileTransferRequestId, + ) -> error_stack::Result, KernelError> { + let con: &mut PgConnection = executor; + sqlx::query_as::<_, ProfileTransferRequestRow>( + "SELECT id, profile_id, from_account_id, to_org_account_id, status, version, nanoid + FROM profile_transfer_requests WHERE id = $1", + ) + .bind(id.as_ref()) + .fetch_optional(con) + .await + .convert_error()? + .map(TryFrom::try_from) + .transpose() + } + + async fn find_by_id_unfiltered( + &self, + executor: &mut Self::Connection, + id: &ProfileTransferRequestId, + ) -> error_stack::Result, KernelError> { + self.find_by_id(executor, id).await + } + + async fn find_by_nanoid( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + let con: &mut PgConnection = executor; + sqlx::query_as::<_, ProfileTransferRequestRow>( + "SELECT id, profile_id, from_account_id, to_org_account_id, status, version, nanoid + FROM profile_transfer_requests WHERE nanoid = $1", + ) + .bind(nanoid.as_ref()) + .fetch_optional(con) + .await + .convert_error()? + .map(TryFrom::try_from) + .transpose() + } + + async fn find_pending_by_profile_id( + &self, + executor: &mut Self::Connection, + profile_id: &ProfileId, + ) -> error_stack::Result, KernelError> { + let con: &mut PgConnection = executor; + sqlx::query_as::<_, ProfileTransferRequestRow>( + "SELECT id, profile_id, from_account_id, to_org_account_id, status, version, nanoid + FROM profile_transfer_requests WHERE profile_id = $1 AND status = 'pending' LIMIT 1", + ) + .bind(profile_id.as_ref()) + .fetch_optional(con) + .await + .convert_error()? + .map(TryFrom::try_from) + .transpose() + } + + async fn create( + &self, + executor: &mut Self::Connection, + request: &ProfileTransferRequest, + ) -> error_stack::Result<(), KernelError> { + let con: &mut PgConnection = executor; + sqlx::query( + "INSERT INTO profile_transfer_requests + (id, profile_id, from_account_id, to_org_account_id, status, version, nanoid) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(request.id().as_ref()) + .bind(request.profile_id().as_ref()) + .bind(request.from_account_id().as_ref()) + .bind(request.to_org_account_id().as_ref()) + .bind(status_value(request.status())) + .bind(request.version().as_ref()) + .bind(request.nanoid().as_ref()) + .execute(con) + .await + .convert_error()?; + Ok(()) + } + + async fn update( + &self, + executor: &mut Self::Connection, + request: &ProfileTransferRequest, + ) -> error_stack::Result<(), KernelError> { + let con: &mut PgConnection = executor; + let result = sqlx::query( + "UPDATE profile_transfer_requests + SET status = $2, version = $3 + WHERE id = $1", + ) + .bind(request.id().as_ref()) + .bind(status_value(request.status())) + .bind(request.version().as_ref()) + .execute(con) + .await + .convert_error()?; + if result.rows_affected() == 0 { + return Err(Report::new(KernelError::NotFound).attach_printable(format!( + "Target profile transfer request not found for update: {}", + request.id().as_ref() + ))); + } + Ok(()) + } +} + +impl DependOnProfileTransferRequestReadModel for PostgresDatabase { + type ProfileTransferRequestReadModel = PostgresProfileTransferRequestReadModel; + + fn profile_transfer_request_read_model(&self) -> &Self::ProfileTransferRequestReadModel { + &PostgresProfileTransferRequestReadModel + } +} + +#[cfg(test)] +mod test { + use kernel::interfaces::database::DatabaseConnection; + use kernel::interfaces::read_model::{AccountReadModel, DependOnAccountReadModel}; + use kernel::interfaces::read_model::{ + DependOnProfileReadModel, DependOnProfileTransferRequestReadModel, ProfileReadModel, + ProfileTransferRequestReadModel, + }; + use kernel::prelude::entity::{ + AccountId, AccountKind, EventVersion, Nanoid, ProfileId, ProfileTransferRequest, + ProfileTransferRequestId, + }; + use kernel::test_utils::{AccountBuilder, ProfileBuilder}; + + use crate::database::PostgresDatabase; + + async fn create_account( + db: &PostgresDatabase, + kind: AccountKind, + ) -> kernel::prelude::entity::Account { + let mut conn = db.connection().await.unwrap(); + let account = AccountBuilder::new() + .kind(kind) + .name(kernel::test_utils::unique_account_name()) + .nanoid(Nanoid::default()) + .version(EventVersion::new(1)) + .build(); + db.account_read_model() + .create(&mut conn, &account) + .await + .unwrap(); + account + } + + #[test_with::env(DATABASE_URL)] + #[tokio::test] + async fn create_find_update_roundtrip() { + kernel::ensure_generator_initialized(); + let db = PostgresDatabase::new().await.unwrap(); + let mut conn = db.connection().await.unwrap(); + let from = create_account(&db, AccountKind::Personal).await; + let to = create_account(&db, AccountKind::Organization).await; + let owner_profile = ProfileBuilder::new().account_id(from.id().clone()).build(); + db.profile_read_model() + .create(&mut conn, &owner_profile) + .await + .unwrap(); + let request_id = ProfileTransferRequestId::new(kernel::generate_id()); + let command = ProfileTransferRequest::request( + request_id.clone(), + owner_profile.id().clone(), + from.id().clone(), + to.id().clone(), + Nanoid::default(), + ); + let mut request = None; + kernel::interfaces::event::EventApplier::apply( + &mut request, + kernel::prelude::entity::EventEnvelope::new( + kernel::prelude::entity::EventId::from(request_id.clone()), + command.event().clone(), + EventVersion::new(1), + ), + ) + .unwrap(); + let request = request.unwrap(); + + db.profile_transfer_request_read_model() + .create(&mut conn, &request) + .await + .unwrap(); + + let found = db + .profile_transfer_request_read_model() + .find_by_id(&mut conn, &request_id) + .await + .unwrap(); + assert!(found.is_some()); + let found = found.unwrap(); + assert_eq!(found.profile_id(), request.profile_id()); + assert_eq!(found.from_account_id(), request.from_account_id()); + assert_eq!(found.to_org_account_id(), request.to_org_account_id()); + assert_eq!( + found.status(), + &kernel::prelude::entity::ProfileTransferStatus::Pending + ); + + let by_nanoid = db + .profile_transfer_request_read_model() + .find_by_nanoid(&mut conn, found.nanoid()) + .await + .unwrap(); + assert_eq!(by_nanoid.as_ref().map(|r| r.id()), Some(request.id())); + + let pending = db + .profile_transfer_request_read_model() + .find_pending_by_profile_id(&mut conn, owner_profile.id()) + .await + .unwrap(); + assert_eq!(pending.as_ref().map(|r| r.id()), Some(request.id())); + + let accept = ProfileTransferRequest::accept( + request_id.clone(), + EventVersion::new(request.version().as_ref().to_owned()), + ); + let mut accepted = Some(request); + kernel::interfaces::event::EventApplier::apply( + &mut accepted, + kernel::prelude::entity::EventEnvelope::new( + kernel::prelude::entity::EventId::from(request_id.clone()), + accept.event().clone(), + EventVersion::new(2), + ), + ) + .unwrap(); + let accepted = accepted.unwrap(); + db.profile_transfer_request_read_model() + .update(&mut conn, &accepted) + .await + .unwrap(); + let updated = db + .profile_transfer_request_read_model() + .find_by_id(&mut conn, &request_id) + .await + .unwrap() + .unwrap(); + assert_eq!( + updated.status(), + &kernel::prelude::entity::ProfileTransferStatus::Accepted + ); + + db.account_read_model() + .deactivate(&mut conn, from.id()) + .await + .unwrap(); + db.account_read_model() + .deactivate(&mut conn, to.id()) + .await + .unwrap(); + } + + #[test_with::env(DATABASE_URL)] + #[tokio::test] + async fn partial_unique_personal_profile_blocks_second_personal_profile() { + kernel::ensure_generator_initialized(); + let db = PostgresDatabase::new().await.unwrap(); + let mut conn = db.connection().await.unwrap(); + let personal = create_account(&db, AccountKind::Personal).await; + let first = ProfileBuilder::new() + .id(ProfileId::new(kernel::generate_id())) + .account_id(personal.id().clone()) + .build(); + db.profile_read_model() + .create(&mut conn, &first) + .await + .unwrap(); + + let second = ProfileBuilder::new() + .id(ProfileId::new(kernel::generate_id())) + .account_id(personal.id().clone()) + .build(); + let result = db.profile_read_model().create(&mut conn, &second).await; + assert!(result.is_err()); + let error_string = format!("{result:?}"); + assert!( + error_string.contains("unique") || error_string.contains("Unique"), + "expected unique violation, got: {error_string}" + ); + + db.account_read_model() + .deactivate(&mut conn, personal.id()) + .await + .unwrap(); + } + + #[test_with::env(DATABASE_URL)] + #[tokio::test] + async fn organization_may_have_multiple_profiles() { + kernel::ensure_generator_initialized(); + let db = PostgresDatabase::new().await.unwrap(); + let mut conn = db.connection().await.unwrap(); + let org = create_account(&db, AccountKind::Organization).await; + let first = ProfileBuilder::new() + .id(ProfileId::new(kernel::generate_id())) + .account_id(org.id().clone()) + .build(); + let second = ProfileBuilder::new() + .id(ProfileId::new(kernel::generate_id())) + .account_id(org.id().clone()) + .build(); + db.profile_read_model() + .create(&mut conn, &first) + .await + .unwrap(); + db.profile_read_model() + .create(&mut conn, &second) + .await + .unwrap(); + + let profiles = db + .profile_read_model() + .find_by_account_id(&mut conn, org.id()) + .await + .unwrap() + .map(|_| 1) + .unwrap_or(0); + assert_eq!(profiles, 1); + + db.account_read_model() + .deactivate(&mut conn, org.id()) + .await + .unwrap(); + } +} diff --git a/driver/src/database/postgres/profile_transfer_request_event_store.rs b/driver/src/database/postgres/profile_transfer_request_event_store.rs new file mode 100644 index 0000000..715302b --- /dev/null +++ b/driver/src/database/postgres/profile_transfer_request_event_store.rs @@ -0,0 +1,274 @@ +use crate::database::{PostgresConnection, PostgresDatabase}; +use crate::ConvertError; +use error_stack::Report; +use kernel::interfaces::event_store::{ + DependOnProfileTransferRequestEventStore, ProfileTransferRequestEventStore, +}; +use kernel::prelude::entity::{ + CommandEnvelope, EventEnvelope, EventId, EventVersion, ExpectedVersion, ProfileTransferRequest, + ProfileTransferRequestEvent, +}; +use kernel::KernelError; +use sqlx::PgConnection; + +#[derive(sqlx::FromRow)] +struct EventRow { + version: i64, + id: i64, + #[allow(dead_code)] + event_name: String, + data: serde_json::Value, +} + +impl TryFrom for EventEnvelope { + type Error = Report; + + fn try_from(value: EventRow) -> Result { + let event: ProfileTransferRequestEvent = + serde_json::from_value(value.data).convert_error()?; + Ok(EventEnvelope::new( + EventId::new(value.id), + event, + EventVersion::new(value.version), + )) + } +} + +pub struct PostgresProfileTransferRequestEventStore; + +impl ProfileTransferRequestEventStore for PostgresProfileTransferRequestEventStore { + type Connection = PostgresConnection; + + async fn find_by_id( + &self, + executor: &mut Self::Connection, + id: &EventId, + since: Option<&EventVersion>, + ) -> error_stack::Result< + Vec>, + KernelError, + > { + let con: &mut PgConnection = executor; + let rows = if let Some(version) = since { + sqlx::query_as::<_, EventRow>( + //language=postgresql + r#" + SELECT version, id, event_name, data + FROM profile_transfer_request_events + WHERE id = $1 AND version > $2 + ORDER BY version + "#, + ) + .bind(id.as_ref()) + .bind(version.as_ref()) + .fetch_all(con) + .await + .convert_error()? + } else { + sqlx::query_as::<_, EventRow>( + //language=postgresql + r#" + SELECT version, id, event_name, data + FROM profile_transfer_request_events + WHERE id = $1 + ORDER BY version + "#, + ) + .bind(id.as_ref()) + .fetch_all(con) + .await + .convert_error()? + }; + rows.into_iter() + .map(TryFrom::try_from) + .collect::, KernelError>>() + } + + async fn persist( + &self, + executor: &mut Self::Connection, + command: &CommandEnvelope, + ) -> error_stack::Result<(), KernelError> { + self.persist_internal(executor, command, kernel::generate_id()) + .await + } + + async fn persist_and_transform( + &self, + executor: &mut Self::Connection, + command: CommandEnvelope, + ) -> error_stack::Result< + EventEnvelope, + KernelError, + > { + let version = kernel::generate_id(); + self.persist_internal(executor, &command, version).await?; + + let command = command.into_destruct(); + Ok(EventEnvelope::new( + command.id, + command.event, + EventVersion::new(version), + )) + } +} + +impl PostgresProfileTransferRequestEventStore { + async fn persist_internal( + &self, + executor: &mut PostgresConnection, + command: &CommandEnvelope, + version: i64, + ) -> error_stack::Result<(), KernelError> { + let con: &mut PgConnection = executor; + let event_name = command.event_name(); + let data = serde_json::to_value(command.event()).convert_error()?; + let prev_version = command.prev_version().as_ref(); + + let result = match prev_version { + Some(ExpectedVersion::Nothing) => { + sqlx::query( + //language=postgresql + r#" + INSERT INTO profile_transfer_request_events (version, id, event_name, data) + SELECT $1, $2, $3, $4 + WHERE NOT EXISTS (SELECT 1 FROM profile_transfer_request_events WHERE id = $2) + "#, + ) + .bind(version) + .bind(command.id().as_ref()) + .bind(event_name) + .bind(&data) + .execute(&mut *con) + .await + .convert_error()? + } + Some(ExpectedVersion::At(prev)) => { + sqlx::query( + //language=postgresql + r#" + INSERT INTO profile_transfer_request_events (version, id, event_name, data) + SELECT $1, $2, $3, $4 + WHERE (SELECT MAX(version) FROM profile_transfer_request_events WHERE id = $2) = $5 + "#, + ) + .bind(version) + .bind(command.id().as_ref()) + .bind(event_name) + .bind(&data) + .bind(prev.as_ref()) + .execute(&mut *con) + .await + .convert_error()? + } + None => { + sqlx::query( + //language=postgresql + r#" + INSERT INTO profile_transfer_request_events (version, id, event_name, data) + VALUES ($1, $2, $3, $4) + "#, + ) + .bind(version) + .bind(command.id().as_ref()) + .bind(event_name) + .bind(&data) + .execute(con) + .await + .convert_error()? + } + }; + + if prev_version.is_some() && result.rows_affected() == 0 { + return Err( + Report::new(KernelError::Concurrency).attach_printable(format!( + "Concurrency conflict for event {}", + command.id().as_ref() + )), + ); + } + + Ok(()) + } +} + +impl DependOnProfileTransferRequestEventStore for PostgresDatabase { + type ProfileTransferRequestEventStore = PostgresProfileTransferRequestEventStore; + + fn profile_transfer_request_event_store(&self) -> &Self::ProfileTransferRequestEventStore { + &PostgresProfileTransferRequestEventStore + } +} + +#[cfg(test)] +mod test { + use crate::database::PostgresDatabase; + use kernel::interfaces::database::DatabaseConnection; + use kernel::interfaces::event_store::{ + DependOnProfileTransferRequestEventStore, ProfileTransferRequestEventStore, + }; + use kernel::prelude::entity::{ + AccountId, EventId, EventVersion, Nanoid, ProfileId, ProfileTransferRequest, + ProfileTransferRequestId, + }; + use kernel::KernelError; + + #[test_with::env(DATABASE_URL)] + #[tokio::test] + async fn requested_event_round_trips_by_id() { + kernel::ensure_generator_initialized(); + let database = PostgresDatabase::new().await.unwrap(); + let mut connection = database.connection().await.unwrap(); + let request_id = ProfileTransferRequestId::new(kernel::generate_id()); + let command = ProfileTransferRequest::request( + request_id.clone(), + ProfileId::new(kernel::generate_id()), + AccountId::new(kernel::generate_id()), + AccountId::new(kernel::generate_id()), + Nanoid::default(), + ); + + database + .profile_transfer_request_event_store() + .persist(&mut connection, &command) + .await + .unwrap(); + + let events = database + .profile_transfer_request_event_store() + .find_by_id(&mut connection, &EventId::from(request_id), None) + .await + .unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(&events[0].event, command.event()); + } + + #[test_with::env(DATABASE_URL)] + #[tokio::test] + async fn accept_with_wrong_expected_version_returns_concurrency_error() { + kernel::ensure_generator_initialized(); + let database = PostgresDatabase::new().await.unwrap(); + let mut connection = database.connection().await.unwrap(); + let request_id = ProfileTransferRequestId::new(kernel::generate_id()); + let request = ProfileTransferRequest::request( + request_id.clone(), + ProfileId::new(kernel::generate_id()), + AccountId::new(kernel::generate_id()), + AccountId::new(kernel::generate_id()), + Nanoid::default(), + ); + database + .profile_transfer_request_event_store() + .persist_and_transform(&mut connection, request) + .await + .unwrap(); + let accept = ProfileTransferRequest::accept(request_id, EventVersion::new(0)); + + let result = database + .profile_transfer_request_event_store() + .persist(&mut connection, &accept) + .await; + + assert!(result.is_err_and(|error| error.current_context() == &KernelError::Concurrency)); + } +} diff --git a/driver/src/database/postgres/profile_transfer_request_repository.rs b/driver/src/database/postgres/profile_transfer_request_repository.rs new file mode 100644 index 0000000..f96a8cc --- /dev/null +++ b/driver/src/database/postgres/profile_transfer_request_repository.rs @@ -0,0 +1,56 @@ +use super::profile_transfer_request_event_store::PostgresProfileTransferRequestEventStore; +use crate::database::{PostgresConnection, PostgresDatabase}; +use error_stack::Report; +use kernel::interfaces::event_store::ProfileTransferRequestEventStore; +use kernel::interfaces::repository::{ + AggregateRepository, DependOnProfileTransferRequestRepository, Rehydrated, +}; +use kernel::prelude::entity::{ + CommandEnvelope, EventEnvelope, EventId, ProfileTransferRequest, ProfileTransferRequestEvent, + ProfileTransferRequestId, +}; +use kernel::KernelError; + +pub struct PostgresProfileTransferRequestRepository; + +impl AggregateRepository for PostgresProfileTransferRequestRepository { + type Connection = PostgresConnection; + type Id = ProfileTransferRequestId; + + async fn load( + &self, + executor: &mut Self::Connection, + id: &Self::Id, + ) -> error_stack::Result, KernelError> { + let events = PostgresProfileTransferRequestEventStore + .find_by_id(executor, &EventId::from(id.clone()), None) + .await?; + Rehydrated::::from_events(events)?.ok_or_else(|| { + Report::new(KernelError::NotFound).attach_printable(format!( + "No events found for profile transfer request: {}", + id.as_ref() + )) + }) + } + + async fn save( + &self, + executor: &mut Self::Connection, + command: CommandEnvelope, + ) -> error_stack::Result< + EventEnvelope, + KernelError, + > { + PostgresProfileTransferRequestEventStore + .persist_and_transform(executor, command) + .await + } +} + +impl DependOnProfileTransferRequestRepository for PostgresDatabase { + type ProfileTransferRequestRepository = PostgresProfileTransferRequestRepository; + + fn profile_transfer_request_repository(&self) -> &Self::ProfileTransferRequestRepository { + &PostgresProfileTransferRequestRepository + } +} diff --git a/driver/src/database/postgres/projection.rs b/driver/src/database/postgres/projection.rs index 0ae0b39..01b37b4 100644 --- a/driver/src/database/postgres/projection.rs +++ b/driver/src/database/postgres/projection.rs @@ -289,10 +289,11 @@ impl ProfileProjectionWriter for PostgresProfileProjectionWriter { sqlx::query( //language=postgresql r#" - INSERT INTO profiles (id, account_id, display, summary, icon_id, banner_id, version, nanoid) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO profiles (id, account_id, owner_kind, display, summary, icon_id, banner_id, version, nanoid) + VALUES ($1, $2, (SELECT kind FROM accounts WHERE id = $2), $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO UPDATE SET account_id = EXCLUDED.account_id, + owner_kind = EXCLUDED.owner_kind, display = EXCLUDED.display, summary = EXCLUDED.summary, icon_id = EXCLUDED.icon_id, diff --git a/driver/src/storage.rs b/driver/src/storage.rs index bdeb9e2..bc87ec3 100644 --- a/driver/src/storage.rs +++ b/driver/src/storage.rs @@ -3,7 +3,9 @@ use aws_sdk_s3::config::Credentials; use aws_sdk_s3::error::ProvideErrorMetadata; use aws_sdk_s3::primitives::ByteStream; use error_stack::Report; -use kernel::interfaces::storage::{ImageStorage, StoredObject}; +use kernel::interfaces::storage::{ + ImageStorage, ProfileMediaCopyGateway, ProfileMediaCopyRequest, StoredObject, +}; use kernel::KernelError; #[derive(Clone)] @@ -114,3 +116,19 @@ impl ImageStorage for S3ImageStorage { fn env_or(name: &str, default: &str) -> String { dotenvy::var(name).unwrap_or_else(|_| default.to_string()) } + +#[derive(Clone)] +pub struct NoopProfileMediaCopyGateway; + +impl ProfileMediaCopyGateway for NoopProfileMediaCopyGateway { + async fn request_copy( + &self, + request: ProfileMediaCopyRequest, + ) -> error_stack::Result<(), KernelError> { + tracing::trace!( + ?request, + "No-op profile media copy gateway skipping request" + ); + Ok(()) + } +} diff --git a/migrations/20260904000001_profile_transfer.sql b/migrations/20260904000001_profile_transfer.sql new file mode 100644 index 0000000..c07e8e8 --- /dev/null +++ b/migrations/20260904000001_profile_transfer.sql @@ -0,0 +1,36 @@ +CREATE TABLE "profile_transfer_request_events" ( + "id" BIGINT NOT NULL, + "version" BIGINT NOT NULL, + "event_name" TEXT NOT NULL, + "data" JSONB NOT NULL, + "occurred_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + "seq" BIGSERIAL, + PRIMARY KEY ("id", "version") +); +CREATE INDEX idx_profile_transfer_request_events_seq ON profile_transfer_request_events (seq); + +CREATE TABLE "profile_transfer_requests" ( + "id" BIGINT PRIMARY KEY, + "profile_id" BIGINT NOT NULL REFERENCES "profiles" ("id") ON DELETE CASCADE, + "from_account_id" BIGINT NOT NULL REFERENCES "accounts" ("id") ON DELETE CASCADE, + "to_org_account_id" BIGINT NOT NULL REFERENCES "accounts" ("id") ON DELETE CASCADE, + "status" TEXT NOT NULL DEFAULT 'pending', + "version" BIGINT NOT NULL, + "nanoid" TEXT UNIQUE NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_profile_transfer_request_status + CHECK (status IN ('pending', 'accepted', 'rejected', 'cancelled')) +); +CREATE INDEX idx_profile_transfer_requests_profile_id ON profile_transfer_requests (profile_id); +CREATE INDEX idx_profile_transfer_requests_to_org ON profile_transfer_requests (to_org_account_id); +CREATE UNIQUE INDEX profile_transfer_requests_one_pending_per_profile + ON profile_transfer_requests (profile_id) WHERE status = 'pending'; + +ALTER TABLE "profiles" ADD COLUMN "owner_kind" TEXT NOT NULL DEFAULT 'personal'; +ALTER TABLE "profiles" ADD CONSTRAINT chk_profiles_owner_kind + CHECK (owner_kind IN ('personal', 'organization')); +UPDATE "profiles" SET "owner_kind" = + (SELECT "kind" FROM "accounts" WHERE "accounts"."id" = "profiles"."account_id"); +ALTER TABLE "profiles" DROP CONSTRAINT "profiles_account_id_key"; +CREATE UNIQUE INDEX profiles_personal_account_unique + ON "profiles" ("account_id") WHERE owner_kind = 'personal'; From d684bb6d618a467733bb43f1b35991f1380b3b66 Mon Sep 17 00:00:00 2001 From: turtton Date: Fri, 4 Sep 2026 01:28:14 +0900 Subject: [PATCH 3/4] :sparkles: profile-transfer: expose HTTP routes, OpenAPI contract, and NoopProfileMediaCopyGateway wiring --- openapi.json | 222 ++++++++++++++++++++ server/src/api/mod.rs | 2 + server/src/api/profile_transfer.rs | 79 +++++++ server/src/handler.rs | 13 +- server/src/main.rs | 2 + server/src/openapi.rs | 7 + server/src/openapi/tests.rs | 41 ++++ server/src/route.rs | 3 + server/src/route/profile_transfer/client.rs | 129 ++++++++++++ server/src/route/profile_transfer/mod.rs | 37 ++++ server/src/schema.rs | 1 + server/src/schema/profile_transfer.rs | 27 +++ 12 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 server/src/api/profile_transfer.rs create mode 100644 server/src/route/profile_transfer/client.rs create mode 100644 server/src/route/profile_transfer/mod.rs create mode 100644 server/src/schema/profile_transfer.rs diff --git a/openapi.json b/openapi.json index b98ff99..0d64a11 100644 --- a/openapi.json +++ b/openapi.json @@ -1963,6 +1963,129 @@ ] } }, + "/api/v1/profile-transfer-requests/{request_nanoid}/accept": { + "post": { + "tags": [ + "ProfileTransfer" + ], + "description": "Accept a profile transfer request as an organization owner or admin.", + "operationId": "accept_profile_transfer_request", + "parameters": [ + { + "name": "request_nanoid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Transfer accepted" + }, + "401": { + "description": "Missing or invalid bearer token" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Transfer request not found" + }, + "422": { + "description": "Request not pending" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/profile-transfer-requests/{request_nanoid}/cancel": { + "post": { + "tags": [ + "ProfileTransfer" + ], + "description": "Cancel a pending profile transfer request as the requester.", + "operationId": "cancel_profile_transfer_request", + "parameters": [ + { + "name": "request_nanoid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Transfer request cancelled" + }, + "401": { + "description": "Missing or invalid bearer token" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Transfer request not found" + }, + "422": { + "description": "Request not pending" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/api/v1/profile-transfer-requests/{request_nanoid}/reject": { + "post": { + "tags": [ + "ProfileTransfer" + ], + "description": "Reject a profile transfer request as an organization owner or admin.", + "operationId": "reject_profile_transfer_request", + "parameters": [ + { + "name": "request_nanoid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Transfer rejected" + }, + "401": { + "description": "Missing or invalid bearer token" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Transfer request not found" + }, + "422": { + "description": "Request not pending" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/api/v1/profiles": { "post": { "tags": [ @@ -2018,6 +2141,67 @@ ] } }, + "/api/v1/profiles/{profile_nanoid}/transfer-requests": { + "post": { + "tags": [ + "ProfileTransfer" + ], + "description": "Request transfer of a personal profile to an organization.", + "operationId": "create_profile_transfer_request", + "parameters": [ + { + "name": "profile_nanoid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProfileTransferRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Transfer request created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProfileTransferRequestResponse" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Missing or invalid bearer token" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Profile or organization not found" + }, + "422": { + "description": "Duplicate pending request" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/api/v1/reports": { "post": { "tags": [ @@ -2651,6 +2835,17 @@ } } }, + "CreateProfileTransferRequest": { + "type": "object", + "required": [ + "org_account_nanoid" + ], + "properties": { + "org_account_nanoid": { + "type": "string" + } + } + }, "CreateReportRequest": { "type": "object", "required": [ @@ -3097,6 +3292,29 @@ } } }, + "ProfileTransferRequestResponse": { + "type": "object", + "required": [ + "nanoid", + "profile_nanoid", + "org_account_nanoid", + "status" + ], + "properties": { + "nanoid": { + "type": "string" + }, + "org_account_nanoid": { + "type": "string" + }, + "profile_nanoid": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "PublicKey": { "type": "object", "description": "An ActivityPub public key object attached to an Actor.", @@ -3440,6 +3658,10 @@ "name": "Report", "description": "Account moderation reports" }, + { + "name": "ProfileTransfer", + "description": "Profile ownership transfer requests" + }, { "name": "Organization", "description": "Organization account and membership management" diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 0e0a3bd..cb3b770 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod media; pub(crate) mod oauth2; pub(crate) mod organization; pub(crate) mod organization_context; +pub(crate) mod profile_transfer; pub(crate) mod report; pub(crate) mod signing; @@ -34,6 +35,7 @@ pub(crate) use me::MeApi; pub(crate) use media::MediaApi; pub(crate) use oauth2::OAuth2Api; pub(crate) use organization::OrgAccountApi; +pub(crate) use profile_transfer::ProfileTransferApi; pub(crate) use report::{AdminReportApi, ReportApi}; pub(crate) use signing::SigningApi; diff --git a/server/src/api/profile_transfer.rs b/server/src/api/profile_transfer.rs new file mode 100644 index 0000000..6198911 --- /dev/null +++ b/server/src/api/profile_transfer.rs @@ -0,0 +1,79 @@ +use super::resolve_auth_account_id; +use crate::auth::OidcAuthInfo; +use crate::handler::AppModule; +use application::service::profile_transfer::{ + AcceptProfileTransferRequestUseCase, CancelProfileTransferRequestUseCase, + RejectProfileTransferRequestUseCase, RequestProfileTransferUseCase, +}; +use axum::extract::FromRef; +use kernel::prelude::entity::AuthAccountId; +use kernel::KernelError; +use std::sync::Arc; + +#[derive(Clone)] +pub struct ProfileTransferApi { + module: Arc, +} + +impl ProfileTransferApi { + pub fn new(module: Arc) -> Self { + Self { module } + } + + pub async fn resolve_auth_account_id( + &self, + auth_info: OidcAuthInfo, + ) -> error_stack::Result { + resolve_auth_account_id(&self.module, auth_info).await + } + + pub async fn request_profile_transfer( + &self, + auth_account_id: &AuthAccountId, + profile_nanoid: String, + org_account_nanoid: String, + ) -> error_stack::Result< + application::dto::profile_transfer::ProfileTransferRequestDto, + KernelError, + > { + self.module + .request_profile_transfer(auth_account_id, profile_nanoid, org_account_nanoid) + .await + } + + pub async fn accept_profile_transfer_request( + &self, + auth_account_id: &AuthAccountId, + request_nanoid: String, + ) -> error_stack::Result<(), KernelError> { + self.module + .accept_profile_transfer_request(auth_account_id, request_nanoid) + .await + } + + pub async fn reject_profile_transfer_request( + &self, + auth_account_id: &AuthAccountId, + request_nanoid: String, + ) -> error_stack::Result<(), KernelError> { + self.module + .reject_profile_transfer_request(auth_account_id, request_nanoid) + .await + } + + pub async fn cancel_profile_transfer_request( + &self, + auth_account_id: &AuthAccountId, + request_nanoid: String, + ) -> error_stack::Result<(), KernelError> { + self.module + .cancel_profile_transfer_request(auth_account_id, request_nanoid) + .await + } +} + +impl FromRef for ProfileTransferApi { + fn from_ref(module: &AppModule) -> Self { + Self::new(Arc::new(module.clone())) + } +} diff --git a/server/src/handler.rs b/server/src/handler.rs index 314ef6e..6de0dce 100644 --- a/server/src/handler.rs +++ b/server/src/handler.rs @@ -6,7 +6,7 @@ use driver::crypto::{ use driver::database::PostgresDatabase; use driver::http_signing::{HttpSignatureVerifierImpl, HttpSignerImpl}; use driver::keto::KetoClient; -use driver::storage::S3ImageStorage; +use driver::storage::{NoopProfileMediaCopyGateway, S3ImageStorage}; use kernel::interfaces::config::{DependOnPublicBaseUrl, PublicBaseUrl}; use kernel::interfaces::crypto::{ DependOnKeyEncryptor, DependOnPasswordProvider, DependOnRawKeyGenerator, @@ -36,6 +36,7 @@ pub struct AppModule { kratos_client: KratosClient, keto_client: KetoClient, image_storage: S3ImageStorage, + profile_media_copy_gateway: NoopProfileMediaCopyGateway, } impl AppModule { @@ -68,6 +69,7 @@ impl AppModule { kratos_client: KratosClient::new(kratos_public_url), keto_client: KetoClient::new(keto_read_url, keto_write_url), image_storage, + profile_media_copy_gateway: NoopProfileMediaCopyGateway, }) } @@ -96,6 +98,7 @@ impl AppModule { kratos_client: KratosClient::new(kratos_public_url), keto_client: KetoClient::new(keto_read_url, keto_write_url), image_storage, + profile_media_copy_gateway: NoopProfileMediaCopyGateway, }) } @@ -204,3 +207,11 @@ impl kernel::interfaces::storage::DependOnImageStorage for AppModule { &self.image_storage } } + +impl kernel::interfaces::storage::DependOnProfileMediaCopyGateway for AppModule { + type ProfileMediaCopyGateway = NoopProfileMediaCopyGateway; + + fn profile_media_copy_gateway(&self) -> &Self::ProfileMediaCopyGateway { + &self.profile_media_copy_gateway + } +} diff --git a/server/src/main.rs b/server/src/main.rs index a4667f9..62e0041 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -18,6 +18,7 @@ use crate::route::activitypub::{ActivityPubRouter, FederationRouter}; use crate::route::me::MeRouter; use crate::route::media::MediaRouter; use crate::route::oauth2::OAuth2Router; +use crate::route::profile_transfer::ProfileTransferRouter; use crate::route::report::{AdminReportRouter, ReportRouter}; use crate::route::signing::SigningRouter; #[cfg(feature = "test-mode")] @@ -96,6 +97,7 @@ async fn main() -> Result<(), StackTrace> { let api_v1 = axum::Router::new() .route_account() .route_org_account() + .route_profile_transfers() .route_reports() .route_me() .route_media() diff --git a/server/src/openapi.rs b/server/src/openapi.rs index 763c337..0b1eb2f 100644 --- a/server/src/openapi.rs +++ b/server/src/openapi.rs @@ -79,6 +79,10 @@ impl Modify for SecurityAddon { crate::route::report::list_reports, crate::route::report::resolve_report, crate::route::report::dismiss_report, + crate::route::profile_transfer::create_profile_transfer_request, + crate::route::profile_transfer::accept_profile_transfer_request, + crate::route::profile_transfer::reject_profile_transfer_request, + crate::route::profile_transfer::cancel_profile_transfer_request, ), components(schemas( crate::schema::account::CreateAccountRequest, @@ -118,6 +122,8 @@ impl Modify for SecurityAddon { crate::schema::report::CloseReportRequest, crate::schema::report::AccountReportResponse, crate::schema::report::AccountReportListResponse, + crate::schema::profile_transfer::CreateProfileTransferRequest, + crate::schema::profile_transfer::ProfileTransferRequestResponse, crate::schema::organization::CreateOrganizationRequest, crate::schema::organization::InviteMemberRequest, crate::schema::organization::ChangeRoleRequest, @@ -138,6 +144,7 @@ impl Modify for SecurityAddon { (name = "Signing", description = "HTTP Signature signing"), (name = "ActivityPub", description = "ActivityPub discovery and actor endpoints"), (name = "Report", description = "Account moderation reports"), + (name = "ProfileTransfer", description = "Profile ownership transfer requests"), (name = "Organization", description = "Organization account and membership management"), ) )] diff --git a/server/src/openapi/tests.rs b/server/src/openapi/tests.rs index e7e327c..f8e3d02 100644 --- a/server/src/openapi/tests.rs +++ b/server/src/openapi/tests.rs @@ -149,3 +149,44 @@ fn account_report_contract_is_registered() { ); } } + +#[test] +fn profile_transfer_contract_is_registered() { + let spec: serde_json::Value = serde_json::from_str(&generate_openapi_json()) + .expect("generated OpenAPI spec is valid JSON"); + for (path, method) in [ + ( + "/api/v1/profiles/{profile_nanoid}/transfer-requests", + "post", + ), + ( + "/api/v1/profile-transfer-requests/{request_nanoid}/accept", + "post", + ), + ( + "/api/v1/profile-transfer-requests/{request_nanoid}/reject", + "post", + ), + ( + "/api/v1/profile-transfer-requests/{request_nanoid}/cancel", + "post", + ), + ] { + let operation = &spec["paths"][path][method]; + assert!(operation.is_object(), "{method} {path} must be registered"); + assert_eq!( + operation["security"], + serde_json::json!([{"bearer_auth": []}]), + "{method} {path} must require bearer authentication" + ); + } + for schema in [ + "CreateProfileTransferRequest", + "ProfileTransferRequestResponse", + ] { + assert!( + spec["components"]["schemas"].get(schema).is_some(), + "{schema} must be registered" + ); + } +} diff --git a/server/src/route.rs b/server/src/route.rs index ac5ac3b..70eda45 100644 --- a/server/src/route.rs +++ b/server/src/route.rs @@ -7,6 +7,7 @@ pub mod activitypub; pub mod me; pub mod media; pub mod oauth2; +pub mod profile_transfer; pub mod report; pub mod signing; @@ -79,12 +80,14 @@ pub(crate) fn build_test_router_with_auth( use crate::route::me::MeRouter; use crate::route::media::MediaRouter; use crate::route::oauth2::OAuth2Router; + use crate::route::profile_transfer::ProfileTransferRouter; use crate::route::report::{AdminReportRouter, ReportRouter}; use crate::route::signing::SigningRouter; let api_v1 = axum::Router::new() .route_account() .route_org_account() + .route_profile_transfers() .route_reports() .route_me() .route_media() diff --git a/server/src/route/profile_transfer/client.rs b/server/src/route/profile_transfer/client.rs new file mode 100644 index 0000000..7b4d440 --- /dev/null +++ b/server/src/route/profile_transfer/client.rs @@ -0,0 +1,129 @@ +use crate::api::ProfileTransferApi; +use crate::auth::{AuthClaims, OidcAuthInfo}; +use crate::error::ErrorStatus; +use crate::schema::profile_transfer::{ + CreateProfileTransferRequest, ProfileTransferRequestResponse, +}; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::{Extension, Json}; + +#[utoipa::path( + post, + path = "/api/v1/profiles/{profile_nanoid}/transfer-requests", + description = "Request transfer of a personal profile to an organization.", + request_body = CreateProfileTransferRequest, + responses( + (status = 201, description = "Transfer request created", body = ProfileTransferRequestResponse), + (status = 400, description = "Invalid request"), + (status = 401, description = "Missing or invalid bearer token"), + (status = 403, description = "Permission denied"), + (status = 404, description = "Profile or organization not found"), + (status = 422, description = "Duplicate pending request"), + ), + security(("bearer_auth" = [])), + tag = "ProfileTransfer", +)] +pub(crate) async fn create_profile_transfer_request( + Extension(claims): Extension, + State(api): State, + Path(profile_nanoid): Path, + Json(request): Json, +) -> Result<(StatusCode, Json), ErrorStatus> { + let auth_account_id = api + .resolve_auth_account_id(OidcAuthInfo::from(claims)) + .await + .map_err(ErrorStatus::from)?; + let response = api + .request_profile_transfer(&auth_account_id, profile_nanoid, request.org_account_nanoid) + .await + .map_err(ErrorStatus::from)?; + Ok((StatusCode::CREATED, Json((&response).into()))) +} + +#[utoipa::path( + post, + path = "/api/v1/profile-transfer-requests/{request_nanoid}/accept", + description = "Accept a profile transfer request as an organization owner or admin.", + responses( + (status = 204, description = "Transfer accepted"), + (status = 401, description = "Missing or invalid bearer token"), + (status = 403, description = "Permission denied"), + (status = 404, description = "Transfer request not found"), + (status = 422, description = "Request not pending"), + ), + security(("bearer_auth" = [])), + tag = "ProfileTransfer", +)] +pub(crate) async fn accept_profile_transfer_request( + Extension(claims): Extension, + State(api): State, + Path(request_nanoid): Path, +) -> Result { + let auth_account_id = api + .resolve_auth_account_id(OidcAuthInfo::from(claims)) + .await + .map_err(ErrorStatus::from)?; + api.accept_profile_transfer_request(&auth_account_id, request_nanoid) + .await + .map_err(ErrorStatus::from)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/api/v1/profile-transfer-requests/{request_nanoid}/reject", + description = "Reject a profile transfer request as an organization owner or admin.", + responses( + (status = 204, description = "Transfer rejected"), + (status = 401, description = "Missing or invalid bearer token"), + (status = 403, description = "Permission denied"), + (status = 404, description = "Transfer request not found"), + (status = 422, description = "Request not pending"), + ), + security(("bearer_auth" = [])), + tag = "ProfileTransfer", +)] +pub(crate) async fn reject_profile_transfer_request( + Extension(claims): Extension, + State(api): State, + Path(request_nanoid): Path, +) -> Result { + let auth_account_id = api + .resolve_auth_account_id(OidcAuthInfo::from(claims)) + .await + .map_err(ErrorStatus::from)?; + api.reject_profile_transfer_request(&auth_account_id, request_nanoid) + .await + .map_err(ErrorStatus::from)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/api/v1/profile-transfer-requests/{request_nanoid}/cancel", + description = "Cancel a pending profile transfer request as the requester.", + responses( + (status = 204, description = "Transfer request cancelled"), + (status = 401, description = "Missing or invalid bearer token"), + (status = 403, description = "Permission denied"), + (status = 404, description = "Transfer request not found"), + (status = 422, description = "Request not pending"), + ), + security(("bearer_auth" = [])), + tag = "ProfileTransfer", +)] +pub(crate) async fn cancel_profile_transfer_request( + Extension(claims): Extension, + State(api): State, + Path(request_nanoid): Path, +) -> Result { + let auth_account_id = api + .resolve_auth_account_id(OidcAuthInfo::from(claims)) + .await + .map_err(ErrorStatus::from)?; + api.cancel_profile_transfer_request(&auth_account_id, request_nanoid) + .await + .map_err(ErrorStatus::from)?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/route/profile_transfer/mod.rs b/server/src/route/profile_transfer/mod.rs new file mode 100644 index 0000000..34b6b42 --- /dev/null +++ b/server/src/route/profile_transfer/mod.rs @@ -0,0 +1,37 @@ +mod client; + +pub(crate) use client::{ + __path_accept_profile_transfer_request, __path_cancel_profile_transfer_request, + __path_create_profile_transfer_request, __path_reject_profile_transfer_request, + accept_profile_transfer_request, cancel_profile_transfer_request, + create_profile_transfer_request, reject_profile_transfer_request, +}; + +use crate::handler::AppModule; +use axum::routing::post; +use axum::Router; + +pub trait ProfileTransferRouter { + fn route_profile_transfers(self) -> Self; +} + +impl ProfileTransferRouter for Router { + fn route_profile_transfers(self) -> Self { + self.route( + "/profiles/{profile_nanoid}/transfer-requests", + post(create_profile_transfer_request), + ) + .route( + "/profile-transfer-requests/{request_nanoid}/accept", + post(accept_profile_transfer_request), + ) + .route( + "/profile-transfer-requests/{request_nanoid}/reject", + post(reject_profile_transfer_request), + ) + .route( + "/profile-transfer-requests/{request_nanoid}/cancel", + post(cancel_profile_transfer_request), + ) + } +} diff --git a/server/src/schema.rs b/server/src/schema.rs index bff8da8..221aa49 100644 --- a/server/src/schema.rs +++ b/server/src/schema.rs @@ -3,4 +3,5 @@ pub mod me; pub mod media; pub mod oauth2; pub mod organization; +pub mod profile_transfer; pub mod report; diff --git a/server/src/schema/profile_transfer.rs b/server/src/schema/profile_transfer.rs new file mode 100644 index 0000000..faf7561 --- /dev/null +++ b/server/src/schema/profile_transfer.rs @@ -0,0 +1,27 @@ +use application::dto::profile_transfer::ProfileTransferRequestDto; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct CreateProfileTransferRequest { + pub org_account_nanoid: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ProfileTransferRequestResponse { + pub nanoid: String, + pub profile_nanoid: String, + pub org_account_nanoid: String, + pub status: String, +} + +impl From<&ProfileTransferRequestDto> for ProfileTransferRequestResponse { + fn from(dto: &ProfileTransferRequestDto) -> Self { + Self { + nanoid: dto.nanoid.clone(), + profile_nanoid: dto.profile_nanoid.clone(), + org_account_nanoid: dto.org_account_nanoid.clone(), + status: dto.status.clone(), + } + } +} From 3c9c4b96b9e185ee6b19dcc732ea320aa721b043 Mon Sep 17 00:00:00 2001 From: turtton Date: Fri, 4 Sep 2026 05:16:22 +0900 Subject: [PATCH 4/4] :bug: profile-transfer: fix accept/reject actor resolution + add use case unit tests --- .../src/service/profile_transfer/cancel.rs | 3 + .../service/profile_transfer/cancel/tests.rs | 101 +++++ .../src/service/profile_transfer/mod.rs | 104 +++-- .../src/service/profile_transfer/request.rs | 18 +- .../service/profile_transfer/request/tests.rs | 203 ++++++++++ .../src/service/profile_transfer/respond.rs | 29 +- .../service/profile_transfer/respond/tests.rs | 371 ++++++++++++++++++ .../profile_transfer/test_support/account.rs | 299 ++++++++++++++ .../profile_transfer/test_support/database.rs | 41 ++ .../profile_transfer/test_support/mod.rs | 14 + .../profile_transfer/test_support/module.rs | 359 +++++++++++++++++ .../profile_transfer/test_support/profile.rs | 184 +++++++++ .../profile_transfer/test_support/request.rs | 177 +++++++++ kernel/src/entity/profile_transfer_request.rs | 155 ++++++++ server/src/route/account/client_tests.rs | 14 +- 15 files changed, 2025 insertions(+), 47 deletions(-) create mode 100644 application/src/service/profile_transfer/cancel/tests.rs create mode 100644 application/src/service/profile_transfer/request/tests.rs create mode 100644 application/src/service/profile_transfer/respond/tests.rs create mode 100644 application/src/service/profile_transfer/test_support/account.rs create mode 100644 application/src/service/profile_transfer/test_support/database.rs create mode 100644 application/src/service/profile_transfer/test_support/mod.rs create mode 100644 application/src/service/profile_transfer/test_support/module.rs create mode 100644 application/src/service/profile_transfer/test_support/profile.rs create mode 100644 application/src/service/profile_transfer/test_support/request.rs diff --git a/application/src/service/profile_transfer/cancel.rs b/application/src/service/profile_transfer/cancel.rs index 6738236..50fb2dd 100644 --- a/application/src/service/profile_transfer/cancel.rs +++ b/application/src/service/profile_transfer/cancel.rs @@ -16,6 +16,9 @@ use kernel::prelude::entity::{AuthAccountId, Nanoid, ProfileTransferRequest}; use kernel::KernelError; use std::future::Future; +#[cfg(test)] +mod tests; + pub trait CancelProfileTransferRequestUseCase: 'static + Sync + Send + Clone { fn cancel_profile_transfer_request<'a>( &'a self, diff --git a/application/src/service/profile_transfer/cancel/tests.rs b/application/src/service/profile_transfer/cancel/tests.rs new file mode 100644 index 0000000..9305003 --- /dev/null +++ b/application/src/service/profile_transfer/cancel/tests.rs @@ -0,0 +1,101 @@ +use super::*; +use crate::service::profile_transfer::test_support::{fixture, Fixture}; +use kernel::prelude::entity::{ + AccountId, AccountKind, EventVersion, Nanoid, ProfileTransferRequest, ProfileTransferStatus, +}; +use kernel::test_utils::AccountBuilder; +use kernel::KernelError; + +#[tokio::test] +async fn cancel_succeeds_when_actor_owns_from_account() { + let mut f = fixture(); + f.module + .cancel_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap(); + + let saved = f + .module + .transfer_request_repository + .saved_events + .lock() + .unwrap(); + assert_eq!(saved.len(), 1); + assert!(matches!( + saved[0], + kernel::prelude::entity::ProfileTransferRequestEvent::Cancelled + )); +} + +#[tokio::test] +async fn cancel_returns_permission_denied_when_actor_does_not_own_from_account() { + let mut f = fixture(); + let other_account = AccountBuilder::new() + .id(AccountId::new(500)) + .nanoid(Nanoid::new("other")) + .kind(AccountKind::Personal) + .build(); + f.module.accounts.owned_accounts = vec![other_account]; + + let err = f + .module + .cancel_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::PermissionDenied); +} + +#[tokio::test] +async fn cancel_returns_rejected_when_request_already_decided() { + let mut f = fixture(); + let decided = { + let request = f + .module + .transfer_request_repository + .request + .as_ref() + .unwrap(); + ProfileTransferRequest::new( + request.id().clone(), + f.profile.id().clone(), + f.owner_account.id().clone(), + f.org_account.id().clone(), + ProfileTransferStatus::Accepted, + EventVersion::default(), + request.nanoid().clone(), + ) + }; + f.module.transfer_request_repository.request = Some(decided); + + let err = f + .module + .cancel_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::Rejected); +} + +#[tokio::test] +async fn cancel_returns_not_found_for_unknown_request() { + let f = fixture(); + + let err = f + .module + .cancel_profile_transfer_request(&f.auth, "unknown".to_string()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::NotFound); +} + +#[tokio::test] +async fn cancel_updates_read_model() { + let mut f = fixture(); + f.module + .cancel_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap(); + + let updated = f.module.transfer_requests.updated.lock().unwrap(); + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].status(), &ProfileTransferStatus::Cancelled); +} diff --git a/application/src/service/profile_transfer/mod.rs b/application/src/service/profile_transfer/mod.rs index 153577b..2d0cd3d 100644 --- a/application/src/service/profile_transfer/mod.rs +++ b/application/src/service/profile_transfer/mod.rs @@ -4,6 +4,9 @@ mod cancel; mod request; mod respond; +#[cfg(test)] +mod test_support; + pub use cancel::CancelProfileTransferRequestUseCase; pub use request::RequestProfileTransferUseCase; pub use respond::{AcceptProfileTransferRequestUseCase, RejectProfileTransferRequestUseCase}; @@ -15,23 +18,41 @@ use kernel::interfaces::repository::{ DependOnOrganizationMembershipRepository, OrganizationMembershipRepository, }; use kernel::prelude::entity::{ - Account, AccountId, AccountKind, AuthAccountId, OrganizationMembershipStatus, + Account, AccountId, AccountKind, AuthAccountId, OrgRole, OrganizationMembershipStatus, }; use kernel::KernelError; -async fn resolve_personal_actor_account( +async fn personal_accounts_of( deps: &T, auth_id: &AuthAccountId, -) -> error_stack::Result +) -> error_stack::Result, KernelError> where T: DependOnAccountQuery + DependOnDatabaseConnection, { let mut connection = deps.database_connection().connection().await?; - deps.account_query() + Ok(deps + .account_query() .find_by_auth_id(&mut connection, auth_id) .await? .into_iter() - .find(|account| account.kind() == &AccountKind::Personal && account.deleted_at().is_none()) + .filter(|account| { + account.kind() == &AccountKind::Personal && account.deleted_at().is_none() + }) + .collect()) +} + +#[allow(dead_code)] +async fn resolve_personal_actor_account( + deps: &T, + auth_id: &AuthAccountId, +) -> error_stack::Result +where + T: DependOnAccountQuery + DependOnDatabaseConnection, +{ + personal_accounts_of(deps, auth_id) + .await? + .into_iter() + .next() .ok_or_else(|| { Report::new(KernelError::NotFound) .attach_printable("No personal account belongs to the authenticated user") @@ -46,49 +67,74 @@ async fn resolve_personal_actor_account_among( where T: DependOnAccountQuery + DependOnDatabaseConnection, { - let mut connection = deps.database_connection().connection().await?; - deps.account_query() - .find_by_auth_id(&mut connection, auth_id) + personal_accounts_of(deps, auth_id) .await? .into_iter() - .find(|account| { - account.kind() == &AccountKind::Personal - && account.deleted_at().is_none() - && account.id() == account_id - }) + .find(|account| account.id() == account_id) .ok_or_else(|| { Report::new(KernelError::PermissionDenied) .attach_printable("Authenticated user does not control the required account") }) } -async fn require_active_membership( +async fn find_active_membership( deps: &T, org_account_id: &AccountId, member_account_id: &AccountId, -) -> error_stack::Result<(), KernelError> +) -> error_stack::Result where T: DependOnOrganizationMembershipRepository + DependOnDatabaseConnection, { let mut connection = deps.database_connection().connection().await?; - match deps - .organization_membership_repository() + deps.organization_membership_repository() .find(&mut connection, org_account_id, member_account_id) .await? - { - Some(membership) - if membership.status() == &OrganizationMembershipStatus::Active - && (membership.role() == &kernel::prelude::entity::OrgRole::Owner - || membership.role() == &kernel::prelude::entity::OrgRole::Admin - || membership.role() == &kernel::prelude::entity::OrgRole::Member) => - { - Ok(()) + .filter(|membership| membership.status() == &OrganizationMembershipStatus::Active) + .ok_or_else(|| { + Report::new(KernelError::PermissionDenied) + .attach_printable("Actor is not an active member of the organization") + }) +} + +async fn resolve_actor_with_active_membership( + deps: &T, + auth_id: &AuthAccountId, + org_account_id: &AccountId, + required_roles: &[OrgRole], +) -> error_stack::Result +where + T: DependOnAccountQuery + DependOnOrganizationMembershipRepository + DependOnDatabaseConnection, +{ + let accounts = personal_accounts_of(deps, auth_id).await?; + if accounts.is_empty() { + return Err(Report::new(KernelError::PermissionDenied) + .attach_printable("No personal account belongs to the authenticated user")); + } + for account in accounts { + if let Ok(membership) = find_active_membership(deps, org_account_id, account.id()).await { + if required_roles.contains(membership.role()) { + return Ok(account); + } } - _ => Err(Report::new(KernelError::PermissionDenied) - .attach_printable("Actor is not an active member of the organization")), } + Err(Report::new(KernelError::PermissionDenied) + .attach_printable("No qualifying actor found for the organization")) +} + +async fn require_active_membership( + deps: &T, + org_account_id: &AccountId, + member_account_id: &AccountId, +) -> error_stack::Result<(), KernelError> +where + T: DependOnOrganizationMembershipRepository + DependOnDatabaseConnection, +{ + find_active_membership(deps, org_account_id, member_account_id) + .await + .map(|_| ()) } +#[allow(dead_code)] async fn require_active_owner_or_admin_membership( deps: &T, org_account_id: &AccountId, @@ -105,8 +151,8 @@ where { Some(membership) if membership.status() == &OrganizationMembershipStatus::Active - && (membership.role() == &kernel::prelude::entity::OrgRole::Owner - || membership.role() == &kernel::prelude::entity::OrgRole::Admin) => + && (membership.role() == &OrgRole::Owner + || membership.role() == &OrgRole::Admin) => { Ok(()) } diff --git a/application/src/service/profile_transfer/request.rs b/application/src/service/profile_transfer/request.rs index 090848c..9a607b4 100644 --- a/application/src/service/profile_transfer/request.rs +++ b/application/src/service/profile_transfer/request.rs @@ -1,4 +1,4 @@ -use super::{require_active_membership, resolve_personal_actor_account}; +use super::{personal_accounts_of, require_active_membership}; use crate::dto::profile_transfer::ProfileTransferRequestDto; use error_stack::Report; use kernel::interfaces::database::{ @@ -21,6 +21,9 @@ use kernel::prelude::entity::{ use kernel::KernelError; use std::future::Future; +#[cfg(test)] +mod tests; + pub trait RequestProfileTransferUseCase: 'static + Sync + Send + Clone { fn request_profile_transfer<'a>( &'a self, @@ -77,11 +80,14 @@ where })? }; - let actor_account = resolve_personal_actor_account(self, auth_id).await?; - if actor_account.id() != profile.account_id() { - return Err(Report::new(KernelError::PermissionDenied) - .attach_printable("Authenticated user does not own the profile")); - } + let actor_account = personal_accounts_of(self, auth_id) + .await? + .into_iter() + .find(|account| account.id() == profile.account_id()) + .ok_or_else(|| { + Report::new(KernelError::PermissionDenied) + .attach_printable("Authenticated user does not own the profile") + })?; require_active_membership(self, org_account.id(), actor_account.id()).await?; if self diff --git a/application/src/service/profile_transfer/request/tests.rs b/application/src/service/profile_transfer/request/tests.rs new file mode 100644 index 0000000..3d23b09 --- /dev/null +++ b/application/src/service/profile_transfer/request/tests.rs @@ -0,0 +1,203 @@ +use super::*; +use crate::service::profile_transfer::test_support::{active_membership, fixture, Fixture}; +use kernel::prelude::entity::{ + AccountId, AccountKind, Nanoid, OrgRole, Profile, ProfileId, ProfileTransferRequestEvent, + ProfileTransferStatus, +}; +use kernel::test_utils::{AccountBuilder, ProfileBuilder}; +use kernel::KernelError; +use std::sync::{Arc, Mutex}; + +fn assert_requested_event( + f: &Fixture, +) -> ( + ProfileId, + AccountId, + AccountId, + Nanoid, +) { + let saved = f + .module + .transfer_request_repository + .saved_events + .lock() + .unwrap(); + assert_eq!(saved.len(), 1); + match &saved[0] { + ProfileTransferRequestEvent::Requested { + profile_id, + from_account_id, + to_org_account_id, + nanoid, + } => { + assert_eq!(profile_id, f.profile.id()); + assert_eq!(from_account_id, f.owner_account.id()); + assert_eq!(to_org_account_id, f.org_account.id()); + ( + profile_id.clone(), + from_account_id.clone(), + to_org_account_id.clone(), + nanoid.clone(), + ) + } + _ => panic!("Expected Requested event"), + } +} + +#[tokio::test] +async fn request_creates_pending_transfer() { + let mut f = fixture(); + f.module.transfer_requests.requests.lock().unwrap().clear(); + f.module.transfer_request_repository.request = None; + + let dto = f + .module + .request_profile_transfer(&f.auth, f.profile_nanoid.clone(), "org".to_string()) + .await + .unwrap(); + + assert_eq!(dto.profile_nanoid, f.profile_nanoid); + assert_eq!(dto.org_account_nanoid, "org"); + assert_eq!(dto.status, "pending"); + assert!(!dto.nanoid.is_empty()); + + assert_requested_event(&f); + + let created = f.module.transfer_requests.created.lock().unwrap(); + assert_eq!(created.len(), 1); + assert_eq!(created[0].profile_id(), f.profile.id()); + assert_eq!(created[0].status(), &ProfileTransferStatus::Pending); +} + +#[tokio::test] +async fn request_returns_not_found_for_unknown_profile() { + let f = fixture(); + let err = f + .module + .request_profile_transfer(&f.auth, "unknown".to_string(), "org".to_string()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::NotFound); +} + +#[tokio::test] +async fn request_returns_not_found_for_unknown_org() { + let f = fixture(); + let err = f + .module + .request_profile_transfer(&f.auth, f.profile_nanoid.clone(), "unknown".to_string()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::NotFound); +} + +#[tokio::test] +async fn request_returns_not_found_when_org_nanoid_is_personal_account() { + let mut f = fixture(); + f.module.accounts.target = Some(f.owner_account.clone()); + let err = f + .module + .request_profile_transfer(&f.auth, f.profile_nanoid.clone(), "owner".to_string()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::NotFound); +} + +#[tokio::test] +async fn request_returns_permission_denied_when_actor_does_not_own_profile() { + let mut f = fixture(); + let other_owner = AccountBuilder::new() + .id(AccountId::new(999)) + .nanoid(Nanoid::new("other-owner")) + .kind(AccountKind::Personal) + .build(); + f.module.accounts.owned_accounts = vec![other_owner.clone()]; + f.module.memberships.lock().unwrap().push(active_membership( + f.org_account.id().clone(), + other_owner.id().clone(), + OrgRole::Member, + )); + let err = f + .module + .request_profile_transfer(&f.auth, f.profile_nanoid.clone(), "org".to_string()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::PermissionDenied); +} + +#[tokio::test] +async fn request_returns_permission_denied_when_owner_not_active_member() { + let mut f = fixture(); + f.module.memberships.lock().unwrap().clear(); + let err = f + .module + .request_profile_transfer(&f.auth, f.profile_nanoid.clone(), "org".to_string()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::PermissionDenied); +} + +#[tokio::test] +async fn request_returns_rejected_when_pending_request_already_exists() { + let f = fixture(); + let err = f + .module + .request_profile_transfer(&f.auth, f.profile_nanoid.clone(), "org".to_string()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::Rejected); +} + +#[tokio::test] +async fn request_succeeds_with_second_personal_account_that_owns_profile_and_is_member() { + let mut f = fixture(); + f.module.transfer_requests.requests.lock().unwrap().clear(); + f.module.transfer_request_repository.request = None; + + let second_owner = AccountBuilder::new() + .id(AccountId::new(101)) + .nanoid(Nanoid::new("owner")) + .kind(AccountKind::Personal) + .build(); + let profile = ProfileBuilder::new() + .id(ProfileId::new(2)) + .account_id(second_owner.id().clone()) + .nanoid(Nanoid::::new("profile-2")) + .build(); + + f.module.accounts.owned_accounts = vec![ + AccountBuilder::new() + .id(AccountId::new(1000)) + .nanoid(Nanoid::new("another")) + .kind(AccountKind::Personal) + .build(), + second_owner.clone(), + ]; + f.module.profiles.profiles = Arc::new(Mutex::new(vec![profile.clone().into()])); + f.module.profile_repository.profile = Some(profile.clone()); + f.module.memberships.lock().unwrap().push(active_membership( + f.org_account.id().clone(), + second_owner.id().clone(), + OrgRole::Member, + )); + + f.module + .request_profile_transfer(&f.auth, "profile-2".to_string(), "org".to_string()) + .await + .unwrap(); + + let saved = f + .module + .transfer_request_repository + .saved_events + .lock() + .unwrap(); + match &saved[0] { + ProfileTransferRequestEvent::Requested { + from_account_id, .. + } => { + assert_eq!(from_account_id, second_owner.id()); + } + _ => panic!("Expected Requested event"), + } +} diff --git a/application/src/service/profile_transfer/respond.rs b/application/src/service/profile_transfer/respond.rs index 3bccb2f..9b65eaf 100644 --- a/application/src/service/profile_transfer/respond.rs +++ b/application/src/service/profile_transfer/respond.rs @@ -1,4 +1,4 @@ -use super::{require_active_owner_or_admin_membership, resolve_personal_actor_account_among}; +use super::resolve_actor_with_active_membership; use error_stack::Report; use kernel::interfaces::database::{ DatabaseConnection, DependOnDatabaseConnection, TransactionManager, @@ -16,10 +16,13 @@ use kernel::interfaces::repository::{ use kernel::interfaces::storage::{ DependOnProfileMediaCopyGateway, ProfileMediaCopyGateway, ProfileMediaCopyRequest, }; -use kernel::prelude::entity::{AuthAccountId, Nanoid, Profile, ProfileTransferRequest}; +use kernel::prelude::entity::{AuthAccountId, Nanoid, OrgRole, Profile, ProfileTransferRequest}; use kernel::KernelError; use std::future::Future; +#[cfg(test)] +mod tests; + pub trait AcceptProfileTransferRequestUseCase: 'static + Sync + Send + Clone { fn accept_profile_transfer_request<'a>( &'a self, @@ -70,10 +73,13 @@ where let from_account_id = projection.from_account_id().clone(); let to_org_account_id = projection.to_org_account_id().clone(); - let actor_account = - resolve_personal_actor_account_among(self, auth_id, &to_org_account_id).await?; - require_active_owner_or_admin_membership(self, &to_org_account_id, actor_account.id()) - .await?; + resolve_actor_with_active_membership( + self, + auth_id, + &to_org_account_id, + &[OrgRole::Owner, OrgRole::Admin], + ) + .await?; let deps = self.clone(); let copy_from_account_id = from_account_id.clone(); @@ -213,10 +219,13 @@ where let request_id = projection.id().clone(); let to_org_account_id = projection.to_org_account_id().clone(); - let actor_account = - resolve_personal_actor_account_among(self, auth_id, &to_org_account_id).await?; - require_active_owner_or_admin_membership(self, &to_org_account_id, actor_account.id()) - .await?; + resolve_actor_with_active_membership( + self, + auth_id, + &to_org_account_id, + &[OrgRole::Owner, OrgRole::Admin], + ) + .await?; let deps = self.clone(); self.transaction_manager() diff --git a/application/src/service/profile_transfer/respond/tests.rs b/application/src/service/profile_transfer/respond/tests.rs new file mode 100644 index 0000000..91e6e9b --- /dev/null +++ b/application/src/service/profile_transfer/respond/tests.rs @@ -0,0 +1,371 @@ +use super::*; +use crate::service::profile_transfer::test_support::{ + active_membership, fixture, profile_with_media, Fixture, +}; +use kernel::prelude::entity::{ + AccountId, AccountKind, EventVersion, ImageId, Nanoid, OrgRole, ProfileEvent, + ProfileTransferRequest, ProfileTransferRequestEvent, ProfileTransferStatus, +}; +use kernel::test_utils::AccountBuilder; +use kernel::KernelError; +use std::sync::{Arc, Mutex}; + +fn assert_accept_event(f: &Fixture) { + let saved = f + .module + .transfer_request_repository + .saved_events + .lock() + .unwrap(); + assert_eq!(saved.len(), 1); + assert!(matches!(saved[0], ProfileTransferRequestEvent::Accepted)); +} + +fn assert_transfer_event(f: &Fixture) -> (AccountId, AccountId) { + let saved = f.module.profile_repository.saved_events.lock().unwrap(); + assert_eq!(saved.len(), 1); + match &saved[0] { + ProfileEvent::AccountTransferred { + from_account_id, + to_account_id, + } => (from_account_id.clone(), to_account_id.clone()), + _ => panic!("Expected AccountTransferred event"), + } +} + +fn add_org_actor(f: &mut Fixture, account_id: AccountId, role: OrgRole) { + let account = AccountBuilder::new() + .id(account_id.clone()) + .nanoid(Nanoid::new("actor")) + .kind(AccountKind::Personal) + .build(); + f.module.accounts.owned_accounts.push(account); + f.module.memberships.lock().unwrap().push(active_membership( + f.org_account.id().clone(), + account_id, + role, + )); +} + +#[tokio::test] +async fn accept_succeeds_as_owner_with_actor_different_from_org_account() { + let mut f = fixture(); + let actor_id = AccountId::new(777); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Owner); + + f.module + .accept_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap(); + + assert_accept_event(&f); + let (from, to) = assert_transfer_event(&f); + assert_eq!(from, *f.owner_account.id()); + assert_eq!(to, *f.org_account.id()); + assert_ne!( + actor_id, + *f.org_account.id(), + "regression lock: actor != org id" + ); + + let updated = f.module.profiles.updated.lock().unwrap(); + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].account_id(), f.org_account.id()); +} + +#[tokio::test] +async fn accept_succeeds_as_admin_with_actor_different_from_org_account() { + let mut f = fixture(); + let actor_id = AccountId::new(778); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Admin); + + f.module + .accept_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap(); + + assert_accept_event(&f); + assert_transfer_event(&f); + assert_ne!( + actor_id, + *f.org_account.id(), + "regression lock: actor != org id" + ); +} + +#[tokio::test] +async fn accept_copies_media_when_icon_and_banner_exist() { + let mut f = fixture(); + let actor_id = AccountId::new(779); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Owner); + + let icon_id = ImageId::new(10); + let banner_id = ImageId::new(11); + let profile = profile_with_media( + f.profile.id().clone(), + f.owner_account.id().clone(), + f.profile.nanoid().clone(), + icon_id.clone(), + banner_id.clone(), + ); + f.module.profile_repository.profile = Some(profile.clone()); + f.module.profiles.profiles = Arc::new(Mutex::new(vec![profile.into()])); + + f.module + .accept_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap(); + + let recorded = f.module.media_copy_gateway.recorded.lock().unwrap(); + assert_eq!(recorded.len(), 1); + assert_eq!(recorded[0].from_account_id, *f.owner_account.id()); + assert_eq!(recorded[0].to_account_id, *f.org_account.id()); + assert!(recorded[0].image_ids.contains(&icon_id)); + assert!(recorded[0].image_ids.contains(&banner_id)); +} + +#[tokio::test] +async fn accept_skips_media_copy_when_no_images() { + let mut f = fixture(); + let actor_id = AccountId::new(780); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Owner); + + f.module + .accept_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap(); + + let recorded = f.module.media_copy_gateway.recorded.lock().unwrap(); + assert!(recorded.is_empty()); +} + +#[tokio::test] +async fn accept_returns_rejected_when_request_already_decided() { + let mut f = fixture(); + let actor_id = AccountId::new(781); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Owner); + + let decided = { + let request = f + .module + .transfer_request_repository + .request + .as_ref() + .unwrap(); + ProfileTransferRequest::new( + request.id().clone(), + f.profile.id().clone(), + f.owner_account.id().clone(), + f.org_account.id().clone(), + ProfileTransferStatus::Accepted, + EventVersion::default(), + request.nanoid().clone(), + ) + }; + f.module.transfer_request_repository.request = Some(decided); + + let err = f + .module + .accept_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::Rejected); +} + +#[tokio::test] +async fn accept_returns_permission_denied_for_member_role() { + let mut f = fixture(); + let actor_id = AccountId::new(782); + f.module.accounts.owned_accounts.clear(); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Member); + + let err = f + .module + .accept_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::PermissionDenied); +} + +#[tokio::test] +async fn accept_returns_permission_denied_for_non_member() { + let mut f = fixture(); + f.module.accounts.owned_accounts.clear(); + f.module.accounts.owned_accounts.push( + AccountBuilder::new() + .id(AccountId::new(783)) + .nanoid(Nanoid::new("stranger")) + .kind(AccountKind::Personal) + .build(), + ); + + let err = f + .module + .accept_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::PermissionDenied); +} + +#[tokio::test] +async fn accept_returns_not_found_for_unknown_request() { + let mut f = fixture(); + let actor_id = AccountId::new(784); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Owner); + + let err = f + .module + .accept_profile_transfer_request(&f.auth, "unknown".to_string()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::NotFound); +} + +fn assert_reject_event(f: &Fixture) { + let saved = f + .module + .transfer_request_repository + .saved_events + .lock() + .unwrap(); + assert_eq!(saved.len(), 1); + assert!(matches!(saved[0], ProfileTransferRequestEvent::Rejected)); +} + +#[tokio::test] +async fn reject_succeeds_as_owner_with_actor_different_from_org_account() { + let mut f = fixture(); + let actor_id = AccountId::new(785); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Owner); + + f.module + .reject_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap(); + + assert_reject_event(&f); + assert!(f + .module + .profile_repository + .saved_events + .lock() + .unwrap() + .is_empty()); + assert_ne!( + actor_id, + *f.org_account.id(), + "regression lock: actor != org id" + ); +} + +#[tokio::test] +async fn reject_succeeds_as_admin_with_actor_different_from_org_account() { + let mut f = fixture(); + let actor_id = AccountId::new(786); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Admin); + + f.module + .reject_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap(); + + assert_reject_event(&f); +} + +#[tokio::test] +async fn reject_returns_permission_denied_for_member_role() { + let mut f = fixture(); + let actor_id = AccountId::new(787); + f.module.accounts.owned_accounts.clear(); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Member); + + let err = f + .module + .reject_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::PermissionDenied); +} + +#[tokio::test] +async fn reject_returns_permission_denied_for_non_member() { + let mut f = fixture(); + f.module.accounts.owned_accounts.clear(); + f.module.accounts.owned_accounts.push( + AccountBuilder::new() + .id(AccountId::new(788)) + .nanoid(Nanoid::new("stranger")) + .kind(AccountKind::Personal) + .build(), + ); + + let err = f + .module + .reject_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::PermissionDenied); +} + +#[tokio::test] +async fn reject_returns_rejected_when_request_already_decided() { + let mut f = fixture(); + let actor_id = AccountId::new(789); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Owner); + + let decided = { + let request = f + .module + .transfer_request_repository + .request + .as_ref() + .unwrap(); + ProfileTransferRequest::new( + request.id().clone(), + f.profile.id().clone(), + f.owner_account.id().clone(), + f.org_account.id().clone(), + ProfileTransferStatus::Accepted, + EventVersion::default(), + request.nanoid().clone(), + ) + }; + f.module.transfer_request_repository.request = Some(decided); + + let err = f + .module + .reject_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::Rejected); +} + +#[tokio::test] +async fn reject_returns_not_found_for_unknown_request() { + let mut f = fixture(); + let actor_id = AccountId::new(790); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Owner); + + let err = f + .module + .reject_profile_transfer_request(&f.auth, "unknown".to_string()) + .await + .unwrap_err(); + assert_eq!(err.current_context(), &KernelError::NotFound); +} + +#[tokio::test] +async fn reject_updates_read_model() { + let mut f = fixture(); + let actor_id = AccountId::new(791); + add_org_actor(&mut f, actor_id.clone(), OrgRole::Owner); + + f.module + .reject_profile_transfer_request(&f.auth, f.request_nanoid.clone()) + .await + .unwrap(); + + let updated = f.module.transfer_requests.updated.lock().unwrap(); + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].status(), &ProfileTransferStatus::Rejected); +} diff --git a/application/src/service/profile_transfer/test_support/account.rs b/application/src/service/profile_transfer/test_support/account.rs new file mode 100644 index 0000000..2904312 --- /dev/null +++ b/application/src/service/profile_transfer/test_support/account.rs @@ -0,0 +1,299 @@ +use super::database::MockConnection; +use kernel::interfaces::read_model::{AccountQuery, AccountReadModel, AccountWarning}; +use kernel::prelude::entity::{Account, AccountId, AccountName, AuthAccountId, Nanoid}; +use kernel::KernelError; +use time::OffsetDateTime; + +#[derive(Clone)] +pub struct MockAccountQuery { + pub owned_accounts: Vec, + pub target: Option, +} + +impl AccountQuery for MockAccountQuery { + type Connection = MockConnection; + + async fn find_by_id( + &self, + _executor: &mut Self::Connection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(self + .target + .as_ref() + .filter(|account| account.id() == id) + .cloned()) + } + + async fn find_by_auth_id( + &self, + _executor: &mut Self::Connection, + _auth_id: &AuthAccountId, + ) -> error_stack::Result, KernelError> { + Ok(self.owned_accounts.clone()) + } + + async fn find_auth_account_id_by_account_id( + &self, + _executor: &mut Self::Connection, + _account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(None) + } + + async fn find_by_name( + &self, + _executor: &mut Self::Connection, + name: &AccountName, + ) -> error_stack::Result, KernelError> { + Ok(self + .target + .as_ref() + .filter(|account| account.name() == name) + .cloned()) + } + + async fn find_by_nanoid( + &self, + _executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + Ok(self + .target + .as_ref() + .filter(|account| account.nanoid() == nanoid) + .cloned()) + } + + async fn find_by_nanoids( + &self, + _executor: &mut Self::Connection, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + Ok(self + .target + .as_ref() + .filter(|account| nanoids.contains(account.nanoid())) + .cloned() + .into_iter() + .collect()) + } + + async fn find_by_id_unfiltered( + &self, + executor: &mut Self::Connection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_id(self, executor, id).await + } + + async fn find_by_nanoid_unfiltered( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoid(self, executor, nanoid).await + } + + async fn find_by_nanoids_unfiltered( + &self, + executor: &mut Self::Connection, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoids(self, executor, nanoids).await + } + + async fn find_by_nanoid_including_deleted( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoid(self, executor, nanoid).await + } + + async fn is_linked_including_deleted( + &self, + _executor: &mut Self::Connection, + _auth_id: &AuthAccountId, + _account_id: &AccountId, + ) -> error_stack::Result { + Ok(false) + } +} + +impl AccountReadModel for MockAccountQuery { + type Connection = MockConnection; + + async fn find_by_id( + &self, + executor: &mut Self::Connection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_id(self, executor, id).await + } + + async fn find_by_auth_id( + &self, + executor: &mut Self::Connection, + auth_id: &AuthAccountId, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_auth_id(self, executor, auth_id).await + } + + async fn find_auth_account_id_by_account_id( + &self, + executor: &mut Self::Connection, + account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_auth_account_id_by_account_id(self, executor, account_id).await + } + + async fn find_by_name( + &self, + executor: &mut Self::Connection, + name: &AccountName, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_name(self, executor, name).await + } + + async fn find_by_nanoid( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoid(self, executor, nanoid).await + } + + async fn find_by_nanoids( + &self, + executor: &mut Self::Connection, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoids(self, executor, nanoids).await + } + + async fn find_by_id_unfiltered( + &self, + executor: &mut Self::Connection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_id(self, executor, id).await + } + + async fn find_by_nanoid_unfiltered( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoid(self, executor, nanoid).await + } + + async fn find_by_nanoids_unfiltered( + &self, + executor: &mut Self::Connection, + nanoids: &[Nanoid], + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoids(self, executor, nanoids).await + } + + async fn find_by_nanoid_including_deleted( + &self, + executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_nanoid(self, executor, nanoid).await + } + + async fn is_linked_including_deleted( + &self, + _executor: &mut Self::Connection, + _auth_id: &AuthAccountId, + _account_id: &AccountId, + ) -> error_stack::Result { + Ok(false) + } + + async fn find_warnings( + &self, + _executor: &mut Self::Connection, + _account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(Vec::new()) + } + + async fn create( + &self, + _executor: &mut Self::Connection, + _account: &Account, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + + async fn update( + &self, + _executor: &mut Self::Connection, + _account: &Account, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + + async fn deactivate( + &self, + _executor: &mut Self::Connection, + _account_id: &AccountId, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + + async fn unlink_all_auth_accounts( + &self, + _executor: &mut Self::Connection, + _account_id: &AccountId, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + + async fn link_auth_account( + &self, + _executor: &mut Self::Connection, + _account_id: &AccountId, + _auth_id: &AuthAccountId, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + + async fn find_by_id_including_deleted( + &self, + executor: &mut Self::Connection, + id: &AccountId, + ) -> error_stack::Result, KernelError> { + AccountQuery::find_by_id_unfiltered(self, executor, id).await + } + + async fn suspend( + &self, + _executor: &mut Self::Connection, + _account_id: &AccountId, + _reason: &str, + _expires_at: Option, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + + async fn unsuspend( + &self, + _executor: &mut Self::Connection, + _account_id: &AccountId, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + + async fn ban( + &self, + _executor: &mut Self::Connection, + _account_id: &AccountId, + _reason: &str, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } +} diff --git a/application/src/service/profile_transfer/test_support/database.rs b/application/src/service/profile_transfer/test_support/database.rs new file mode 100644 index 0000000..7b28c87 --- /dev/null +++ b/application/src/service/profile_transfer/test_support/database.rs @@ -0,0 +1,41 @@ +use kernel::interfaces::database::{Connection, DatabaseConnection, TransactionManager}; +use kernel::KernelError; +use std::future::Future; +use std::pin::Pin; + +#[derive(Clone)] +pub struct MockConnection; + +impl Connection for MockConnection {} + +#[derive(Clone)] +pub struct MockDatabaseConnection; + +impl DatabaseConnection for MockDatabaseConnection { + type Connection = MockConnection; + + async fn connection(&self) -> error_stack::Result { + Ok(MockConnection) + } +} + +impl TransactionManager for MockDatabaseConnection { + fn transaction<'a, F, T>( + &'a self, + operation: F, + ) -> Pin> + Send + 'a>> + where + F: for<'connection> FnOnce( + &'connection mut Self::Connection, + ) -> Pin< + Box> + Send + 'connection>, + > + Send + + 'a, + T: Send + 'a, + { + Box::pin(async move { + let mut connection = self.connection().await?; + operation(&mut connection).await + }) + } +} diff --git a/application/src/service/profile_transfer/test_support/mod.rs b/application/src/service/profile_transfer/test_support/mod.rs new file mode 100644 index 0000000..4f4f272 --- /dev/null +++ b/application/src/service/profile_transfer/test_support/mod.rs @@ -0,0 +1,14 @@ +mod account; +mod database; +mod module; +mod profile; +mod request; + +pub(super) use account::MockAccountQuery; +pub(super) use database::{MockConnection, MockDatabaseConnection}; +pub(super) use module::{fixture, Fixture}; +pub(super) use profile::{profile_with_media, MockProfileReadModel, MockProfileRepository}; +pub(super) use request::{ + active_membership, MockProfileMediaCopyGateway, MockProfileTransferRequestReadModel, + MockProfileTransferRequestRepository, +}; diff --git a/application/src/service/profile_transfer/test_support/module.rs b/application/src/service/profile_transfer/test_support/module.rs new file mode 100644 index 0000000..8c10aa2 --- /dev/null +++ b/application/src/service/profile_transfer/test_support/module.rs @@ -0,0 +1,359 @@ +use super::account::MockAccountQuery; +use super::database::MockDatabaseConnection; +use super::profile::{MockProfileReadModel, MockProfileRepository}; +use super::request::{ + active_membership, MockProfileMediaCopyGateway, MockProfileTransferRequestReadModel, + MockProfileTransferRequestRepository, +}; +use kernel::interfaces::database::{DependOnDatabaseConnection, DependOnTransactionManager}; +use kernel::interfaces::read_model::ProfileTransferRequestProjection; +use kernel::interfaces::read_model::{ + DependOnAccountReadModel, DependOnProfileReadModel, DependOnProfileTransferRequestReadModel, +}; +use kernel::interfaces::repository::{ + DependOnOrganizationMembershipRepository, DependOnProfileRepository, + DependOnProfileTransferRequestRepository, OrganizationMembershipRepository, +}; +use kernel::interfaces::storage::DependOnProfileMediaCopyGateway; +use kernel::prelude::entity::{ + Account, AccountId, AccountKind, AuthAccountId, EventVersion, Nanoid, OrgRole, + OrganizationMembership, OrganizationMembershipStatus, Profile, ProfileId, + ProfileTransferRequest, ProfileTransferRequestId, ProfileTransferStatus, +}; +use kernel::test_utils::{AccountBuilder, ProfileBuilder}; +use std::sync::{Arc, Mutex}; + +use super::database::MockConnection; +use error_stack::Report; +use kernel::KernelError; + +#[derive(Clone)] +pub struct MockModule { + pub database: MockDatabaseConnection, + pub accounts: MockAccountQuery, + pub profiles: MockProfileReadModel, + pub profile_repository: MockProfileRepository, + pub transfer_requests: MockProfileTransferRequestReadModel, + pub transfer_request_repository: MockProfileTransferRequestRepository, + pub memberships: Arc>>, + pub media_copy_gateway: MockProfileMediaCopyGateway, +} + +impl DependOnDatabaseConnection for MockModule { + type DatabaseConnection = MockDatabaseConnection; + + fn database_connection(&self) -> &Self::DatabaseConnection { + &self.database + } +} + +impl DependOnTransactionManager for MockModule { + type TransactionManager = MockDatabaseConnection; + + fn transaction_manager(&self) -> &Self::TransactionManager { + &self.database + } +} + +impl DependOnAccountReadModel for MockModule { + type AccountReadModel = MockAccountQuery; + + fn account_read_model(&self) -> &Self::AccountReadModel { + &self.accounts + } +} + +impl DependOnProfileReadModel for MockModule { + type ProfileReadModel = MockProfileReadModel; + + fn profile_read_model(&self) -> &Self::ProfileReadModel { + &self.profiles + } +} + +impl DependOnProfileRepository for MockModule { + type ProfileRepository = MockProfileRepository; + + fn profile_repository(&self) -> &Self::ProfileRepository { + &self.profile_repository + } +} + +impl DependOnProfileTransferRequestReadModel for MockModule { + type ProfileTransferRequestReadModel = MockProfileTransferRequestReadModel; + + fn profile_transfer_request_read_model(&self) -> &Self::ProfileTransferRequestReadModel { + &self.transfer_requests + } +} + +impl DependOnProfileTransferRequestRepository for MockModule { + type ProfileTransferRequestRepository = MockProfileTransferRequestRepository; + + fn profile_transfer_request_repository(&self) -> &Self::ProfileTransferRequestRepository { + &self.transfer_request_repository + } +} + +impl OrganizationMembershipRepository for MockModule { + type Connection = MockConnection; + + async fn create( + &self, + _executor: &mut Self::Connection, + membership: &OrganizationMembership, + ) -> error_stack::Result<(), KernelError> { + self.memberships.lock().unwrap().push(membership.clone()); + Ok(()) + } + + async fn find( + &self, + _executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(self + .memberships + .lock() + .unwrap() + .iter() + .find(|m| { + m.org_account_id() == org_account_id && m.member_account_id() == member_account_id + }) + .cloned()) + } + + async fn find_by_org( + &self, + _executor: &mut Self::Connection, + org_account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(self + .memberships + .lock() + .unwrap() + .iter() + .filter(|m| m.org_account_id() == org_account_id) + .cloned() + .collect()) + } + + async fn find_by_member( + &self, + _executor: &mut Self::Connection, + member_account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(self + .memberships + .lock() + .unwrap() + .iter() + .filter(|m| m.member_account_id() == member_account_id) + .cloned() + .collect()) + } + + async fn update_role( + &self, + _executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + role: OrgRole, + ) -> error_stack::Result<(), KernelError> { + let mut values = self.memberships.lock().unwrap(); + let value = values + .iter_mut() + .find(|m| { + m.org_account_id() == org_account_id && m.member_account_id() == member_account_id + }) + .ok_or_else(|| Report::new(KernelError::NotFound))?; + *value = OrganizationMembership::new( + org_account_id.clone(), + member_account_id.clone(), + role, + *value.status(), + value.invited_by().clone(), + value.created_at().clone(), + ); + Ok(()) + } + + async fn update_status( + &self, + _executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + status: OrganizationMembershipStatus, + ) -> error_stack::Result<(), KernelError> { + let mut values = self.memberships.lock().unwrap(); + let value = values + .iter_mut() + .find(|m| { + m.org_account_id() == org_account_id && m.member_account_id() == member_account_id + }) + .ok_or_else(|| Report::new(KernelError::NotFound))?; + *value = OrganizationMembership::new( + org_account_id.clone(), + member_account_id.clone(), + *value.role(), + status, + value.invited_by().clone(), + value.created_at().clone(), + ); + Ok(()) + } + + async fn delete( + &self, + _executor: &mut Self::Connection, + org_account_id: &AccountId, + member_account_id: &AccountId, + ) -> error_stack::Result<(), KernelError> { + let mut values = self.memberships.lock().unwrap(); + let len = values.len(); + values.retain(|m| { + m.org_account_id() != org_account_id || m.member_account_id() != member_account_id + }); + if values.len() == len { + Err(Report::new(KernelError::NotFound)) + } else { + Ok(()) + } + } + + async fn count_active_owners( + &self, + _executor: &mut Self::Connection, + org_account_id: &AccountId, + ) -> error_stack::Result { + Ok(self + .memberships + .lock() + .unwrap() + .iter() + .filter(|m| { + m.org_account_id() == org_account_id + && m.role() == &OrgRole::Owner + && m.status() == &OrganizationMembershipStatus::Active + }) + .count() as i64) + } + + async fn lock_active_owner_rows( + &self, + _executor: &mut Self::Connection, + _org_account_id: &AccountId, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } +} + +impl DependOnOrganizationMembershipRepository for MockModule { + type OrganizationMembershipRepository = Self; + + fn organization_membership_repository(&self) -> &Self::OrganizationMembershipRepository { + self + } +} + +impl DependOnProfileMediaCopyGateway for MockModule { + type ProfileMediaCopyGateway = MockProfileMediaCopyGateway; + + fn profile_media_copy_gateway(&self) -> &Self::ProfileMediaCopyGateway { + &self.media_copy_gateway + } +} + +pub struct Fixture { + pub module: MockModule, + pub auth: AuthAccountId, + pub owner_account: Account, + pub org_account: Account, + pub profile: Profile, + pub profile_nanoid: String, + pub request_nanoid: String, +} + +pub fn fixture() -> Fixture { + kernel::ensure_generator_initialized(); + let auth = AuthAccountId::default(); + + let owner_account = AccountBuilder::new() + .id(AccountId::new(100)) + .nanoid(Nanoid::new("owner")) + .kind(AccountKind::Personal) + .build(); + let org_account = AccountBuilder::new() + .id(AccountId::new(200)) + .nanoid(Nanoid::new("org")) + .kind(AccountKind::Organization) + .build(); + + let profile_id = ProfileId::new(1); + let profile_nanoid = Nanoid::::new("profile-1"); + let profile = ProfileBuilder::new() + .id(profile_id.clone()) + .account_id(owner_account.id().clone()) + .nanoid(profile_nanoid.clone()) + .build(); + + let request_id = ProfileTransferRequestId::new(10); + let request_nanoid = Nanoid::::new("request-1"); + let request = ProfileTransferRequest::new( + request_id.clone(), + profile_id.clone(), + owner_account.id().clone(), + org_account.id().clone(), + ProfileTransferStatus::Pending, + EventVersion::default(), + request_nanoid.clone(), + ); + + let request_projection: ProfileTransferRequestProjection = request.clone().into(); + + let memberships = vec![active_membership( + org_account.id().clone(), + owner_account.id().clone(), + OrgRole::Owner, + )]; + + let module = MockModule { + database: MockDatabaseConnection, + accounts: MockAccountQuery { + owned_accounts: vec![owner_account.clone()], + target: Some(org_account.clone()), + }, + profiles: MockProfileReadModel { + profiles: Arc::new(Mutex::new(vec![profile.clone().into()])), + updated: Arc::new(Mutex::new(Vec::new())), + }, + profile_repository: MockProfileRepository { + profile: Some(profile.clone()), + saved_events: Arc::new(Mutex::new(Vec::new())), + }, + transfer_requests: MockProfileTransferRequestReadModel { + requests: Arc::new(Mutex::new(vec![request_projection])), + created: Arc::new(Mutex::new(Vec::new())), + updated: Arc::new(Mutex::new(Vec::new())), + }, + transfer_request_repository: MockProfileTransferRequestRepository { + request: Some(request), + saved_events: Arc::new(Mutex::new(Vec::new())), + }, + memberships: Arc::new(Mutex::new(memberships)), + media_copy_gateway: MockProfileMediaCopyGateway { + recorded: Arc::new(Mutex::new(Vec::new())), + }, + }; + + Fixture { + module, + auth, + owner_account, + org_account, + profile, + profile_nanoid: profile_nanoid.as_ref().to_string(), + request_nanoid: request_nanoid.as_ref().to_string(), + } +} diff --git a/application/src/service/profile_transfer/test_support/profile.rs b/application/src/service/profile_transfer/test_support/profile.rs new file mode 100644 index 0000000..393697e --- /dev/null +++ b/application/src/service/profile_transfer/test_support/profile.rs @@ -0,0 +1,184 @@ +use super::database::MockConnection; +use error_stack::Report; +use kernel::interfaces::event::EventApplier; +use kernel::interfaces::read_model::{ProfileProjection, ProfileReadModel}; +use kernel::interfaces::repository::{AggregateRepository, Rehydrated}; +use kernel::prelude::entity::{ + AccountId, CommandEnvelope, EventEnvelope, EventVersion, ImageId, Nanoid, Profile, + ProfileEvent, ProfileId, +}; +use kernel::KernelError; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +pub struct MockProfileRepository { + pub profile: Option, + pub saved_events: Arc>>, +} + +impl AggregateRepository for MockProfileRepository { + type Connection = MockConnection; + type Id = ProfileId; + + async fn load( + &self, + _executor: &mut Self::Connection, + id: &Self::Id, + ) -> error_stack::Result, KernelError> { + self.profile + .as_ref() + .filter(|profile| profile.id() == id) + .map(|profile| Rehydrated::new(profile.clone(), profile.version().clone())) + .ok_or_else(|| Report::new(KernelError::NotFound)) + } + + async fn save( + &self, + _executor: &mut Self::Connection, + command: CommandEnvelope, + ) -> error_stack::Result, KernelError> { + self.saved_events + .lock() + .unwrap() + .push(command.event().clone()); + Ok(EventEnvelope::new( + command.id().clone(), + command.event().clone(), + EventVersion::new(command.id().as_ref() + 1), + )) + } +} + +#[derive(Clone)] +pub struct MockProfileReadModel { + pub profiles: Arc>>, + pub updated: Arc>>, +} + +impl ProfileReadModel for MockProfileReadModel { + type Connection = MockConnection; + + async fn find_by_id( + &self, + _executor: &mut Self::Connection, + id: &ProfileId, + ) -> error_stack::Result, KernelError> { + Ok(self + .profiles + .lock() + .unwrap() + .iter() + .find(|profile| profile.id() == id) + .cloned()) + } + + async fn find_by_id_unfiltered( + &self, + executor: &mut Self::Connection, + id: &ProfileId, + ) -> error_stack::Result, KernelError> { + self.find_by_id(executor, id).await + } + + async fn find_by_account_id( + &self, + _executor: &mut Self::Connection, + account_id: &AccountId, + ) -> error_stack::Result, KernelError> { + Ok(self + .profiles + .lock() + .unwrap() + .iter() + .find(|profile| profile.account_id() == account_id) + .cloned()) + } + + async fn find_by_account_ids( + &self, + _executor: &mut Self::Connection, + account_ids: &[AccountId], + ) -> error_stack::Result, KernelError> { + Ok(self + .profiles + .lock() + .unwrap() + .iter() + .filter(|profile| account_ids.contains(profile.account_id())) + .cloned() + .collect()) + } + + async fn find_by_nanoid( + &self, + _executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + Ok(self + .profiles + .lock() + .unwrap() + .iter() + .find(|profile| profile.nanoid() == nanoid) + .cloned()) + } + + async fn create( + &self, + _executor: &mut Self::Connection, + _profile: &Profile, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } + + async fn update( + &self, + _executor: &mut Self::Connection, + profile: &Profile, + ) -> error_stack::Result<(), KernelError> { + let projection: ProfileProjection = profile.clone().into(); + let mut profiles = self.profiles.lock().unwrap(); + if let Some(existing) = profiles.iter_mut().find(|item| item.id() == profile.id()) { + *existing = projection.clone(); + } + self.updated.lock().unwrap().push(projection); + Ok(()) + } + + async fn delete( + &self, + _executor: &mut Self::Connection, + _profile_id: &ProfileId, + ) -> error_stack::Result<(), KernelError> { + Ok(()) + } +} + +pub fn profile_with_media( + profile_id: ProfileId, + account_id: AccountId, + nanoid: Nanoid, + icon: ImageId, + banner: ImageId, +) -> Profile { + let command = Profile::create( + profile_id, + account_id, + None, + None, + Some(icon), + Some(banner), + nanoid, + ); + let mut profile = None; + Profile::apply( + &mut profile, + EventEnvelope::new( + command.id().clone(), + command.event().clone(), + EventVersion::default(), + ), + ) + .unwrap(); + profile.unwrap() +} diff --git a/application/src/service/profile_transfer/test_support/request.rs b/application/src/service/profile_transfer/test_support/request.rs new file mode 100644 index 0000000..3068cbd --- /dev/null +++ b/application/src/service/profile_transfer/test_support/request.rs @@ -0,0 +1,177 @@ +use super::database::MockConnection; +use error_stack::Report; +use kernel::interfaces::read_model::{ + ProfileTransferRequestProjection, ProfileTransferRequestReadModel, +}; +use kernel::interfaces::repository::{AggregateRepository, Rehydrated}; +use kernel::interfaces::storage::{ProfileMediaCopyGateway, ProfileMediaCopyRequest}; +use kernel::prelude::entity::{ + AccountId, CommandEnvelope, EventEnvelope, EventVersion, Nanoid, ProfileId, + ProfileTransferRequest, ProfileTransferRequestEvent, ProfileTransferRequestId, + ProfileTransferStatus, +}; +use kernel::KernelError; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +pub struct MockProfileTransferRequestRepository { + pub request: Option, + pub saved_events: Arc>>, +} + +impl AggregateRepository for MockProfileTransferRequestRepository { + type Connection = MockConnection; + type Id = ProfileTransferRequestId; + + async fn load( + &self, + _executor: &mut Self::Connection, + id: &Self::Id, + ) -> error_stack::Result, KernelError> { + self.request + .as_ref() + .filter(|request| request.id() == id) + .map(|request| Rehydrated::new(request.clone(), request.version().clone())) + .ok_or_else(|| Report::new(KernelError::NotFound)) + } + + async fn save( + &self, + _executor: &mut Self::Connection, + command: CommandEnvelope, + ) -> error_stack::Result< + EventEnvelope, + KernelError, + > { + self.saved_events + .lock() + .unwrap() + .push(command.event().clone()); + Ok(EventEnvelope::new( + command.id().clone(), + command.event().clone(), + EventVersion::new(command.id().as_ref() + 1), + )) + } +} + +#[derive(Clone)] +pub struct MockProfileTransferRequestReadModel { + pub requests: Arc>>, + pub created: Arc>>, + pub updated: Arc>>, +} + +impl ProfileTransferRequestReadModel for MockProfileTransferRequestReadModel { + type Connection = MockConnection; + + async fn find_by_id( + &self, + _executor: &mut Self::Connection, + id: &ProfileTransferRequestId, + ) -> error_stack::Result, KernelError> { + Ok(self + .requests + .lock() + .unwrap() + .iter() + .find(|request| request.id() == id) + .cloned()) + } + + async fn find_by_id_unfiltered( + &self, + executor: &mut Self::Connection, + id: &ProfileTransferRequestId, + ) -> error_stack::Result, KernelError> { + self.find_by_id(executor, id).await + } + + async fn find_by_nanoid( + &self, + _executor: &mut Self::Connection, + nanoid: &Nanoid, + ) -> error_stack::Result, KernelError> { + Ok(self + .requests + .lock() + .unwrap() + .iter() + .find(|request| request.nanoid() == nanoid) + .cloned()) + } + + async fn find_pending_by_profile_id( + &self, + _executor: &mut Self::Connection, + profile_id: &ProfileId, + ) -> error_stack::Result, KernelError> { + Ok(self + .requests + .lock() + .unwrap() + .iter() + .find(|request| { + request.profile_id() == profile_id + && request.status() == &ProfileTransferStatus::Pending + }) + .cloned()) + } + + async fn create( + &self, + _executor: &mut Self::Connection, + request: &ProfileTransferRequest, + ) -> error_stack::Result<(), KernelError> { + let projection = request.clone().into(); + self.created.lock().unwrap().push(projection); + Ok(()) + } + + async fn update( + &self, + _executor: &mut Self::Connection, + request: &ProfileTransferRequest, + ) -> error_stack::Result<(), KernelError> { + let projection: ProfileTransferRequestProjection = request.clone().into(); + let mut requests = self.requests.lock().unwrap(); + if let Some(existing) = requests + .iter_mut() + .find(|item| item.id() == projection.id()) + { + *existing = projection.clone(); + } + self.updated.lock().unwrap().push(projection); + Ok(()) + } +} + +#[derive(Clone)] +pub struct MockProfileMediaCopyGateway { + pub recorded: Arc>>, +} + +impl ProfileMediaCopyGateway for MockProfileMediaCopyGateway { + async fn request_copy( + &self, + request: ProfileMediaCopyRequest, + ) -> error_stack::Result<(), KernelError> { + self.recorded.lock().unwrap().push(request); + Ok(()) + } +} + +pub fn active_membership( + org_id: AccountId, + member_id: AccountId, + role: kernel::prelude::entity::OrgRole, +) -> kernel::prelude::entity::OrganizationMembership { + kernel::prelude::entity::OrganizationMembership::new( + org_id, + member_id.clone(), + role, + kernel::prelude::entity::OrganizationMembershipStatus::Active, + member_id, + kernel::prelude::entity::CreatedAt::now(), + ) +} diff --git a/kernel/src/entity/profile_transfer_request.rs b/kernel/src/entity/profile_transfer_request.rs index 8caede7..ec657f3 100644 --- a/kernel/src/entity/profile_transfer_request.rs +++ b/kernel/src/entity/profile_transfer_request.rs @@ -195,6 +195,161 @@ impl EventApplier for ProfileTransferRequest { } } +#[cfg(test)] +mod tests { + use super::*; + + fn request_id() -> ProfileTransferRequestId { + ProfileTransferRequestId::new(1) + } + + fn profile_id() -> ProfileId { + ProfileId::new(10) + } + + fn from_account_id() -> AccountId { + AccountId::new(100) + } + + fn to_org_account_id() -> AccountId { + AccountId::new(200) + } + + fn nanoid() -> Nanoid { + Nanoid::new("ptr-test") + } + + fn apply_requested() -> ProfileTransferRequest { + let id = request_id(); + let command = ProfileTransferRequest::request( + id.clone(), + profile_id(), + from_account_id(), + to_org_account_id(), + nanoid(), + ); + let envelope = EventEnvelope::new( + command.id().clone(), + command.event().clone(), + EventVersion::default(), + ); + let mut request = None; + ProfileTransferRequest::apply(&mut request, envelope).unwrap(); + request.unwrap() + } + + #[test] + fn request_creates_pending_transfer() { + crate::ensure_generator_initialized(); + let request = apply_requested(); + assert_eq!(request.id(), &request_id()); + assert_eq!(request.profile_id(), &profile_id()); + assert_eq!(request.from_account_id(), &from_account_id()); + assert_eq!(request.to_org_account_id(), &to_org_account_id()); + assert_eq!(request.status(), &ProfileTransferStatus::Pending); + assert_eq!(request.nanoid(), &nanoid()); + } + + #[test] + fn accept_transitions_pending_to_accepted() { + crate::ensure_generator_initialized(); + let request = apply_requested(); + let command = + ProfileTransferRequest::accept(request.id().clone(), request.version().clone()); + let envelope = EventEnvelope::new( + command.id().clone(), + command.event().clone(), + EventVersion::new(request.id().as_ref() + 1), + ); + let mut request = Some(request); + ProfileTransferRequest::apply(&mut request, envelope).unwrap(); + let request = request.unwrap(); + assert_eq!(request.status(), &ProfileTransferStatus::Accepted); + } + + #[test] + fn reject_transitions_pending_to_rejected() { + crate::ensure_generator_initialized(); + let request = apply_requested(); + let command = + ProfileTransferRequest::reject(request.id().clone(), request.version().clone()); + let envelope = EventEnvelope::new( + command.id().clone(), + command.event().clone(), + EventVersion::new(request.id().as_ref() + 1), + ); + let mut request = Some(request); + ProfileTransferRequest::apply(&mut request, envelope).unwrap(); + let request = request.unwrap(); + assert_eq!(request.status(), &ProfileTransferStatus::Rejected); + } + + #[test] + fn cancel_transitions_pending_to_cancelled() { + crate::ensure_generator_initialized(); + let request = apply_requested(); + let command = + ProfileTransferRequest::cancel(request.id().clone(), request.version().clone()); + let envelope = EventEnvelope::new( + command.id().clone(), + command.event().clone(), + EventVersion::new(request.id().as_ref() + 1), + ); + let mut request = Some(request); + ProfileTransferRequest::apply(&mut request, envelope).unwrap(); + let request = request.unwrap(); + assert_eq!(request.status(), &ProfileTransferStatus::Cancelled); + } + + #[test] + fn accept_on_already_decided_request_fails() { + crate::ensure_generator_initialized(); + let request = apply_requested(); + let command = + ProfileTransferRequest::accept(request.id().clone(), request.version().clone()); + let envelope = EventEnvelope::new( + command.id().clone(), + command.event().clone(), + EventVersion::new(request.id().as_ref() + 1), + ); + let mut request = Some(request); + ProfileTransferRequest::apply(&mut request, envelope).unwrap(); + + let next_command = ProfileTransferRequest::reject( + request.as_ref().unwrap().id().clone(), + request.as_ref().unwrap().version().clone(), + ); + let next_envelope = EventEnvelope::new( + next_command.id().clone(), + next_command.event().clone(), + EventVersion::new(request.as_ref().unwrap().id().as_ref() + 2), + ); + let err = ProfileTransferRequest::apply(&mut request, next_envelope).unwrap_err(); + assert_eq!(err.current_context(), &KernelError::Rejected); + } + + #[test] + fn requested_event_on_existing_request_fails() { + crate::ensure_generator_initialized(); + let request = apply_requested(); + let command = ProfileTransferRequest::request( + request.id().clone(), + request.profile_id().clone(), + request.from_account_id().clone(), + request.to_org_account_id().clone(), + request.nanoid().clone(), + ); + let envelope = EventEnvelope::new( + command.id().clone(), + command.event().clone(), + EventVersion::new(request.id().as_ref() + 1), + ); + let mut request = Some(request); + let err = ProfileTransferRequest::apply(&mut request, envelope).unwrap_err(); + assert_eq!(err.current_context(), &KernelError::Internal); + } +} + fn status_name(status: &ProfileTransferStatus) -> &'static str { match status { ProfileTransferStatus::Pending => "pending", diff --git a/server/src/route/account/client_tests.rs b/server/src/route/account/client_tests.rs index 1c1ff0c..e0580ab 100644 --- a/server/src/route/account/client_tests.rs +++ b/server/src/route/account/client_tests.rs @@ -317,9 +317,11 @@ async fn create_profile_with_organization_context_uses_organization_account_id() assert_eq!(profile.account_id(), fixture.organization.id()); } +/// profile-transfer (issue #61): orgs may hold multiple profiles after the +/// partial-unique migration; the old 1:1 application guard was removed. #[test_with::env(DATABASE_URL)] #[tokio::test] -async fn create_second_profile_for_same_organization_returns_unprocessable_entity() { +async fn create_second_profile_for_same_organization_succeeds() { let keto = empty_keto().await; let fixture = fixture(&keto.uri(), Some(OrgRole::Member)).await; let organization_nanoid = fixture.organization.nanoid().as_ref().to_string(); @@ -340,10 +342,18 @@ async fn create_second_profile_for_same_organization_returns_unprocessable_entit let router = fixture.router; let first_response = router.clone().oneshot(first).await.unwrap(); assert_eq!(first_response.status(), StatusCode::CREATED); + let first_json = response_json(first_response).await; let second_response = router.oneshot(second).await.unwrap(); - assert_eq!(second_response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(second_response.status(), StatusCode::CREATED); + let second_json = response_json(second_response).await; + assert_ne!(first_json["id"].as_str(), second_json["id"].as_str()); + assert_eq!( + second_json["account_id"].as_str(), + Some(organization_nanoid.as_str()) + ); + assert_eq!(second_json["display_name"].as_str(), Some("Second")); } #[test_with::env(DATABASE_URL)]