From 9757ebdabf33723474ba363928f203ed7d5445f8 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 15 May 2026 01:58:40 +0200 Subject: [PATCH] refactor(application): move local/remote follow routing out of presentation handler --- crates/application/src/use_cases/social.rs | 59 +++++++++++++++++++++- crates/presentation/src/handlers/social.rs | 15 +++--- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/crates/application/src/use_cases/social.rs b/crates/application/src/use_cases/social.rs index e801ea3..aff30a3 100644 --- a/crates/application/src/use_cases/social.rs +++ b/crates/application/src/use_cases/social.rs @@ -3,8 +3,11 @@ use domain::{ errors::DomainError, events::DomainEvent, models::social::{Block, Boost, Follow, FollowState, Like}, - ports::{BlockRepository, BoostRepository, EventPublisher, FollowRepository, LikeRepository}, - value_objects::{BoostId, LikeId, ThoughtId, UserId}, + ports::{ + BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository, + LikeRepository, UserRepository, + }, + value_objects::{BoostId, LikeId, ThoughtId, UserId, Username}, }; pub async fn like_thought( @@ -87,6 +90,27 @@ pub async fn unboost_thought( Ok(()) } +pub async fn follow_actor( + follows: &dyn FollowRepository, + users: &dyn UserRepository, + federation: &dyn FederationActionPort, + events: &dyn EventPublisher, + follower_id: &UserId, + username: &str, +) -> Result<(), DomainError> { + if username.contains('@') { + federation.follow_remote(follower_id, username).await + } else { + let uname = Username::new(username) + .map_err(|_| DomainError::InvalidInput("invalid username".into()))?; + let target = users + .find_by_username(&uname) + .await? + .ok_or(DomainError::NotFound)?; + follow_user(follows, events, follower_id, &target.id).await + } +} + pub async fn follow_user( follows: &dyn FollowRepository, events: &dyn EventPublisher, @@ -315,6 +339,37 @@ mod tests { assert!(matches!(err, DomainError::InvalidInput(_))); } + #[tokio::test] + async fn follow_actor_local_routes_to_follow_user() { + let store = TestStore::default(); + let alice = user("alice"); + let bob = user("bob"); + store.users.lock().unwrap().push(bob.clone()); + follow_actor(&store, &store, &store, &store, &alice.id, "bob") + .await + .unwrap(); + assert_eq!(store.follows.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn follow_actor_remote_routes_to_federation() { + let store = TestStore::default(); + let alice = user("alice"); + follow_actor( + &store, + &store, + &store, + &store, + &alice.id, + "@bob@example.com", + ) + .await + .unwrap(); + // TestStore.follow_remote is a no-op that returns Ok(()) + // no local follow should be recorded + assert!(store.follows.lock().unwrap().is_empty()); + } + #[tokio::test] async fn boost_and_unboost() { let store = TestStore::default(); diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs index 8c8e9e7..8648c98 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/presentation/src/handlers/social.rs @@ -57,12 +57,15 @@ pub async fn post_follow( AuthUser(uid): AuthUser, Path(username): Path, ) -> Result { - if username.contains('@') { - s.federation.follow_remote(&uid, &username).await?; - } else { - let target = get_user_by_username(&*s.users, &username).await?; - follow_user(&*s.follows, &*s.events, &uid, &target.id).await?; - } + follow_actor( + &*s.follows, + &*s.users, + &*s.federation, + &*s.events, + &uid, + &username, + ) + .await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(