use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; use api_types::requests::SetTopFriendsRequest; use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends}; use application::use_cases::social::*; use axum::{ extract::{Path, State}, http::StatusCode, Json, }; use domain::value_objects::{ThoughtId, UserId}; use uuid::Uuid; #[utoipa::path(post, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Liked")), security(("bearer_auth" = [])))] pub async fn post_like( State(s): State, AuthUser(uid): AuthUser, Path(id): Path, ) -> Result { like_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(delete, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unliked")), security(("bearer_auth" = [])))] pub async fn delete_like( State(s): State, AuthUser(uid): AuthUser, Path(id): Path, ) -> Result { unlike_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(post, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Boosted")), security(("bearer_auth" = [])))] pub async fn post_boost( State(s): State, AuthUser(uid): AuthUser, Path(id): Path, ) -> Result { boost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(delete, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unboosted")), security(("bearer_auth" = [])))] pub async fn delete_boost( State(s): State, AuthUser(uid): AuthUser, Path(id): Path, ) -> Result { unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path( post, path = "/users/{username}/follow", params(("username" = String, Path, description = "Username or user@domain handle")), responses((status = 204, description = "Following")), security(("bearer_auth" = [])) )] pub async fn post_follow( State(s): State, 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?; } Ok(StatusCode::NO_CONTENT) } #[utoipa::path( delete, path = "/users/{username}/follow", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Unfollowed")), security(("bearer_auth" = [])) )] pub async fn delete_follow( State(s): State, AuthUser(uid): AuthUser, Path(username): Path, ) -> Result { if username.contains('@') { return Err(ApiError::BadRequest( "remote unfollow not yet supported".into(), )); } let target = get_user_by_username(&*s.users, &username).await?; unfollow_user(&*s.follows, &*s.events, &uid, &target.id).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(post, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))] pub async fn post_block( State(s): State, AuthUser(uid): AuthUser, Path(username): Path, ) -> Result { let target = get_user_by_username(&*s.users, &username).await?; block_user(&*s.blocks, &*s.events, &uid, &target.id).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(delete, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))] pub async fn delete_block( State(s): State, AuthUser(uid): AuthUser, Path(username): Path, ) -> Result { let target = get_user_by_username(&*s.users, &username).await?; unblock_user(&*s.blocks, &*s.events, &uid, &target.id).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(put, path = "/users/me/top-friends", request_body = SetTopFriendsRequest, responses((status = 204, description = "Top friends updated")), security(("bearer_auth" = [])))] pub async fn put_top_friends( State(s): State, AuthUser(uid): AuthUser, Json(body): Json, ) -> Result { let ids: Vec = body.friend_ids.into_iter().map(UserId::from_uuid).collect(); set_top_friends(&*s.top_friends, &uid, ids).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(get, path = "/users/{username}/top-friends", params(("username" = String, Path, description = "Username")), responses((status = 200, description = "Top friends list")))] pub async fn get_top_friends_handler( State(s): State, Path(username): Path, ) -> Result, ApiError> { let user = get_user_by_username(&*s.users, &username).await?; let friends = get_top_friends(&*s.top_friends, &user.id).await?; let usernames: Vec<&str> = friends.iter().map(|(_, u)| u.username.as_str()).collect(); Ok(Json(serde_json::json!({ "topFriends": usernames }))) } #[cfg(test)] mod tests { use super::*; use async_trait::async_trait; use axum::{ body::Body, http::Request, routing::{delete, post}, Router, }; use domain::{ errors::DomainError, ports::{AuthService, GeneratedToken, PasswordHasher}, testing::TestStore, value_objects::{PasswordHash, UserId}, }; use std::sync::Arc; use tower::ServiceExt; struct NoOpAuth; impl AuthService for NoOpAuth { fn generate_token(&self, _uid: &UserId) -> Result { Err(DomainError::Internal("noop".into())) } fn validate_token(&self, _token: &str) -> Result { Err(DomainError::Unauthorized) } } struct NoOpHasher; #[async_trait] impl PasswordHasher for NoOpHasher { async fn hash(&self, _plain: &str) -> Result { Err(DomainError::Internal("noop".into())) } async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result { Ok(false) } } fn make_state() -> crate::state::AppState { let store = Arc::new(TestStore::default()); crate::state::AppState { users: store.clone(), thoughts: store.clone(), likes: store.clone(), boosts: store.clone(), follows: store.clone(), blocks: store.clone(), tags: store.clone(), api_keys: store.clone(), top_friends: store.clone(), notifications: store.clone(), remote_actors: store.clone(), feed: store.clone(), search: store.clone(), auth: Arc::new(NoOpAuth), hasher: Arc::new(NoOpHasher), events: store.clone(), federation: store.clone(), } } fn app() -> Router { Router::new() .route( "/users/{username}/follow", post(post_follow).delete(delete_follow), ) .with_state(make_state()) } #[tokio::test] async fn follow_without_auth_returns_401() { let resp = app() .oneshot( Request::builder() .method("POST") .uri("/users/alice/follow") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), 401); } #[tokio::test] async fn unfollow_remote_without_auth_returns_401() { let resp = app() .oneshot( Request::builder() .method("DELETE") .uri("/users/alice@example.com/follow") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), 401); } }