From a6a555e6a729d7e7da01ac210fb8a45036ca9f0c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 28 May 2026 03:37:41 +0200 Subject: [PATCH] feat(domain): add list_mutual to FollowRepository, add remote actor storage to TestStore --- crates/adapters/postgres/src/follow/mod.rs | 44 ++++++++++++++++++++++ crates/domain/src/ports.rs | 5 +++ crates/domain/src/testing/mod.rs | 40 +++++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/crates/adapters/postgres/src/follow/mod.rs b/crates/adapters/postgres/src/follow/mod.rs index 512ce19..3c23930 100644 --- a/crates/adapters/postgres/src/follow/mod.rs +++ b/crates/adapters/postgres/src/follow/mod.rs @@ -187,6 +187,50 @@ impl FollowRepository for PgFollowRepository { .into_domain()?; Ok(ids.into_iter().map(UserId::from_uuid).collect()) } + + async fn list_mutual( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError> { + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(DISTINCT u.id) + FROM users u + WHERE EXISTS ( + SELECT 1 FROM follows f1 WHERE f1.follower_id=$1 AND f1.following_id=u.id AND f1.state='accepted' + ) AND EXISTS ( + SELECT 1 FROM follows f2 WHERE f2.following_id=$1 AND f2.follower_id=u.id AND f2.state='accepted' + )", + ) + .bind(user_id.as_uuid()) + .fetch_one(&self.pool) + .await + .into_domain()?; + + let rows = sqlx::query_as::<_, crate::user::UserRow>( + "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at + FROM users u + WHERE EXISTS ( + SELECT 1 FROM follows f1 WHERE f1.follower_id=$1 AND f1.following_id=u.id AND f1.state='accepted' + ) AND EXISTS ( + SELECT 1 FROM follows f2 WHERE f2.following_id=$1 AND f2.follower_id=u.id AND f2.state='accepted' + ) + ORDER BY u.created_at DESC LIMIT $2 OFFSET $3" + ) + .bind(user_id.as_uuid()) + .bind(page.limit()) + .bind(page.offset()) + .fetch_all(&self.pool) + .await + .into_domain()?; + + Ok(Paginated { + items: rows.into_iter().map(User::from).collect(), + total, + page: page.page, + per_page: page.per_page, + }) + } } #[cfg(test)] diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 8494545..e73a457 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -171,6 +171,11 @@ pub trait FollowRepository: Send + Sync { &self, user_id: &UserId, ) -> Result, DomainError>; + async fn list_mutual( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError>; } #[async_trait] diff --git a/crates/domain/src/testing/mod.rs b/crates/domain/src/testing/mod.rs index f989268..4f2e4d2 100644 --- a/crates/domain/src/testing/mod.rs +++ b/crates/domain/src/testing/mod.rs @@ -37,6 +37,8 @@ pub struct TestStore { pub actor_ap_ids: Arc>>, /// ThoughtId → AP object URL (used by get_thought_ap_id) pub thought_ap_ids: Arc>>, + pub remote_following: Arc>>, + pub remote_followers: Arc>>, } #[async_trait] @@ -452,6 +454,40 @@ impl FollowRepository for TestStore { .map(|f| f.following_id.clone()) .collect()) } + async fn list_mutual( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError> { + use std::collections::HashSet; + let follows = self.follows.lock().unwrap(); + let following_ids: HashSet = follows + .iter() + .filter(|f| &f.follower_id == user_id && f.state == FollowState::Accepted) + .map(|f| f.following_id.clone()) + .collect(); + let follower_ids: HashSet = follows + .iter() + .filter(|f| &f.following_id == user_id && f.state == FollowState::Accepted) + .map(|f| f.follower_id.clone()) + .collect(); + let mutual_ids: HashSet = + following_ids.intersection(&follower_ids).cloned().collect(); + drop(follows); + let users = self.users.lock().unwrap(); + let items: Vec = users + .iter() + .filter(|u| mutual_ids.contains(&u.id)) + .cloned() + .collect(); + let total = items.len() as i64; + Ok(Paginated { + items, + total, + page: page.page, + per_page: page.per_page, + }) + } } #[async_trait] @@ -710,7 +746,7 @@ impl FederationFollowPort for TestStore { &self, _user_id: &UserId, ) -> Result, DomainError> { - Ok(vec![]) + Ok(self.remote_following.lock().unwrap().clone()) } async fn broadcast_move( @@ -751,7 +787,7 @@ impl FederationFollowRequestPort for TestStore { &self, _user_id: &UserId, ) -> Result, DomainError> { - Ok(vec![]) + Ok(self.remote_followers.lock().unwrap().clone()) } async fn remove_remote_follower(