use chrono::Utc; 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}, }; pub async fn like_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { let like = Like { id: LikeId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() }; likes.save(&like).await?; events.publish(&DomainEvent::LikeAdded { like_id: like.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; Ok(()) } pub async fn unlike_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { likes.delete(user_id, thought_id).await?; events.publish(&DomainEvent::LikeRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; Ok(()) } pub async fn boost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { let boost = Boost { id: BoostId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() }; boosts.save(&boost).await?; events.publish(&DomainEvent::BoostAdded { boost_id: boost.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; Ok(()) } pub async fn unboost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { boosts.delete(user_id, thought_id).await?; events.publish(&DomainEvent::BoostRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; Ok(()) } pub async fn follow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { if follower_id == following_id { return Err(DomainError::InvalidInput("cannot follow yourself".into())); } let follow = Follow { follower_id: follower_id.clone(), following_id: following_id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() }; follows.save(&follow).await?; events.publish(&DomainEvent::FollowAccepted { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; Ok(()) } pub async fn unfollow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { follows.delete(follower_id, following_id).await?; events.publish(&DomainEvent::Unfollowed { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; Ok(()) } pub async fn accept_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { follows.update_state(follower_id, following_id, &FollowState::Accepted).await?; events.publish(&DomainEvent::FollowAccepted { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; Ok(()) } pub async fn reject_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { follows.update_state(follower_id, following_id, &FollowState::Rejected).await?; events.publish(&DomainEvent::FollowRejected { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; Ok(()) } pub async fn block_user(blocks: &dyn BlockRepository, events: &dyn EventPublisher, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { if blocker_id == blocked_id { return Err(DomainError::InvalidInput("cannot block yourself".into())); } let block = Block { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone(), created_at: Utc::now() }; blocks.save(&block).await?; events.publish(&DomainEvent::UserBlocked { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone() }).await?; Ok(()) } pub async fn unblock_user( blocks: &dyn BlockRepository, events: &dyn EventPublisher, blocker_id: &UserId, blocked_id: &UserId, ) -> Result<(), DomainError> { blocks.delete(blocker_id, blocked_id).await?; events.publish(&DomainEvent::UserUnblocked { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone(), }).await?; Ok(()) } #[cfg(test)] mod tests { use super::*; use domain::{ models::{thought::{Thought, Visibility}, user::User}, testing::TestStore, value_objects::*, }; fn user(name: &str) -> User { User::new_local(UserId::new(), Username::new(name).unwrap(), Email::new(format!("{name}@ex.com")).unwrap(), PasswordHash("h".into())) } #[tokio::test] async fn like_and_unlike() { let store = TestStore::default(); let alice = user("alice"); let tid = ThoughtId::new(); store.thoughts.lock().unwrap().push(Thought::new_local(tid.clone(), alice.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false)); like_thought(&store, &store, &alice.id, &tid).await.unwrap(); assert_eq!(store.likes.lock().unwrap().len(), 1); unlike_thought(&store, &store, &alice.id, &tid).await.unwrap(); assert!(store.likes.lock().unwrap().is_empty()); } #[tokio::test] async fn follow_and_unfollow() { let store = TestStore::default(); let alice = user("alice"); let bob = user("bob"); follow_user(&store, &store, &alice.id, &bob.id).await.unwrap(); assert_eq!(store.follows.lock().unwrap().len(), 1); unfollow_user(&store, &store, &alice.id, &bob.id).await.unwrap(); assert!(store.follows.lock().unwrap().is_empty()); } #[tokio::test] async fn cannot_follow_self() { let store = TestStore::default(); let alice = user("alice"); let err = follow_user(&store, &store, &alice.id, &alice.id).await.unwrap_err(); assert!(matches!(err, DomainError::InvalidInput(_))); } #[tokio::test] async fn unblock_user_publishes_event() { let store = TestStore::default(); let alice = user("alice"); let bob = user("bob"); block_user(&store, &store, &alice.id, &bob.id).await.unwrap(); store.events.lock().unwrap().clear(); unblock_user(&store, &store, &alice.id, &bob.id).await.unwrap(); let events = store.events.lock().unwrap(); assert_eq!(events.len(), 1); assert!(matches!(events[0], DomainEvent::UserUnblocked { .. })); } }