From adc210292780456d2913ccea1b229c357bc553d9 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 03:52:36 +0200 Subject: [PATCH] feat(application): all use cases --- crates/application/Cargo.toml | 2 + crates/application/src/lib.rs | 1 + crates/application/src/use_cases/api_keys.rs | 29 +++++ crates/application/src/use_cases/auth.rs | 115 +++++++++++++++++ crates/application/src/use_cases/feed.rs | 43 +++++++ crates/application/src/use_cases/mod.rs | 6 + crates/application/src/use_cases/profile.rs | 37 ++++++ crates/application/src/use_cases/social.rs | 117 ++++++++++++++++++ crates/application/src/use_cases/thoughts.rs | 122 +++++++++++++++++++ 9 files changed, 472 insertions(+) create mode 100644 crates/application/src/use_cases/api_keys.rs create mode 100644 crates/application/src/use_cases/auth.rs create mode 100644 crates/application/src/use_cases/feed.rs create mode 100644 crates/application/src/use_cases/mod.rs create mode 100644 crates/application/src/use_cases/profile.rs create mode 100644 crates/application/src/use_cases/social.rs create mode 100644 crates/application/src/use_cases/thoughts.rs diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index a8e8c59..b0986bd 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -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"] } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index e69de29..d07542b 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -0,0 +1 @@ +pub mod use_cases; diff --git a/crates/application/src/use_cases/api_keys.rs b/crates/application/src/use_cases/api_keys.rs new file mode 100644 index 0000000..2f8e63c --- /dev/null +++ b/crates/application/src/use_cases/api_keys.rs @@ -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, 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) +} diff --git a/crates/application/src/use_cases/auth.rs b/crates/application/src/use_cases/auth.rs new file mode 100644 index 0000000..1e96a6a --- /dev/null +++ b/crates/application/src/use_cases/auth.rs @@ -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 { + 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 { + 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 { Ok(PasswordHash(plain.to_string())) } + async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result { Ok(plain == hash.0) } + } + + struct FakeAuth; + impl AuthService for FakeAuth { + fn generate_token(&self, uid: &UserId) -> Result { + Ok(GeneratedToken { token: uid.to_string(), user_id: uid.clone() }) + } + fn validate_token(&self, token: &str) -> Result { + 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)); + } +} diff --git a/crates/application/src/use_cases/feed.rs b/crates/application/src/use_cases/feed.rs new file mode 100644 index 0000000..fc63eae --- /dev/null +++ b/crates/application/src/use_cases/feed.rs @@ -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, 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, DomainError> { + feed.public_feed(&page, viewer_id).await +} + +pub async fn get_user_feed(thoughts: &dyn ThoughtRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { + thoughts.list_by_user(user_id, &page).await +} + +pub async fn get_followers(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { + follows.list_followers(user_id, &page).await +} + +pub async fn get_following(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { + follows.list_following(user_id, &page).await +} + +pub async fn get_by_tag(tags: &dyn TagRepository, tag_name: &str, page: PageParams) -> Result, 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, DomainError> { + feed.search(query, &page, viewer_id).await +} + +pub async fn list_users(users: &dyn UserRepository) -> Result, DomainError> { + users.list_with_stats().await +} diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs new file mode 100644 index 0000000..8b8f07e --- /dev/null +++ b/crates/application/src/use_cases/mod.rs @@ -0,0 +1,6 @@ +pub mod api_keys; +pub mod auth; +pub mod feed; +pub mod profile; +pub mod social; +pub mod thoughts; diff --git a/crates/application/src/use_cases/profile.rs b/crates/application/src/use_cases/profile.rs new file mode 100644 index 0000000..006ba54 --- /dev/null +++ b/crates/application/src/use_cases/profile.rs @@ -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 { + users.find_by_id(user_id).await?.ok_or(DomainError::NotFound) +} + +pub async fn get_user_by_username(users: &dyn UserRepository, username: &str) -> Result { + 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, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, +) -> 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, 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) -> 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 +} diff --git a/crates/application/src/use_cases/social.rs b/crates/application/src/use_cases/social.rs new file mode 100644 index 0000000..1761d39 --- /dev/null +++ b/crates/application/src/use_cases/social.rs @@ -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(_))); + } +} diff --git a/crates/application/src/use_cases/thoughts.rs b/crates/application/src/use_cases/thoughts.rs new file mode 100644 index 0000000..48d0470 --- /dev/null +++ b/crates/application/src/use_cases/thoughts.rs @@ -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, + pub visibility: Option, + pub content_warning: Option, + pub sensitive: bool, +} +pub struct CreateThoughtOutput { pub thought: Thought } + +pub async fn create_thought( + thoughts: &dyn ThoughtRepository, + _users: &dyn UserRepository, + events: &dyn EventPublisher, + input: CreateThoughtInput, +) -> Result { + let content = Content::new_local(input.content)?; + let visibility = input.visibility.as_deref().map(Visibility::from_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 { + thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound) +} + +pub async fn get_thread(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result, DomainError> { + thoughts.get_thread(id).await +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::{ + models::user::User, + testing::{NoOpEventPublisher, TestStore}, + value_objects::*, + }; + + fn user() -> User { + User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())) + } + + fn input(uid: UserId) -> CreateThoughtInput { + CreateThoughtInput { user_id: uid, content: "hello".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false } + } + + #[tokio::test] + async fn create_thought_saves_and_emits_event() { + let store = TestStore::default(); + let u = user(); store.users.lock().unwrap().push(u.clone()); + let out = create_thought(&store, &store, &store, input(u.id.clone())).await.unwrap(); + assert_eq!(out.thought.content.as_str(), "hello"); + assert_eq!(store.events.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn delete_own_thought_succeeds() { + let store = TestStore::default(); + let u = user(); store.users.lock().unwrap().push(u.clone()); + let out = create_thought(&store, &store, &NoOpEventPublisher, input(u.id.clone())).await.unwrap(); + delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id).await.unwrap(); + assert!(store.thoughts.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn delete_other_thought_returns_not_found() { + let store = TestStore::default(); + let alice = user(); + let bob = User::new_local(UserId::new(), Username::new("bob").unwrap(), Email::new("bob@ex.com").unwrap(), PasswordHash("h".into())); + store.users.lock().unwrap().extend([alice.clone(), bob.clone()]); + let out = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())).await.unwrap(); + let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id).await.unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); + } +}