refactor: extract inline test modules to separate files
This commit is contained in:
@@ -1,787 +0,0 @@
|
||||
use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::thought::Visibility,
|
||||
ports::{ThoughtRepository, UserReader},
|
||||
value_objects::ThoughtId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct FederationEventService {
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub users: Arc<dyn UserReader>,
|
||||
pub ap: Arc<dyn OutboundFederationPort>,
|
||||
pub base_url: String,
|
||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||
}
|
||||
|
||||
impl FederationEventService {
|
||||
async fn object_ap_id(&self, thought_id: &ThoughtId) -> Result<String, DomainError> {
|
||||
if let Some(ap_id) = self.ap_repo.get_thought_ap_id(thought_id).await? {
|
||||
return Ok(ap_id);
|
||||
}
|
||||
Ok(format!("{}/thoughts/{}", self.base_url, thought_id))
|
||||
}
|
||||
|
||||
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
match event {
|
||||
DomainEvent::ThoughtCreated {
|
||||
thought_id,
|
||||
user_id,
|
||||
..
|
||||
} => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t)
|
||||
if t.local
|
||||
&& matches!(
|
||||
t.visibility,
|
||||
Visibility::Public | Visibility::Unlisted
|
||||
) =>
|
||||
{
|
||||
t
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let user = match self.users.find_by_id(user_id).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
// Resolve in_reply_to_url for the parent thought via AP repo.
|
||||
let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id {
|
||||
let ap_id = self
|
||||
.ap_repo
|
||||
.get_thought_ap_id(reply_id)
|
||||
.await?
|
||||
.unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, reply_id));
|
||||
Some(ap_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.ap
|
||||
.broadcast_create(
|
||||
user_id,
|
||||
&thought,
|
||||
user.username.as_str(),
|
||||
in_reply_to_url.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::ThoughtDeleted {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => {
|
||||
// No DB lookup — thought is already deleted when this event fires.
|
||||
// No locality guard: delete commands only reach local thoughts via the use case.
|
||||
let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id);
|
||||
self.ap.broadcast_delete(user_id, &ap_id).await
|
||||
}
|
||||
|
||||
DomainEvent::ThoughtUpdated {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t)
|
||||
if t.local
|
||||
&& matches!(
|
||||
t.visibility,
|
||||
Visibility::Public | Visibility::Unlisted
|
||||
) =>
|
||||
{
|
||||
t
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let user = match self.users.find_by_id(user_id).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id {
|
||||
self.ap_repo
|
||||
.get_thought_ap_id(reply_id)
|
||||
.await?
|
||||
.or_else(|| Some(format!("{}/thoughts/{}", self.base_url, reply_id)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.ap
|
||||
.broadcast_update(
|
||||
user_id,
|
||||
&thought,
|
||||
user.username.as_str(),
|
||||
in_reply_to_url.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::BoostAdded {
|
||||
boost_id: _,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
// Only fan-out if the booster is a local user. Remote boosts must not be re-broadcast.
|
||||
let booster = match self.users.find_by_id(user_id).await? {
|
||||
Some(u) if u.local => u,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let _ = booster;
|
||||
if self.thoughts.find_by_id(thought_id).await?.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let object_ap_id = self.object_ap_id(thought_id).await?;
|
||||
self.ap.broadcast_announce(user_id, &object_ap_id).await
|
||||
}
|
||||
|
||||
DomainEvent::BoostRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
if self.thoughts.find_by_id(thought_id).await?.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let object_ap_id = self.object_ap_id(thought_id).await?;
|
||||
self.ap
|
||||
.broadcast_undo_announce(user_id, &object_ap_id)
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::LikeAdded {
|
||||
like_id: _,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
// Only federate: local liker + remote thought (has ap_id) + author has inbox.
|
||||
let liker = match self.users.find_by_id(user_id).await? {
|
||||
Some(u) if u.local => u,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let _ = liker;
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
|
||||
Some(id) => id,
|
||||
None => return Ok(()), // local thought — no federation needed
|
||||
};
|
||||
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
self.ap
|
||||
.broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::LikeRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
let liker = match self.users.find_by_id(user_id).await? {
|
||||
Some(u) if u.local => u,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let _ = liker;
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
self.ap
|
||||
.broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::ProfileUpdated { user_id } => {
|
||||
self.ap.broadcast_actor_update(user_id).await
|
||||
}
|
||||
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use activitypub_base::{ActorApUrls, OutboundFederationPort};
|
||||
use async_trait::async_trait;
|
||||
use crate::testing::TestApRepo;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::thought::{Thought, Visibility},
|
||||
models::user::User,
|
||||
testing::TestStore,
|
||||
value_objects::*,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// ── Spy port ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyPort {
|
||||
created: Mutex<Vec<ThoughtId>>,
|
||||
deleted: Mutex<Vec<String>>,
|
||||
updated: Mutex<Vec<ThoughtId>>,
|
||||
announced: Mutex<Vec<String>>,
|
||||
undo_announced: Mutex<Vec<String>>,
|
||||
liked: Mutex<Vec<String>>,
|
||||
undo_liked: Mutex<Vec<String>>,
|
||||
actor_updated: Mutex<Vec<UserId>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OutboundFederationPort for SpyPort {
|
||||
async fn broadcast_create(
|
||||
&self,
|
||||
_: &UserId,
|
||||
thought: &Thought,
|
||||
_: &str,
|
||||
_in_reply_to_url: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.created.lock().unwrap().push(thought.id.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn broadcast_delete(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
|
||||
self.deleted.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
async fn broadcast_update(
|
||||
&self,
|
||||
_: &UserId,
|
||||
thought: &Thought,
|
||||
_: &str,
|
||||
_in_reply_to_url: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.updated.lock().unwrap().push(thought.id.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn broadcast_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
|
||||
self.announced.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
async fn broadcast_undo_announce(
|
||||
&self,
|
||||
_: &UserId,
|
||||
ap_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.undo_announced.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast_like(
|
||||
&self,
|
||||
_: &UserId,
|
||||
ap_id: &str,
|
||||
_: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.liked.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast_undo_like(
|
||||
&self,
|
||||
_: &UserId,
|
||||
ap_id: &str,
|
||||
_: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.undo_liked.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.actor_updated.lock().unwrap().push(user_id.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn alice() -> User {
|
||||
User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
)
|
||||
}
|
||||
|
||||
fn local_thought(author_id: UserId) -> Thought {
|
||||
Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
author_id,
|
||||
Content::new_local("hello").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
FederationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
users: Arc::new(store.clone()),
|
||||
ap: spy,
|
||||
base_url: "https://example.com".to_string(),
|
||||
ap_repo: Arc::new(ap_repo),
|
||||
}
|
||||
}
|
||||
|
||||
fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc<SpyPort>) -> FederationEventService {
|
||||
FederationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
users: Arc::new(store.clone()),
|
||||
ap: spy,
|
||||
base_url: "https://example.com".to_string(),
|
||||
ap_repo: Arc::new(ap_repo),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_created_broadcasts_create() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone());
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spy.created.lock().unwrap().len(), 1);
|
||||
assert_eq!(spy.created.lock().unwrap()[0], thought.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_thought_created_does_not_broadcast() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
// Remote thought: local = false
|
||||
let mut thought = local_thought(alice.id.clone());
|
||||
thought.local = false;
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.created.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_deleted_broadcasts_delete_with_constructed_ap_id() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let tid = ThoughtId::new();
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtDeleted {
|
||||
thought_id: tid.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deleted = spy.deleted.lock().unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert_eq!(deleted[0], format!("https://example.com/thoughts/{}", tid));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_updated_broadcasts_update() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone());
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtUpdated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spy.updated.lock().unwrap().len(), 1);
|
||||
assert_eq!(spy.updated.lock().unwrap()[0], thought.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_of_local_thought_announces_constructed_url() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone()); // ap_id = None
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let announced = spy.announced.lock().unwrap();
|
||||
assert_eq!(announced.len(), 1);
|
||||
assert_eq!(
|
||||
announced[0],
|
||||
format!("https://example.com/thoughts/{}", thought.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_of_remote_thought_announces_remote_ap_id() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let mut thought = local_thought(alice.id.clone());
|
||||
thought.local = false;
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||
thought.id.clone(),
|
||||
"https://mastodon.social/users/bob/statuses/123".into(),
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc_with_ap(&store, ap_repo, spy.clone())
|
||||
.process(&DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let announced = spy.announced.lock().unwrap();
|
||||
assert_eq!(
|
||||
announced[0],
|
||||
"https://mastodon.social/users/bob/statuses/123"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_thought_created_does_not_broadcast() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("private").unwrap(),
|
||||
None,
|
||||
Visibility::Direct,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.created.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn followers_only_thought_does_not_broadcast_publicly() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("for followers").unwrap(),
|
||||
None,
|
||||
Visibility::Followers,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.created.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_events_are_noop() {
|
||||
let store = TestStore::default();
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
let svc = svc(&store, spy.clone());
|
||||
|
||||
svc.process(&DomainEvent::UserBlocked {
|
||||
blocker_id: UserId::new(),
|
||||
blocked_id: UserId::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.created.lock().unwrap().is_empty());
|
||||
assert!(spy.deleted.lock().unwrap().is_empty());
|
||||
assert!(spy.updated.lock().unwrap().is_empty());
|
||||
assert!(spy.announced.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_created_does_not_broadcast_if_user_missing() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone());
|
||||
// Don't push alice into users — simulates user deleted before handler runs
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.created.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_removed_sends_undo_announce_for_local_thought() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone()); // ap_id = None → constructed URL
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::BoostRemoved {
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let undo_announced = spy.undo_announced.lock().unwrap();
|
||||
assert_eq!(undo_announced.len(), 1);
|
||||
assert_eq!(
|
||||
undo_announced[0],
|
||||
format!("https://example.com/thoughts/{}", thought.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_removed_sends_undo_announce_for_remote_thought() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let mut thought = local_thought(alice.id.clone());
|
||||
thought.local = false;
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||
thought.id.clone(),
|
||||
"https://mastodon.social/users/bob/statuses/456".into(),
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc_with_ap(&store, ap_repo, spy.clone())
|
||||
.process(&DomainEvent::BoostRemoved {
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let undo_announced = spy.undo_announced.lock().unwrap();
|
||||
assert_eq!(undo_announced.len(), 1);
|
||||
assert_eq!(
|
||||
undo_announced[0],
|
||||
"https://mastodon.social/users/bob/statuses/456"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_removed_does_not_broadcast_if_thought_missing() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::BoostRemoved {
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: ThoughtId::new(), // doesn't exist in store
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(spy.undo_announced.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_updated_does_not_broadcast_if_user_missing() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone());
|
||||
// Don't push alice into users
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtUpdated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.updated.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn like_added_local_user_remote_thought_broadcasts_like() {
|
||||
let store = TestStore::default();
|
||||
|
||||
let mut author = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("remote_author").unwrap(),
|
||||
Email::new("r@remote.example").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
author.local = false;
|
||||
let thought = local_thought(author.id.clone());
|
||||
let liker = alice();
|
||||
|
||||
store.users.lock().unwrap().push(author.clone());
|
||||
store.users.lock().unwrap().push(liker.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
ap_repo.actor_ap_urls.lock().unwrap().insert(
|
||||
author.id.clone(),
|
||||
ActorApUrls {
|
||||
ap_id: "https://mastodon.social/users/author".into(),
|
||||
inbox_url: "https://mastodon.social/users/author/inbox".into(),
|
||||
},
|
||||
);
|
||||
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||
thought.id.clone(),
|
||||
"https://mastodon.social/posts/123".into(),
|
||||
);
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc_with_ap(&store, ap_repo, spy.clone())
|
||||
.process(&DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: liker.id,
|
||||
thought_id: thought.id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spy.liked.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn like_added_remote_user_skips_broadcast() {
|
||||
let store = TestStore::default();
|
||||
|
||||
let author = alice();
|
||||
let thought = local_thought(author.id.clone()); // local thought — no ap_id
|
||||
|
||||
let mut remote_liker = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("bob").unwrap(),
|
||||
Email::new("bob@remote").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
remote_liker.local = false;
|
||||
|
||||
store.users.lock().unwrap().push(author);
|
||||
store.users.lock().unwrap().push(remote_liker.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: remote_liker.id,
|
||||
thought_id: thought.id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.liked.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_added_remote_user_skips_broadcast() {
|
||||
let store = TestStore::default();
|
||||
|
||||
let author = alice();
|
||||
let thought = local_thought(author.id.clone());
|
||||
|
||||
let mut remote_booster = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("bob").unwrap(),
|
||||
Email::new("bob@remote").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
remote_booster.local = false;
|
||||
|
||||
store.users.lock().unwrap().push(author);
|
||||
store.users.lock().unwrap().push(remote_booster.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::new(),
|
||||
user_id: remote_booster.id,
|
||||
thought_id: thought.id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.announced.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
214
crates/application/src/services/federation_event/mod.rs
Normal file
214
crates/application/src/services/federation_event/mod.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::thought::Visibility,
|
||||
ports::{ThoughtRepository, UserReader},
|
||||
value_objects::ThoughtId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct FederationEventService {
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub users: Arc<dyn UserReader>,
|
||||
pub ap: Arc<dyn OutboundFederationPort>,
|
||||
pub base_url: String,
|
||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||
}
|
||||
|
||||
impl FederationEventService {
|
||||
async fn object_ap_id(&self, thought_id: &ThoughtId) -> Result<String, DomainError> {
|
||||
if let Some(ap_id) = self.ap_repo.get_thought_ap_id(thought_id).await? {
|
||||
return Ok(ap_id);
|
||||
}
|
||||
Ok(format!("{}/thoughts/{}", self.base_url, thought_id))
|
||||
}
|
||||
|
||||
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
match event {
|
||||
DomainEvent::ThoughtCreated {
|
||||
thought_id,
|
||||
user_id,
|
||||
..
|
||||
} => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t)
|
||||
if t.local
|
||||
&& matches!(
|
||||
t.visibility,
|
||||
Visibility::Public | Visibility::Unlisted
|
||||
) =>
|
||||
{
|
||||
t
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let user = match self.users.find_by_id(user_id).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
// Resolve in_reply_to_url for the parent thought via AP repo.
|
||||
let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id {
|
||||
let ap_id = self
|
||||
.ap_repo
|
||||
.get_thought_ap_id(reply_id)
|
||||
.await?
|
||||
.unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, reply_id));
|
||||
Some(ap_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.ap
|
||||
.broadcast_create(
|
||||
user_id,
|
||||
&thought,
|
||||
user.username.as_str(),
|
||||
in_reply_to_url.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::ThoughtDeleted {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => {
|
||||
// No DB lookup — thought is already deleted when this event fires.
|
||||
// No locality guard: delete commands only reach local thoughts via the use case.
|
||||
let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id);
|
||||
self.ap.broadcast_delete(user_id, &ap_id).await
|
||||
}
|
||||
|
||||
DomainEvent::ThoughtUpdated {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t)
|
||||
if t.local
|
||||
&& matches!(
|
||||
t.visibility,
|
||||
Visibility::Public | Visibility::Unlisted
|
||||
) =>
|
||||
{
|
||||
t
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let user = match self.users.find_by_id(user_id).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id {
|
||||
self.ap_repo
|
||||
.get_thought_ap_id(reply_id)
|
||||
.await?
|
||||
.or_else(|| Some(format!("{}/thoughts/{}", self.base_url, reply_id)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.ap
|
||||
.broadcast_update(
|
||||
user_id,
|
||||
&thought,
|
||||
user.username.as_str(),
|
||||
in_reply_to_url.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::BoostAdded {
|
||||
boost_id: _,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
// Only fan-out if the booster is a local user. Remote boosts must not be re-broadcast.
|
||||
let booster = match self.users.find_by_id(user_id).await? {
|
||||
Some(u) if u.local => u,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let _ = booster;
|
||||
if self.thoughts.find_by_id(thought_id).await?.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let object_ap_id = self.object_ap_id(thought_id).await?;
|
||||
self.ap.broadcast_announce(user_id, &object_ap_id).await
|
||||
}
|
||||
|
||||
DomainEvent::BoostRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
if self.thoughts.find_by_id(thought_id).await?.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let object_ap_id = self.object_ap_id(thought_id).await?;
|
||||
self.ap
|
||||
.broadcast_undo_announce(user_id, &object_ap_id)
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::LikeAdded {
|
||||
like_id: _,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
// Only federate: local liker + remote thought (has ap_id) + author has inbox.
|
||||
let liker = match self.users.find_by_id(user_id).await? {
|
||||
Some(u) if u.local => u,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let _ = liker;
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
|
||||
Some(id) => id,
|
||||
None => return Ok(()), // local thought — no federation needed
|
||||
};
|
||||
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
self.ap
|
||||
.broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::LikeRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
let liker = match self.users.find_by_id(user_id).await? {
|
||||
Some(u) if u.local => u,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let _ = liker;
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
|
||||
Some(u) => u,
|
||||
None => return Ok(()),
|
||||
};
|
||||
self.ap
|
||||
.broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::ProfileUpdated { user_id } => {
|
||||
self.ap.broadcast_actor_update(user_id).await
|
||||
}
|
||||
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
572
crates/application/src/services/federation_event/tests.rs
Normal file
572
crates/application/src/services/federation_event/tests.rs
Normal file
@@ -0,0 +1,572 @@
|
||||
use super::*;
|
||||
use activitypub_base::{ActorApUrls, OutboundFederationPort};
|
||||
use async_trait::async_trait;
|
||||
use crate::testing::TestApRepo;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::thought::{Thought, Visibility},
|
||||
models::user::User,
|
||||
testing::TestStore,
|
||||
value_objects::*,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// ── Spy port ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyPort {
|
||||
created: Mutex<Vec<ThoughtId>>,
|
||||
deleted: Mutex<Vec<String>>,
|
||||
updated: Mutex<Vec<ThoughtId>>,
|
||||
announced: Mutex<Vec<String>>,
|
||||
undo_announced: Mutex<Vec<String>>,
|
||||
liked: Mutex<Vec<String>>,
|
||||
undo_liked: Mutex<Vec<String>>,
|
||||
actor_updated: Mutex<Vec<UserId>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OutboundFederationPort for SpyPort {
|
||||
async fn broadcast_create(
|
||||
&self,
|
||||
_: &UserId,
|
||||
thought: &Thought,
|
||||
_: &str,
|
||||
_in_reply_to_url: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.created.lock().unwrap().push(thought.id.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn broadcast_delete(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
|
||||
self.deleted.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
async fn broadcast_update(
|
||||
&self,
|
||||
_: &UserId,
|
||||
thought: &Thought,
|
||||
_: &str,
|
||||
_in_reply_to_url: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.updated.lock().unwrap().push(thought.id.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn broadcast_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
|
||||
self.announced.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
async fn broadcast_undo_announce(
|
||||
&self,
|
||||
_: &UserId,
|
||||
ap_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.undo_announced.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast_like(
|
||||
&self,
|
||||
_: &UserId,
|
||||
ap_id: &str,
|
||||
_: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.liked.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast_undo_like(
|
||||
&self,
|
||||
_: &UserId,
|
||||
ap_id: &str,
|
||||
_: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.undo_liked.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.actor_updated.lock().unwrap().push(user_id.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn alice() -> User {
|
||||
User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
)
|
||||
}
|
||||
|
||||
fn local_thought(author_id: UserId) -> Thought {
|
||||
Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
author_id,
|
||||
Content::new_local("hello").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
FederationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
users: Arc::new(store.clone()),
|
||||
ap: spy,
|
||||
base_url: "https://example.com".to_string(),
|
||||
ap_repo: Arc::new(ap_repo),
|
||||
}
|
||||
}
|
||||
|
||||
fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc<SpyPort>) -> FederationEventService {
|
||||
FederationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
users: Arc::new(store.clone()),
|
||||
ap: spy,
|
||||
base_url: "https://example.com".to_string(),
|
||||
ap_repo: Arc::new(ap_repo),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_created_broadcasts_create() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone());
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spy.created.lock().unwrap().len(), 1);
|
||||
assert_eq!(spy.created.lock().unwrap()[0], thought.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_thought_created_does_not_broadcast() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
// Remote thought: local = false
|
||||
let mut thought = local_thought(alice.id.clone());
|
||||
thought.local = false;
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.created.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_deleted_broadcasts_delete_with_constructed_ap_id() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let tid = ThoughtId::new();
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtDeleted {
|
||||
thought_id: tid.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deleted = spy.deleted.lock().unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert_eq!(deleted[0], format!("https://example.com/thoughts/{}", tid));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_updated_broadcasts_update() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone());
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtUpdated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spy.updated.lock().unwrap().len(), 1);
|
||||
assert_eq!(spy.updated.lock().unwrap()[0], thought.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_of_local_thought_announces_constructed_url() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone()); // ap_id = None
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let announced = spy.announced.lock().unwrap();
|
||||
assert_eq!(announced.len(), 1);
|
||||
assert_eq!(
|
||||
announced[0],
|
||||
format!("https://example.com/thoughts/{}", thought.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_of_remote_thought_announces_remote_ap_id() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let mut thought = local_thought(alice.id.clone());
|
||||
thought.local = false;
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||
thought.id.clone(),
|
||||
"https://mastodon.social/users/bob/statuses/123".into(),
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc_with_ap(&store, ap_repo, spy.clone())
|
||||
.process(&DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let announced = spy.announced.lock().unwrap();
|
||||
assert_eq!(
|
||||
announced[0],
|
||||
"https://mastodon.social/users/bob/statuses/123"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_thought_created_does_not_broadcast() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("private").unwrap(),
|
||||
None,
|
||||
Visibility::Direct,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.created.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn followers_only_thought_does_not_broadcast_publicly() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("for followers").unwrap(),
|
||||
None,
|
||||
Visibility::Followers,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.created.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_events_are_noop() {
|
||||
let store = TestStore::default();
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
let svc = svc(&store, spy.clone());
|
||||
|
||||
svc.process(&DomainEvent::UserBlocked {
|
||||
blocker_id: UserId::new(),
|
||||
blocked_id: UserId::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.created.lock().unwrap().is_empty());
|
||||
assert!(spy.deleted.lock().unwrap().is_empty());
|
||||
assert!(spy.updated.lock().unwrap().is_empty());
|
||||
assert!(spy.announced.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_created_does_not_broadcast_if_user_missing() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone());
|
||||
// Don't push alice into users — simulates user deleted before handler runs
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.created.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_removed_sends_undo_announce_for_local_thought() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone()); // ap_id = None → constructed URL
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::BoostRemoved {
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let undo_announced = spy.undo_announced.lock().unwrap();
|
||||
assert_eq!(undo_announced.len(), 1);
|
||||
assert_eq!(
|
||||
undo_announced[0],
|
||||
format!("https://example.com/thoughts/{}", thought.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_removed_sends_undo_announce_for_remote_thought() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let mut thought = local_thought(alice.id.clone());
|
||||
thought.local = false;
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||
thought.id.clone(),
|
||||
"https://mastodon.social/users/bob/statuses/456".into(),
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc_with_ap(&store, ap_repo, spy.clone())
|
||||
.process(&DomainEvent::BoostRemoved {
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let undo_announced = spy.undo_announced.lock().unwrap();
|
||||
assert_eq!(undo_announced.len(), 1);
|
||||
assert_eq!(
|
||||
undo_announced[0],
|
||||
"https://mastodon.social/users/bob/statuses/456"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_removed_does_not_broadcast_if_thought_missing() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::BoostRemoved {
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: ThoughtId::new(), // doesn't exist in store
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(spy.undo_announced.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_updated_does_not_broadcast_if_user_missing() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone());
|
||||
// Don't push alice into users
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::ThoughtUpdated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: alice.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.updated.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn like_added_local_user_remote_thought_broadcasts_like() {
|
||||
let store = TestStore::default();
|
||||
|
||||
let mut author = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("remote_author").unwrap(),
|
||||
Email::new("r@remote.example").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
author.local = false;
|
||||
let thought = local_thought(author.id.clone());
|
||||
let liker = alice();
|
||||
|
||||
store.users.lock().unwrap().push(author.clone());
|
||||
store.users.lock().unwrap().push(liker.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
ap_repo.actor_ap_urls.lock().unwrap().insert(
|
||||
author.id.clone(),
|
||||
ActorApUrls {
|
||||
ap_id: "https://mastodon.social/users/author".into(),
|
||||
inbox_url: "https://mastodon.social/users/author/inbox".into(),
|
||||
},
|
||||
);
|
||||
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||
thought.id.clone(),
|
||||
"https://mastodon.social/posts/123".into(),
|
||||
);
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc_with_ap(&store, ap_repo, spy.clone())
|
||||
.process(&DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: liker.id,
|
||||
thought_id: thought.id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(spy.liked.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn like_added_remote_user_skips_broadcast() {
|
||||
let store = TestStore::default();
|
||||
|
||||
let author = alice();
|
||||
let thought = local_thought(author.id.clone()); // local thought — no ap_id
|
||||
|
||||
let mut remote_liker = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("bob").unwrap(),
|
||||
Email::new("bob@remote").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
remote_liker.local = false;
|
||||
|
||||
store.users.lock().unwrap().push(author);
|
||||
store.users.lock().unwrap().push(remote_liker.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: remote_liker.id,
|
||||
thought_id: thought.id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.liked.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_added_remote_user_skips_broadcast() {
|
||||
let store = TestStore::default();
|
||||
|
||||
let author = alice();
|
||||
let thought = local_thought(author.id.clone());
|
||||
|
||||
let mut remote_booster = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("bob").unwrap(),
|
||||
Email::new("bob@remote").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
remote_booster.local = false;
|
||||
|
||||
store.users.lock().unwrap().push(author);
|
||||
store.users.lock().unwrap().push(remote_booster.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::new(),
|
||||
user_id: remote_booster.id,
|
||||
thought_id: thought.id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.announced.lock().unwrap().is_empty());
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::notification::{Notification, NotificationKind},
|
||||
ports::{NotificationRepository, ThoughtRepository},
|
||||
value_objects::NotificationId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct NotificationEventService {
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub notifications: Arc<dyn NotificationRepository>,
|
||||
}
|
||||
|
||||
fn is_self_action(
|
||||
thought_author: &domain::value_objects::UserId,
|
||||
actor: &domain::value_objects::UserId,
|
||||
) -> bool {
|
||||
thought_author == actor
|
||||
}
|
||||
|
||||
impl NotificationEventService {
|
||||
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
match event {
|
||||
DomainEvent::LikeAdded {
|
||||
like_id: _,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(()),
|
||||
};
|
||||
if is_self_action(&thought.user_id, user_id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: thought.user_id,
|
||||
kind: NotificationKind::Like {
|
||||
thought_id: thought_id.clone(),
|
||||
from_user_id: user_id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
DomainEvent::BoostAdded {
|
||||
boost_id: _,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(()),
|
||||
};
|
||||
if is_self_action(&thought.user_id, user_id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: thought.user_id,
|
||||
kind: NotificationKind::Boost {
|
||||
thought_id: thought_id.clone(),
|
||||
from_user_id: user_id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
DomainEvent::FollowAccepted {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => {
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: following_id.clone(),
|
||||
kind: NotificationKind::Follow {
|
||||
from_user_id: follower_id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
DomainEvent::ThoughtCreated {
|
||||
thought_id,
|
||||
user_id,
|
||||
in_reply_to_id,
|
||||
} => {
|
||||
let reply_to_id = match in_reply_to_id {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let original = match self.thoughts.find_by_id(reply_to_id).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(()),
|
||||
};
|
||||
if is_self_action(&original.user_id, user_id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: original.user_id,
|
||||
kind: NotificationKind::Reply {
|
||||
thought_id: thought_id.clone(),
|
||||
from_user_id: user_id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
DomainEvent::MentionReceived {
|
||||
thought_id,
|
||||
mentioned_user_id,
|
||||
author_user_id,
|
||||
} => {
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: mentioned_user_id.clone(),
|
||||
kind: NotificationKind::Mention {
|
||||
thought_id: thought_id.clone(),
|
||||
from_user_id: author_user_id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::{
|
||||
notification::NotificationKind,
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
testing::TestStore,
|
||||
value_objects::*,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn alice() -> User {
|
||||
User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn like_creates_notification_for_thought_author() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let bob_id = UserId::new();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("hello").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: bob_id,
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert!(matches!(notifs[0].kind, NotificationKind::Like { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_like_creates_no_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("hello").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_accepted_creates_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let bob_id = UserId::new();
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::FollowAccepted {
|
||||
follower_id: bob_id,
|
||||
following_id: alice.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert!(matches!(notifs[0].kind, NotificationKind::Follow { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reply_creates_notification_for_original_author() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let bob_id = UserId::new();
|
||||
let original = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("original").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(original.clone());
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: bob_id,
|
||||
in_reply_to_id: Some(original.id.clone()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert!(matches!(notifs[0].kind, NotificationKind::Reply { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_reply_creates_no_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let original = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("original").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(original.clone());
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: Some(original.id.clone()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_boost_creates_no_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("hello").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
145
crates/application/src/services/notification_event/mod.rs
Normal file
145
crates/application/src/services/notification_event/mod.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::notification::{Notification, NotificationKind},
|
||||
ports::{NotificationRepository, ThoughtRepository},
|
||||
value_objects::NotificationId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct NotificationEventService {
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub notifications: Arc<dyn NotificationRepository>,
|
||||
}
|
||||
|
||||
fn is_self_action(
|
||||
thought_author: &domain::value_objects::UserId,
|
||||
actor: &domain::value_objects::UserId,
|
||||
) -> bool {
|
||||
thought_author == actor
|
||||
}
|
||||
|
||||
impl NotificationEventService {
|
||||
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
match event {
|
||||
DomainEvent::LikeAdded {
|
||||
like_id: _,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(()),
|
||||
};
|
||||
if is_self_action(&thought.user_id, user_id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: thought.user_id,
|
||||
kind: NotificationKind::Like {
|
||||
thought_id: thought_id.clone(),
|
||||
from_user_id: user_id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
DomainEvent::BoostAdded {
|
||||
boost_id: _,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(()),
|
||||
};
|
||||
if is_self_action(&thought.user_id, user_id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: thought.user_id,
|
||||
kind: NotificationKind::Boost {
|
||||
thought_id: thought_id.clone(),
|
||||
from_user_id: user_id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
DomainEvent::FollowAccepted {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => {
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: following_id.clone(),
|
||||
kind: NotificationKind::Follow {
|
||||
from_user_id: follower_id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
DomainEvent::ThoughtCreated {
|
||||
thought_id,
|
||||
user_id,
|
||||
in_reply_to_id,
|
||||
} => {
|
||||
let reply_to_id = match in_reply_to_id {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let original = match self.thoughts.find_by_id(reply_to_id).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(()),
|
||||
};
|
||||
if is_self_action(&original.user_id, user_id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: original.user_id,
|
||||
kind: NotificationKind::Reply {
|
||||
thought_id: thought_id.clone(),
|
||||
from_user_id: user_id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
DomainEvent::MentionReceived {
|
||||
thought_id,
|
||||
mentioned_user_id,
|
||||
author_user_id,
|
||||
} => {
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: mentioned_user_id.clone(),
|
||||
kind: NotificationKind::Mention {
|
||||
thought_id: thought_id.clone(),
|
||||
from_user_id: author_user_id.clone(),
|
||||
},
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
186
crates/application/src/services/notification_event/tests.rs
Normal file
186
crates/application/src/services/notification_event/tests.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::{
|
||||
notification::NotificationKind,
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
testing::TestStore,
|
||||
value_objects::*,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn alice() -> User {
|
||||
User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn like_creates_notification_for_thought_author() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let bob_id = UserId::new();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("hello").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: bob_id,
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert!(matches!(notifs[0].kind, NotificationKind::Like { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_like_creates_no_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("hello").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_accepted_creates_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let bob_id = UserId::new();
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::FollowAccepted {
|
||||
follower_id: bob_id,
|
||||
following_id: alice.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert!(matches!(notifs[0].kind, NotificationKind::Follow { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reply_creates_notification_for_original_author() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let bob_id = UserId::new();
|
||||
let original = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("original").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(original.clone());
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: bob_id,
|
||||
in_reply_to_id: Some(original.id.clone()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert!(matches!(notifs[0].kind, NotificationKind::Reply { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_reply_creates_no_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let original = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("original").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(original.clone());
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: Some(original.id.clone()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_boost_creates_no_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("hello").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
svc.process(&DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::api_key::ApiKey,
|
||||
ports::ApiKeyRepository,
|
||||
value_objects::{ApiKeyId, UserId},
|
||||
};
|
||||
|
||||
pub async fn list_api_keys(
|
||||
keys: &dyn ApiKeyRepository,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<ApiKey>, DomainError> {
|
||||
keys.list_for_user(user_id).await
|
||||
}
|
||||
|
||||
pub async fn create_api_key(
|
||||
keys: &dyn ApiKeyRepository,
|
||||
user_id: &UserId,
|
||||
name: String,
|
||||
) -> Result<(ApiKey, String), DomainError> {
|
||||
let raw_key = uuid::Uuid::new_v4().to_string().replace('-', "");
|
||||
let key_hash = sha256_hex(&raw_key);
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: user_id.clone(),
|
||||
key_hash,
|
||||
name,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
keys.save(&key).await?;
|
||||
Ok((key, raw_key))
|
||||
}
|
||||
|
||||
pub async fn delete_api_key(
|
||||
keys: &dyn ApiKeyRepository,
|
||||
user_id: &UserId,
|
||||
key_id: &ApiKeyId,
|
||||
) -> Result<(), DomainError> {
|
||||
keys.delete(key_id, user_id).await
|
||||
}
|
||||
|
||||
fn sha256_hex(s: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(s.as_bytes());
|
||||
hex::encode(hash)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{testing::TestStore, value_objects::UserId};
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_key_saves_hashed_not_raw() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let (key, raw) = create_api_key(&store, &uid, "my-key".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(key.key_hash, raw, "stored hash must differ from raw key");
|
||||
assert!(!key.key_hash.is_empty());
|
||||
assert_eq!(key.name, "my-key");
|
||||
assert_eq!(key.user_id, uid);
|
||||
assert_eq!(store.api_keys.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_key_verifies_against_stored_hash() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let (key, raw) = create_api_key(&store, &uid, "test".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_hash = hex::encode(Sha256::digest(raw.as_bytes()));
|
||||
assert_eq!(key.key_hash, expected_hash);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_key_removes_it() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let (key, _) = create_api_key(&store, &uid, "k".to_string()).await.unwrap();
|
||||
delete_api_key(&store, &uid, &key.id).await.unwrap();
|
||||
assert!(store.api_keys.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_keys_returns_only_own_keys() {
|
||||
let store = TestStore::default();
|
||||
let alice = UserId::new();
|
||||
let bob = UserId::new();
|
||||
create_api_key(&store, &alice, "a".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
create_api_key(&store, &bob, "b".to_string()).await.unwrap();
|
||||
let alice_keys = list_api_keys(&store, &alice).await.unwrap();
|
||||
assert_eq!(alice_keys.len(), 1);
|
||||
assert_eq!(alice_keys[0].user_id, alice);
|
||||
}
|
||||
}
|
||||
49
crates/application/src/use_cases/api_keys/mod.rs
Normal file
49
crates/application/src/use_cases/api_keys/mod.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::api_key::ApiKey,
|
||||
ports::ApiKeyRepository,
|
||||
value_objects::{ApiKeyId, UserId},
|
||||
};
|
||||
|
||||
pub async fn list_api_keys(
|
||||
keys: &dyn ApiKeyRepository,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<ApiKey>, DomainError> {
|
||||
keys.list_for_user(user_id).await
|
||||
}
|
||||
|
||||
pub async fn create_api_key(
|
||||
keys: &dyn ApiKeyRepository,
|
||||
user_id: &UserId,
|
||||
name: String,
|
||||
) -> Result<(ApiKey, String), DomainError> {
|
||||
let raw_key = uuid::Uuid::new_v4().to_string().replace('-', "");
|
||||
let key_hash = sha256_hex(&raw_key);
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: user_id.clone(),
|
||||
key_hash,
|
||||
name,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
keys.save(&key).await?;
|
||||
Ok((key, raw_key))
|
||||
}
|
||||
|
||||
pub async fn delete_api_key(
|
||||
keys: &dyn ApiKeyRepository,
|
||||
user_id: &UserId,
|
||||
key_id: &ApiKeyId,
|
||||
) -> Result<(), DomainError> {
|
||||
keys.delete(key_id, user_id).await
|
||||
}
|
||||
|
||||
fn sha256_hex(s: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(s.as_bytes());
|
||||
hex::encode(hash)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
51
crates/application/src/use_cases/api_keys/tests.rs
Normal file
51
crates/application/src/use_cases/api_keys/tests.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use super::*;
|
||||
use domain::{testing::TestStore, value_objects::UserId};
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_key_saves_hashed_not_raw() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let (key, raw) = create_api_key(&store, &uid, "my-key".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(key.key_hash, raw, "stored hash must differ from raw key");
|
||||
assert!(!key.key_hash.is_empty());
|
||||
assert_eq!(key.name, "my-key");
|
||||
assert_eq!(key.user_id, uid);
|
||||
assert_eq!(store.api_keys.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_key_verifies_against_stored_hash() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let (key, raw) = create_api_key(&store, &uid, "test".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_hash = hex::encode(Sha256::digest(raw.as_bytes()));
|
||||
assert_eq!(key.key_hash, expected_hash);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_key_removes_it() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let (key, _) = create_api_key(&store, &uid, "k".to_string()).await.unwrap();
|
||||
delete_api_key(&store, &uid, &key.id).await.unwrap();
|
||||
assert!(store.api_keys.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_keys_returns_only_own_keys() {
|
||||
let store = TestStore::default();
|
||||
let alice = UserId::new();
|
||||
let bob = UserId::new();
|
||||
create_api_key(&store, &alice, "a".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
create_api_key(&store, &bob, "b".to_string()).await.unwrap();
|
||||
let alice_keys = list_api_keys(&store, &alice).await.unwrap();
|
||||
assert_eq!(alice_keys.len(), 1);
|
||||
assert_eq!(alice_keys[0].user_id, alice);
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::user::User,
|
||||
ports::{AuthService, EventPublisher, PasswordHasher, UserReader, UserRepository},
|
||||
value_objects::{Email, UserId, Username},
|
||||
};
|
||||
|
||||
pub struct RegisterInput {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct RegisterOutput {
|
||||
pub user: User,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
users: &dyn UserRepository,
|
||||
hasher: &dyn PasswordHasher,
|
||||
auth: &dyn AuthService,
|
||||
events: &dyn EventPublisher,
|
||||
input: RegisterInput,
|
||||
) -> Result<RegisterOutput, DomainError> {
|
||||
let username = Username::new(input.username)?;
|
||||
let email = Email::new(input.email)?;
|
||||
if users.find_by_username(&username).await?.is_some() {
|
||||
return Err(DomainError::Conflict("username taken".into()));
|
||||
}
|
||||
if users.find_by_email(&email).await?.is_some() {
|
||||
return Err(DomainError::Conflict("email taken".into()));
|
||||
}
|
||||
let hash = hasher.hash(&input.password).await?;
|
||||
let user = User::new_local(UserId::new(), username, email, hash);
|
||||
users
|
||||
.save(&user)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DomainError::UniqueViolation { field: "username" } => {
|
||||
DomainError::Conflict("username taken".into())
|
||||
}
|
||||
DomainError::UniqueViolation { field: "email" } => {
|
||||
DomainError::Conflict("email taken".into())
|
||||
}
|
||||
DomainError::UniqueViolation { .. } => {
|
||||
DomainError::Conflict("already exists".into())
|
||||
}
|
||||
other => other,
|
||||
})?;
|
||||
events
|
||||
.publish(&DomainEvent::UserRegistered {
|
||||
user_id: user.id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let token = auth.generate_token(&user.id)?;
|
||||
Ok(RegisterOutput {
|
||||
user,
|
||||
token: token.token,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct LoginInput {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct LoginOutput {
|
||||
pub user: User,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
users: &dyn UserReader,
|
||||
hasher: &dyn PasswordHasher,
|
||||
auth: &dyn AuthService,
|
||||
input: LoginInput,
|
||||
) -> Result<LoginOutput, DomainError> {
|
||||
let email = Email::new(input.email)?;
|
||||
let user = users.find_by_email(&email).await?;
|
||||
if user.is_none() {
|
||||
// Timing equalization — prevents email enumeration via response-time oracle.
|
||||
// Running the hasher on a miss makes "no such user" take the same time as
|
||||
// "wrong password", so attackers cannot distinguish the two cases.
|
||||
let _ = hasher.hash(&input.password).await;
|
||||
return Err(DomainError::Unauthorized);
|
||||
}
|
||||
let user = user.unwrap();
|
||||
if !hasher.verify(&input.password, &user.password_hash).await? {
|
||||
return Err(DomainError::Unauthorized);
|
||||
}
|
||||
let token = auth.generate_token(&user.id)?;
|
||||
Ok(LoginOutput {
|
||||
user,
|
||||
token: token.token,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{feed::{PageParams, Paginated, UserSummary}, user::User},
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
|
||||
testing::{NoOpEventPublisher, TestStore},
|
||||
value_objects::{Email, PasswordHash, UserId, Username},
|
||||
};
|
||||
|
||||
/// Simulates a concurrent registration that slips past the pre-checks and
|
||||
/// hits the DB unique constraint — exactly what happens in the TOCTOU window.
|
||||
struct ConflictOnSaveStore(TestStore);
|
||||
struct EmailConflictOnSaveStore(TestStore);
|
||||
|
||||
#[async_trait]
|
||||
impl UserReader for ConflictOnSaveStore {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_id(id).await
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
username: &Username,
|
||||
) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_username(username).await
|
||||
}
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_email(email).await
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||
self.0.list_with_stats().await
|
||||
}
|
||||
async fn count(&self) -> Result<i64, DomainError> {
|
||||
self.0.count().await
|
||||
}
|
||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
||||
self.0.list_paginated(page).await
|
||||
}
|
||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
|
||||
self.0.find_by_ids(ids).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserWriter for ConflictOnSaveStore {
|
||||
async fn save(&self, _user: &User) -> Result<(), DomainError> {
|
||||
Err(DomainError::UniqueViolation { field: "username" })
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
custom_css: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.0
|
||||
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserReader for EmailConflictOnSaveStore {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_id(id).await
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
username: &Username,
|
||||
) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_username(username).await
|
||||
}
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_email(email).await
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||
self.0.list_with_stats().await
|
||||
}
|
||||
async fn count(&self) -> Result<i64, DomainError> {
|
||||
self.0.count().await
|
||||
}
|
||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
||||
self.0.list_paginated(page).await
|
||||
}
|
||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
|
||||
self.0.find_by_ids(ids).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserWriter for EmailConflictOnSaveStore {
|
||||
async fn save(&self, _user: &User) -> Result<(), DomainError> {
|
||||
Err(DomainError::UniqueViolation { field: "email" })
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
custom_css: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.0
|
||||
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for FakeHasher {
|
||||
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> {
|
||||
Ok(PasswordHash(plain.to_string()))
|
||||
}
|
||||
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
Ok(plain == hash.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeAuth;
|
||||
impl AuthService for FakeAuth {
|
||||
fn generate_token(&self, uid: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
Ok(GeneratedToken {
|
||||
token: uid.to_string(),
|
||||
user_id: uid.clone(),
|
||||
})
|
||||
}
|
||||
fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||
Ok(UserId::from_uuid(
|
||||
uuid::Uuid::parse_str(token).map_err(|_| DomainError::Unauthorized)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn input() -> RegisterInput {
|
||||
RegisterInput {
|
||||
username: "alice".into(),
|
||||
email: "alice@ex.com".into(),
|
||||
password: "pw".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_creates_user() {
|
||||
let store = TestStore::default();
|
||||
let out = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.user.username.as_str(), "alice");
|
||||
assert!(!out.token.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_rejects_duplicate_username() {
|
||||
let store = TestStore::default();
|
||||
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap();
|
||||
let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::Conflict(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_succeeds_with_correct_password() {
|
||||
let store = TestStore::default();
|
||||
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap();
|
||||
let out = login(
|
||||
&store,
|
||||
&FakeHasher,
|
||||
&FakeAuth,
|
||||
LoginInput {
|
||||
email: "alice@ex.com".into(),
|
||||
password: "pw".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!out.token.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_fails_wrong_password() {
|
||||
let store = TestStore::default();
|
||||
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap();
|
||||
let err = login(
|
||||
&store,
|
||||
&FakeHasher,
|
||||
&FakeAuth,
|
||||
LoginInput {
|
||||
email: "alice@ex.com".into(),
|
||||
password: "wrong".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::Unauthorized));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_publishes_user_registered_event() {
|
||||
let store = TestStore::default();
|
||||
register(&store, &FakeHasher, &FakeAuth, &store, input())
|
||||
.await
|
||||
.unwrap();
|
||||
let events = store.events.lock().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], DomainEvent::UserRegistered { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_fails_for_nonexistent_user() {
|
||||
let store = TestStore::default();
|
||||
let err = login(
|
||||
&store,
|
||||
&FakeHasher,
|
||||
&FakeAuth,
|
||||
LoginInput {
|
||||
email: "ghost@ex.com".into(),
|
||||
password: "pass".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::Unauthorized));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_rejects_duplicate_email() {
|
||||
let store = TestStore::default();
|
||||
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap();
|
||||
let err = register(
|
||||
&store,
|
||||
&FakeHasher,
|
||||
&FakeAuth,
|
||||
&NoOpEventPublisher,
|
||||
RegisterInput {
|
||||
username: "alice2".into(),
|
||||
email: "alice@ex.com".into(),
|
||||
password: "pass2".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::Conflict(_)));
|
||||
}
|
||||
|
||||
/// TOCTOU: a concurrent registration slips past the pre-checks and the DB
|
||||
/// unique constraint fires on save. The map_err must convert it to a
|
||||
/// human-readable Conflict, not bubble up a raw constraint name.
|
||||
#[tokio::test]
|
||||
async fn register_maps_db_conflict_on_username_to_conflict() {
|
||||
let store = ConflictOnSaveStore(TestStore::default());
|
||||
let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, DomainError::Conflict(ref m) if m == "username taken"),
|
||||
"expected 'username taken', got: {:?}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_maps_db_conflict_on_email_to_conflict() {
|
||||
let store = EmailConflictOnSaveStore(TestStore::default());
|
||||
let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, DomainError::Conflict(ref m) if m == "email taken"),
|
||||
"expected 'email taken', got: {:?}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
101
crates/application/src/use_cases/auth/mod.rs
Normal file
101
crates/application/src/use_cases/auth/mod.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::user::User,
|
||||
ports::{AuthService, EventPublisher, PasswordHasher, UserReader, UserRepository},
|
||||
value_objects::{Email, UserId, Username},
|
||||
};
|
||||
|
||||
pub struct RegisterInput {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct RegisterOutput {
|
||||
pub user: User,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
users: &dyn UserRepository,
|
||||
hasher: &dyn PasswordHasher,
|
||||
auth: &dyn AuthService,
|
||||
events: &dyn EventPublisher,
|
||||
input: RegisterInput,
|
||||
) -> Result<RegisterOutput, DomainError> {
|
||||
let username = Username::new(input.username)?;
|
||||
let email = Email::new(input.email)?;
|
||||
if users.find_by_username(&username).await?.is_some() {
|
||||
return Err(DomainError::Conflict("username taken".into()));
|
||||
}
|
||||
if users.find_by_email(&email).await?.is_some() {
|
||||
return Err(DomainError::Conflict("email taken".into()));
|
||||
}
|
||||
let hash = hasher.hash(&input.password).await?;
|
||||
let user = User::new_local(UserId::new(), username, email, hash);
|
||||
users
|
||||
.save(&user)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DomainError::UniqueViolation { field: "username" } => {
|
||||
DomainError::Conflict("username taken".into())
|
||||
}
|
||||
DomainError::UniqueViolation { field: "email" } => {
|
||||
DomainError::Conflict("email taken".into())
|
||||
}
|
||||
DomainError::UniqueViolation { .. } => {
|
||||
DomainError::Conflict("already exists".into())
|
||||
}
|
||||
other => other,
|
||||
})?;
|
||||
events
|
||||
.publish(&DomainEvent::UserRegistered {
|
||||
user_id: user.id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let token = auth.generate_token(&user.id)?;
|
||||
Ok(RegisterOutput {
|
||||
user,
|
||||
token: token.token,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct LoginInput {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct LoginOutput {
|
||||
pub user: User,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
users: &dyn UserReader,
|
||||
hasher: &dyn PasswordHasher,
|
||||
auth: &dyn AuthService,
|
||||
input: LoginInput,
|
||||
) -> Result<LoginOutput, DomainError> {
|
||||
let email = Email::new(input.email)?;
|
||||
let user = users.find_by_email(&email).await?;
|
||||
if user.is_none() {
|
||||
// Timing equalization — prevents email enumeration via response-time oracle.
|
||||
// Running the hasher on a miss makes "no such user" take the same time as
|
||||
// "wrong password", so attackers cannot distinguish the two cases.
|
||||
let _ = hasher.hash(&input.password).await;
|
||||
return Err(DomainError::Unauthorized);
|
||||
}
|
||||
let user = user.unwrap();
|
||||
if !hasher.verify(&input.password, &user.password_hash).await? {
|
||||
return Err(DomainError::Unauthorized);
|
||||
}
|
||||
let token = auth.generate_token(&user.id)?;
|
||||
Ok(LoginOutput {
|
||||
user,
|
||||
token: token.token,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
286
crates/application/src/use_cases/auth/tests.rs
Normal file
286
crates/application/src/use_cases/auth/tests.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{feed::{PageParams, Paginated, UserSummary}, user::User},
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
|
||||
testing::{NoOpEventPublisher, TestStore},
|
||||
value_objects::{Email, PasswordHash, UserId, Username},
|
||||
};
|
||||
|
||||
/// Simulates a concurrent registration that slips past the pre-checks and
|
||||
/// hits the DB unique constraint — exactly what happens in the TOCTOU window.
|
||||
struct ConflictOnSaveStore(TestStore);
|
||||
struct EmailConflictOnSaveStore(TestStore);
|
||||
|
||||
#[async_trait]
|
||||
impl UserReader for ConflictOnSaveStore {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_id(id).await
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
username: &Username,
|
||||
) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_username(username).await
|
||||
}
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_email(email).await
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||
self.0.list_with_stats().await
|
||||
}
|
||||
async fn count(&self) -> Result<i64, DomainError> {
|
||||
self.0.count().await
|
||||
}
|
||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
||||
self.0.list_paginated(page).await
|
||||
}
|
||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
|
||||
self.0.find_by_ids(ids).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserWriter for ConflictOnSaveStore {
|
||||
async fn save(&self, _user: &User) -> Result<(), DomainError> {
|
||||
Err(DomainError::UniqueViolation { field: "username" })
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
custom_css: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.0
|
||||
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserReader for EmailConflictOnSaveStore {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_id(id).await
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
username: &Username,
|
||||
) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_username(username).await
|
||||
}
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_email(email).await
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||
self.0.list_with_stats().await
|
||||
}
|
||||
async fn count(&self) -> Result<i64, DomainError> {
|
||||
self.0.count().await
|
||||
}
|
||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
||||
self.0.list_paginated(page).await
|
||||
}
|
||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
|
||||
self.0.find_by_ids(ids).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserWriter for EmailConflictOnSaveStore {
|
||||
async fn save(&self, _user: &User) -> Result<(), DomainError> {
|
||||
Err(DomainError::UniqueViolation { field: "email" })
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
custom_css: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.0
|
||||
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for FakeHasher {
|
||||
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> {
|
||||
Ok(PasswordHash(plain.to_string()))
|
||||
}
|
||||
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
Ok(plain == hash.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeAuth;
|
||||
impl AuthService for FakeAuth {
|
||||
fn generate_token(&self, uid: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
Ok(GeneratedToken {
|
||||
token: uid.to_string(),
|
||||
user_id: uid.clone(),
|
||||
})
|
||||
}
|
||||
fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||
Ok(UserId::from_uuid(
|
||||
uuid::Uuid::parse_str(token).map_err(|_| DomainError::Unauthorized)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn input() -> RegisterInput {
|
||||
RegisterInput {
|
||||
username: "alice".into(),
|
||||
email: "alice@ex.com".into(),
|
||||
password: "pw".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_creates_user() {
|
||||
let store = TestStore::default();
|
||||
let out = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.user.username.as_str(), "alice");
|
||||
assert!(!out.token.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_rejects_duplicate_username() {
|
||||
let store = TestStore::default();
|
||||
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap();
|
||||
let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::Conflict(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_succeeds_with_correct_password() {
|
||||
let store = TestStore::default();
|
||||
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap();
|
||||
let out = login(
|
||||
&store,
|
||||
&FakeHasher,
|
||||
&FakeAuth,
|
||||
LoginInput {
|
||||
email: "alice@ex.com".into(),
|
||||
password: "pw".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!out.token.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_fails_wrong_password() {
|
||||
let store = TestStore::default();
|
||||
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap();
|
||||
let err = login(
|
||||
&store,
|
||||
&FakeHasher,
|
||||
&FakeAuth,
|
||||
LoginInput {
|
||||
email: "alice@ex.com".into(),
|
||||
password: "wrong".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::Unauthorized));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_publishes_user_registered_event() {
|
||||
let store = TestStore::default();
|
||||
register(&store, &FakeHasher, &FakeAuth, &store, input())
|
||||
.await
|
||||
.unwrap();
|
||||
let events = store.events.lock().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], DomainEvent::UserRegistered { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_fails_for_nonexistent_user() {
|
||||
let store = TestStore::default();
|
||||
let err = login(
|
||||
&store,
|
||||
&FakeHasher,
|
||||
&FakeAuth,
|
||||
LoginInput {
|
||||
email: "ghost@ex.com".into(),
|
||||
password: "pass".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::Unauthorized));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_rejects_duplicate_email() {
|
||||
let store = TestStore::default();
|
||||
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap();
|
||||
let err = register(
|
||||
&store,
|
||||
&FakeHasher,
|
||||
&FakeAuth,
|
||||
&NoOpEventPublisher,
|
||||
RegisterInput {
|
||||
username: "alice2".into(),
|
||||
email: "alice@ex.com".into(),
|
||||
password: "pass2".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::Conflict(_)));
|
||||
}
|
||||
|
||||
/// TOCTOU: a concurrent registration slips past the pre-checks and the DB
|
||||
/// unique constraint fires on save. The map_err must convert it to a
|
||||
/// human-readable Conflict, not bubble up a raw constraint name.
|
||||
#[tokio::test]
|
||||
async fn register_maps_db_conflict_on_username_to_conflict() {
|
||||
let store = ConflictOnSaveStore(TestStore::default());
|
||||
let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, DomainError::Conflict(ref m) if m == "username taken"),
|
||||
"expected 'username taken', got: {:?}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_maps_db_conflict_on_email_to_conflict() {
|
||||
let store = EmailConflictOnSaveStore(TestStore::default());
|
||||
let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, DomainError::Conflict(ref m) if m == "email taken"),
|
||||
"expected 'email taken', got: {:?}",
|
||||
err
|
||||
);
|
||||
}
|
||||
@@ -134,58 +134,4 @@ pub async fn get_actor_connections_page(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::testing::TestStore;
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_pending_returns_empty_by_default() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let result = list_pending_requests(&store, &uid).await.unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accept_follow_request_returns_ok() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
accept_follow_request(&store, &uid, "https://mastodon.social/users/alice")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_follow_request_returns_ok() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
reject_follow_request(&store, &uid, "https://mastodon.social/users/alice")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_remote_followers_returns_empty_by_default() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let result = list_remote_followers(&store, &uid).await.unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_remote_follower_returns_ok() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
remove_remote_follower(&store, &uid, "https://mastodon.social/users/alice")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_remote_following_returns_empty_by_default() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let result = list_remote_following(&store, &uid).await.unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,53 @@
|
||||
use super::*;
|
||||
use domain::testing::TestStore;
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_pending_returns_empty_by_default() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let result = list_pending_requests(&store, &uid).await.unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accept_follow_request_returns_ok() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
accept_follow_request(&store, &uid, "https://mastodon.social/users/alice")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_follow_request_returns_ok() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
reject_follow_request(&store, &uid, "https://mastodon.social/users/alice")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_remote_followers_returns_empty_by_default() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let result = list_remote_followers(&store, &uid).await.unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_remote_follower_returns_ok() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
remove_remote_follower(&store, &uid, "https://mastodon.social/users/alice")
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_remote_following_returns_empty_by_default() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let result = list_remote_following(&store, &uid).await.unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
@@ -93,71 +93,4 @@ pub async fn set_top_friends(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::user::User,
|
||||
testing::TestStore,
|
||||
value_objects::{Email, PasswordHash, UserId, Username},
|
||||
};
|
||||
|
||||
fn make_user() -> User {
|
||||
User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_top_friends_rejects_more_than_eight() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let friends: Vec<UserId> = (0..9).map(|_| UserId::new()).collect();
|
||||
let err = set_top_friends(&store, &uid, friends).await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_top_friends_assigns_sequential_positions() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let f1 = UserId::new();
|
||||
let f2 = UserId::new();
|
||||
let f3 = UserId::new();
|
||||
set_top_friends(&store, &uid, vec![f1.clone(), f2.clone(), f3.clone()])
|
||||
.await
|
||||
.unwrap();
|
||||
let tf = store.top_friends.lock().unwrap();
|
||||
assert_eq!(tf.len(), 3);
|
||||
let pos_f1 = tf
|
||||
.iter()
|
||||
.find(|t| t.friend_id == f1)
|
||||
.map(|t| t.position)
|
||||
.unwrap();
|
||||
let pos_f2 = tf
|
||||
.iter()
|
||||
.find(|t| t.friend_id == f2)
|
||||
.map(|t| t.position)
|
||||
.unwrap();
|
||||
assert!(pos_f1 < pos_f2, "f1 should come before f2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_user_by_username_returns_not_found_for_missing_user() {
|
||||
let store = TestStore::default();
|
||||
let err = get_user_by_username(&store, "nobody").await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_user_by_username_returns_correct_user() {
|
||||
let store = TestStore::default();
|
||||
let user = make_user();
|
||||
store.users.lock().unwrap().push(user.clone());
|
||||
let found = get_user_by_username(&store, "alice").await.unwrap();
|
||||
assert_eq!(found.id, user.id);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
66
crates/application/src/use_cases/profile/tests.rs
Normal file
66
crates/application/src/use_cases/profile/tests.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use super::*;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::user::User,
|
||||
testing::TestStore,
|
||||
value_objects::{Email, PasswordHash, UserId, Username},
|
||||
};
|
||||
|
||||
fn make_user() -> User {
|
||||
User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_top_friends_rejects_more_than_eight() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let friends: Vec<UserId> = (0..9).map(|_| UserId::new()).collect();
|
||||
let err = set_top_friends(&store, &uid, friends).await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_top_friends_assigns_sequential_positions() {
|
||||
let store = TestStore::default();
|
||||
let uid = UserId::new();
|
||||
let f1 = UserId::new();
|
||||
let f2 = UserId::new();
|
||||
let f3 = UserId::new();
|
||||
set_top_friends(&store, &uid, vec![f1.clone(), f2.clone(), f3.clone()])
|
||||
.await
|
||||
.unwrap();
|
||||
let tf = store.top_friends.lock().unwrap();
|
||||
assert_eq!(tf.len(), 3);
|
||||
let pos_f1 = tf
|
||||
.iter()
|
||||
.find(|t| t.friend_id == f1)
|
||||
.map(|t| t.position)
|
||||
.unwrap();
|
||||
let pos_f2 = tf
|
||||
.iter()
|
||||
.find(|t| t.friend_id == f2)
|
||||
.map(|t| t.position)
|
||||
.unwrap();
|
||||
assert!(pos_f1 < pos_f2, "f1 should come before f2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_user_by_username_returns_not_found_for_missing_user() {
|
||||
let store = TestStore::default();
|
||||
let err = get_user_by_username(&store, "nobody").await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_user_by_username_returns_correct_user() {
|
||||
let store = TestStore::default();
|
||||
let user = make_user();
|
||||
store.users.lock().unwrap().push(user.clone());
|
||||
let found = get_user_by_username(&store, "alice").await.unwrap();
|
||||
assert_eq!(found.id, user.id);
|
||||
}
|
||||
@@ -281,207 +281,4 @@ pub async fn unblock_user(
|
||||
}
|
||||
|
||||
#[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 { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_user_saves_block_and_publishes_event() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let bob = user("bob");
|
||||
block_user(&store, &store, &alice.id, &bob.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.blocks.lock().unwrap().len(), 1);
|
||||
let events = store.events.lock().unwrap();
|
||||
assert!(events.iter().any(
|
||||
|e| matches!(e, DomainEvent::UserBlocked { blocker_id, .. } if blocker_id == &alice.id)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cannot_block_self() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let err = block_user(&store, &store, &alice.id, &alice.id)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_actor_local_routes_to_follow_user() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let bob = user("bob");
|
||||
store.users.lock().unwrap().push(bob.clone());
|
||||
follow_actor(&store, &store, &store, &store, &alice.id, "bob")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.follows.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_actor_remote_routes_to_federation() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
follow_actor(
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&alice.id,
|
||||
"@bob@example.com",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// TestStore.follow_remote is a no-op that returns Ok(())
|
||||
// no local follow should be recorded
|
||||
assert!(store.follows.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unfollow_actor_local_routes_to_unfollow_user() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let bob = user("bob");
|
||||
store.users.lock().unwrap().push(bob.clone());
|
||||
// Create an existing follow first
|
||||
store
|
||||
.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(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(),
|
||||
});
|
||||
unfollow_actor(&store, &store, &store, &store, &alice.id, "bob")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.follows.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unfollow_actor_remote_routes_to_federation() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
unfollow_actor(
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&alice.id,
|
||||
"@bob@example.com",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// TestStore.unfollow_remote is a no-op — just verify it doesn't error
|
||||
assert!(store.follows.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_and_unboost() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let tid = ThoughtId::new();
|
||||
boost_thought(&store, &store, &alice.id, &tid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.boosts.lock().unwrap().len(), 1);
|
||||
unboost_thought(&store, &store, &alice.id, &tid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.boosts.lock().unwrap().is_empty());
|
||||
let events = store.events.lock().unwrap();
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::BoostAdded { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::BoostRemoved { .. })));
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
202
crates/application/src/use_cases/social/tests.rs
Normal file
202
crates/application/src/use_cases/social/tests.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
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 { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_user_saves_block_and_publishes_event() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let bob = user("bob");
|
||||
block_user(&store, &store, &alice.id, &bob.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.blocks.lock().unwrap().len(), 1);
|
||||
let events = store.events.lock().unwrap();
|
||||
assert!(events.iter().any(
|
||||
|e| matches!(e, DomainEvent::UserBlocked { blocker_id, .. } if blocker_id == &alice.id)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cannot_block_self() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let err = block_user(&store, &store, &alice.id, &alice.id)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_actor_local_routes_to_follow_user() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let bob = user("bob");
|
||||
store.users.lock().unwrap().push(bob.clone());
|
||||
follow_actor(&store, &store, &store, &store, &alice.id, "bob")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.follows.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_actor_remote_routes_to_federation() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
follow_actor(
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&alice.id,
|
||||
"@bob@example.com",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// TestStore.follow_remote is a no-op that returns Ok(())
|
||||
// no local follow should be recorded
|
||||
assert!(store.follows.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unfollow_actor_local_routes_to_unfollow_user() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let bob = user("bob");
|
||||
store.users.lock().unwrap().push(bob.clone());
|
||||
// Create an existing follow first
|
||||
store
|
||||
.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(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(),
|
||||
});
|
||||
unfollow_actor(&store, &store, &store, &store, &alice.id, "bob")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.follows.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unfollow_actor_remote_routes_to_federation() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
unfollow_actor(
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&alice.id,
|
||||
"@bob@example.com",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// TestStore.unfollow_remote is a no-op — just verify it doesn't error
|
||||
assert!(store.follows.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_and_unboost() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let tid = ThoughtId::new();
|
||||
boost_thought(&store, &store, &alice.id, &tid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(store.boosts.lock().unwrap().len(), 1);
|
||||
unboost_thought(&store, &store, &alice.id, &tid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.boosts.lock().unwrap().is_empty());
|
||||
let events = store.events.lock().unwrap();
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::BoostAdded { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::BoostRemoved { .. })));
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
feed::{EngagementStats, FeedEntry},
|
||||
thought::{Thought, Visibility},
|
||||
},
|
||||
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
|
||||
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<ThoughtId>,
|
||||
pub visibility: Option<String>,
|
||||
pub content_warning: Option<String>,
|
||||
pub sensitive: bool,
|
||||
}
|
||||
pub struct CreateThoughtOutput {
|
||||
pub thought: Thought,
|
||||
}
|
||||
|
||||
pub async fn create_thought(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
_users: &dyn UserReader,
|
||||
tags: &dyn TagRepository,
|
||||
_events: &dyn EventPublisher,
|
||||
outbox: &dyn OutboxWriter,
|
||||
input: CreateThoughtInput,
|
||||
) -> Result<CreateThoughtOutput, DomainError> {
|
||||
let content = Content::new_local(input.content)?;
|
||||
let visibility = match input.visibility.as_deref() {
|
||||
Some("followers") => Visibility::Followers,
|
||||
Some("unlisted") => Visibility::Unlisted,
|
||||
Some("direct") => Visibility::Direct,
|
||||
_ => Visibility::Public,
|
||||
};
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
input.user_id,
|
||||
content.clone(),
|
||||
input.in_reply_to_id.clone(),
|
||||
visibility,
|
||||
input.content_warning,
|
||||
input.sensitive,
|
||||
);
|
||||
thoughts.save(&thought).await?;
|
||||
|
||||
// Extract and attach hashtags from content.
|
||||
for h in domain::hashtag::extract(content.as_str()) {
|
||||
if let Ok(tag) = tags.find_or_create(&h.normalized).await {
|
||||
let _ = tags.attach_to_thought(&thought.id, tag.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
outbox
|
||||
.append(&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,
|
||||
outbox: &dyn OutboxWriter,
|
||||
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?;
|
||||
outbox
|
||||
.append(&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(())
|
||||
}
|
||||
|
||||
/// Fetches a single thought enriched with author + real engagement stats.
|
||||
pub async fn get_thought_view(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
users: &dyn UserReader,
|
||||
engagement: &dyn EngagementRepository,
|
||||
id: &ThoughtId,
|
||||
viewer: Option<&UserId>,
|
||||
) -> Result<FeedEntry, DomainError> {
|
||||
let thought = thoughts
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let author = users
|
||||
.find_by_id(&thought.user_id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?;
|
||||
let (stats, viewer_ctx) = map.remove(id).unwrap_or(
|
||||
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
|
||||
);
|
||||
Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx })
|
||||
}
|
||||
|
||||
/// Fetches a thread (root + replies) enriched with authors + real engagement stats.
|
||||
/// Batches all DB lookups — one query per resource type regardless of thread length.
|
||||
pub async fn get_thread_views(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
users: &dyn UserReader,
|
||||
engagement: &dyn EngagementRepository,
|
||||
root_id: &ThoughtId,
|
||||
viewer: Option<&UserId>,
|
||||
) -> Result<Vec<FeedEntry>, DomainError> {
|
||||
let thread = thoughts.get_thread(root_id).await?;
|
||||
if thread.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let thought_ids: Vec<ThoughtId> = thread.iter().map(|t| t.id.clone()).collect();
|
||||
let user_ids: Vec<UserId> = thread.iter().map(|t| t.user_id.clone()).collect();
|
||||
|
||||
let (authors_map, engagement_map) = tokio::join!(
|
||||
users.find_by_ids(&user_ids),
|
||||
engagement.get_for_thoughts(&thought_ids, viewer),
|
||||
);
|
||||
let authors_map = authors_map?;
|
||||
let mut engagement_map = engagement_map?;
|
||||
|
||||
let mut entries = Vec::with_capacity(thread.len());
|
||||
for thought in thread {
|
||||
let author = authors_map
|
||||
.get(&thought.user_id)
|
||||
.cloned()
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or(
|
||||
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
|
||||
);
|
||||
entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx });
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::user::User,
|
||||
testing::{NoOpEventPublisher, NoOpOutboxWriter, TestOutbox, 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_stages_outbox_event() {
|
||||
let store = TestStore::default();
|
||||
let outbox = TestOutbox::default();
|
||||
let u = user();
|
||||
store.users.lock().unwrap().push(u.clone());
|
||||
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &outbox, input(u.id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.thought.content.as_str(), "hello");
|
||||
let staged = outbox.staged();
|
||||
assert_eq!(staged.len(), 1);
|
||||
assert!(matches!(staged[0], DomainEvent::ThoughtCreated { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_thought_stages_outbox_event() {
|
||||
let store = TestStore::default();
|
||||
let outbox = TestOutbox::default();
|
||||
let u = user();
|
||||
store.users.lock().unwrap().push(u.clone());
|
||||
let out = create_thought(
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
input(u.id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let tid = out.thought.id.clone();
|
||||
|
||||
delete_thought(&store, &NoOpEventPublisher, &outbox, &tid, &u.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let staged = outbox.staged();
|
||||
assert_eq!(staged.len(), 1);
|
||||
assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid));
|
||||
}
|
||||
|
||||
#[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,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
input(u.id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &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,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
input(alice.id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &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, &NoOpEventPublisher, &NoOpOutboxWriter, 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,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
input(alice.id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.thought;
|
||||
|
||||
create_thought(
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod enrichment_tests {
|
||||
use super::*;
|
||||
use domain::testing::TestStore;
|
||||
use domain::models::user::User;
|
||||
use domain::models::thought::{Thought, Visibility};
|
||||
use domain::value_objects::*;
|
||||
use domain::ports::{ThoughtRepository, UserWriter};
|
||||
|
||||
fn make_user() -> User {
|
||||
User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("a@a.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
)
|
||||
}
|
||||
|
||||
fn make_thought(user_id: UserId) -> Thought {
|
||||
Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user_id,
|
||||
Content::new_local(String::from("hello")).unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_thought_view_returns_feed_entry() {
|
||||
let store = TestStore::default();
|
||||
let user = make_user();
|
||||
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
|
||||
let thought = make_thought(user.id.clone());
|
||||
<TestStore as ThoughtRepository>::save(&store, &thought).await.unwrap();
|
||||
|
||||
let entry = get_thought_view(&store, &store, &store, &thought.id, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(entry.thought.id, thought.id);
|
||||
assert_eq!(entry.author.id, user.id);
|
||||
assert_eq!(entry.stats.like_count, 0);
|
||||
assert!(entry.viewer.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_thought_view_returns_not_found_for_missing_thought() {
|
||||
let store = TestStore::default();
|
||||
let err = get_thought_view(&store, &store, &store, &ThoughtId::new(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_thread_views_batches_correctly() {
|
||||
let store = TestStore::default();
|
||||
let user = make_user();
|
||||
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
|
||||
let root = make_thought(user.id.clone());
|
||||
<TestStore as ThoughtRepository>::save(&store, &root).await.unwrap();
|
||||
let reply = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local(String::from("reply")).unwrap(),
|
||||
Some(root.id.clone()),
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
<TestStore as ThoughtRepository>::save(&store, &reply).await.unwrap();
|
||||
|
||||
let entries = get_thread_views(&store, &store, &store, &root.id, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
}
|
||||
}
|
||||
181
crates/application/src/use_cases/thoughts/mod.rs
Normal file
181
crates/application/src/use_cases/thoughts/mod.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
feed::{EngagementStats, FeedEntry},
|
||||
thought::{Thought, Visibility},
|
||||
},
|
||||
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
|
||||
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<ThoughtId>,
|
||||
pub visibility: Option<String>,
|
||||
pub content_warning: Option<String>,
|
||||
pub sensitive: bool,
|
||||
}
|
||||
pub struct CreateThoughtOutput {
|
||||
pub thought: Thought,
|
||||
}
|
||||
|
||||
pub async fn create_thought(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
_users: &dyn UserReader,
|
||||
tags: &dyn TagRepository,
|
||||
_events: &dyn EventPublisher,
|
||||
outbox: &dyn OutboxWriter,
|
||||
input: CreateThoughtInput,
|
||||
) -> Result<CreateThoughtOutput, DomainError> {
|
||||
let content = Content::new_local(input.content)?;
|
||||
let visibility = match input.visibility.as_deref() {
|
||||
Some("followers") => Visibility::Followers,
|
||||
Some("unlisted") => Visibility::Unlisted,
|
||||
Some("direct") => Visibility::Direct,
|
||||
_ => Visibility::Public,
|
||||
};
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
input.user_id,
|
||||
content.clone(),
|
||||
input.in_reply_to_id.clone(),
|
||||
visibility,
|
||||
input.content_warning,
|
||||
input.sensitive,
|
||||
);
|
||||
thoughts.save(&thought).await?;
|
||||
|
||||
// Extract and attach hashtags from content.
|
||||
for h in domain::hashtag::extract(content.as_str()) {
|
||||
if let Ok(tag) = tags.find_or_create(&h.normalized).await {
|
||||
let _ = tags.attach_to_thought(&thought.id, tag.id).await;
|
||||
}
|
||||
}
|
||||
|
||||
outbox
|
||||
.append(&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,
|
||||
outbox: &dyn OutboxWriter,
|
||||
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?;
|
||||
outbox
|
||||
.append(&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(())
|
||||
}
|
||||
|
||||
/// Fetches a single thought enriched with author + real engagement stats.
|
||||
pub async fn get_thought_view(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
users: &dyn UserReader,
|
||||
engagement: &dyn EngagementRepository,
|
||||
id: &ThoughtId,
|
||||
viewer: Option<&UserId>,
|
||||
) -> Result<FeedEntry, DomainError> {
|
||||
let thought = thoughts
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let author = users
|
||||
.find_by_id(&thought.user_id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?;
|
||||
let (stats, viewer_ctx) = map.remove(id).unwrap_or(
|
||||
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
|
||||
);
|
||||
Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx })
|
||||
}
|
||||
|
||||
/// Fetches a thread (root + replies) enriched with authors + real engagement stats.
|
||||
/// Batches all DB lookups — one query per resource type regardless of thread length.
|
||||
pub async fn get_thread_views(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
users: &dyn UserReader,
|
||||
engagement: &dyn EngagementRepository,
|
||||
root_id: &ThoughtId,
|
||||
viewer: Option<&UserId>,
|
||||
) -> Result<Vec<FeedEntry>, DomainError> {
|
||||
let thread = thoughts.get_thread(root_id).await?;
|
||||
if thread.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let thought_ids: Vec<ThoughtId> = thread.iter().map(|t| t.id.clone()).collect();
|
||||
let user_ids: Vec<UserId> = thread.iter().map(|t| t.user_id.clone()).collect();
|
||||
|
||||
let (authors_map, engagement_map) = tokio::join!(
|
||||
users.find_by_ids(&user_ids),
|
||||
engagement.get_for_thoughts(&thought_ids, viewer),
|
||||
);
|
||||
let authors_map = authors_map?;
|
||||
let mut engagement_map = engagement_map?;
|
||||
|
||||
let mut entries = Vec::with_capacity(thread.len());
|
||||
for thought in thread {
|
||||
let author = authors_map
|
||||
.get(&thought.user_id)
|
||||
.cloned()
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or(
|
||||
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
|
||||
);
|
||||
entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx });
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
269
crates/application/src/use_cases/thoughts/tests.rs
Normal file
269
crates/application/src/use_cases/thoughts/tests.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::user::User,
|
||||
testing::{NoOpEventPublisher, NoOpOutboxWriter, TestOutbox, 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_stages_outbox_event() {
|
||||
let store = TestStore::default();
|
||||
let outbox = TestOutbox::default();
|
||||
let u = user();
|
||||
store.users.lock().unwrap().push(u.clone());
|
||||
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &outbox, input(u.id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.thought.content.as_str(), "hello");
|
||||
let staged = outbox.staged();
|
||||
assert_eq!(staged.len(), 1);
|
||||
assert!(matches!(staged[0], DomainEvent::ThoughtCreated { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_thought_stages_outbox_event() {
|
||||
let store = TestStore::default();
|
||||
let outbox = TestOutbox::default();
|
||||
let u = user();
|
||||
store.users.lock().unwrap().push(u.clone());
|
||||
let out = create_thought(
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
input(u.id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let tid = out.thought.id.clone();
|
||||
|
||||
delete_thought(&store, &NoOpEventPublisher, &outbox, &tid, &u.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let staged = outbox.staged();
|
||||
assert_eq!(staged.len(), 1);
|
||||
assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid));
|
||||
}
|
||||
|
||||
#[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,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
input(u.id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &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,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
input(alice.id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &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, &NoOpEventPublisher, &NoOpOutboxWriter, 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,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
input(alice.id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.thought;
|
||||
|
||||
create_thought(
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
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()));
|
||||
}
|
||||
|
||||
// enrichment_tests (combined from second cfg(test) block)
|
||||
|
||||
use domain::models::thought::{Thought, Visibility};
|
||||
use domain::ports::{ThoughtRepository, UserWriter};
|
||||
|
||||
fn make_user() -> User {
|
||||
User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("a@a.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
)
|
||||
}
|
||||
|
||||
fn make_thought(user_id: UserId) -> Thought {
|
||||
Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user_id,
|
||||
Content::new_local(String::from("hello")).unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_thought_view_returns_feed_entry() {
|
||||
let store = TestStore::default();
|
||||
let user = make_user();
|
||||
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
|
||||
let thought = make_thought(user.id.clone());
|
||||
<TestStore as ThoughtRepository>::save(&store, &thought).await.unwrap();
|
||||
|
||||
let entry = get_thought_view(&store, &store, &store, &thought.id, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(entry.thought.id, thought.id);
|
||||
assert_eq!(entry.author.id, user.id);
|
||||
assert_eq!(entry.stats.like_count, 0);
|
||||
assert!(entry.viewer.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_thought_view_returns_not_found_for_missing_thought() {
|
||||
let store = TestStore::default();
|
||||
let err = get_thought_view(&store, &store, &store, &ThoughtId::new(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_thread_views_batches_correctly() {
|
||||
let store = TestStore::default();
|
||||
let user = make_user();
|
||||
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
|
||||
let root = make_thought(user.id.clone());
|
||||
<TestStore as ThoughtRepository>::save(&store, &root).await.unwrap();
|
||||
let reply = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local(String::from("reply")).unwrap(),
|
||||
Some(root.id.clone()),
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
<TestStore as ThoughtRepository>::save(&store, &reply).await.unwrap();
|
||||
|
||||
let entries = get_thread_views(&store, &store, &store, &root.id, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
}
|
||||
Reference in New Issue
Block a user