structural refactor and codebase improvements
This commit is contained in:
10
crates/application/src/social/count_pending_followers.rs
Normal file
10
crates/application/src/social/count_pending_followers.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(deps: &SocialQueryDeps, user_id: Uuid) -> Result<usize, DomainError> {
|
||||
deps.follow_graph
|
||||
.count_pending_followers(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventPublisher, SocialCommand, SocialQuery};
|
||||
use domain::ports::{BlockQuery, EventPublisher, FollowGraphQuery, SocialCommand};
|
||||
|
||||
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>,
|
||||
pub follow_graph: Arc<dyn FollowGraphQuery>,
|
||||
pub block_query: Arc<dyn BlockQuery>,
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
|
||||
|
||||
use super::{
|
||||
commands::SocialCmd,
|
||||
deps::{SocialCommandDeps, SocialQueryDeps},
|
||||
queries::SocialQry,
|
||||
};
|
||||
use super::{commands::SocialCmd, deps::SocialCommandDeps};
|
||||
|
||||
pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result<(), DomainError> {
|
||||
let event = match cmd {
|
||||
@@ -69,24 +61,6 @@ pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result
|
||||
deps.event_publisher.publish(&event).await
|
||||
}
|
||||
|
||||
pub async fn execute_query(
|
||||
deps: &SocialQueryDeps,
|
||||
query: SocialQry,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let user_id = match &query {
|
||||
SocialQry::GetFollowing { user_id }
|
||||
| SocialQry::GetFollowers { user_id }
|
||||
| SocialQry::GetPending { user_id }
|
||||
| SocialQry::GetBlocked { user_id } => UserId::from_uuid(*user_id),
|
||||
};
|
||||
match query {
|
||||
SocialQry::GetFollowing { .. } => deps.social_query.get_following(&user_id).await,
|
||||
SocialQry::GetFollowers { .. } => deps.social_query.get_followers(&user_id).await,
|
||||
SocialQry::GetPending { .. } => deps.social_query.get_pending_followers(&user_id).await,
|
||||
SocialQry::GetBlocked { .. } => deps.social_query.get_blocked(&user_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/execute.rs"]
|
||||
mod tests;
|
||||
|
||||
16
crates/application/src/social/get_blocked.rs
Normal file
16
crates/application/src/social/get_blocked.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
deps.block_query
|
||||
.get_blocked(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
16
crates/application/src/social/get_followers.rs
Normal file
16
crates/application/src/social/get_followers.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
deps.follow_graph
|
||||
.get_followers(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
16
crates/application/src/social/get_following.rs
Normal file
16
crates/application/src/social/get_following.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
deps.follow_graph
|
||||
.get_following(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
16
crates/application/src/social/get_pending_followers.rs
Normal file
16
crates/application/src/social/get_pending_followers.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
deps.follow_graph
|
||||
.get_pending_followers(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
16
crates/application/src/social/get_pending_following.rs
Normal file
16
crates/application/src/social/get_pending_following.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
deps.follow_graph
|
||||
.get_pending_following(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
17
crates/application/src/social/get_relation.rs
Normal file
17
crates/application/src/social/get_relation.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{FollowRelation, SocialIdentity, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
viewer_id: Uuid,
|
||||
target: SocialIdentity,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
deps.follow_graph
|
||||
.get_relation(&UserId::from_uuid(viewer_id), &target)
|
||||
.await
|
||||
}
|
||||
276
crates/application/src/social/local_service.rs
Normal file
276
crates/application/src/social/local_service.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{
|
||||
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowTargetResolver,
|
||||
ResolvedFollow, SocialCommand, UserRepository,
|
||||
},
|
||||
value_objects::{
|
||||
FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity,
|
||||
UserId, Username,
|
||||
},
|
||||
};
|
||||
|
||||
/// The subset of `SocialCommand`/`FollowGraphQuery`/`BlockQuery` that a single
|
||||
/// instance can serve with only its own database — no ActivityPub involved.
|
||||
///
|
||||
/// This is the local half of `activitypub::CompositeSocialAdapter`, extracted
|
||||
/// here so `application` needs no dependency on the `activitypub` crate to
|
||||
/// offer it. It implements `domain::ports::LocalSocial` via that trait's
|
||||
/// blanket impl over the five ports below.
|
||||
pub struct LocalSocialService {
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
follow_command: Arc<dyn FollowCommand>,
|
||||
follow_query: Arc<dyn FollowQuery>,
|
||||
instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
impl LocalSocialService {
|
||||
pub fn new(
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
follow_command: Arc<dyn FollowCommand>,
|
||||
follow_query: Arc<dyn FollowQuery>,
|
||||
instance: InstanceIdentity,
|
||||
) -> Self {
|
||||
Self {
|
||||
user_repo,
|
||||
follow_command,
|
||||
follow_query,
|
||||
instance,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_target_identity(
|
||||
&self,
|
||||
target: &FollowTarget,
|
||||
) -> Result<SocialIdentity, DomainError> {
|
||||
match target {
|
||||
FollowTarget::Identity(id) => Ok(id.clone()),
|
||||
FollowTarget::Handle(handle) => {
|
||||
let host = handle.rsplit_once('@').map(|(_, h)| h).unwrap_or("");
|
||||
let local_host = self.instance.host();
|
||||
if host == local_host {
|
||||
let username_str = handle
|
||||
.trim_start_matches('@')
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
if let Ok(username) = Username::new(username_str.to_string())
|
||||
&& let Some(user) = self.user_repo.find_by_username(&username).await?
|
||||
{
|
||||
return Ok(SocialIdentity::Local(user.id().clone()));
|
||||
}
|
||||
}
|
||||
Ok(SocialIdentity::Remote {
|
||||
actor_url: handle.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_unsupported(actor: &str) -> DomainError {
|
||||
DomainError::ValidationError(format!(
|
||||
"cannot reach remote actor {actor}: this instance was built without the federation feature"
|
||||
))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SocialCommand for LocalSocialService {
|
||||
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
|
||||
let identity = self.resolve_target_identity(target).await?;
|
||||
self.follow_resolved(follower, &identity).await
|
||||
}
|
||||
|
||||
async fn unfollow(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
match target {
|
||||
SocialIdentity::Local(target_id) => {
|
||||
let follower_url = self.instance.actor_url_for(follower);
|
||||
self.follow_command
|
||||
.remove_follow(follower.value(), &actor_url)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follower_record(target_id.value(), &follower_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_follow(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(requester);
|
||||
match requester {
|
||||
SocialIdentity::Local(requester_id) => {
|
||||
let owner_url = self.instance.actor_url_for(owner);
|
||||
self.follow_command
|
||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Accepted)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.update_follow_status(requester_id.value(), &owner_url, FollowStatus::Accepted)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reject_follow(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(requester);
|
||||
match requester {
|
||||
SocialIdentity::Local(requester_id) => {
|
||||
let owner_url = self.instance.actor_url_for(owner);
|
||||
self.follow_command
|
||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Rejected)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follow(requester_id.value(), &owner_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
follower: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(follower);
|
||||
match follower {
|
||||
SocialIdentity::Local(follower_id) => {
|
||||
let owner_url = self.instance.actor_url_for(owner);
|
||||
self.follow_command
|
||||
.remove_follower_record(owner.value(), &actor_url)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follow(follower_id.value(), &owner_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn block(&self, _blocker: &UserId, _target: &SocialIdentity) -> Result<(), DomainError> {
|
||||
Err(DomainError::ValidationError(
|
||||
"blocking requires the federation feature".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn unblock(
|
||||
&self,
|
||||
_blocker: &UserId,
|
||||
_target: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
Err(DomainError::ValidationError(
|
||||
"blocking requires the federation feature".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowGraphQuery for LocalSocialService {
|
||||
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.follow_query.get_following(user.value()).await
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.follow_query.get_followers(user.value()).await
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.follow_query.get_pending_followers(user.value()).await
|
||||
}
|
||||
|
||||
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.follow_query.get_pending_following(user.value()).await
|
||||
}
|
||||
|
||||
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.follow_query.count_following(user.value()).await
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.follow_query.count_followers(user.value()).await
|
||||
}
|
||||
|
||||
async fn count_pending_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.follow_query
|
||||
.count_pending_followers(user.value())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
viewer: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
self.follow_query
|
||||
.get_relation(viewer.value(), &actor_url)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockQuery for LocalSocialService {
|
||||
async fn get_blocked(&self, _user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowTargetResolver for LocalSocialService {
|
||||
async fn resolve_target(&self, target: &FollowTarget) -> Result<SocialIdentity, DomainError> {
|
||||
self.resolve_target_identity(target).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResolvedFollow for LocalSocialService {
|
||||
async fn follow_resolved(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let SocialIdentity::Local(target_id) = target else {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
return Err(remote_unsupported(&actor_url));
|
||||
};
|
||||
if follower == target_id {
|
||||
return Err(DomainError::ValidationError(
|
||||
"Cannot follow yourself".into(),
|
||||
));
|
||||
}
|
||||
let follower_url = self.instance.actor_url_for(follower);
|
||||
let target_url = self.instance.actor_url_for(target_id);
|
||||
self.follow_command
|
||||
.add_follower(target_id.value(), &follower_url, FollowStatus::Pending)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.add_follow(follower.value(), &target_url, FollowStatus::Pending)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/local_service.rs"]
|
||||
mod tests;
|
||||
@@ -1,4 +1,11 @@
|
||||
pub mod commands;
|
||||
pub mod count_pending_followers;
|
||||
pub mod deps;
|
||||
pub mod execute;
|
||||
pub mod queries;
|
||||
pub mod get_blocked;
|
||||
pub mod get_followers;
|
||||
pub mod get_following;
|
||||
pub mod get_pending_followers;
|
||||
pub mod get_pending_following;
|
||||
pub mod get_relation;
|
||||
pub mod local_service;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
pub enum SocialQry {
|
||||
GetFollowing { user_id: Uuid },
|
||||
GetFollowers { user_id: Uuid },
|
||||
GetPending { user_id: Uuid },
|
||||
GetBlocked { user_id: Uuid },
|
||||
}
|
||||
@@ -3,15 +3,14 @@ use std::sync::Arc;
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
testing::{InMemorySocialRepository, NoopEventPublisher},
|
||||
value_objects::{FollowTarget, SocialIdentity, UserId},
|
||||
value_objects::{FollowStatus, FollowTarget, SocialIdentity, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::social::{
|
||||
commands::SocialCmd,
|
||||
deps::{SocialCommandDeps, SocialQueryDeps},
|
||||
execute::{execute_command, execute_query},
|
||||
queries::SocialQry,
|
||||
execute::execute_command,
|
||||
};
|
||||
|
||||
fn make_cmd_deps() -> (
|
||||
@@ -23,7 +22,6 @@ fn make_cmd_deps() -> (
|
||||
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)
|
||||
@@ -312,62 +310,49 @@ async fn unblock_emits_actor_unblocked_event() {
|
||||
// ── Get following ───────────────────────────────────────────────────────────
|
||||
|
||||
#[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 _,
|
||||
};
|
||||
async fn get_following_returns_accepted_targets() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
social_query: Arc::clone(&social) as _,
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
|
||||
let follower_id = Uuid::new_v4();
|
||||
let target_id = Uuid::new_v4();
|
||||
let follower = Uuid::new_v4();
|
||||
let target = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(target_id))),
|
||||
follower_id: follower,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Pending follow should not appear
|
||||
let following = execute_query(
|
||||
&query_deps,
|
||||
SocialQry::GetFollowing {
|
||||
user_id: follower_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(following.is_empty());
|
||||
let before = crate::social::get_following::execute(&query_deps, follower)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
before.is_empty(),
|
||||
"a pending follow must not appear in get_following"
|
||||
);
|
||||
|
||||
// Accept, then it should appear
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id: target_id,
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
|
||||
owner_id: target.value(),
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(follower)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let following = execute_query(
|
||||
&query_deps,
|
||||
SocialQry::GetFollowing {
|
||||
user_id: follower_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(following.len(), 1);
|
||||
let actors = crate::social::get_following::execute(&query_deps, follower)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(actors.len(), 1);
|
||||
assert_eq!(actors[0].identity, SocialIdentity::Local(target));
|
||||
}
|
||||
|
||||
// ── Get followers ───────────────────────────────────────────────────────────
|
||||
@@ -378,11 +363,11 @@ async fn returns_accepted_followers() {
|
||||
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 _,
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
|
||||
let follower_id = Uuid::new_v4();
|
||||
@@ -408,7 +393,7 @@ async fn returns_accepted_followers() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let followers = execute_query(&query_deps, SocialQry::GetFollowers { user_id: owner_id })
|
||||
let followers = crate::social::get_followers::execute(&query_deps, owner_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(followers.len(), 1);
|
||||
@@ -422,11 +407,11 @@ async fn returns_only_pending_followers() {
|
||||
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 _,
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
|
||||
let follower_id = Uuid::new_v4();
|
||||
@@ -442,8 +427,254 @@ async fn returns_only_pending_followers() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pending = execute_query(&query_deps, SocialQry::GetPending { user_id: owner_id })
|
||||
let pending = crate::social::get_pending_followers::execute(&query_deps, owner_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
}
|
||||
|
||||
// ── get_relation ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_reports_none_for_strangers() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, _deps) = make_cmd_deps();
|
||||
let rel = FollowGraphQuery::get_relation(
|
||||
&*social,
|
||||
&UserId::from_uuid(Uuid::new_v4()),
|
||||
&SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rel.following, None);
|
||||
assert_eq!(rel.followed_by, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_reports_pending_before_acceptance() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, deps) = make_cmd_deps();
|
||||
let a = Uuid::new_v4();
|
||||
let b_id = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: a,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(b_id.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rel = FollowGraphQuery::get_relation(
|
||||
&*social,
|
||||
&UserId::from_uuid(a),
|
||||
&SocialIdentity::Local(b_id),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rel.following, Some(FollowStatus::Pending));
|
||||
assert_eq!(rel.followed_by, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_reports_accepted_in_both_directions_after_mutual_follow() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, deps) = make_cmd_deps();
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
let a_id = UserId::from_uuid(a);
|
||||
let b_id = UserId::from_uuid(b);
|
||||
|
||||
for (from, to) in [(a, b_id.clone()), (b, a_id.clone())] {
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: from,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(to.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let owner = if from == a { b } else { a };
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id: owner,
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(from)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let rel = FollowGraphQuery::get_relation(&*social, &a_id, &SocialIdentity::Local(b_id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rel.following, Some(FollowStatus::Accepted));
|
||||
assert_eq!(rel.followed_by, Some(FollowStatus::Accepted));
|
||||
}
|
||||
|
||||
// ── get_pending_following ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn unaccepted_follow_appears_in_pending_following_not_in_following() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, deps) = make_cmd_deps();
|
||||
let viewer = Uuid::new_v4();
|
||||
let target = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: viewer,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pending = FollowGraphQuery::get_pending_following(&*social, &UserId::from_uuid(viewer))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].identity, SocialIdentity::Local(target.clone()));
|
||||
|
||||
let following = FollowGraphQuery::get_following(&*social, &UserId::from_uuid(viewer))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
following.is_empty(),
|
||||
"an unaccepted follow must not appear in get_following"
|
||||
);
|
||||
}
|
||||
|
||||
// ── get_pending_following use case ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_pending_following_returns_unaccepted_targets_and_get_following_does_not() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
let follower = Uuid::new_v4();
|
||||
let target = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: follower,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// deliberately NOT accepted
|
||||
|
||||
let pending = crate::social::get_pending_following::execute(&query_deps, follower)
|
||||
.await
|
||||
.unwrap();
|
||||
let accepted = crate::social::get_following::execute(&query_deps, follower)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].identity, SocialIdentity::Local(target));
|
||||
assert!(
|
||||
accepted.is_empty(),
|
||||
"a pending follow must not leak into get_following — the privacy invariant"
|
||||
);
|
||||
}
|
||||
|
||||
// ── count_pending_followers use case ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_pending_followers_drops_as_requests_are_accepted() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
let owner = UserId::from_uuid(Uuid::new_v4());
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
|
||||
for follower in [a, b] {
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: follower,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(owner.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let before = crate::social::count_pending_followers::execute(&query_deps, owner.value())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(before, 2);
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id: owner.value(),
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(a)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let after = crate::social::count_pending_followers::execute(&query_deps, owner.value())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
after, 1,
|
||||
"accepting a request must remove it from the pending count"
|
||||
);
|
||||
}
|
||||
|
||||
// ── get_relation use case ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_use_case_reports_direction_asymmetrically() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
let viewer = Uuid::new_v4();
|
||||
let target = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: viewer,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rel =
|
||||
crate::social::get_relation::execute(&query_deps, viewer, SocialIdentity::Local(target))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rel.following,
|
||||
Some(FollowStatus::Pending),
|
||||
"viewer -> target"
|
||||
);
|
||||
assert_eq!(rel.followed_by, None, "target has not followed back");
|
||||
}
|
||||
|
||||
444
crates/application/src/social/tests/local_service.rs
Normal file
444
crates/application/src/social/tests/local_service.rs
Normal file
@@ -0,0 +1,444 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{User, UserRole},
|
||||
ports::{
|
||||
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, SocialCommand, UserRepository,
|
||||
},
|
||||
testing::InMemoryUserRepository,
|
||||
value_objects::{
|
||||
Email, FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, PasswordHash,
|
||||
SocialActor, SocialIdentity, UserId, Username,
|
||||
},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::social::local_service::LocalSocialService;
|
||||
|
||||
// ── Fakes ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// No fake exists anywhere for FollowCommand/FollowQuery. `InMemorySocialRepository`
|
||||
// (domain::testing) implements SocialCommand/FollowGraphQuery/BlockQuery — the same
|
||||
// level as LocalSocialService itself — so it cannot stand in as its dependency.
|
||||
// A single struct backs both ports, mirroring how one database table serves both
|
||||
// sides of the follow edge in production.
|
||||
|
||||
struct FollowFakeStore {
|
||||
// (follower_id, target_actor_url, status) — written by add_follow/update_follow_status/remove_follow
|
||||
follows: Mutex<Vec<(Uuid, String, FollowStatus)>>,
|
||||
// (local_user_id, follower_actor_url, status) — written by add_follower/update_follower_status/remove_follower_record
|
||||
followers: Mutex<Vec<(Uuid, String, FollowStatus)>>,
|
||||
}
|
||||
|
||||
impl FollowFakeStore {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
follows: Mutex::new(Vec::new()),
|
||||
followers: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_actor(url: &str) -> SocialActor {
|
||||
SocialActor {
|
||||
identity: SocialIdentity::Remote {
|
||||
actor_url: url.to_string(),
|
||||
},
|
||||
handle: url.to_string(),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowCommand for FollowFakeStore {
|
||||
async fn add_follow(
|
||||
&self,
|
||||
follower_id: Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
self.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((follower_id, target_actor_url.to_string(), status));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follow_status(
|
||||
&self,
|
||||
follower_id: Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.follows.lock().unwrap();
|
||||
if let Some(entry) = store
|
||||
.iter_mut()
|
||||
.find(|(f, t, _)| *f == follower_id && t == target_actor_url)
|
||||
{
|
||||
entry.2 = status;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follow(
|
||||
&self,
|
||||
follower_id: Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(f, t, _)| !(*f == follower_id && t == target_actor_url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
self.followers.lock().unwrap().push((
|
||||
local_user_id,
|
||||
follower_actor_url.to_string(),
|
||||
status,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.followers.lock().unwrap();
|
||||
if let Some(entry) = store
|
||||
.iter_mut()
|
||||
.find(|(u, f, _)| *u == local_user_id && f == follower_actor_url)
|
||||
{
|
||||
entry.2 = status;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follower_record(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
follower_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(u, f, _)| !(*u == local_user_id && f == follower_actor_url));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowQuery for FollowFakeStore {
|
||||
async fn get_following(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(self
|
||||
.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(f, _, _)| *f == user_id)
|
||||
.map(|(_, t, _)| fake_actor(t))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(self
|
||||
.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(u, _, _)| *u == user_id)
|
||||
.map(|(_, f, _)| fake_actor(f))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(self
|
||||
.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(u, _, s)| *u == user_id && *s == FollowStatus::Pending)
|
||||
.map(|(_, f, _)| fake_actor(f))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_following(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(self
|
||||
.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(f, _, s)| *f == user_id && *s == FollowStatus::Pending)
|
||||
.map(|(_, t, _)| fake_actor(t))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: Uuid) -> Result<usize, DomainError> {
|
||||
Ok(self
|
||||
.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(f, _, _)| *f == user_id)
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user_id: Uuid) -> Result<usize, DomainError> {
|
||||
Ok(self
|
||||
.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(u, _, _)| *u == user_id)
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn count_pending_followers(&self, user_id: Uuid) -> Result<usize, DomainError> {
|
||||
Ok(self
|
||||
.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(u, _, s)| *u == user_id && *s == FollowStatus::Pending)
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
viewer_id: Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
let following = self
|
||||
.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|(f, t, _)| *f == viewer_id && t == target_actor_url)
|
||||
.map(|(_, _, s)| *s);
|
||||
let followed_by = self
|
||||
.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|(u, f, _)| *u == viewer_id && f == target_actor_url)
|
||||
.map(|(_, _, s)| *s);
|
||||
Ok(FollowRelation {
|
||||
following,
|
||||
followed_by,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn instance() -> InstanceIdentity {
|
||||
InstanceIdentity::new("http://md.example")
|
||||
}
|
||||
|
||||
async fn register_user(repo: &Arc<InMemoryUserRepository>, username: &str) -> UserId {
|
||||
let user = User::new(
|
||||
Email::new(format!("{username}@example.com")).unwrap(),
|
||||
Username::new(username.to_string()).unwrap(),
|
||||
PasswordHash::new("hashed-password".to_string()).unwrap(),
|
||||
UserRole::Standard,
|
||||
);
|
||||
let id = user.id().clone();
|
||||
repo.save(&user).await.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn service(
|
||||
user_repo: Arc<InMemoryUserRepository>,
|
||||
follow_store: &Arc<FollowFakeStore>,
|
||||
instance: InstanceIdentity,
|
||||
) -> LocalSocialService {
|
||||
LocalSocialService::new(
|
||||
user_repo as Arc<dyn UserRepository>,
|
||||
follow_store.clone() as Arc<dyn FollowCommand>,
|
||||
follow_store.clone() as Arc<dyn FollowQuery>,
|
||||
instance,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_local_user_writes_both_sides() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let bob_id = register_user(&user_repo, "bob").await;
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let instance = instance();
|
||||
let svc = service(user_repo, &follow_store, instance.clone());
|
||||
|
||||
svc.follow(
|
||||
&alice_id,
|
||||
&FollowTarget::Handle("@bob@md.example".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let bob_url = instance.actor_url_for(&bob_id);
|
||||
let alice_url = instance.actor_url_for(&alice_id);
|
||||
|
||||
let followers = svc.get_followers(&bob_id).await.unwrap();
|
||||
assert!(
|
||||
followers.iter().any(|a| a.handle == alice_url),
|
||||
"add_follower should have recorded alice as bob's follower"
|
||||
);
|
||||
|
||||
let following = svc.get_following(&alice_id).await.unwrap();
|
||||
assert!(
|
||||
following.iter().any(|a| a.handle == bob_url),
|
||||
"add_follow should have recorded bob in alice's following list"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_self_is_rejected() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let svc = service(user_repo, &follow_store, instance());
|
||||
|
||||
let result = svc
|
||||
.follow(
|
||||
&alice_id,
|
||||
&FollowTarget::Handle("@alice@md.example".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(DomainError::ValidationError(msg)) => assert_eq!(
|
||||
msg, "Cannot follow yourself",
|
||||
"the self-follow guard must not collapse into the generic remote-target error"
|
||||
),
|
||||
other => panic!("expected ValidationError, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
follow_store.follows.lock().unwrap().len(),
|
||||
0,
|
||||
"self-follow must write nothing to the follows side"
|
||||
);
|
||||
assert_eq!(
|
||||
follow_store.followers.lock().unwrap().len(),
|
||||
0,
|
||||
"self-follow must write nothing to the followers side"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_remote_target_errors_rather_than_silently_succeeding() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let svc = service(user_repo, &follow_store, instance());
|
||||
|
||||
let result = svc
|
||||
.follow(
|
||||
&alice_id,
|
||||
&FollowTarget::Handle("@carol@other.example".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(DomainError::ValidationError(msg)) => {
|
||||
assert!(
|
||||
msg.contains("@carol@other.example"),
|
||||
"the remote-target error must name the specific handle that failed, \
|
||||
not a generic message: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg != "Cannot follow yourself",
|
||||
"the remote-target error must not collapse into the self-follow message"
|
||||
);
|
||||
}
|
||||
other => panic!("expected ValidationError, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
follow_store.follows.lock().unwrap().len(),
|
||||
0,
|
||||
"a remote target must write nothing — this replaces NoopSocialCommand's silent Ok(())"
|
||||
);
|
||||
assert_eq!(follow_store.followers.lock().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_follow_updates_follower_status_but_removes_the_follow_row() {
|
||||
// Asymmetry with accept_follow, which updates the follow row's status instead
|
||||
// of removing it: reject_follow marks the follower row Rejected but deletes
|
||||
// the follow row outright. A test that only checked the follower row would
|
||||
// pass against a reject_follow "corrected" into accept_follow's symmetry —
|
||||
// the second assertion is the one that carries the weight.
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let owner_id = register_user(&user_repo, "owner").await;
|
||||
let requester_id = register_user(&user_repo, "requester").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let instance = instance();
|
||||
let svc = service(user_repo, &follow_store, instance.clone());
|
||||
|
||||
svc.follow(
|
||||
&requester_id,
|
||||
&FollowTarget::Handle("@owner@md.example".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let requester_url = instance.actor_url_for(&requester_id);
|
||||
let owner_url = instance.actor_url_for(&owner_id);
|
||||
|
||||
{
|
||||
let followers = follow_store.followers.lock().unwrap();
|
||||
let follower_row = followers
|
||||
.iter()
|
||||
.find(|(u, f, _)| *u == owner_id.value() && f == &requester_url)
|
||||
.expect("the follower row must still exist after rejection");
|
||||
assert_eq!(
|
||||
follower_row.2,
|
||||
FollowStatus::Rejected,
|
||||
"reject_follow must mark the follower row Rejected"
|
||||
);
|
||||
}
|
||||
|
||||
let follows = follow_store.follows.lock().unwrap();
|
||||
assert!(
|
||||
!follows
|
||||
.iter()
|
||||
.any(|(f, t, _)| *f == requester_id.value() && t == &owner_url),
|
||||
"reject_follow must remove the follow row outright, not merely mark it Rejected — \
|
||||
this is the deliberate asymmetry with accept_follow"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_errors_and_get_blocked_is_empty() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let svc = service(user_repo, &follow_store, instance());
|
||||
|
||||
let target = SocialIdentity::Remote {
|
||||
actor_url: "https://other.example/users/carol".to_string(),
|
||||
};
|
||||
|
||||
let result = svc.block(&alice_id, &target).await;
|
||||
assert!(matches!(result, Err(DomainError::ValidationError(_))));
|
||||
|
||||
let blocked = svc.get_blocked(&alice_id).await.unwrap();
|
||||
assert_eq!(blocked.len(), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user