Refactor handlers and OpenAPI documentation for improved readability and consistency
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 6m49s
test / unit (pull_request) Successful in 16m24s
test / integration (pull_request) Failing after 17m7s
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 6m49s
test / unit (pull_request) Successful in 16m24s
test / integration (pull_request) Failing after 17m7s
- Reorganized imports in health, notifications, social, thoughts, and users handlers for clarity. - Updated function signatures in handlers to improve readability by aligning parameters. - Enhanced JSON response formatting in notifications and thoughts handlers. - Improved error handling in user-related functions. - Refactored OpenAPI documentation to maintain consistent formatting and structure. - Cleaned up unnecessary code and comments across various files. - Ensured consistent use of `Arc` for shared state in AppState and WorkerHandlers.
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
use std::sync::Arc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
@@ -6,55 +5,91 @@ use domain::{
|
||||
ports::{OutboundFederationPort, ThoughtRepository, UserRepository},
|
||||
value_objects::ThoughtId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct FederationEventService {
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub ap: Arc<dyn OutboundFederationPort>,
|
||||
pub base_url: String,
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub ap: Arc<dyn OutboundFederationPort>,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
impl FederationEventService {
|
||||
fn object_ap_id(&self, thought: &Thought, thought_id: &ThoughtId) -> String {
|
||||
thought.ap_id.clone().unwrap_or_else(|| {
|
||||
format!("{}/thoughts/{}", self.base_url, thought_id)
|
||||
})
|
||||
thought
|
||||
.ap_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, thought_id))
|
||||
}
|
||||
|
||||
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
match event {
|
||||
DomainEvent::ThoughtCreated { thought_id, user_id, .. } => {
|
||||
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,
|
||||
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(()),
|
||||
};
|
||||
self.ap.broadcast_create(user_id, &thought, user.username.as_str()).await
|
||||
self.ap
|
||||
.broadcast_create(user_id, &thought, user.username.as_str())
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::ThoughtDeleted { thought_id, user_id } => {
|
||||
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 } => {
|
||||
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,
|
||||
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(()),
|
||||
};
|
||||
self.ap.broadcast_update(user_id, &thought, user.username.as_str()).await
|
||||
self.ap
|
||||
.broadcast_update(user_id, &thought, user.username.as_str())
|
||||
.await
|
||||
}
|
||||
|
||||
DomainEvent::BoostAdded { boost_id: _, user_id, thought_id } => {
|
||||
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(()),
|
||||
@@ -63,13 +98,18 @@ impl FederationEventService {
|
||||
self.ap.broadcast_announce(user_id, &object_ap_id).await
|
||||
}
|
||||
|
||||
DomainEvent::BoostRemoved { user_id, thought_id } => {
|
||||
DomainEvent::BoostRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let object_ap_id = self.object_ap_id(&thought, thought_id);
|
||||
self.ap.broadcast_undo_announce(user_id, &object_ap_id).await
|
||||
self.ap
|
||||
.broadcast_undo_announce(user_id, &object_ap_id)
|
||||
.await
|
||||
}
|
||||
|
||||
_ => Ok(()),
|
||||
@@ -96,16 +136,21 @@ mod tests {
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyPort {
|
||||
created: Mutex<Vec<ThoughtId>>,
|
||||
deleted: Mutex<Vec<String>>,
|
||||
updated: Mutex<Vec<ThoughtId>>,
|
||||
announced: Mutex<Vec<String>>,
|
||||
created: Mutex<Vec<ThoughtId>>,
|
||||
deleted: Mutex<Vec<String>>,
|
||||
updated: Mutex<Vec<ThoughtId>>,
|
||||
announced: Mutex<Vec<String>>,
|
||||
undo_announced: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OutboundFederationPort for SpyPort {
|
||||
async fn broadcast_create(&self, _: &UserId, thought: &Thought, _: &str) -> Result<(), DomainError> {
|
||||
async fn broadcast_create(
|
||||
&self,
|
||||
_: &UserId,
|
||||
thought: &Thought,
|
||||
_: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.created.lock().unwrap().push(thought.id.clone());
|
||||
Ok(())
|
||||
}
|
||||
@@ -113,7 +158,12 @@ mod tests {
|
||||
self.deleted.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
async fn broadcast_update(&self, _: &UserId, thought: &Thought, _: &str) -> Result<(), DomainError> {
|
||||
async fn broadcast_update(
|
||||
&self,
|
||||
_: &UserId,
|
||||
thought: &Thought,
|
||||
_: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.updated.lock().unwrap().push(thought.id.clone());
|
||||
Ok(())
|
||||
}
|
||||
@@ -121,7 +171,11 @@ mod tests {
|
||||
self.announced.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
|
||||
async fn broadcast_undo_announce(
|
||||
&self,
|
||||
_: &UserId,
|
||||
ap_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.undo_announced.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
@@ -138,9 +192,13 @@ mod tests {
|
||||
|
||||
fn local_thought(author_id: UserId) -> Thought {
|
||||
Thought::new_local(
|
||||
ThoughtId::new(), author_id,
|
||||
ThoughtId::new(),
|
||||
author_id,
|
||||
Content::new_local("hello").unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -259,7 +317,10 @@ mod tests {
|
||||
|
||||
let announced = spy.announced.lock().unwrap();
|
||||
assert_eq!(announced.len(), 1);
|
||||
assert_eq!(announced[0], format!("https://example.com/thoughts/{}", thought.id));
|
||||
assert_eq!(
|
||||
announced[0],
|
||||
format!("https://example.com/thoughts/{}", thought.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -282,7 +343,10 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let announced = spy.announced.lock().unwrap();
|
||||
assert_eq!(announced[0], "https://mastodon.social/users/bob/statuses/123");
|
||||
assert_eq!(
|
||||
announced[0],
|
||||
"https://mastodon.social/users/bob/statuses/123"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -290,9 +354,13 @@ mod tests {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(), alice.id.clone(),
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("private").unwrap(),
|
||||
None, Visibility::Direct, None, false,
|
||||
None,
|
||||
Visibility::Direct,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
@@ -315,9 +383,13 @@ mod tests {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(), alice.id.clone(),
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("for followers").unwrap(),
|
||||
None, Visibility::Followers, None, false,
|
||||
None,
|
||||
Visibility::Followers,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
@@ -344,7 +416,9 @@ mod tests {
|
||||
svc.process(&DomainEvent::UserBlocked {
|
||||
blocker_id: UserId::new(),
|
||||
blocked_id: UserId::new(),
|
||||
}).await.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(spy.created.lock().unwrap().is_empty());
|
||||
assert!(spy.deleted.lock().unwrap().is_empty());
|
||||
@@ -391,7 +465,10 @@ mod tests {
|
||||
|
||||
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));
|
||||
assert_eq!(
|
||||
undo_announced[0],
|
||||
format!("https://example.com/thoughts/{}", thought.id)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -414,7 +491,10 @@ mod tests {
|
||||
|
||||
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");
|
||||
assert_eq!(
|
||||
undo_announced[0],
|
||||
"https://mastodon.social/users/bob/statuses/456"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::sync::Arc;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
@@ -7,9 +6,10 @@ use domain::{
|
||||
ports::{NotificationRepository, ThoughtRepository},
|
||||
value_objects::{NotificationId, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct NotificationEventService {
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub notifications: Arc<dyn NotificationRepository>,
|
||||
}
|
||||
|
||||
@@ -20,50 +20,75 @@ fn is_self_action(thought_author: &UserId, actor: &UserId) -> bool {
|
||||
impl NotificationEventService {
|
||||
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
match event {
|
||||
DomainEvent::LikeAdded { like_id: _, user_id, thought_id } => {
|
||||
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,
|
||||
notification_type: NotificationType::Like,
|
||||
from_user_id: Some(user_id.clone()),
|
||||
thought_id: Some(thought_id.clone()),
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
}).await
|
||||
if is_self_action(&thought.user_id, user_id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: thought.user_id,
|
||||
notification_type: NotificationType::Like,
|
||||
from_user_id: Some(user_id.clone()),
|
||||
thought_id: Some(thought_id.clone()),
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
DomainEvent::BoostAdded { boost_id: _, user_id, thought_id } => {
|
||||
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,
|
||||
notification_type: NotificationType::Boost,
|
||||
from_user_id: Some(user_id.clone()),
|
||||
thought_id: Some(thought_id.clone()),
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
}).await
|
||||
if is_self_action(&thought.user_id, user_id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: thought.user_id,
|
||||
notification_type: NotificationType::Boost,
|
||||
from_user_id: Some(user_id.clone()),
|
||||
thought_id: Some(thought_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(),
|
||||
notification_type: NotificationType::Follow,
|
||||
from_user_id: Some(follower_id.clone()),
|
||||
thought_id: None,
|
||||
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(),
|
||||
notification_type: NotificationType::Follow,
|
||||
from_user_id: Some(follower_id.clone()),
|
||||
thought_id: None,
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => {
|
||||
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(()),
|
||||
@@ -72,16 +97,20 @@ impl NotificationEventService {
|
||||
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,
|
||||
notification_type: NotificationType::Reply,
|
||||
from_user_id: Some(user_id.clone()),
|
||||
thought_id: Some(thought_id.clone()),
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
}).await
|
||||
if is_self_action(&original.user_id, user_id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.notifications
|
||||
.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: original.user_id,
|
||||
notification_type: NotificationType::Reply,
|
||||
from_user_id: Some(user_id.clone()),
|
||||
thought_id: Some(thought_id.clone()),
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
@@ -92,7 +121,10 @@ impl NotificationEventService {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::{thought::{Thought, Visibility}, user::User},
|
||||
models::{
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
testing::TestStore,
|
||||
value_objects::*,
|
||||
};
|
||||
@@ -113,9 +145,13 @@ mod tests {
|
||||
let alice = alice();
|
||||
let bob_id = UserId::new();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(), alice.id.clone(),
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("hello").unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
@@ -126,10 +162,15 @@ mod tests {
|
||||
like_id: LikeId::new(),
|
||||
user_id: bob_id,
|
||||
thought_id: thought.id.clone(),
|
||||
}).await.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert!(matches!(notifs[0].notification_type, NotificationType::Like));
|
||||
assert!(matches!(
|
||||
notifs[0].notification_type,
|
||||
NotificationType::Like
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -137,9 +178,13 @@ mod tests {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(), alice.id.clone(),
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("hello").unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
@@ -150,7 +195,9 @@ mod tests {
|
||||
like_id: LikeId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
}).await.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -166,10 +213,15 @@ mod tests {
|
||||
svc.process(&DomainEvent::FollowAccepted {
|
||||
follower_id: bob_id,
|
||||
following_id: alice.id.clone(),
|
||||
}).await.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert!(matches!(notifs[0].notification_type, NotificationType::Follow));
|
||||
assert!(matches!(
|
||||
notifs[0].notification_type,
|
||||
NotificationType::Follow
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -178,9 +230,13 @@ mod tests {
|
||||
let alice = alice();
|
||||
let bob_id = UserId::new();
|
||||
let original = Thought::new_local(
|
||||
ThoughtId::new(), alice.id.clone(),
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("original").unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(original.clone());
|
||||
let svc = NotificationEventService {
|
||||
@@ -191,10 +247,15 @@ mod tests {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: bob_id,
|
||||
in_reply_to_id: Some(original.id.clone()),
|
||||
}).await.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert!(matches!(notifs[0].notification_type, NotificationType::Reply));
|
||||
assert!(matches!(
|
||||
notifs[0].notification_type,
|
||||
NotificationType::Reply
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -202,9 +263,13 @@ mod tests {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let original = Thought::new_local(
|
||||
ThoughtId::new(), alice.id.clone(),
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("original").unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(original.clone());
|
||||
let svc = NotificationEventService {
|
||||
@@ -215,7 +280,9 @@ mod tests {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: Some(original.id.clone()),
|
||||
}).await.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -224,9 +291,13 @@ mod tests {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(), alice.id.clone(),
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("hello").unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
let svc = NotificationEventService {
|
||||
@@ -237,7 +308,9 @@ mod tests {
|
||||
boost_id: BoostId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
}).await.unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,36 @@ use domain::{
|
||||
value_objects::{ApiKeyId, UserId},
|
||||
};
|
||||
|
||||
pub async fn list_api_keys(keys: &dyn ApiKeyRepository, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
||||
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> {
|
||||
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() };
|
||||
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> {
|
||||
pub async fn delete_api_key(
|
||||
keys: &dyn ApiKeyRepository,
|
||||
user_id: &UserId,
|
||||
key_id: &ApiKeyId,
|
||||
) -> Result<(), DomainError> {
|
||||
keys.delete(key_id, user_id).await
|
||||
}
|
||||
|
||||
@@ -37,7 +54,9 @@ mod tests {
|
||||
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();
|
||||
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");
|
||||
@@ -50,7 +69,9 @@ mod tests {
|
||||
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 (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);
|
||||
}
|
||||
@@ -69,7 +90,9 @@ mod tests {
|
||||
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, &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);
|
||||
|
||||
@@ -6,9 +6,16 @@ use domain::{
|
||||
value_objects::{Email, UserId, Username},
|
||||
};
|
||||
|
||||
pub struct RegisterInput { pub username: String, pub email: String, pub password: String }
|
||||
pub struct RegisterInput {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct RegisterOutput { pub user: User, pub token: String }
|
||||
pub struct RegisterOutput {
|
||||
pub user: User,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
users: &dyn UserRepository,
|
||||
@@ -28,14 +35,27 @@ pub async fn register(
|
||||
let hash = hasher.hash(&input.password).await?;
|
||||
let user = User::new_local(UserId::new(), username, email, hash);
|
||||
users.save(&user).await?;
|
||||
events.publish(&DomainEvent::UserRegistered { user_id: user.id.clone() }).await?;
|
||||
events
|
||||
.publish(&DomainEvent::UserRegistered {
|
||||
user_id: user.id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let token = auth.generate_token(&user.id)?;
|
||||
Ok(RegisterOutput { user, token: token.token })
|
||||
Ok(RegisterOutput {
|
||||
user,
|
||||
token: token.token,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct LoginInput { pub email: String, pub password: String }
|
||||
pub struct LoginInput {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct LoginOutput { pub user: User, pub token: String }
|
||||
pub struct LoginOutput {
|
||||
pub user: User,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
users: &dyn UserRepository,
|
||||
@@ -44,12 +64,18 @@ pub async fn login(
|
||||
input: LoginInput,
|
||||
) -> Result<LoginOutput, DomainError> {
|
||||
let email = Email::new(input.email)?;
|
||||
let user = users.find_by_email(&email).await?.ok_or(DomainError::Unauthorized)?;
|
||||
let user = users
|
||||
.find_by_email(&email)
|
||||
.await?
|
||||
.ok_or(DomainError::Unauthorized)?;
|
||||
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 })
|
||||
Ok(LoginOutput {
|
||||
user,
|
||||
token: token.token,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -65,29 +91,45 @@ mod tests {
|
||||
};
|
||||
|
||||
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) }
|
||||
#[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() })
|
||||
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)?))
|
||||
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() }
|
||||
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();
|
||||
let out = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.user.username.as_str(), "alice");
|
||||
assert!(!out.token.is_empty());
|
||||
}
|
||||
@@ -95,31 +137,61 @@ mod tests {
|
||||
#[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();
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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 { .. }));
|
||||
@@ -128,15 +200,39 @@ mod tests {
|
||||
#[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();
|
||||
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();
|
||||
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(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,32 +8,64 @@ use domain::{
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub async fn get_home_feed(feed: &dyn FeedRepository, follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
pub async fn get_home_feed(
|
||||
feed: &dyn FeedRepository,
|
||||
follows: &dyn FollowRepository,
|
||||
user_id: &UserId,
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let following_ids = follows.get_accepted_following_ids(user_id).await?;
|
||||
feed.home_feed(&following_ids, &page, Some(user_id)).await
|
||||
}
|
||||
|
||||
pub async fn get_public_feed(feed: &dyn FeedRepository, viewer_id: Option<&UserId>, page: PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
pub async fn get_public_feed(
|
||||
feed: &dyn FeedRepository,
|
||||
viewer_id: Option<&UserId>,
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.public_feed(&page, viewer_id).await
|
||||
}
|
||||
|
||||
pub async fn get_user_feed(feed: &dyn FeedRepository, user_id: &UserId, page: PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
pub async fn get_user_feed(
|
||||
feed: &dyn FeedRepository,
|
||||
user_id: &UserId,
|
||||
page: PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.user_feed(user_id, &page, viewer_id).await
|
||||
}
|
||||
|
||||
pub async fn get_followers(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<User>, DomainError> {
|
||||
pub async fn get_followers(
|
||||
follows: &dyn FollowRepository,
|
||||
user_id: &UserId,
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<User>, DomainError> {
|
||||
follows.list_followers(user_id, &page).await
|
||||
}
|
||||
|
||||
pub async fn get_following(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<User>, DomainError> {
|
||||
pub async fn get_following(
|
||||
follows: &dyn FollowRepository,
|
||||
user_id: &UserId,
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<User>, DomainError> {
|
||||
follows.list_following(user_id, &page).await
|
||||
}
|
||||
|
||||
pub async fn get_by_tag(feed: &dyn FeedRepository, tag_name: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
pub async fn get_by_tag(
|
||||
feed: &dyn FeedRepository,
|
||||
tag_name: &str,
|
||||
page: PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.tag_feed(tag_name, &page, viewer_id).await
|
||||
}
|
||||
|
||||
pub async fn search(feed: &dyn FeedRepository, query: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
pub async fn search(
|
||||
feed: &dyn FeedRepository,
|
||||
query: &str,
|
||||
page: PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.search(query, &page, viewer_id).await
|
||||
}
|
||||
|
||||
@@ -41,6 +73,9 @@ pub async fn list_users(users: &dyn UserRepository) -> Result<Vec<UserSummary>,
|
||||
users.list_with_stats().await
|
||||
}
|
||||
|
||||
pub async fn get_popular_tags(tags: &dyn TagRepository, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
|
||||
pub async fn get_popular_tags(
|
||||
tags: &dyn TagRepository,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, i64)>, DomainError> {
|
||||
tags.popular_tags(limit).await
|
||||
}
|
||||
|
||||
@@ -6,12 +6,21 @@ use domain::{
|
||||
};
|
||||
|
||||
pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result<User, DomainError> {
|
||||
users.find_by_id(user_id).await?.ok_or(DomainError::NotFound)
|
||||
users
|
||||
.find_by_id(user_id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(users: &dyn UserRepository, username: &str) -> Result<User, DomainError> {
|
||||
pub async fn get_user_by_username(
|
||||
users: &dyn UserRepository,
|
||||
username: &str,
|
||||
) -> Result<User, DomainError> {
|
||||
let username = Username::new(username).map_err(|_| DomainError::NotFound)?;
|
||||
users.find_by_username(&username).await?.ok_or(DomainError::NotFound)
|
||||
users
|
||||
.find_by_username(&username)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
@@ -23,16 +32,38 @@ pub async fn update_profile(
|
||||
header_url: Option<String>,
|
||||
custom_css: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
users.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css).await
|
||||
users
|
||||
.update_profile(
|
||||
user_id,
|
||||
display_name,
|
||||
bio,
|
||||
avatar_url,
|
||||
header_url,
|
||||
custom_css,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_top_friends(top_friends: &dyn TopFriendRepository, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> {
|
||||
pub async fn get_top_friends(
|
||||
top_friends: &dyn TopFriendRepository,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<(TopFriend, User)>, DomainError> {
|
||||
top_friends.list_for_user(user_id).await
|
||||
}
|
||||
|
||||
pub async fn set_top_friends(top_friends: &dyn TopFriendRepository, user_id: &UserId, friend_ids: Vec<UserId>) -> Result<(), DomainError> {
|
||||
if friend_ids.len() > 8 { return Err(DomainError::InvalidInput("top friends: max 8".into())); }
|
||||
let friends: Vec<(UserId, i16)> = friend_ids.into_iter().enumerate().map(|(i, id)| (id, (i + 1) as i16)).collect();
|
||||
pub async fn set_top_friends(
|
||||
top_friends: &dyn TopFriendRepository,
|
||||
user_id: &UserId,
|
||||
friend_ids: Vec<UserId>,
|
||||
) -> Result<(), DomainError> {
|
||||
if friend_ids.len() > 8 {
|
||||
return Err(DomainError::InvalidInput("top friends: max 8".into()));
|
||||
}
|
||||
let friends: Vec<(UserId, i16)> = friend_ids
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, id)| (id, (i + 1) as i16))
|
||||
.collect();
|
||||
top_friends.set_top_friends(user_id, friends).await
|
||||
}
|
||||
|
||||
@@ -71,11 +102,21 @@ mod tests {
|
||||
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();
|
||||
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();
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -7,63 +7,185 @@ use domain::{
|
||||
value_objects::{BoostId, LikeId, ThoughtId, UserId},
|
||||
};
|
||||
|
||||
pub async fn like_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
let like = Like { id: LikeId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() };
|
||||
pub async fn like_thought(
|
||||
likes: &dyn LikeRepository,
|
||||
events: &dyn EventPublisher,
|
||||
user_id: &UserId,
|
||||
thought_id: &ThoughtId,
|
||||
) -> Result<(), DomainError> {
|
||||
let like = Like {
|
||||
id: LikeId::new(),
|
||||
user_id: user_id.clone(),
|
||||
thought_id: thought_id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
likes.save(&like).await?;
|
||||
events.publish(&DomainEvent::LikeAdded { like_id: like.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||
events
|
||||
.publish(&DomainEvent::LikeAdded {
|
||||
like_id: like.id,
|
||||
user_id: user_id.clone(),
|
||||
thought_id: thought_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unlike_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
pub async fn unlike_thought(
|
||||
likes: &dyn LikeRepository,
|
||||
events: &dyn EventPublisher,
|
||||
user_id: &UserId,
|
||||
thought_id: &ThoughtId,
|
||||
) -> Result<(), DomainError> {
|
||||
likes.delete(user_id, thought_id).await?;
|
||||
events.publish(&DomainEvent::LikeRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||
events
|
||||
.publish(&DomainEvent::LikeRemoved {
|
||||
user_id: user_id.clone(),
|
||||
thought_id: thought_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn boost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
let boost = Boost { id: BoostId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() };
|
||||
pub async fn boost_thought(
|
||||
boosts: &dyn BoostRepository,
|
||||
events: &dyn EventPublisher,
|
||||
user_id: &UserId,
|
||||
thought_id: &ThoughtId,
|
||||
) -> Result<(), DomainError> {
|
||||
let boost = Boost {
|
||||
id: BoostId::new(),
|
||||
user_id: user_id.clone(),
|
||||
thought_id: thought_id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
boosts.save(&boost).await?;
|
||||
events.publish(&DomainEvent::BoostAdded { boost_id: boost.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||
events
|
||||
.publish(&DomainEvent::BoostAdded {
|
||||
boost_id: boost.id,
|
||||
user_id: user_id.clone(),
|
||||
thought_id: thought_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unboost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
pub async fn unboost_thought(
|
||||
boosts: &dyn BoostRepository,
|
||||
events: &dyn EventPublisher,
|
||||
user_id: &UserId,
|
||||
thought_id: &ThoughtId,
|
||||
) -> Result<(), DomainError> {
|
||||
boosts.delete(user_id, thought_id).await?;
|
||||
events.publish(&DomainEvent::BoostRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||
events
|
||||
.publish(&DomainEvent::BoostRemoved {
|
||||
user_id: user_id.clone(),
|
||||
thought_id: thought_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn follow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||
if follower_id == following_id { return Err(DomainError::InvalidInput("cannot follow yourself".into())); }
|
||||
let follow = Follow { follower_id: follower_id.clone(), following_id: following_id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() };
|
||||
pub async fn follow_user(
|
||||
follows: &dyn FollowRepository,
|
||||
events: &dyn EventPublisher,
|
||||
follower_id: &UserId,
|
||||
following_id: &UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
if follower_id == following_id {
|
||||
return Err(DomainError::InvalidInput("cannot follow yourself".into()));
|
||||
}
|
||||
let follow = Follow {
|
||||
follower_id: follower_id.clone(),
|
||||
following_id: following_id.clone(),
|
||||
state: FollowState::Accepted,
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
follows.save(&follow).await?;
|
||||
events.publish(&DomainEvent::FollowAccepted { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||
events
|
||||
.publish(&DomainEvent::FollowAccepted {
|
||||
follower_id: follower_id.clone(),
|
||||
following_id: following_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unfollow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||
pub async fn unfollow_user(
|
||||
follows: &dyn FollowRepository,
|
||||
events: &dyn EventPublisher,
|
||||
follower_id: &UserId,
|
||||
following_id: &UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
follows.delete(follower_id, following_id).await?;
|
||||
events.publish(&DomainEvent::Unfollowed { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||
events
|
||||
.publish(&DomainEvent::Unfollowed {
|
||||
follower_id: follower_id.clone(),
|
||||
following_id: following_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn accept_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||
follows.update_state(follower_id, following_id, &FollowState::Accepted).await?;
|
||||
events.publish(&DomainEvent::FollowAccepted { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||
pub async fn accept_follow(
|
||||
follows: &dyn FollowRepository,
|
||||
events: &dyn EventPublisher,
|
||||
follower_id: &UserId,
|
||||
following_id: &UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
follows
|
||||
.update_state(follower_id, following_id, &FollowState::Accepted)
|
||||
.await?;
|
||||
events
|
||||
.publish(&DomainEvent::FollowAccepted {
|
||||
follower_id: follower_id.clone(),
|
||||
following_id: following_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reject_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||
follows.update_state(follower_id, following_id, &FollowState::Rejected).await?;
|
||||
events.publish(&DomainEvent::FollowRejected { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||
pub async fn reject_follow(
|
||||
follows: &dyn FollowRepository,
|
||||
events: &dyn EventPublisher,
|
||||
follower_id: &UserId,
|
||||
following_id: &UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
follows
|
||||
.update_state(follower_id, following_id, &FollowState::Rejected)
|
||||
.await?;
|
||||
events
|
||||
.publish(&DomainEvent::FollowRejected {
|
||||
follower_id: follower_id.clone(),
|
||||
following_id: following_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn block_user(blocks: &dyn BlockRepository, events: &dyn EventPublisher, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
|
||||
if blocker_id == blocked_id { return Err(DomainError::InvalidInput("cannot block yourself".into())); }
|
||||
let block = Block { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone(), created_at: Utc::now() };
|
||||
pub async fn block_user(
|
||||
blocks: &dyn BlockRepository,
|
||||
events: &dyn EventPublisher,
|
||||
blocker_id: &UserId,
|
||||
blocked_id: &UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
if blocker_id == blocked_id {
|
||||
return Err(DomainError::InvalidInput("cannot block yourself".into()));
|
||||
}
|
||||
let block = Block {
|
||||
blocker_id: blocker_id.clone(),
|
||||
blocked_id: blocked_id.clone(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
blocks.save(&block).await?;
|
||||
events.publish(&DomainEvent::UserBlocked { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone() }).await?;
|
||||
events
|
||||
.publish(&DomainEvent::UserBlocked {
|
||||
blocker_id: blocker_id.clone(),
|
||||
blocked_id: blocked_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -74,10 +196,12 @@ pub async fn unblock_user(
|
||||
blocked_id: &UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
blocks.delete(blocker_id, blocked_id).await?;
|
||||
events.publish(&DomainEvent::UserUnblocked {
|
||||
blocker_id: blocker_id.clone(),
|
||||
blocked_id: blocked_id.clone(),
|
||||
}).await?;
|
||||
events
|
||||
.publish(&DomainEvent::UserUnblocked {
|
||||
blocker_id: blocker_id.clone(),
|
||||
blocked_id: blocked_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -85,13 +209,21 @@ pub async fn unblock_user(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::{thought::{Thought, Visibility}, user::User},
|
||||
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()))
|
||||
User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(name).unwrap(),
|
||||
Email::new(format!("{name}@ex.com")).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -99,20 +231,35 @@ mod tests {
|
||||
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));
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
unfollow_user(&store, &store, &alice.id, &bob.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.follows.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -120,7 +267,9 @@ mod tests {
|
||||
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();
|
||||
let err = follow_user(&store, &store, &alice.id, &alice.id)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
@@ -129,9 +278,13 @@ mod tests {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let bob = user("bob");
|
||||
block_user(&store, &store, &alice.id, &bob.id).await.unwrap();
|
||||
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();
|
||||
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 { .. }));
|
||||
@@ -142,17 +295,23 @@ mod tests {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let bob = user("bob");
|
||||
block_user(&store, &store, &alice.id, &bob.id).await.unwrap();
|
||||
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)));
|
||||
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();
|
||||
let err = block_user(&store, &store, &alice.id, &alice.id)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||
}
|
||||
|
||||
@@ -161,12 +320,20 @@ mod tests {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let tid = ThoughtId::new();
|
||||
boost_thought(&store, &store, &alice.id, &tid).await.unwrap();
|
||||
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();
|
||||
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 { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::BoostAdded { .. })));
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::BoostRemoved { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ pub struct CreateThoughtInput {
|
||||
pub content_warning: Option<String>,
|
||||
pub sensitive: bool,
|
||||
}
|
||||
pub struct CreateThoughtOutput { pub thought: Thought }
|
||||
pub struct CreateThoughtOutput {
|
||||
pub thought: Thought,
|
||||
}
|
||||
|
||||
pub async fn create_thought(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
@@ -30,18 +32,28 @@ pub async fn create_thought(
|
||||
input: CreateThoughtInput,
|
||||
) -> Result<CreateThoughtOutput, DomainError> {
|
||||
let content = Content::new_local(input.content)?;
|
||||
let visibility = input.visibility.as_deref().map(Visibility::from_str).unwrap_or(Visibility::Public);
|
||||
let visibility = input
|
||||
.visibility
|
||||
.as_deref()
|
||||
.map(Visibility::from_str)
|
||||
.unwrap_or(Visibility::Public);
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(), input.user_id,
|
||||
content, input.in_reply_to_id.clone(),
|
||||
visibility, input.content_warning, input.sensitive,
|
||||
ThoughtId::new(),
|
||||
input.user_id,
|
||||
content,
|
||||
input.in_reply_to_id.clone(),
|
||||
visibility,
|
||||
input.content_warning,
|
||||
input.sensitive,
|
||||
);
|
||||
thoughts.save(&thought).await?;
|
||||
events.publish(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: thought.user_id.clone(),
|
||||
in_reply_to_id: input.in_reply_to_id,
|
||||
}).await?;
|
||||
events
|
||||
.publish(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: thought.user_id.clone(),
|
||||
in_reply_to_id: input.in_reply_to_id,
|
||||
})
|
||||
.await?;
|
||||
Ok(CreateThoughtOutput { thought })
|
||||
}
|
||||
|
||||
@@ -51,10 +63,18 @@ pub async fn delete_thought(
|
||||
id: &ThoughtId,
|
||||
user_id: &UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?;
|
||||
let thought = thoughts
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
require_owner(&thought, user_id)?;
|
||||
thoughts.delete(id, user_id).await?;
|
||||
events.publish(&DomainEvent::ThoughtDeleted { thought_id: id.clone(), user_id: user_id.clone() }).await?;
|
||||
events
|
||||
.publish(&DomainEvent::ThoughtDeleted {
|
||||
thought_id: id.clone(),
|
||||
user_id: user_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -65,19 +85,33 @@ pub async fn edit_thought(
|
||||
user_id: &UserId,
|
||||
new_content: String,
|
||||
) -> Result<(), DomainError> {
|
||||
let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?;
|
||||
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?;
|
||||
events
|
||||
.publish(&DomainEvent::ThoughtUpdated {
|
||||
thought_id: id.clone(),
|
||||
user_id: user_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_thought(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result<Thought, DomainError> {
|
||||
pub async fn get_thought(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
id: &ThoughtId,
|
||||
) -> Result<Thought, DomainError> {
|
||||
thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn get_thread(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result<Vec<Thought>, DomainError> {
|
||||
pub async fn get_thread(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
id: &ThoughtId,
|
||||
) -> Result<Vec<Thought>, DomainError> {
|
||||
thoughts.get_thread(id).await
|
||||
}
|
||||
|
||||
@@ -91,18 +125,33 @@ mod tests {
|
||||
};
|
||||
|
||||
fn user() -> User {
|
||||
User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()))
|
||||
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 }
|
||||
CreateThoughtInput {
|
||||
user_id: uid,
|
||||
content: "hello".into(),
|
||||
in_reply_to_id: None,
|
||||
visibility: None,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_thought_saves_and_emits_event() {
|
||||
let store = TestStore::default();
|
||||
let u = user(); store.users.lock().unwrap().push(u.clone());
|
||||
let out = create_thought(&store, &store, &store, input(u.id.clone())).await.unwrap();
|
||||
let u = user();
|
||||
store.users.lock().unwrap().push(u.clone());
|
||||
let out = create_thought(&store, &store, &store, input(u.id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.thought.content.as_str(), "hello");
|
||||
assert_eq!(store.events.lock().unwrap().len(), 1);
|
||||
}
|
||||
@@ -110,9 +159,14 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn delete_own_thought_succeeds() {
|
||||
let store = TestStore::default();
|
||||
let u = user(); store.users.lock().unwrap().push(u.clone());
|
||||
let out = create_thought(&store, &store, &NoOpEventPublisher, input(u.id.clone())).await.unwrap();
|
||||
delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id).await.unwrap();
|
||||
let u = user();
|
||||
store.users.lock().unwrap().push(u.clone());
|
||||
let out = create_thought(&store, &store, &NoOpEventPublisher, input(u.id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.thoughts.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -120,10 +174,23 @@ mod tests {
|
||||
async fn delete_other_thought_returns_not_found() {
|
||||
let store = TestStore::default();
|
||||
let alice = user();
|
||||
let bob = User::new_local(UserId::new(), Username::new("bob").unwrap(), Email::new("bob@ex.com").unwrap(), PasswordHash("h".into()));
|
||||
store.users.lock().unwrap().extend([alice.clone(), bob.clone()]);
|
||||
let out = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())).await.unwrap();
|
||||
let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id).await.unwrap_err();
|
||||
let bob = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("bob").unwrap(),
|
||||
Email::new("bob@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
store
|
||||
.users
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend([alice.clone(), bob.clone()]);
|
||||
let out = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
@@ -132,16 +199,29 @@ mod tests {
|
||||
let store = TestStore::default();
|
||||
let alice = user();
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
let out = create_thought(&store, &store, &store, input(alice.id.clone())).await.unwrap();
|
||||
let out = create_thought(&store, &store, &store, input(alice.id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
let tid = out.thought.id.clone();
|
||||
|
||||
edit_thought(&store, &store, &tid, &alice.id, "updated".to_string()).await.unwrap();
|
||||
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();
|
||||
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)));
|
||||
assert!(events.iter().any(
|
||||
|e| matches!(e, DomainEvent::ThoughtUpdated { thought_id, .. } if thought_id == &tid)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -149,19 +229,32 @@ mod tests {
|
||||
let store = TestStore::default();
|
||||
let alice = user();
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
let original = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())).await.unwrap().thought;
|
||||
let original = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.thought;
|
||||
|
||||
create_thought(&store, &store, &NoOpEventPublisher, CreateThoughtInput {
|
||||
user_id: alice.id.clone(),
|
||||
content: "reply".into(),
|
||||
in_reply_to_id: Some(original.id.clone()),
|
||||
visibility: None,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
}).await.unwrap();
|
||||
create_thought(
|
||||
&store,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
CreateThoughtInput {
|
||||
user_id: alice.id.clone(),
|
||||
content: "reply".into(),
|
||||
in_reply_to_id: Some(original.id.clone()),
|
||||
visibility: None,
|
||||
content_warning: None,
|
||||
sensitive: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let thoughts = store.thoughts.lock().unwrap();
|
||||
let reply = thoughts.iter().find(|t| t.content.as_str() == "reply").unwrap();
|
||||
let reply = thoughts
|
||||
.iter()
|
||||
.find(|t| t.content.as_str() == "reply")
|
||||
.unwrap();
|
||||
assert_eq!(reply.in_reply_to_id, Some(original.id.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user