feat(application): all use cases
This commit is contained in:
@@ -9,6 +9,8 @@ async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod use_cases;
|
||||
|
||||
29
crates/application/src/use_cases/api_keys.rs
Normal file
29
crates/application/src/use_cases/api_keys.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
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)
|
||||
}
|
||||
115
crates/application/src/use_cases/auth.rs
Normal file
115
crates/application/src/use_cases/auth.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::user::User,
|
||||
ports::{AuthService, EventPublisher, PasswordHasher, 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?;
|
||||
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 UserRepository,
|
||||
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?.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 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
testing::{NoOpEventPublisher, TestStore},
|
||||
value_objects::{PasswordHash, UserId},
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
43
crates/application/src/use_cases/feed.rs
Normal file
43
crates/application/src/use_cases/feed.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
||||
thought::Thought,
|
||||
user::User,
|
||||
},
|
||||
ports::{FeedRepository, FollowRepository, TagRepository, ThoughtRepository, UserRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
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> {
|
||||
feed.public_feed(&page, viewer_id).await
|
||||
}
|
||||
|
||||
pub async fn get_user_feed(thoughts: &dyn ThoughtRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
thoughts.list_by_user(user_id, &page).await
|
||||
}
|
||||
|
||||
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> {
|
||||
follows.list_following(user_id, &page).await
|
||||
}
|
||||
|
||||
pub async fn get_by_tag(tags: &dyn TagRepository, tag_name: &str, page: PageParams) -> Result<Paginated<Thought>, DomainError> {
|
||||
tags.list_thoughts_by_tag(tag_name, &page).await
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub async fn list_users(users: &dyn UserRepository) -> Result<Vec<UserSummary>, DomainError> {
|
||||
users.list_with_stats().await
|
||||
}
|
||||
6
crates/application/src/use_cases/mod.rs
Normal file
6
crates/application/src/use_cases/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod api_keys;
|
||||
pub mod auth;
|
||||
pub mod feed;
|
||||
pub mod profile;
|
||||
pub mod social;
|
||||
pub mod thoughts;
|
||||
37
crates/application/src/use_cases/profile.rs
Normal file
37
crates/application/src/use_cases/profile.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{top_friend::TopFriend, user::User},
|
||||
ports::{TopFriendRepository, UserRepository},
|
||||
value_objects::{UserId, Username},
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn get_user_by_username(users: &dyn UserRepository, username: &str) -> Result<User, DomainError> {
|
||||
let username = Username::from_trusted(username.to_string());
|
||||
users.find_by_username(&username).await?.ok_or(DomainError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn update_profile(
|
||||
users: &dyn UserRepository,
|
||||
user_id: &UserId,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
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
|
||||
}
|
||||
|
||||
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();
|
||||
top_friends.set_top_friends(user_id, friends).await
|
||||
}
|
||||
117
crates/application/src/use_cases/social.rs
Normal file
117
crates/application/src/use_cases/social.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::social::{Block, Boost, Follow, FollowState, Like},
|
||||
ports::{BlockRepository, BoostRepository, EventPublisher, FollowRepository, LikeRepository},
|
||||
value_objects::{BoostId, LikeId, ThoughtId, UserId},
|
||||
};
|
||||
|
||||
pub async fn like_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
let like = Like { id: LikeId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() };
|
||||
likes.save(&like).await?;
|
||||
events.publish(&DomainEvent::LikeAdded { like_id: like.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unlike_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
likes.delete(user_id, thought_id).await?;
|
||||
events.publish(&DomainEvent::LikeRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn boost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
let boost = Boost { id: BoostId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() };
|
||||
boosts.save(&boost).await?;
|
||||
events.publish(&DomainEvent::BoostAdded { boost_id: boost.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unboost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
boosts.delete(user_id, thought_id).await?;
|
||||
events.publish(&DomainEvent::BoostRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn follow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||
if follower_id == following_id { return Err(DomainError::InvalidInput("cannot follow yourself".into())); }
|
||||
let follow = Follow { follower_id: follower_id.clone(), following_id: following_id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() };
|
||||
follows.save(&follow).await?;
|
||||
events.publish(&DomainEvent::FollowRequested { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unfollow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||
follows.delete(follower_id, following_id).await?;
|
||||
events.publish(&DomainEvent::Unfollowed { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn accept_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||
follows.update_state(follower_id, following_id, &FollowState::Accepted).await?;
|
||||
events.publish(&DomainEvent::FollowAccepted { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reject_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||
follows.update_state(follower_id, following_id, &FollowState::Rejected).await?;
|
||||
events.publish(&DomainEvent::FollowRejected { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn block_user(blocks: &dyn BlockRepository, events: &dyn EventPublisher, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
|
||||
if blocker_id == blocked_id { return Err(DomainError::InvalidInput("cannot block yourself".into())); }
|
||||
let block = Block { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone(), created_at: Utc::now() };
|
||||
blocks.save(&block).await?;
|
||||
events.publish(&DomainEvent::UserBlocked { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone() }).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unblock_user(blocks: &dyn BlockRepository, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
|
||||
blocks.delete(blocker_id, blocked_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::{thought::{Thought, Visibility}, user::User},
|
||||
testing::TestStore,
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
fn user(name: &str) -> User {
|
||||
User::new_local(UserId::new(), Username::new(name).unwrap(), Email::new(format!("{name}@ex.com")).unwrap(), PasswordHash("h".into()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn like_and_unlike() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let tid = ThoughtId::new();
|
||||
store.thoughts.lock().unwrap().push(Thought::new_local(tid.clone(), alice.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false));
|
||||
like_thought(&store, &store, &alice.id, &tid).await.unwrap();
|
||||
assert_eq!(store.likes.lock().unwrap().len(), 1);
|
||||
unlike_thought(&store, &store, &alice.id, &tid).await.unwrap();
|
||||
assert!(store.likes.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_and_unfollow() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice"); let bob = user("bob");
|
||||
follow_user(&store, &store, &alice.id, &bob.id).await.unwrap();
|
||||
assert_eq!(store.follows.lock().unwrap().len(), 1);
|
||||
unfollow_user(&store, &store, &alice.id, &bob.id).await.unwrap();
|
||||
assert!(store.follows.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cannot_follow_self() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let err = follow_user(&store, &store, &alice.id, &alice.id).await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||
}
|
||||
}
|
||||
122
crates/application/src/use_cases/thoughts.rs
Normal file
122
crates/application/src/use_cases/thoughts.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::thought::{Thought, Visibility},
|
||||
ports::{EventPublisher, ThoughtRepository, UserRepository},
|
||||
value_objects::{Content, ThoughtId, UserId},
|
||||
};
|
||||
|
||||
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 UserRepository,
|
||||
events: &dyn EventPublisher,
|
||||
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 thought = Thought::new_local(
|
||||
ThoughtId::new(), input.user_id,
|
||||
content, input.in_reply_to_id.clone(),
|
||||
visibility, input.content_warning, input.sensitive,
|
||||
);
|
||||
thoughts.save(&thought).await?;
|
||||
events.publish(&DomainEvent::ThoughtCreated {
|
||||
thought_id: thought.id.clone(),
|
||||
user_id: thought.user_id.clone(),
|
||||
in_reply_to_id: input.in_reply_to_id,
|
||||
}).await?;
|
||||
Ok(CreateThoughtOutput { thought })
|
||||
}
|
||||
|
||||
pub async fn delete_thought(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
events: &dyn EventPublisher,
|
||||
id: &ThoughtId,
|
||||
user_id: &UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?;
|
||||
if thought.user_id != *user_id { return Err(DomainError::NotFound); }
|
||||
thoughts.delete(id, user_id).await?;
|
||||
events.publish(&DomainEvent::ThoughtDeleted { thought_id: id.clone(), user_id: user_id.clone() }).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn edit_thought(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
events: &dyn EventPublisher,
|
||||
id: &ThoughtId,
|
||||
user_id: &UserId,
|
||||
new_content: String,
|
||||
) -> Result<(), DomainError> {
|
||||
let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?;
|
||||
if thought.user_id != *user_id { return Err(DomainError::NotFound); }
|
||||
let content = Content::new_local(new_content)?;
|
||||
thoughts.update_content(id, &content).await?;
|
||||
events.publish(&DomainEvent::ThoughtUpdated { thought_id: id.clone(), user_id: user_id.clone() }).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_thought(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result<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> {
|
||||
thoughts.get_thread(id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::user::User,
|
||||
testing::{NoOpEventPublisher, TestStore},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
fn user() -> User {
|
||||
User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()))
|
||||
}
|
||||
|
||||
fn input(uid: UserId) -> CreateThoughtInput {
|
||||
CreateThoughtInput { user_id: uid, content: "hello".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_thought_saves_and_emits_event() {
|
||||
let store = TestStore::default();
|
||||
let u = user(); store.users.lock().unwrap().push(u.clone());
|
||||
let out = create_thought(&store, &store, &store, input(u.id.clone())).await.unwrap();
|
||||
assert_eq!(out.thought.content.as_str(), "hello");
|
||||
assert_eq!(store.events.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_own_thought_succeeds() {
|
||||
let store = TestStore::default();
|
||||
let u = user(); store.users.lock().unwrap().push(u.clone());
|
||||
let out = create_thought(&store, &store, &NoOpEventPublisher, input(u.id.clone())).await.unwrap();
|
||||
delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id).await.unwrap();
|
||||
assert!(store.thoughts.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_other_thought_returns_not_found() {
|
||||
let store = TestStore::default();
|
||||
let alice = user();
|
||||
let bob = User::new_local(UserId::new(), Username::new("bob").unwrap(), Email::new("bob@ex.com").unwrap(), PasswordHash("h".into()));
|
||||
store.users.lock().unwrap().extend([alice.clone(), bob.clone()]);
|
||||
let out = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())).await.unwrap();
|
||||
let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id).await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user