feat: unified social identity layer — SocialIdentity, ports, use cases, adapter

SocialIdentity value object (Local|Remote), SocialCommand/SocialQuery
domain ports, 11 CQRS use cases w/ 14 tests, CompositeSocialAdapter
wrapping k_ap, 6 new domain events, handlers migrated from ap_service
to use cases. Closes #12 foundation — AP handlers + legacy cleanup TBD.
This commit is contained in:
2026-07-10 15:29:46 +02:00
parent 96ce5f7d26
commit 322e9ee81a
45 changed files with 2095 additions and 160 deletions

View File

@@ -0,0 +1,23 @@
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
use super::{commands::AcceptFollowCommand, deps::SocialCommandDeps};
pub async fn execute(
deps: &SocialCommandDeps,
cmd: AcceptFollowCommand,
) -> Result<(), DomainError> {
let owner = UserId::from_uuid(cmd.owner_id);
deps.social_command
.accept_follow(&owner, &cmd.requester)
.await?;
deps.event_publisher
.publish(&DomainEvent::FollowAccepted {
owner,
requester: cmd.requester,
})
.await
}
#[cfg(test)]
#[path = "tests/accept.rs"]
mod tests;

View File

@@ -0,0 +1,18 @@
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
use super::{commands::BlockCommand, deps::SocialCommandDeps};
pub async fn execute(deps: &SocialCommandDeps, cmd: BlockCommand) -> Result<(), DomainError> {
let blocker = UserId::from_uuid(cmd.blocker_id);
deps.social_command.block(&blocker, &cmd.target).await?;
deps.event_publisher
.publish(&DomainEvent::ActorBlocked {
blocker,
target: cmd.target,
})
.await
}
#[cfg(test)]
#[path = "tests/block.rs"]
mod tests;

View File

@@ -0,0 +1,37 @@
use domain::value_objects::SocialIdentity;
use uuid::Uuid;
pub struct FollowCommand {
pub follower_id: Uuid,
pub target: SocialIdentity,
}
pub struct UnfollowCommand {
pub follower_id: Uuid,
pub target: SocialIdentity,
}
pub struct AcceptFollowCommand {
pub owner_id: Uuid,
pub requester: SocialIdentity,
}
pub struct RejectFollowCommand {
pub owner_id: Uuid,
pub requester: SocialIdentity,
}
pub struct RemoveFollowerCommand {
pub owner_id: Uuid,
pub follower: SocialIdentity,
}
pub struct BlockCommand {
pub blocker_id: Uuid,
pub target: SocialIdentity,
}
pub struct UnblockCommand {
pub blocker_id: Uuid,
pub target: SocialIdentity,
}

View File

@@ -0,0 +1,13 @@
use std::sync::Arc;
use domain::ports::{EventPublisher, SocialCommand, SocialQuery};
pub struct SocialCommandDeps {
pub social_command: Arc<dyn SocialCommand>,
pub social_query: Arc<dyn SocialQuery>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct SocialQueryDeps {
pub social_query: Arc<dyn SocialQuery>,
}

View File

@@ -0,0 +1,18 @@
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
use super::{commands::FollowCommand, deps::SocialCommandDeps};
pub async fn execute(deps: &SocialCommandDeps, cmd: FollowCommand) -> Result<(), DomainError> {
let follower = UserId::from_uuid(cmd.follower_id);
deps.social_command.follow(&follower, &cmd.target).await?;
deps.event_publisher
.publish(&DomainEvent::FollowRequested {
follower,
target: cmd.target,
})
.await
}
#[cfg(test)]
#[path = "tests/follow.rs"]
mod tests;

View File

@@ -0,0 +1,11 @@
use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}};
use super::{deps::SocialQueryDeps, queries::GetBlockedQuery};
pub async fn execute(
deps: &SocialQueryDeps,
query: GetBlockedQuery,
) -> Result<Vec<SocialIdentity>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
deps.social_query.get_blocked(&user_id).await
}

View File

@@ -0,0 +1,15 @@
use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}};
use super::{deps::SocialQueryDeps, queries::GetFollowersQuery};
pub async fn execute(
deps: &SocialQueryDeps,
query: GetFollowersQuery,
) -> Result<Vec<SocialIdentity>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
deps.social_query.get_followers(&user_id).await
}
#[cfg(test)]
#[path = "tests/get_followers.rs"]
mod tests;

View File

@@ -0,0 +1,15 @@
use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}};
use super::{deps::SocialQueryDeps, queries::GetFollowingQuery};
pub async fn execute(
deps: &SocialQueryDeps,
query: GetFollowingQuery,
) -> Result<Vec<SocialIdentity>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
deps.social_query.get_following(&user_id).await
}
#[cfg(test)]
#[path = "tests/get_following.rs"]
mod tests;

View File

@@ -0,0 +1,15 @@
use domain::{errors::DomainError, value_objects::{SocialIdentity, UserId}};
use super::{deps::SocialQueryDeps, queries::GetPendingFollowersQuery};
pub async fn execute(
deps: &SocialQueryDeps,
query: GetPendingFollowersQuery,
) -> Result<Vec<SocialIdentity>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
deps.social_query.get_pending_followers(&user_id).await
}
#[cfg(test)]
#[path = "tests/get_pending.rs"]
mod tests;

View File

@@ -0,0 +1,15 @@
pub mod commands;
pub mod deps;
pub mod queries;
pub mod accept;
pub mod block;
pub mod follow;
pub mod get_blocked;
pub mod get_followers;
pub mod get_following;
pub mod get_pending;
pub mod reject;
pub mod remove_follower;
pub mod unblock;
pub mod unfollow;

View File

@@ -0,0 +1,17 @@
use uuid::Uuid;
pub struct GetFollowingQuery {
pub user_id: Uuid,
}
pub struct GetFollowersQuery {
pub user_id: Uuid,
}
pub struct GetPendingFollowersQuery {
pub user_id: Uuid,
}
pub struct GetBlockedQuery {
pub user_id: Uuid,
}

View File

@@ -0,0 +1,17 @@
use domain::{errors::DomainError, value_objects::UserId};
use super::{commands::RejectFollowCommand, deps::SocialCommandDeps};
pub async fn execute(
deps: &SocialCommandDeps,
cmd: RejectFollowCommand,
) -> Result<(), DomainError> {
let owner = UserId::from_uuid(cmd.owner_id);
deps.social_command
.reject_follow(&owner, &cmd.requester)
.await
}
#[cfg(test)]
#[path = "tests/reject.rs"]
mod tests;

View File

@@ -0,0 +1,23 @@
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
use super::{commands::RemoveFollowerCommand, deps::SocialCommandDeps};
pub async fn execute(
deps: &SocialCommandDeps,
cmd: RemoveFollowerCommand,
) -> Result<(), DomainError> {
let owner = UserId::from_uuid(cmd.owner_id);
deps.social_command
.remove_follower(&owner, &cmd.follower)
.await?;
deps.event_publisher
.publish(&DomainEvent::FollowerRemoved {
owner,
follower: cmd.follower,
})
.await
}
#[cfg(test)]
#[path = "tests/remove_follower.rs"]
mod tests;

View File

@@ -0,0 +1,59 @@
use std::sync::Arc;
use domain::{
events::DomainEvent,
testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId},
};
use uuid::Uuid;
use crate::social::{
accept,
commands::{AcceptFollowCommand, FollowCommand},
deps::SocialCommandDeps,
follow,
};
fn make_deps() -> (Arc<InMemorySocialRepository>, Arc<NoopEventPublisher>, SocialCommandDeps) {
let social = InMemorySocialRepository::new();
let events = NoopEventPublisher::new();
let deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
social_query: Arc::clone(&social) as _,
event_publisher: Arc::clone(&events) as _,
};
(social, events, deps)
}
#[tokio::test]
async fn accept_follow_emits_follow_accepted_event() {
let (_social, events, deps) = make_deps();
let follower_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let requester = SocialIdentity::Local(UserId::from_uuid(follower_id));
follow::execute(
&deps,
FollowCommand {
follower_id,
target: SocialIdentity::Local(UserId::from_uuid(owner_id)),
},
)
.await
.unwrap();
accept::execute(
&deps,
AcceptFollowCommand {
owner_id,
requester,
},
)
.await
.unwrap();
let published = events.published();
assert!(published
.iter()
.any(|e| matches!(e, DomainEvent::FollowAccepted { .. })));
}

View File

@@ -0,0 +1,41 @@
use std::sync::Arc;
use domain::{
events::DomainEvent,
testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId},
};
use uuid::Uuid;
use crate::social::{block, commands::BlockCommand, deps::SocialCommandDeps};
fn make_deps() -> (Arc<InMemorySocialRepository>, Arc<NoopEventPublisher>, SocialCommandDeps) {
let social = InMemorySocialRepository::new();
let events = NoopEventPublisher::new();
let deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
social_query: Arc::clone(&social) as _,
event_publisher: Arc::clone(&events) as _,
};
(social, events, deps)
}
#[tokio::test]
async fn block_emits_actor_blocked_event() {
let (_social, events, deps) = make_deps();
block::execute(
&deps,
BlockCommand {
blocker_id: Uuid::new_v4(),
target: SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())),
},
)
.await
.unwrap();
let published = events.published();
assert!(published
.iter()
.any(|e| matches!(e, DomainEvent::ActorBlocked { .. })));
}

View File

@@ -0,0 +1,86 @@
use std::sync::Arc;
use domain::{
events::DomainEvent,
testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId},
};
use uuid::Uuid;
use crate::social::{commands::FollowCommand, deps::SocialCommandDeps, follow};
fn make_deps() -> (Arc<InMemorySocialRepository>, Arc<NoopEventPublisher>, SocialCommandDeps) {
let social = InMemorySocialRepository::new();
let events = NoopEventPublisher::new();
let deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
social_query: Arc::clone(&social) as _,
event_publisher: Arc::clone(&events) as _,
};
(social, events, deps)
}
#[tokio::test]
async fn follow_emits_follow_requested_event() {
let (_social, events, deps) = make_deps();
follow::execute(
&deps,
FollowCommand {
follower_id: Uuid::new_v4(),
target: SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())),
},
)
.await
.unwrap();
let published = events.published();
assert!(published
.iter()
.any(|e| matches!(e, DomainEvent::FollowRequested { .. })));
}
#[tokio::test]
async fn cannot_follow_yourself() {
let (_social, _events, deps) = make_deps();
let user_id = Uuid::new_v4();
let result = follow::execute(
&deps,
FollowCommand {
follower_id: user_id,
target: SocialIdentity::Local(UserId::from_uuid(user_id)),
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn cannot_follow_same_target_twice() {
let (_social, _events, deps) = make_deps();
let follower_id = Uuid::new_v4();
let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
follow::execute(
&deps,
FollowCommand {
follower_id,
target: target.clone(),
},
)
.await
.unwrap();
let result = follow::execute(
&deps,
FollowCommand {
follower_id,
target,
},
)
.await;
assert!(result.is_err());
}

View File

@@ -0,0 +1,62 @@
use std::sync::Arc;
use domain::{
testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId},
};
use uuid::Uuid;
use crate::social::{
accept,
commands::{AcceptFollowCommand, FollowCommand},
deps::{SocialCommandDeps, SocialQueryDeps},
follow, get_followers,
queries::GetFollowersQuery,
};
#[tokio::test]
async fn returns_accepted_followers() {
let social = InMemorySocialRepository::new();
let events = NoopEventPublisher::new();
let cmd_deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
social_query: Arc::clone(&social) as _,
event_publisher: Arc::clone(&events) as _,
};
let query_deps = SocialQueryDeps {
social_query: Arc::clone(&social) as _,
};
let follower_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
follow::execute(
&cmd_deps,
FollowCommand {
follower_id,
target: SocialIdentity::Local(UserId::from_uuid(owner_id)),
},
)
.await
.unwrap();
accept::execute(
&cmd_deps,
AcceptFollowCommand {
owner_id,
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
},
)
.await
.unwrap();
let followers = get_followers::execute(
&query_deps,
GetFollowersQuery {
user_id: owner_id,
},
)
.await
.unwrap();
assert_eq!(followers.len(), 1);
}

View File

@@ -0,0 +1,74 @@
use std::sync::Arc;
use domain::{
testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId},
};
use uuid::Uuid;
use crate::social::{
accept,
commands::{AcceptFollowCommand, FollowCommand},
deps::{SocialCommandDeps, SocialQueryDeps},
follow, get_following,
queries::GetFollowingQuery,
};
#[tokio::test]
async fn returns_accepted_follows() {
let social = InMemorySocialRepository::new();
let events = NoopEventPublisher::new();
let cmd_deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
social_query: Arc::clone(&social) as _,
event_publisher: Arc::clone(&events) as _,
};
let query_deps = SocialQueryDeps {
social_query: Arc::clone(&social) as _,
};
let follower_id = Uuid::new_v4();
let target_id = Uuid::new_v4();
follow::execute(
&cmd_deps,
FollowCommand {
follower_id,
target: SocialIdentity::Local(UserId::from_uuid(target_id)),
},
)
.await
.unwrap();
// Pending follow should not appear
let following = get_following::execute(
&query_deps,
GetFollowingQuery {
user_id: follower_id,
},
)
.await
.unwrap();
assert!(following.is_empty());
// Accept, then it should appear
accept::execute(
&cmd_deps,
AcceptFollowCommand {
owner_id: target_id,
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
},
)
.await
.unwrap();
let following = get_following::execute(
&query_deps,
GetFollowingQuery {
user_id: follower_id,
},
)
.await
.unwrap();
assert_eq!(following.len(), 1);
}

View File

@@ -0,0 +1,51 @@
use std::sync::Arc;
use domain::{
testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId},
};
use uuid::Uuid;
use crate::social::{
commands::FollowCommand,
deps::{SocialCommandDeps, SocialQueryDeps},
follow, get_pending,
queries::GetPendingFollowersQuery,
};
#[tokio::test]
async fn returns_only_pending_followers() {
let social = InMemorySocialRepository::new();
let events = NoopEventPublisher::new();
let cmd_deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
social_query: Arc::clone(&social) as _,
event_publisher: Arc::clone(&events) as _,
};
let query_deps = SocialQueryDeps {
social_query: Arc::clone(&social) as _,
};
let follower_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
follow::execute(
&cmd_deps,
FollowCommand {
follower_id,
target: SocialIdentity::Local(UserId::from_uuid(owner_id)),
},
)
.await
.unwrap();
let pending = get_pending::execute(
&query_deps,
GetPendingFollowersQuery {
user_id: owner_id,
},
)
.await
.unwrap();
assert_eq!(pending.len(), 1);
}

View File

@@ -0,0 +1,51 @@
use std::sync::Arc;
use domain::{
testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId},
};
use uuid::Uuid;
use crate::social::{
commands::{FollowCommand, RejectFollowCommand},
deps::SocialCommandDeps,
follow, reject,
};
fn make_deps() -> (Arc<InMemorySocialRepository>, Arc<NoopEventPublisher>, SocialCommandDeps) {
let social = InMemorySocialRepository::new();
let events = NoopEventPublisher::new();
let deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
social_query: Arc::clone(&social) as _,
event_publisher: Arc::clone(&events) as _,
};
(social, events, deps)
}
#[tokio::test]
async fn reject_follow_completes_without_error() {
let (_social, _events, deps) = make_deps();
let follower_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
follow::execute(
&deps,
FollowCommand {
follower_id,
target: SocialIdentity::Local(UserId::from_uuid(owner_id)),
},
)
.await
.unwrap();
reject::execute(
&deps,
RejectFollowCommand {
owner_id,
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
},
)
.await
.unwrap();
}

View File

@@ -0,0 +1,68 @@
use std::sync::Arc;
use domain::{
events::DomainEvent,
testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId},
};
use uuid::Uuid;
use crate::social::{
accept,
commands::{AcceptFollowCommand, FollowCommand, RemoveFollowerCommand},
deps::SocialCommandDeps,
follow, remove_follower,
};
fn make_deps() -> (Arc<InMemorySocialRepository>, Arc<NoopEventPublisher>, SocialCommandDeps) {
let social = InMemorySocialRepository::new();
let events = NoopEventPublisher::new();
let deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
social_query: Arc::clone(&social) as _,
event_publisher: Arc::clone(&events) as _,
};
(social, events, deps)
}
#[tokio::test]
async fn remove_follower_emits_follower_removed_event() {
let (_social, events, deps) = make_deps();
let follower_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
follow::execute(
&deps,
FollowCommand {
follower_id,
target: SocialIdentity::Local(UserId::from_uuid(owner_id)),
},
)
.await
.unwrap();
accept::execute(
&deps,
AcceptFollowCommand {
owner_id,
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
},
)
.await
.unwrap();
remove_follower::execute(
&deps,
RemoveFollowerCommand {
owner_id,
follower: SocialIdentity::Local(UserId::from_uuid(follower_id)),
},
)
.await
.unwrap();
let published = events.published();
assert!(published
.iter()
.any(|e| matches!(e, DomainEvent::FollowerRemoved { .. })));
}

View File

@@ -0,0 +1,57 @@
use std::sync::Arc;
use domain::{
events::DomainEvent,
testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId},
};
use uuid::Uuid;
use crate::social::{
commands::{BlockCommand, UnblockCommand},
deps::SocialCommandDeps,
block, unblock,
};
fn make_deps() -> (Arc<InMemorySocialRepository>, Arc<NoopEventPublisher>, SocialCommandDeps) {
let social = InMemorySocialRepository::new();
let events = NoopEventPublisher::new();
let deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
social_query: Arc::clone(&social) as _,
event_publisher: Arc::clone(&events) as _,
};
(social, events, deps)
}
#[tokio::test]
async fn unblock_emits_actor_unblocked_event() {
let (_social, events, deps) = make_deps();
let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
let blocker_id = Uuid::new_v4();
block::execute(
&deps,
BlockCommand {
blocker_id,
target: target.clone(),
},
)
.await
.unwrap();
unblock::execute(
&deps,
UnblockCommand {
blocker_id,
target,
},
)
.await
.unwrap();
let published = events.published();
assert!(published
.iter()
.any(|e| matches!(e, DomainEvent::ActorUnblocked { .. })));
}

View File

@@ -0,0 +1,57 @@
use std::sync::Arc;
use domain::{
events::DomainEvent,
testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId},
};
use uuid::Uuid;
use crate::social::{
commands::{FollowCommand, UnfollowCommand},
deps::SocialCommandDeps,
follow, unfollow,
};
fn make_deps() -> (Arc<InMemorySocialRepository>, Arc<NoopEventPublisher>, SocialCommandDeps) {
let social = InMemorySocialRepository::new();
let events = NoopEventPublisher::new();
let deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
social_query: Arc::clone(&social) as _,
event_publisher: Arc::clone(&events) as _,
};
(social, events, deps)
}
#[tokio::test]
async fn unfollow_emits_unfollowed_event() {
let (_social, events, deps) = make_deps();
let follower_id = Uuid::new_v4();
let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
follow::execute(
&deps,
FollowCommand {
follower_id,
target: target.clone(),
},
)
.await
.unwrap();
unfollow::execute(
&deps,
UnfollowCommand {
follower_id,
target,
},
)
.await
.unwrap();
let published = events.published();
assert!(published
.iter()
.any(|e| matches!(e, DomainEvent::Unfollowed { .. })));
}

View File

@@ -0,0 +1,18 @@
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
use super::{commands::UnblockCommand, deps::SocialCommandDeps};
pub async fn execute(deps: &SocialCommandDeps, cmd: UnblockCommand) -> Result<(), DomainError> {
let blocker = UserId::from_uuid(cmd.blocker_id);
deps.social_command.unblock(&blocker, &cmd.target).await?;
deps.event_publisher
.publish(&DomainEvent::ActorUnblocked {
blocker,
target: cmd.target,
})
.await
}
#[cfg(test)]
#[path = "tests/unblock.rs"]
mod tests;

View File

@@ -0,0 +1,20 @@
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
use super::{commands::UnfollowCommand, deps::SocialCommandDeps};
pub async fn execute(deps: &SocialCommandDeps, cmd: UnfollowCommand) -> Result<(), DomainError> {
let follower = UserId::from_uuid(cmd.follower_id);
deps.social_command
.unfollow(&follower, &cmd.target)
.await?;
deps.event_publisher
.publish(&DomainEvent::Unfollowed {
follower,
target: cmd.target,
})
.await
}
#[cfg(test)]
#[path = "tests/unfollow.rs"]
mod tests;