Compare commits
60 Commits
de1d2f7ec7
...
e406464f9f
| Author | SHA1 | Date | |
|---|---|---|---|
| e406464f9f | |||
| 0e2b72b77a | |||
| d4da172398 | |||
| 4e750420bf | |||
| e6330125be | |||
| 14b7928026 | |||
| a6a555e6a7 | |||
| 4d4171a9c5 | |||
| 75e6fe61ca | |||
| 4f1b9a5cfb | |||
| d68c628335 | |||
| af5c4481b6 | |||
| 5e3db44043 | |||
| 915163aac4 | |||
| a06d09c101 | |||
| 0dce4fbe64 | |||
| 9c93baaa39 | |||
| a253efacec | |||
| 04f39e35c2 | |||
| 2060317867 | |||
| e338254099 | |||
| 84c66dd461 | |||
| 2445cad1c9 | |||
| fc290dc18f | |||
| 43e5175db5 | |||
| 50a90efbce | |||
| ff75361eb1 | |||
| 5ca5ad9561 | |||
| f6893b19dc | |||
| 6f65742284 | |||
| 904dd5f1a0 | |||
| 0164b03e5c | |||
| 0797dde39c | |||
| 7d2d597264 | |||
| fe4960d30d | |||
| 5097c91261 | |||
| f4932af2ba | |||
| fccc4064cf | |||
| 01932cf337 | |||
| 1874954ad7 | |||
| f12cc7e2a7 | |||
| 6936b7ce62 | |||
| d56d34cc27 | |||
| 2f5c89c381 | |||
| 2c2decba72 | |||
| 2d1044e5c3 | |||
| d813e59b5c | |||
| 54910c6459 | |||
| be0924d463 | |||
| 2c34eb44e4 | |||
| 7dcdbb4551 | |||
| bb48819cad | |||
| 39f7d39232 | |||
| 4a84c595d5 | |||
| f89a466fd9 | |||
| c180b1c1f5 | |||
| a85cb2eee5 | |||
| 7e2c5adffd | |||
| 82778c82dd | |||
| b02f3c73e3 |
@@ -187,6 +187,59 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
Ok(ids.into_iter().map(UserId::from_uuid).collect())
|
Ok(ids.into_iter().map(UserId::from_uuid).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_mutual(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM follows f1
|
||||||
|
WHERE f1.follower_id = $1 AND f1.state = 'accepted'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM follows f2
|
||||||
|
WHERE f2.follower_id = f1.following_id
|
||||||
|
AND f2.following_id = f1.follower_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.created_at, u.updated_at
|
||||||
|
FROM users u
|
||||||
|
JOIN follows f1
|
||||||
|
ON f1.follower_id = $1
|
||||||
|
AND f1.following_id = u.id
|
||||||
|
AND f1.state = 'accepted'
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM follows f2
|
||||||
|
WHERE f2.follower_id = u.id
|
||||||
|
AND f2.following_id = $1
|
||||||
|
AND f2.state = 'accepted'
|
||||||
|
)
|
||||||
|
ORDER BY f1.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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -56,3 +56,86 @@ async fn get_accepted_following_ids(pool: sqlx::PgPool) {
|
|||||||
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
|
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
|
||||||
assert_eq!(ids, vec![bob.id]);
|
assert_eq!(ids, vec![bob.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_mutual_returns_only_mutual_accepted_follows(pool: sqlx::PgPool) {
|
||||||
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
|
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
|
||||||
|
let repo = PgFollowRepository::new(pool);
|
||||||
|
let page = domain::models::feed::PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
// alice → bob (accepted), bob → alice (accepted) = friends
|
||||||
|
repo.save(&Follow {
|
||||||
|
follower_id: alice.id.clone(),
|
||||||
|
following_id: bob.id.clone(),
|
||||||
|
state: FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.save(&Follow {
|
||||||
|
follower_id: bob.id.clone(),
|
||||||
|
following_id: alice.id.clone(),
|
||||||
|
state: FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// alice → carol (accepted), carol does NOT follow back = not a friend
|
||||||
|
repo.save(&Follow {
|
||||||
|
follower_id: alice.id.clone(),
|
||||||
|
following_id: carol.id.clone(),
|
||||||
|
state: FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = repo.list_mutual(&alice.id, &page).await.unwrap();
|
||||||
|
assert_eq!(result.total, 1);
|
||||||
|
assert_eq!(result.items.len(), 1);
|
||||||
|
assert_eq!(result.items[0].id, bob.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn list_mutual_excludes_pending_follows(pool: sqlx::PgPool) {
|
||||||
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
|
let repo = PgFollowRepository::new(pool);
|
||||||
|
let page = domain::models::feed::PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
// alice → bob (accepted), bob → alice (PENDING) = NOT a friend
|
||||||
|
repo.save(&Follow {
|
||||||
|
follower_id: alice.id.clone(),
|
||||||
|
following_id: bob.id.clone(),
|
||||||
|
state: FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.save(&Follow {
|
||||||
|
follower_id: bob.id.clone(),
|
||||||
|
following_id: alice.id.clone(),
|
||||||
|
state: FollowState::Pending,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = repo.list_mutual(&alice.id, &page).await.unwrap();
|
||||||
|
assert_eq!(result.total, 0);
|
||||||
|
assert!(result.items.is_empty());
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,20 @@ pub async fn list_remote_following(
|
|||||||
federation.get_remote_following(user_id).await
|
federation.get_remote_following(user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_remote_friends(
|
||||||
|
federation: &dyn FederationActionPort,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let following = federation.get_remote_following(user_id).await?;
|
||||||
|
let followers = federation.get_remote_followers(user_id).await?;
|
||||||
|
let follower_urls: HashSet<&str> = followers.iter().map(|a| a.url.as_str()).collect();
|
||||||
|
Ok(following
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| follower_urls.contains(a.url.as_str()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn remove_remote_following(
|
pub async fn remove_remote_following(
|
||||||
follows: &dyn FollowRepository,
|
follows: &dyn FollowRepository,
|
||||||
users: &dyn UserReader,
|
users: &dyn UserReader,
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use domain::models::remote_actor::RemoteActor;
|
||||||
use domain::testing::TestStore;
|
use domain::testing::TestStore;
|
||||||
|
|
||||||
|
fn remote_actor(url: &str, handle: &str) -> RemoteActor {
|
||||||
|
RemoteActor {
|
||||||
|
url: url.to_string(),
|
||||||
|
handle: handle.to_string(),
|
||||||
|
display_name: None,
|
||||||
|
avatar_url: None,
|
||||||
|
bio: None,
|
||||||
|
banner_url: None,
|
||||||
|
also_known_as: None,
|
||||||
|
outbox_url: None,
|
||||||
|
followers_url: None,
|
||||||
|
following_url: None,
|
||||||
|
attachment: vec![],
|
||||||
|
last_fetched_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_pending_returns_empty_by_default() {
|
async fn list_pending_returns_empty_by_default() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
@@ -51,3 +70,41 @@ async fn list_remote_following_returns_empty_by_default() {
|
|||||||
let result = list_remote_following(&store, &uid).await.unwrap();
|
let result = list_remote_following(&store, &uid).await.unwrap();
|
||||||
assert!(result.is_empty());
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_remote_friends_returns_intersection() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let uid = UserId::new();
|
||||||
|
|
||||||
|
let bob = remote_actor("https://bob.example.com/users/bob", "bob@bob.example.com");
|
||||||
|
let carol = remote_actor(
|
||||||
|
"https://carol.example.com/users/carol",
|
||||||
|
"carol@carol.example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
// uid follows bob and carol
|
||||||
|
store
|
||||||
|
.remote_following
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.extend([bob.clone(), carol.clone()]);
|
||||||
|
// only bob follows back
|
||||||
|
store.remote_followers.lock().unwrap().push(bob.clone());
|
||||||
|
|
||||||
|
let friends = get_remote_friends(&store, &uid).await.unwrap();
|
||||||
|
assert_eq!(friends.len(), 1);
|
||||||
|
assert_eq!(friends[0].url, bob.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_remote_friends_empty_when_no_mutual() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let uid = UserId::new();
|
||||||
|
|
||||||
|
let bob = remote_actor("https://bob.example.com/users/bob", "bob@bob.example.com");
|
||||||
|
store.remote_following.lock().unwrap().push(bob.clone());
|
||||||
|
// bob does NOT follow back
|
||||||
|
|
||||||
|
let friends = get_remote_friends(&store, &uid).await.unwrap();
|
||||||
|
assert!(friends.is_empty());
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ use chrono::Utc;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::social::{Block, Boost, Follow, FollowState, Like},
|
models::{
|
||||||
|
feed::{PageParams, Paginated},
|
||||||
|
social::{Block, Boost, Follow, FollowState, Like},
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
ports::{
|
ports::{
|
||||||
BlockRepository, BoostRepository, EventPublisher, FederationFollowPort, FollowRepository,
|
BlockRepository, BoostRepository, EventPublisher, FederationFollowPort, FollowRepository,
|
||||||
LikeRepository, UserReader,
|
LikeRepository, UserReader,
|
||||||
@@ -280,5 +284,13 @@ pub async fn unblock_user(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_local_friends(
|
||||||
|
follows: &dyn FollowRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError> {
|
||||||
|
follows.list_mutual(user_id, page).await
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -204,3 +204,51 @@ async fn boost_and_unboost() {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::BoostRemoved { .. })));
|
.any(|e| matches!(e, DomainEvent::BoostRemoved { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_local_friends_returns_mutual_follows() {
|
||||||
|
use domain::models::feed::PageParams;
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user("alice");
|
||||||
|
let bob = user("bob");
|
||||||
|
let carol = user("carol");
|
||||||
|
|
||||||
|
store
|
||||||
|
.users
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.extend([alice.clone(), bob.clone(), carol.clone()]);
|
||||||
|
|
||||||
|
// alice ↔ bob = friends; alice → carol but not back
|
||||||
|
store.follows.lock().unwrap().extend([
|
||||||
|
domain::models::social::Follow {
|
||||||
|
follower_id: alice.id.clone(),
|
||||||
|
following_id: bob.id.clone(),
|
||||||
|
state: domain::models::social::FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
},
|
||||||
|
domain::models::social::Follow {
|
||||||
|
follower_id: bob.id.clone(),
|
||||||
|
following_id: alice.id.clone(),
|
||||||
|
state: domain::models::social::FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
},
|
||||||
|
domain::models::social::Follow {
|
||||||
|
follower_id: alice.id.clone(),
|
||||||
|
following_id: carol.id.clone(),
|
||||||
|
state: domain::models::social::FollowState::Accepted,
|
||||||
|
ap_id: None,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let page = PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
};
|
||||||
|
let result = get_local_friends(&store, &alice.id, &page).await.unwrap();
|
||||||
|
assert_eq!(result.total, 1);
|
||||||
|
assert_eq!(result.items[0].id, bob.id);
|
||||||
|
}
|
||||||
|
|||||||
@@ -171,6 +171,11 @@ pub trait FollowRepository: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
) -> Result<Vec<UserId>, DomainError>;
|
) -> Result<Vec<UserId>, DomainError>;
|
||||||
|
async fn list_mutual(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ pub struct TestStore {
|
|||||||
pub actor_ap_ids: Arc<Mutex<HashMap<String, UserId>>>,
|
pub actor_ap_ids: Arc<Mutex<HashMap<String, UserId>>>,
|
||||||
/// ThoughtId → AP object URL (used by get_thought_ap_id)
|
/// ThoughtId → AP object URL (used by get_thought_ap_id)
|
||||||
pub thought_ap_ids: Arc<Mutex<HashMap<ThoughtId, String>>>,
|
pub thought_ap_ids: Arc<Mutex<HashMap<ThoughtId, String>>>,
|
||||||
|
pub remote_following: Arc<Mutex<Vec<RemoteActor>>>,
|
||||||
|
pub remote_followers: Arc<Mutex<Vec<RemoteActor>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -452,6 +454,46 @@ impl FollowRepository for TestStore {
|
|||||||
.map(|f| f.following_id.clone())
|
.map(|f| f.following_id.clone())
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
async fn list_mutual(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: &PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
let follows = self.follows.lock().unwrap();
|
||||||
|
let following_ids: HashSet<UserId> = follows
|
||||||
|
.iter()
|
||||||
|
.filter(|f| &f.follower_id == user_id && f.state == FollowState::Accepted)
|
||||||
|
.map(|f| f.following_id.clone())
|
||||||
|
.collect();
|
||||||
|
let follower_ids: HashSet<UserId> = follows
|
||||||
|
.iter()
|
||||||
|
.filter(|f| &f.following_id == user_id && f.state == FollowState::Accepted)
|
||||||
|
.map(|f| f.follower_id.clone())
|
||||||
|
.collect();
|
||||||
|
let mutual_ids: HashSet<UserId> =
|
||||||
|
following_ids.intersection(&follower_ids).cloned().collect();
|
||||||
|
drop(follows);
|
||||||
|
let users = self.users.lock().unwrap();
|
||||||
|
let all_items: Vec<User> = users
|
||||||
|
.iter()
|
||||||
|
.filter(|u| mutual_ids.contains(&u.id))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let total = all_items.len() as i64;
|
||||||
|
let offset = page.offset() as usize;
|
||||||
|
let items: Vec<User> = all_items
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset)
|
||||||
|
.take(page.limit() as usize)
|
||||||
|
.collect();
|
||||||
|
Ok(Paginated {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -710,7 +752,7 @@ impl FederationFollowPort for TestStore {
|
|||||||
&self,
|
&self,
|
||||||
_user_id: &UserId,
|
_user_id: &UserId,
|
||||||
) -> Result<Vec<RemoteActor>, DomainError> {
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
||||||
Ok(vec![])
|
Ok(self.remote_following.lock().unwrap().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn broadcast_move(
|
async fn broadcast_move(
|
||||||
@@ -751,7 +793,7 @@ impl FederationFollowRequestPort for TestStore {
|
|||||||
&self,
|
&self,
|
||||||
_user_id: &UserId,
|
_user_id: &UserId,
|
||||||
) -> Result<Vec<RemoteActor>, DomainError> {
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
||||||
Ok(vec![])
|
Ok(self.remote_followers.lock().unwrap().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_remote_follower(
|
async fn remove_remote_follower(
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use api_types::responses::{ProfileField, RemoteActorResponse};
|
use api_types::responses::{ProfileField, RemoteActorResponse};
|
||||||
use application::use_cases::federation_management::{
|
use application::use_cases::federation_management::{
|
||||||
accept_follow_request, initiate_actor_move, list_pending_requests, list_remote_followers,
|
accept_follow_request, get_remote_friends, initiate_actor_move, list_pending_requests,
|
||||||
list_remote_following, reject_follow_request, remove_remote_following,
|
list_remote_followers, list_remote_following, reject_follow_request, remove_remote_following,
|
||||||
};
|
};
|
||||||
use axum::{http::StatusCode, Json};
|
use axum::{http::StatusCode, Json};
|
||||||
use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository};
|
use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository};
|
||||||
@@ -96,6 +96,14 @@ pub async fn get_remote_following(
|
|||||||
Ok(Json(actors.into_iter().map(to_response).collect()))
|
Ok(Json(actors.into_iter().map(to_response).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_remote_friends_handler(
|
||||||
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
|
AuthUser(uid): AuthUser,
|
||||||
|
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
|
||||||
|
let actors = get_remote_friends(&*d.federation, &uid).await?;
|
||||||
|
Ok(Json(actors.into_iter().map(to_response).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_following(
|
pub async fn delete_following(
|
||||||
Deps(d): Deps<FederationManagementDeps>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ use crate::{
|
|||||||
errors::ApiError,
|
errors::ApiError,
|
||||||
extractors::{AuthUser, Deps},
|
extractors::{AuthUser, Deps},
|
||||||
};
|
};
|
||||||
use api_types::requests::SetTopFriendsRequest;
|
use api_types::requests::{PaginationQuery, SetTopFriendsRequest};
|
||||||
use api_types::responses::TopFriendsResponse;
|
use api_types::responses::{PagedResponse, TopFriendsResponse, UserResponse};
|
||||||
use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends};
|
use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends};
|
||||||
use application::use_cases::social::*;
|
use application::use_cases::social::*;
|
||||||
use axum::{extract::Path, http::StatusCode, Json};
|
use axum::{
|
||||||
|
extract::{Path, Query},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
ports::{
|
ports::{
|
||||||
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,
|
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,
|
||||||
@@ -150,5 +154,33 @@ pub async fn get_top_friends_handler(
|
|||||||
Ok(Json(TopFriendsResponse { top_friends }))
|
Ok(Json(TopFriendsResponse { top_friends }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/users/me/friends",
|
||||||
|
params(PaginationQuery),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Local mutual follows (paginated)", body = inline(PagedResponse<UserResponse>)),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = []))
|
||||||
|
)]
|
||||||
|
pub async fn get_friends_handler(
|
||||||
|
Deps(d): Deps<SocialDeps>,
|
||||||
|
AuthUser(uid): AuthUser,
|
||||||
|
Query(q): Query<PaginationQuery>,
|
||||||
|
) -> Result<Json<PagedResponse<UserResponse>>, ApiError> {
|
||||||
|
use domain::models::feed::PageParams;
|
||||||
|
let page = PageParams {
|
||||||
|
page: q.page(),
|
||||||
|
per_page: q.per_page(),
|
||||||
|
};
|
||||||
|
let result = get_local_friends(&*d.follows, &uid, &page).await?;
|
||||||
|
Ok(Json(PagedResponse {
|
||||||
|
items: result.items.iter().map(to_user_response).collect(),
|
||||||
|
total: result.total,
|
||||||
|
page: result.page,
|
||||||
|
per_page: result.per_page,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
use super::get_friends_handler;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::testing::make_state;
|
use crate::testing::make_state;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
http::Request,
|
http::Request,
|
||||||
routing::{delete, post},
|
routing::{delete, get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
@@ -32,6 +33,24 @@ async fn follow_without_auth_returns_401() {
|
|||||||
assert_eq!(resp.status(), 401);
|
assert_eq!(resp.status(), 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_friends_without_auth_returns_401() {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/users/me/friends", get(get_friends_handler))
|
||||||
|
.with_state(make_state());
|
||||||
|
let resp = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/users/me/friends")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), 401);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unfollow_remote_without_auth_returns_401() {
|
async fn unfollow_remote_without_auth_returns_401() {
|
||||||
let resp = app()
|
let resp = app()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use utoipa::OpenApi;
|
|||||||
crate::handlers::social::delete_block,
|
crate::handlers::social::delete_block,
|
||||||
crate::handlers::social::put_top_friends,
|
crate::handlers::social::put_top_friends,
|
||||||
crate::handlers::social::get_top_friends_handler,
|
crate::handlers::social::get_top_friends_handler,
|
||||||
|
crate::handlers::social::get_friends_handler,
|
||||||
),
|
),
|
||||||
components(schemas(SetTopFriendsRequest))
|
components(schemas(SetTopFriendsRequest))
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub fn router() -> Router<AppState> {
|
|||||||
put(users::upload_banner).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
|
put(users::upload_banner).layer(DefaultBodyLimit::max(10 * 1024 * 1024)),
|
||||||
)
|
)
|
||||||
.route("/users/me/following", get(users::get_me_following))
|
.route("/users/me/following", get(users::get_me_following))
|
||||||
|
.route("/users/me/friends", get(social::get_friends_handler))
|
||||||
.route("/users/me/top-friends", put(social::put_top_friends))
|
.route("/users/me/top-friends", put(social::put_top_friends))
|
||||||
.route("/users/{username}", get(users::get_user))
|
.route("/users/{username}", get(users::get_user))
|
||||||
.route(
|
.route(
|
||||||
@@ -104,6 +105,10 @@ pub fn router() -> Router<AppState> {
|
|||||||
get(federation_management::get_remote_following)
|
get(federation_management::get_remote_following)
|
||||||
.delete(federation_management::delete_following),
|
.delete(federation_management::delete_following),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/federation/me/friends",
|
||||||
|
get(federation_management::get_remote_friends_handler),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/federation/me/move",
|
"/federation/me/move",
|
||||||
post(federation_management::post_move_account),
|
post(federation_management::post_move_account),
|
||||||
|
|||||||
Reference in New Issue
Block a user