use domain::{ errors::DomainError, events::DomainEvent, models::thought::{Thought, Visibility}, ports::{EventPublisher, ThoughtRepository, UserRepository}, value_objects::{Content, ThoughtId, UserId}, }; fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> { if thought.user_id != *user_id { return Err(DomainError::NotFound); } Ok(()) } pub struct CreateThoughtInput { pub user_id: UserId, pub content: String, pub in_reply_to_id: Option, pub visibility: Option, pub content_warning: Option, pub sensitive: bool, } pub struct CreateThoughtOutput { pub thought: Thought, } pub async fn create_thought( thoughts: &dyn ThoughtRepository, _users: &dyn UserRepository, events: &dyn EventPublisher, input: CreateThoughtInput, ) -> Result { let content = Content::new_local(input.content)?; let visibility = input .visibility .as_deref() .map(Visibility::from_db_str) .unwrap_or(Visibility::Public); let thought = Thought::new_local( ThoughtId::new(), input.user_id, content, input.in_reply_to_id.clone(), visibility, input.content_warning, input.sensitive, ); thoughts.save(&thought).await?; events .publish(&DomainEvent::ThoughtCreated { thought_id: thought.id.clone(), user_id: thought.user_id.clone(), in_reply_to_id: input.in_reply_to_id, }) .await?; Ok(CreateThoughtOutput { thought }) } pub async fn delete_thought( thoughts: &dyn ThoughtRepository, events: &dyn EventPublisher, id: &ThoughtId, user_id: &UserId, ) -> Result<(), DomainError> { let thought = thoughts .find_by_id(id) .await? .ok_or(DomainError::NotFound)?; require_owner(&thought, user_id)?; thoughts.delete(id, user_id).await?; events .publish(&DomainEvent::ThoughtDeleted { thought_id: id.clone(), user_id: user_id.clone(), }) .await?; Ok(()) } pub async fn edit_thought( thoughts: &dyn ThoughtRepository, events: &dyn EventPublisher, id: &ThoughtId, user_id: &UserId, new_content: String, ) -> Result<(), DomainError> { let thought = thoughts .find_by_id(id) .await? .ok_or(DomainError::NotFound)?; require_owner(&thought, user_id)?; let content = Content::new_local(new_content)?; thoughts.update_content(id, &content).await?; events .publish(&DomainEvent::ThoughtUpdated { thought_id: id.clone(), user_id: user_id.clone(), }) .await?; Ok(()) } pub async fn get_thought( thoughts: &dyn ThoughtRepository, id: &ThoughtId, ) -> Result { thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound) } pub async fn get_thread( thoughts: &dyn ThoughtRepository, id: &ThoughtId, ) -> Result, DomainError> { thoughts.get_thread(id).await } #[cfg(test)] mod tests { use super::*; use domain::{ models::user::User, testing::{NoOpEventPublisher, TestStore}, value_objects::*, }; fn user() -> User { User::new_local( UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()), ) } fn input(uid: UserId) -> CreateThoughtInput { CreateThoughtInput { user_id: uid, content: "hello".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false, } } #[tokio::test] async fn create_thought_saves_and_emits_event() { let store = TestStore::default(); let u = user(); store.users.lock().unwrap().push(u.clone()); let out = create_thought(&store, &store, &store, input(u.id.clone())) .await .unwrap(); assert_eq!(out.thought.content.as_str(), "hello"); assert_eq!(store.events.lock().unwrap().len(), 1); } #[tokio::test] async fn delete_own_thought_succeeds() { let store = TestStore::default(); let u = user(); store.users.lock().unwrap().push(u.clone()); let out = create_thought(&store, &store, &NoOpEventPublisher, input(u.id.clone())) .await .unwrap(); delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id) .await .unwrap(); assert!(store.thoughts.lock().unwrap().is_empty()); } #[tokio::test] async fn delete_other_thought_returns_not_found() { let store = TestStore::default(); let alice = user(); let bob = User::new_local( UserId::new(), Username::new("bob").unwrap(), Email::new("bob@ex.com").unwrap(), PasswordHash("h".into()), ); store .users .lock() .unwrap() .extend([alice.clone(), bob.clone()]); let out = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())) .await .unwrap(); let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id) .await .unwrap_err(); assert!(matches!(err, DomainError::NotFound)); } #[tokio::test] async fn edit_thought_changes_content_and_emits_event() { let store = TestStore::default(); let alice = user(); store.users.lock().unwrap().push(alice.clone()); let out = create_thought(&store, &store, &store, input(alice.id.clone())) .await .unwrap(); let tid = out.thought.id.clone(); edit_thought(&store, &store, &tid, &alice.id, "updated".to_string()) .await .unwrap(); let saved = store .thoughts .lock() .unwrap() .iter() .find(|t| t.id == tid) .unwrap() .clone(); assert_eq!(saved.content.as_str(), "updated"); let events = store.events.lock().unwrap(); assert!(events.iter().any( |e| matches!(e, DomainEvent::ThoughtUpdated { thought_id, .. } if thought_id == &tid) )); } #[tokio::test] async fn create_reply_sets_in_reply_to_id() { let store = TestStore::default(); let alice = user(); store.users.lock().unwrap().push(alice.clone()); let original = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())) .await .unwrap() .thought; create_thought( &store, &store, &NoOpEventPublisher, CreateThoughtInput { user_id: alice.id.clone(), content: "reply".into(), in_reply_to_id: Some(original.id.clone()), visibility: None, content_warning: None, sensitive: false, }, ) .await .unwrap(); let thoughts = store.thoughts.lock().unwrap(); let reply = thoughts .iter() .find(|t| t.content.as_str() == "reply") .unwrap(); assert_eq!(reply.in_reply_to_id, Some(original.id.clone())); } }