From a0aa3f381e7c375bfadf704a3c1449721d17091a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 12:08:38 +0200 Subject: [PATCH] refactor: extract inline test modules to separate files --- .../activitypub/src/{note.rs => note/mod.rs} | 22 +- crates/adapters/activitypub/src/note/tests.rs | 19 + .../activitypub/src/{urls.rs => urls/mod.rs} | 23 +- crates/adapters/activitypub/src/urls/tests.rs | 20 + crates/adapters/auth/src/api_key_service.rs | 89 -- .../adapters/auth/src/api_key_service/mod.rs | 33 + .../auth/src/api_key_service/tests.rs | 55 ++ crates/adapters/auth/src/lib.rs | 29 +- crates/adapters/auth/src/tests.rs | 26 + crates/adapters/event-payload/src/lib.rs | 89 +- crates/adapters/event-payload/src/tests.rs | 85 ++ crates/adapters/event-transport/src/lib.rs | 126 +-- crates/adapters/event-transport/src/tests.rs | 122 +++ crates/adapters/nats/src/lib.rs | 45 +- crates/adapters/nats/src/tests.rs | 41 + crates/adapters/postgres-search/src/lib.rs | 166 +--- crates/adapters/postgres-search/src/tests.rs | 163 ++++ .../{activitypub.rs => activitypub/mod.rs} | 71 +- .../postgres/src/activitypub/tests.rs | 68 ++ .../src/{api_key.rs => api_key/mod.rs} | 52 +- crates/adapters/postgres/src/api_key/tests.rs | 49 ++ .../postgres/src/{block.rs => block/mod.rs} | 37 +- crates/adapters/postgres/src/block/tests.rs | 34 + .../postgres/src/{boost.rs => boost/mod.rs} | 38 +- crates/adapters/postgres/src/boost/tests.rs | 35 + .../postgres/src/{feed.rs => feed/mod.rs} | 72 +- crates/adapters/postgres/src/feed/tests.rs | 69 ++ .../postgres/src/{follow.rs => follow/mod.rs} | 61 +- crates/adapters/postgres/src/follow/tests.rs | 58 ++ .../postgres/src/{like.rs => like/mod.rs} | 38 +- crates/adapters/postgres/src/like/tests.rs | 35 + .../{notification.rs => notification/mod.rs} | 70 +- .../postgres/src/notification/tests.rs | 67 ++ .../postgres/src/{tag.rs => tag/mod.rs} | 51 +- crates/adapters/postgres/src/tag/tests.rs | 48 ++ .../src/{thought.rs => thought/mod.rs} | 93 +-- crates/adapters/postgres/src/thought/tests.rs | 90 ++ .../src/{top_friend.rs => top_friend/mod.rs} | 50 +- .../adapters/postgres/src/top_friend/tests.rs | 47 ++ .../postgres/src/{user.rs => user/mod.rs} | 72 +- crates/adapters/postgres/src/user/tests.rs | 69 ++ .../src/services/federation_event.rs | 787 ------------------ .../src/services/federation_event/mod.rs | 214 +++++ .../src/services/federation_event/tests.rs | 572 +++++++++++++ .../src/services/notification_event.rs | 332 -------- .../src/services/notification_event/mod.rs | 145 ++++ .../src/services/notification_event/tests.rs | 186 +++++ crates/application/src/use_cases/api_keys.rs | 101 --- .../application/src/use_cases/api_keys/mod.rs | 49 ++ .../src/use_cases/api_keys/tests.rs | 51 ++ crates/application/src/use_cases/auth.rs | 388 --------- crates/application/src/use_cases/auth/mod.rs | 101 +++ .../application/src/use_cases/auth/tests.rs | 286 +++++++ .../mod.rs} | 56 +- .../use_cases/federation_management/tests.rs | 53 ++ .../use_cases/{profile.rs => profile/mod.rs} | 69 +- .../src/use_cases/profile/tests.rs | 66 ++ .../use_cases/{social.rs => social/mod.rs} | 205 +---- .../application/src/use_cases/social/tests.rs | 202 +++++ crates/application/src/use_cases/thoughts.rs | 456 ---------- .../application/src/use_cases/thoughts/mod.rs | 181 ++++ .../src/use_cases/thoughts/tests.rs | 269 ++++++ crates/domain/src/hashtag.rs | 139 ---- crates/domain/src/hashtag/mod.rs | 61 ++ crates/domain/src/hashtag/tests.rs | 71 ++ .../domain/src/{testing.rs => testing/mod.rs} | 101 +-- crates/domain/src/testing/tests.rs | 98 +++ .../mod.rs} | 33 +- crates/domain/src/value_objects/tests.rs | 30 + .../mod.rs} | 30 +- .../src/handlers/federation_actors/tests.rs | 27 + .../mod.rs} | 51 +- .../src/handlers/notifications/tests.rs | 48 ++ .../src/handlers/{social.rs => social/mod.rs} | 51 +- .../presentation/src/handlers/social/tests.rs | 48 ++ .../src/handlers/{users.rs => users/mod.rs} | 62 +- .../presentation/src/handlers/users/tests.rs | 59 ++ 77 files changed, 4081 insertions(+), 4124 deletions(-) rename crates/adapters/activitypub/src/{note.rs => note/mod.rs} (71%) create mode 100644 crates/adapters/activitypub/src/note/tests.rs rename crates/adapters/activitypub/src/{urls.rs => urls/mod.rs} (65%) create mode 100644 crates/adapters/activitypub/src/urls/tests.rs delete mode 100644 crates/adapters/auth/src/api_key_service.rs create mode 100644 crates/adapters/auth/src/api_key_service/mod.rs create mode 100644 crates/adapters/auth/src/api_key_service/tests.rs create mode 100644 crates/adapters/auth/src/tests.rs create mode 100644 crates/adapters/event-payload/src/tests.rs create mode 100644 crates/adapters/event-transport/src/tests.rs create mode 100644 crates/adapters/nats/src/tests.rs create mode 100644 crates/adapters/postgres-search/src/tests.rs rename crates/adapters/postgres/src/{activitypub.rs => activitypub/mod.rs} (83%) create mode 100644 crates/adapters/postgres/src/activitypub/tests.rs rename crates/adapters/postgres/src/{api_key.rs => api_key/mod.rs} (62%) create mode 100644 crates/adapters/postgres/src/api_key/tests.rs rename crates/adapters/postgres/src/{block.rs => block/mod.rs} (54%) create mode 100644 crates/adapters/postgres/src/block/tests.rs rename crates/adapters/postgres/src/{boost.rs => boost/mod.rs} (65%) create mode 100644 crates/adapters/postgres/src/boost/tests.rs rename crates/adapters/postgres/src/{feed.rs => feed/mod.rs} (84%) create mode 100644 crates/adapters/postgres/src/feed/tests.rs rename crates/adapters/postgres/src/{follow.rs => follow/mod.rs} (72%) create mode 100644 crates/adapters/postgres/src/follow/tests.rs rename crates/adapters/postgres/src/{like.rs => like/mod.rs} (66%) create mode 100644 crates/adapters/postgres/src/like/tests.rs rename crates/adapters/postgres/src/{notification.rs => notification/mod.rs} (69%) create mode 100644 crates/adapters/postgres/src/notification/tests.rs rename crates/adapters/postgres/src/{tag.rs => tag/mod.rs} (70%) create mode 100644 crates/adapters/postgres/src/tag/tests.rs rename crates/adapters/postgres/src/{thought.rs => thought/mod.rs} (64%) create mode 100644 crates/adapters/postgres/src/thought/tests.rs rename crates/adapters/postgres/src/{top_friend.rs => top_friend/mod.rs} (65%) create mode 100644 crates/adapters/postgres/src/top_friend/tests.rs rename crates/adapters/postgres/src/{user.rs => user/mod.rs} (80%) create mode 100644 crates/adapters/postgres/src/user/tests.rs delete mode 100644 crates/application/src/services/federation_event.rs create mode 100644 crates/application/src/services/federation_event/mod.rs create mode 100644 crates/application/src/services/federation_event/tests.rs delete mode 100644 crates/application/src/services/notification_event.rs create mode 100644 crates/application/src/services/notification_event/mod.rs create mode 100644 crates/application/src/services/notification_event/tests.rs delete mode 100644 crates/application/src/use_cases/api_keys.rs create mode 100644 crates/application/src/use_cases/api_keys/mod.rs create mode 100644 crates/application/src/use_cases/api_keys/tests.rs delete mode 100644 crates/application/src/use_cases/auth.rs create mode 100644 crates/application/src/use_cases/auth/mod.rs create mode 100644 crates/application/src/use_cases/auth/tests.rs rename crates/application/src/use_cases/{federation_management.rs => federation_management/mod.rs} (71%) create mode 100644 crates/application/src/use_cases/federation_management/tests.rs rename crates/application/src/use_cases/{profile.rs => profile/mod.rs} (53%) create mode 100644 crates/application/src/use_cases/profile/tests.rs rename crates/application/src/use_cases/{social.rs => social/mod.rs} (54%) create mode 100644 crates/application/src/use_cases/social/tests.rs delete mode 100644 crates/application/src/use_cases/thoughts.rs create mode 100644 crates/application/src/use_cases/thoughts/mod.rs create mode 100644 crates/application/src/use_cases/thoughts/tests.rs delete mode 100644 crates/domain/src/hashtag.rs create mode 100644 crates/domain/src/hashtag/mod.rs create mode 100644 crates/domain/src/hashtag/tests.rs rename crates/domain/src/{testing.rs => testing/mod.rs} (89%) create mode 100644 crates/domain/src/testing/tests.rs rename crates/domain/src/{value_objects.rs => value_objects/mod.rs} (80%) create mode 100644 crates/domain/src/value_objects/tests.rs rename crates/presentation/src/handlers/{federation_actors.rs => federation_actors/mod.rs} (81%) create mode 100644 crates/presentation/src/handlers/federation_actors/tests.rs rename crates/presentation/src/handlers/{notifications.rs => notifications/mod.rs} (61%) create mode 100644 crates/presentation/src/handlers/notifications/tests.rs rename crates/presentation/src/handlers/{social.rs => social/mod.rs} (82%) create mode 100644 crates/presentation/src/handlers/social/tests.rs rename crates/presentation/src/handlers/{users.rs => users/mod.rs} (80%) create mode 100644 crates/presentation/src/handlers/users/tests.rs diff --git a/crates/adapters/activitypub/src/note.rs b/crates/adapters/activitypub/src/note/mod.rs similarity index 71% rename from crates/adapters/activitypub/src/note.rs rename to crates/adapters/activitypub/src/note/mod.rs index 9d2941f..2476639 100644 --- a/crates/adapters/activitypub/src/note.rs +++ b/crates/adapters/activitypub/src/note/mod.rs @@ -58,24 +58,4 @@ impl ThoughtNote { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn note_serializes_with_public_audience() { - let note = ThoughtNote::new_public( - "https://example.com/thoughts/1".parse().unwrap(), - "https://example.com/users/alice".parse().unwrap(), - "Hello world".to_string(), - chrono::Utc::now(), - None, - false, - None, - "https://example.com/users/alice/followers".parse().unwrap(), - ); - let json = serde_json::to_string(¬e).unwrap(); - assert!(json.contains(AS_PUBLIC)); - assert!(json.contains("Hello world")); - assert!(json.contains("\"url\"")); - } -} +mod tests; diff --git a/crates/adapters/activitypub/src/note/tests.rs b/crates/adapters/activitypub/src/note/tests.rs new file mode 100644 index 0000000..786326e --- /dev/null +++ b/crates/adapters/activitypub/src/note/tests.rs @@ -0,0 +1,19 @@ +use super::*; + +#[test] +fn note_serializes_with_public_audience() { + let note = ThoughtNote::new_public( + "https://example.com/thoughts/1".parse().unwrap(), + "https://example.com/users/alice".parse().unwrap(), + "Hello world".to_string(), + chrono::Utc::now(), + None, + false, + None, + "https://example.com/users/alice/followers".parse().unwrap(), + ); + let json = serde_json::to_string(¬e).unwrap(); + assert!(json.contains(AS_PUBLIC)); + assert!(json.contains("Hello world")); + assert!(json.contains("\"url\"")); +} diff --git a/crates/adapters/activitypub/src/urls.rs b/crates/adapters/activitypub/src/urls/mod.rs similarity index 65% rename from crates/adapters/activitypub/src/urls.rs rename to crates/adapters/activitypub/src/urls/mod.rs index f15f95a..513c078 100644 --- a/crates/adapters/activitypub/src/urls.rs +++ b/crates/adapters/activitypub/src/urls/mod.rs @@ -33,25 +33,4 @@ impl ThoughtsUrls { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn user_url_format() { - let urls = ThoughtsUrls::new("https://example.com"); - assert_eq!( - urls.user_url("alice").as_str(), - "https://example.com/users/alice" - ); - } - - #[test] - fn thought_url_format() { - let urls = ThoughtsUrls::new("https://example.com"); - let id = uuid::Uuid::nil(); - assert!(urls - .thought_url(id) - .as_str() - .starts_with("https://example.com/thoughts/")); - } -} +mod tests; diff --git a/crates/adapters/activitypub/src/urls/tests.rs b/crates/adapters/activitypub/src/urls/tests.rs new file mode 100644 index 0000000..4ffbc0c --- /dev/null +++ b/crates/adapters/activitypub/src/urls/tests.rs @@ -0,0 +1,20 @@ +use super::*; + +#[test] +fn user_url_format() { + let urls = ThoughtsUrls::new("https://example.com"); + assert_eq!( + urls.user_url("alice").as_str(), + "https://example.com/users/alice" + ); +} + +#[test] +fn thought_url_format() { + let urls = ThoughtsUrls::new("https://example.com"); + let id = uuid::Uuid::nil(); + assert!(urls + .thought_url(id) + .as_str() + .starts_with("https://example.com/thoughts/")); +} diff --git a/crates/adapters/auth/src/api_key_service.rs b/crates/adapters/auth/src/api_key_service.rs deleted file mode 100644 index 7622396..0000000 --- a/crates/adapters/auth/src/api_key_service.rs +++ /dev/null @@ -1,89 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - ports::{ApiKeyRepository, ApiKeyService}, - value_objects::UserId, -}; -use sha2::{Digest, Sha256}; -use std::sync::Arc; - -pub struct ApiKeyServiceImpl { - repo: Arc, -} - -impl ApiKeyServiceImpl { - pub fn new(repo: Arc) -> Self { - Self { repo } - } - - fn hash(raw: &str) -> String { - hex::encode(Sha256::digest(raw.as_bytes())) - } -} - -#[async_trait] -impl ApiKeyService for ApiKeyServiceImpl { - async fn validate_key(&self, raw_key: &str) -> Result, DomainError> { - let hash = Self::hash(raw_key); - Ok(self.repo.find_by_hash(&hash).await?.map(|k| k.user_id)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use chrono::Utc; - use domain::{ - errors::DomainError, - models::api_key::ApiKey, - ports::ApiKeyRepository, - value_objects::{ApiKeyId, UserId}, - }; - use std::sync::{Arc, Mutex}; - - struct FakeApiKeyRepo(Mutex>); - - #[async_trait] - impl ApiKeyRepository for FakeApiKeyRepo { - async fn save(&self, key: &ApiKey) -> Result<(), DomainError> { - self.0.lock().unwrap().push(key.clone()); - Ok(()) - } - async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { - Ok(self.0.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned()) - } - async fn list_for_user(&self, _uid: &UserId) -> Result, DomainError> { - Ok(vec![]) - } - async fn delete(&self, _id: &ApiKeyId, _uid: &UserId) -> Result<(), DomainError> { - Ok(()) - } - } - - #[tokio::test] - async fn validate_known_key_returns_user_id() { - let uid = UserId::new(); - let raw = "super-secret-key"; - let hash = ApiKeyServiceImpl::hash(raw); - let key = ApiKey { - id: ApiKeyId::new(), - user_id: uid.clone(), - key_hash: hash, - name: "test".into(), - created_at: Utc::now(), - }; - let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![key]))); - let svc = ApiKeyServiceImpl::new(repo); - let result = svc.validate_key(raw).await.unwrap(); - assert_eq!(result.unwrap().as_uuid(), uid.as_uuid()); - } - - #[tokio::test] - async fn validate_unknown_key_returns_none() { - let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![]))); - let svc = ApiKeyServiceImpl::new(repo); - let result = svc.validate_key("unknown-key").await.unwrap(); - assert!(result.is_none()); - } -} diff --git a/crates/adapters/auth/src/api_key_service/mod.rs b/crates/adapters/auth/src/api_key_service/mod.rs new file mode 100644 index 0000000..2c7ac32 --- /dev/null +++ b/crates/adapters/auth/src/api_key_service/mod.rs @@ -0,0 +1,33 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::{ApiKeyRepository, ApiKeyService}, + value_objects::UserId, +}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; + +pub struct ApiKeyServiceImpl { + repo: Arc, +} + +impl ApiKeyServiceImpl { + pub fn new(repo: Arc) -> Self { + Self { repo } + } + + fn hash(raw: &str) -> String { + hex::encode(Sha256::digest(raw.as_bytes())) + } +} + +#[async_trait] +impl ApiKeyService for ApiKeyServiceImpl { + async fn validate_key(&self, raw_key: &str) -> Result, DomainError> { + let hash = Self::hash(raw_key); + Ok(self.repo.find_by_hash(&hash).await?.map(|k| k.user_id)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/adapters/auth/src/api_key_service/tests.rs b/crates/adapters/auth/src/api_key_service/tests.rs new file mode 100644 index 0000000..03fa9c9 --- /dev/null +++ b/crates/adapters/auth/src/api_key_service/tests.rs @@ -0,0 +1,55 @@ +use super::*; +use async_trait::async_trait; +use chrono::Utc; +use domain::{ + errors::DomainError, + models::api_key::ApiKey, + ports::ApiKeyRepository, + value_objects::{ApiKeyId, UserId}, +}; +use std::sync::{Arc, Mutex}; + +struct FakeApiKeyRepo(Mutex>); + +#[async_trait] +impl ApiKeyRepository for FakeApiKeyRepo { + async fn save(&self, key: &ApiKey) -> Result<(), DomainError> { + self.0.lock().unwrap().push(key.clone()); + Ok(()) + } + async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { + Ok(self.0.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned()) + } + async fn list_for_user(&self, _uid: &UserId) -> Result, DomainError> { + Ok(vec![]) + } + async fn delete(&self, _id: &ApiKeyId, _uid: &UserId) -> Result<(), DomainError> { + Ok(()) + } +} + +#[tokio::test] +async fn validate_known_key_returns_user_id() { + let uid = UserId::new(); + let raw = "super-secret-key"; + let hash = ApiKeyServiceImpl::hash(raw); + let key = ApiKey { + id: ApiKeyId::new(), + user_id: uid.clone(), + key_hash: hash, + name: "test".into(), + created_at: Utc::now(), + }; + let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![key]))); + let svc = ApiKeyServiceImpl::new(repo); + let result = svc.validate_key(raw).await.unwrap(); + assert_eq!(result.unwrap().as_uuid(), uid.as_uuid()); +} + +#[tokio::test] +async fn validate_unknown_key_returns_none() { + let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![]))); + let svc = ApiKeyServiceImpl::new(repo); + let result = svc.validate_key("unknown-key").await.unwrap(); + assert!(result.is_none()); +} diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index c4e240a..afb5e76 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -93,31 +93,4 @@ impl PasswordHasher for Argon2PasswordHasher { } #[cfg(test)] -mod tests { - use super::*; - use domain::ports::AuthService; - - #[test] - fn generate_and_validate_token() { - let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600); - let id = UserId::new(); - let tok = svc.generate_token(&id).unwrap(); - let parsed = svc.validate_token(&tok.token).unwrap(); - assert_eq!(parsed.as_uuid(), id.as_uuid()); - } - - #[test] - fn invalid_token_returns_unauthorized() { - let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600); - let err = svc.validate_token("not.a.token").unwrap_err(); - assert!(matches!(err, DomainError::Unauthorized)); - } - - #[tokio::test] - async fn hash_and_verify() { - let hasher = Argon2PasswordHasher; - let hash = hasher.hash("mypassword").await.unwrap(); - assert!(hasher.verify("mypassword", &hash).await.unwrap()); - assert!(!hasher.verify("wrongpassword", &hash).await.unwrap()); - } -} +mod tests; diff --git a/crates/adapters/auth/src/tests.rs b/crates/adapters/auth/src/tests.rs new file mode 100644 index 0000000..7ef278c --- /dev/null +++ b/crates/adapters/auth/src/tests.rs @@ -0,0 +1,26 @@ +use super::*; +use domain::ports::AuthService; + +#[test] +fn generate_and_validate_token() { + let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600); + let id = UserId::new(); + let tok = svc.generate_token(&id).unwrap(); + let parsed = svc.validate_token(&tok.token).unwrap(); + assert_eq!(parsed.as_uuid(), id.as_uuid()); +} + +#[test] +fn invalid_token_returns_unauthorized() { + let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600); + let err = svc.validate_token("not.a.token").unwrap_err(); + assert!(matches!(err, DomainError::Unauthorized)); +} + +#[tokio::test] +async fn hash_and_verify() { + let hasher = Argon2PasswordHasher; + let hash = hasher.hash("mypassword").await.unwrap(); + assert!(hasher.verify("mypassword", &hash).await.unwrap()); + assert!(!hasher.verify("wrongpassword", &hash).await.unwrap()); +} diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index f8c6c49..54a7825 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -356,91 +356,6 @@ impl TryFrom for DomainEvent { } } + #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn thought_created_roundtrip() { - let p = EventPayload::ThoughtCreated { - thought_id: "abc".into(), - user_id: "def".into(), - in_reply_to_id: None, - }; - let json = serde_json::to_string(&p).unwrap(); - let back: EventPayload = serde_json::from_str(&json).unwrap(); - assert_eq!(back.subject(), "thoughts.created"); - } - - #[test] - fn all_subjects_are_unique() { - let samples: &[EventPayload] = &[ - EventPayload::ThoughtCreated { - thought_id: "a".into(), - user_id: "b".into(), - in_reply_to_id: None, - }, - EventPayload::ThoughtDeleted { - thought_id: "a".into(), - user_id: "b".into(), - }, - EventPayload::ThoughtUpdated { - thought_id: "a".into(), - user_id: "b".into(), - }, - EventPayload::LikeAdded { - like_id: "a".into(), - user_id: "b".into(), - thought_id: "c".into(), - }, - EventPayload::LikeRemoved { - user_id: "b".into(), - thought_id: "c".into(), - }, - EventPayload::BoostAdded { - boost_id: "a".into(), - user_id: "b".into(), - thought_id: "c".into(), - }, - EventPayload::BoostRemoved { - user_id: "b".into(), - thought_id: "c".into(), - }, - EventPayload::FollowRequested { - follower_id: "a".into(), - following_id: "b".into(), - }, - EventPayload::FollowAccepted { - follower_id: "a".into(), - following_id: "b".into(), - }, - EventPayload::FollowRejected { - follower_id: "a".into(), - following_id: "b".into(), - }, - EventPayload::Unfollowed { - follower_id: "a".into(), - following_id: "b".into(), - }, - EventPayload::UserBlocked { - blocker_id: "a".into(), - blocked_id: "b".into(), - }, - EventPayload::UserUnblocked { - blocker_id: "a".into(), - blocked_id: "b".into(), - }, - EventPayload::UserRegistered { - user_id: "a".into(), - }, - ]; - let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect(); - subjects.sort(); - subjects.dedup(); - assert_eq!( - subjects.len(), - samples.len(), - "each event must have a unique subject" - ); - } -} +mod tests; diff --git a/crates/adapters/event-payload/src/tests.rs b/crates/adapters/event-payload/src/tests.rs new file mode 100644 index 0000000..ab974f0 --- /dev/null +++ b/crates/adapters/event-payload/src/tests.rs @@ -0,0 +1,85 @@ +use super::*; + +#[test] +fn thought_created_roundtrip() { + let p = EventPayload::ThoughtCreated { + thought_id: "abc".into(), + user_id: "def".into(), + in_reply_to_id: None, + }; + let json = serde_json::to_string(&p).unwrap(); + let back: EventPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(back.subject(), "thoughts.created"); +} + +#[test] +fn all_subjects_are_unique() { + let samples: &[EventPayload] = &[ + EventPayload::ThoughtCreated { + thought_id: "a".into(), + user_id: "b".into(), + in_reply_to_id: None, + }, + EventPayload::ThoughtDeleted { + thought_id: "a".into(), + user_id: "b".into(), + }, + EventPayload::ThoughtUpdated { + thought_id: "a".into(), + user_id: "b".into(), + }, + EventPayload::LikeAdded { + like_id: "a".into(), + user_id: "b".into(), + thought_id: "c".into(), + }, + EventPayload::LikeRemoved { + user_id: "b".into(), + thought_id: "c".into(), + }, + EventPayload::BoostAdded { + boost_id: "a".into(), + user_id: "b".into(), + thought_id: "c".into(), + }, + EventPayload::BoostRemoved { + user_id: "b".into(), + thought_id: "c".into(), + }, + EventPayload::FollowRequested { + follower_id: "a".into(), + following_id: "b".into(), + }, + EventPayload::FollowAccepted { + follower_id: "a".into(), + following_id: "b".into(), + }, + EventPayload::FollowRejected { + follower_id: "a".into(), + following_id: "b".into(), + }, + EventPayload::Unfollowed { + follower_id: "a".into(), + following_id: "b".into(), + }, + EventPayload::UserBlocked { + blocker_id: "a".into(), + blocked_id: "b".into(), + }, + EventPayload::UserUnblocked { + blocker_id: "a".into(), + blocked_id: "b".into(), + }, + EventPayload::UserRegistered { + user_id: "a".into(), + }, + ]; + let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect(); + subjects.sort(); + subjects.dedup(); + assert_eq!( + subjects.len(), + samples.len(), + "each event must have a unique subject" + ); +} diff --git a/crates/adapters/event-transport/src/lib.rs b/crates/adapters/event-transport/src/lib.rs index 901b810..6a44373 100644 --- a/crates/adapters/event-transport/src/lib.rs +++ b/crates/adapters/event-transport/src/lib.rs @@ -109,128 +109,6 @@ impl EventConsumer for EventConsumerAdapter { } } + #[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use domain::value_objects::{ThoughtId, UserId}; - use std::sync::{Arc, Mutex}; - - struct SpyTransport { - calls: Arc)>>>, - } - impl SpyTransport { - fn new() -> (Self, Arc)>>>) { - let calls = Arc::new(Mutex::new(vec![])); - ( - Self { - calls: calls.clone(), - }, - calls, - ) - } - } - #[async_trait] - impl Transport for SpyTransport { - async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> { - self.calls - .lock() - .unwrap() - .push((subject.to_string(), bytes.to_vec())); - Ok(()) - } - } - - #[tokio::test] - async fn thought_created_routes_to_correct_subject() { - let (spy, calls) = SpyTransport::new(); - let publisher = EventPublisherAdapter::new(spy); - publisher - .publish(&DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: UserId::new(), - in_reply_to_id: None, - }) - .await - .unwrap(); - let calls = calls.lock().unwrap(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].0, "thoughts.created"); - } - - #[tokio::test] - async fn serialized_payload_is_valid_json() { - let (spy, calls) = SpyTransport::new(); - let publisher = EventPublisherAdapter::new(spy); - publisher - .publish(&DomainEvent::UserBlocked { - blocker_id: UserId::new(), - blocked_id: UserId::new(), - }) - .await - .unwrap(); - let bytes = calls.lock().unwrap()[0].1.clone(); - let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON"); - assert_eq!(json["type"], "UserBlocked"); - } - - #[tokio::test] - async fn consumer_adapter_deserializes_and_yields_event() { - use domain::value_objects::ThoughtId; - use futures::StreamExt; - - let event = DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: UserId::new(), - in_reply_to_id: None, - }; - let payload = EventPayload::from(&event); - let bytes = serde_json::to_vec(&payload).unwrap(); - - struct OneMessageSource { - bytes: Vec, - } - #[async_trait::async_trait] - impl MessageSource for OneMessageSource { - fn messages(&self) -> futures::stream::BoxStream<'_, Result> { - let msg = RawMessage { - subject: "thoughts.created".to_string(), - payload: self.bytes.clone(), - delivery_count: 1, - ack: Box::new(|| {}), - nack: Box::new(|| {}), - }; - Box::pin(futures::stream::once(async { Ok(msg) })) - } - } - - let adapter = EventConsumerAdapter::new(OneMessageSource { bytes }); - let mut stream = adapter.consume(); - let envelope = stream.next().await.unwrap().unwrap(); - assert!(matches!(envelope.event, DomainEvent::ThoughtCreated { .. })); - } - - #[tokio::test] - async fn consumer_adapter_skips_invalid_payloads() { - use futures::StreamExt; - - struct BadMessageSource; - #[async_trait::async_trait] - impl MessageSource for BadMessageSource { - fn messages(&self) -> futures::stream::BoxStream<'_, Result> { - let msg = RawMessage { - subject: "bad".to_string(), - payload: b"not valid json".to_vec(), - delivery_count: 1, - ack: Box::new(|| {}), - nack: Box::new(|| {}), - }; - Box::pin(futures::stream::once(async { Ok(msg) })) - } - } - - let adapter = EventConsumerAdapter::new(BadMessageSource); - let mut stream = adapter.consume(); - assert!(stream.next().await.is_none()); - } -} +mod tests; diff --git a/crates/adapters/event-transport/src/tests.rs b/crates/adapters/event-transport/src/tests.rs new file mode 100644 index 0000000..fd5b639 --- /dev/null +++ b/crates/adapters/event-transport/src/tests.rs @@ -0,0 +1,122 @@ +use super::*; +use async_trait::async_trait; +use domain::value_objects::{ThoughtId, UserId}; +use std::sync::{Arc, Mutex}; + +struct SpyTransport { + calls: Arc)>>>, +} +impl SpyTransport { + fn new() -> (Self, Arc)>>>) { + let calls = Arc::new(Mutex::new(vec![])); + ( + Self { + calls: calls.clone(), + }, + calls, + ) + } +} +#[async_trait] +impl Transport for SpyTransport { + async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> { + self.calls + .lock() + .unwrap() + .push((subject.to_string(), bytes.to_vec())); + Ok(()) + } +} + +#[tokio::test] +async fn thought_created_routes_to_correct_subject() { + let (spy, calls) = SpyTransport::new(); + let publisher = EventPublisherAdapter::new(spy); + publisher + .publish(&DomainEvent::ThoughtCreated { + thought_id: ThoughtId::new(), + user_id: UserId::new(), + in_reply_to_id: None, + }) + .await + .unwrap(); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "thoughts.created"); +} + +#[tokio::test] +async fn serialized_payload_is_valid_json() { + let (spy, calls) = SpyTransport::new(); + let publisher = EventPublisherAdapter::new(spy); + publisher + .publish(&DomainEvent::UserBlocked { + blocker_id: UserId::new(), + blocked_id: UserId::new(), + }) + .await + .unwrap(); + let bytes = calls.lock().unwrap()[0].1.clone(); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON"); + assert_eq!(json["type"], "UserBlocked"); +} + +#[tokio::test] +async fn consumer_adapter_deserializes_and_yields_event() { + use domain::value_objects::ThoughtId; + use futures::StreamExt; + + let event = DomainEvent::ThoughtCreated { + thought_id: ThoughtId::new(), + user_id: UserId::new(), + in_reply_to_id: None, + }; + let payload = EventPayload::from(&event); + let bytes = serde_json::to_vec(&payload).unwrap(); + + struct OneMessageSource { + bytes: Vec, + } + #[async_trait::async_trait] + impl MessageSource for OneMessageSource { + fn messages(&self) -> futures::stream::BoxStream<'_, Result> { + let msg = RawMessage { + subject: "thoughts.created".to_string(), + payload: self.bytes.clone(), + delivery_count: 1, + ack: Box::new(|| {}), + nack: Box::new(|| {}), + }; + Box::pin(futures::stream::once(async { Ok(msg) })) + } + } + + let adapter = EventConsumerAdapter::new(OneMessageSource { bytes }); + let mut stream = adapter.consume(); + let envelope = stream.next().await.unwrap().unwrap(); + assert!(matches!(envelope.event, DomainEvent::ThoughtCreated { .. })); +} + +#[tokio::test] +async fn consumer_adapter_skips_invalid_payloads() { + use futures::StreamExt; + + struct BadMessageSource; + #[async_trait::async_trait] + impl MessageSource for BadMessageSource { + fn messages(&self) -> futures::stream::BoxStream<'_, Result> { + let msg = RawMessage { + subject: "bad".to_string(), + payload: b"not valid json".to_vec(), + delivery_count: 1, + ack: Box::new(|| {}), + nack: Box::new(|| {}), + }; + Box::pin(futures::stream::once(async { Ok(msg) })) + } + } + + let adapter = EventConsumerAdapter::new(BadMessageSource); + let mut stream = adapter.consume(); + assert!(stream.next().await.is_none()); +} diff --git a/crates/adapters/nats/src/lib.rs b/crates/adapters/nats/src/lib.rs index 2d047c3..6d11b72 100644 --- a/crates/adapters/nats/src/lib.rs +++ b/crates/adapters/nats/src/lib.rs @@ -239,47 +239,6 @@ impl MessageSource for NatsMessageSource { } } + #[cfg(test)] -mod tests { - use super::*; - use domain::{ - events::DomainEvent, - value_objects::{LikeId, ThoughtId, UserId}, - }; - use event_payload::EventPayload; - - #[test] - fn payload_from_domain_event_has_correct_subject() { - let event = DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: UserId::new(), - in_reply_to_id: None, - }; - let payload = EventPayload::from(&event); - assert_eq!(payload.subject(), "thoughts.created"); - } - - #[test] - fn domain_event_roundtrip_via_payload() { - let uid = UserId::new(); - let tid = ThoughtId::new(); - let event = DomainEvent::LikeAdded { - like_id: LikeId::new(), - user_id: uid.clone(), - thought_id: tid.clone(), - }; - let payload = EventPayload::from(&event); - let back = DomainEvent::try_from(payload).unwrap(); - if let DomainEvent::LikeAdded { - user_id, - thought_id, - .. - } = back - { - assert_eq!(user_id, uid); - assert_eq!(thought_id, tid); - } else { - panic!("wrong variant"); - } - } -} +mod tests; diff --git a/crates/adapters/nats/src/tests.rs b/crates/adapters/nats/src/tests.rs new file mode 100644 index 0000000..9403df8 --- /dev/null +++ b/crates/adapters/nats/src/tests.rs @@ -0,0 +1,41 @@ +use super::*; +use domain::{ + events::DomainEvent, + value_objects::{LikeId, ThoughtId, UserId}, +}; +use event_payload::EventPayload; + +#[test] +fn payload_from_domain_event_has_correct_subject() { + let event = DomainEvent::ThoughtCreated { + thought_id: ThoughtId::new(), + user_id: UserId::new(), + in_reply_to_id: None, + }; + let payload = EventPayload::from(&event); + assert_eq!(payload.subject(), "thoughts.created"); +} + +#[test] +fn domain_event_roundtrip_via_payload() { + let uid = UserId::new(); + let tid = ThoughtId::new(); + let event = DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: uid.clone(), + thought_id: tid.clone(), + }; + let payload = EventPayload::from(&event); + let back = DomainEvent::try_from(payload).unwrap(); + if let DomainEvent::LikeAdded { + user_id, + thought_id, + .. + } = back + { + assert_eq!(user_id, uid); + assert_eq!(thought_id, tid); + } else { + panic!("wrong variant"); + } +} diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs index 7015881..26a0209 100644 --- a/crates/adapters/postgres-search/src/lib.rs +++ b/crates/adapters/postgres-search/src/lib.rs @@ -205,168 +205,4 @@ impl SearchPort for PgSearchRepository { } #[cfg(test)] -mod tests { - use super::*; - use domain::{ - models::{ - thought::{Thought, Visibility}, - user::User, - }, - ports::{SearchPort, ThoughtRepository, UserWriter}, - value_objects::*, - }; - - async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { - use postgres::{thought::PgThoughtRepository, user::PgUserRepository}; - let urepo = PgUserRepository::new(pool.clone()); - let trepo = PgThoughtRepository::new(pool.clone()); - let u = User::new_local( - UserId::new(), - Username::new(username).unwrap(), - Email::new(format!("{username}@ex.com")).unwrap(), - PasswordHash("h".into()), - ); - urepo.save(&u).await.unwrap(); - let t = Thought::new_local( - ThoughtId::new(), - u.id.clone(), - Content::new_local(content).unwrap(), - None, - Visibility::Public, - None, - false, - ); - trepo.save(&t).await.unwrap(); - (u, t) - } - - #[sqlx::test(migrations = "../postgres/migrations")] - async fn search_thoughts_finds_by_keyword(pool: sqlx::PgPool) { - seed_thought(&pool, "alice", "hello world").await; - seed_thought(&pool, "bob", "goodbye universe").await; - let repo = PgSearchRepository::new(pool); - let result = repo - .search_thoughts( - "hello world", - &PageParams { - page: 1, - per_page: 20, - }, - None, - ) - .await - .unwrap(); - assert_eq!(result.total, 1); - assert_eq!(result.items[0].thought.content.as_str(), "hello world"); - } - - #[sqlx::test(migrations = "../postgres/migrations")] - async fn search_users_finds_by_username(pool: sqlx::PgPool) { - use postgres::user::PgUserRepository; - let urepo = PgUserRepository::new(pool.clone()); - let alice = User::new_local( - UserId::new(), - Username::new("alice_search").unwrap(), - Email::new("alice@ex.com").unwrap(), - PasswordHash("h".into()), - ); - urepo.save(&alice).await.unwrap(); - let repo = PgSearchRepository::new(pool); - let result = repo - .search_users( - "alice", - &PageParams { - page: 1, - per_page: 20, - }, - ) - .await - .unwrap(); - assert!(!result.items.is_empty()); - assert!(result - .items - .iter() - .any(|u| u.username.as_str() == "alice_search")); - } - - #[sqlx::test(migrations = "../postgres/migrations")] - async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) { - seed_thought(&pool, "alice", "hello world").await; - let repo = PgSearchRepository::new(pool); - let result = repo - .search_thoughts( - "zzzzzzzzz", - &PageParams { - page: 1, - per_page: 20, - }, - None, - ) - .await - .unwrap(); - assert_eq!(result.total, 0); - } - - #[sqlx::test(migrations = "../postgres/migrations")] - async fn search_thoughts_viewer_context(pool: sqlx::PgPool) { - use domain::models::social::Like; - use domain::ports::{LikeRepository, UserWriter}; - use domain::value_objects::LikeId; - use postgres::{like::PgLikeRepository, user::PgUserRepository}; - - let (alice, thought) = seed_thought(&pool, "alice", "hello world").await; - - // alice likes her own thought - let like_repo = PgLikeRepository::new(pool.clone()); - like_repo - .save(&Like { - id: LikeId::new(), - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - ap_id: None, - created_at: chrono::Utc::now(), - }) - .await - .unwrap(); - - let repo = PgSearchRepository::new(pool); - - // with viewer — should see liked = true - let authed = repo - .search_thoughts( - "hello", - &PageParams { - page: 1, - per_page: 20, - }, - Some(&alice.id), - ) - .await - .unwrap(); - assert_eq!(authed.items.len(), 1); - let ctx = authed.items[0] - .viewer - .as_ref() - .expect("viewer context present"); - assert!(ctx.liked, "alice should see the thought as liked"); - assert!(!ctx.boosted); - - // without viewer — viewer should be None - let anon = repo - .search_thoughts( - "hello", - &PageParams { - page: 1, - per_page: 20, - }, - None, - ) - .await - .unwrap(); - assert_eq!(anon.items.len(), 1); - assert!( - anon.items[0].viewer.is_none(), - "anonymous request has no viewer context" - ); - } -} +mod tests; diff --git a/crates/adapters/postgres-search/src/tests.rs b/crates/adapters/postgres-search/src/tests.rs new file mode 100644 index 0000000..5fe6bb8 --- /dev/null +++ b/crates/adapters/postgres-search/src/tests.rs @@ -0,0 +1,163 @@ +use super::*; +use domain::{ + models::{ + thought::{Thought, Visibility}, + user::User, + }, + ports::{SearchPort, ThoughtRepository, UserWriter}, + value_objects::*, +}; + +async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { + use postgres::{thought::PgThoughtRepository, user::PgUserRepository}; + let urepo = PgUserRepository::new(pool.clone()); + let trepo = PgThoughtRepository::new(pool.clone()); + let u = User::new_local( + UserId::new(), + Username::new(username).unwrap(), + Email::new(format!("{username}@ex.com")).unwrap(), + PasswordHash("h".into()), + ); + urepo.save(&u).await.unwrap(); + let t = Thought::new_local( + ThoughtId::new(), + u.id.clone(), + Content::new_local(content).unwrap(), + None, + Visibility::Public, + None, + false, + ); + trepo.save(&t).await.unwrap(); + (u, t) +} + +#[sqlx::test(migrations = "../postgres/migrations")] +async fn search_thoughts_finds_by_keyword(pool: sqlx::PgPool) { + seed_thought(&pool, "alice", "hello world").await; + seed_thought(&pool, "bob", "goodbye universe").await; + let repo = PgSearchRepository::new(pool); + let result = repo + .search_thoughts( + "hello world", + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); + assert_eq!(result.total, 1); + assert_eq!(result.items[0].thought.content.as_str(), "hello world"); +} + +#[sqlx::test(migrations = "../postgres/migrations")] +async fn search_users_finds_by_username(pool: sqlx::PgPool) { + use postgres::user::PgUserRepository; + let urepo = PgUserRepository::new(pool.clone()); + let alice = User::new_local( + UserId::new(), + Username::new("alice_search").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ); + urepo.save(&alice).await.unwrap(); + let repo = PgSearchRepository::new(pool); + let result = repo + .search_users( + "alice", + &PageParams { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); + assert!(!result.items.is_empty()); + assert!(result + .items + .iter() + .any(|u| u.username.as_str() == "alice_search")); +} + +#[sqlx::test(migrations = "../postgres/migrations")] +async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) { + seed_thought(&pool, "alice", "hello world").await; + let repo = PgSearchRepository::new(pool); + let result = repo + .search_thoughts( + "zzzzzzzzz", + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); + assert_eq!(result.total, 0); +} + +#[sqlx::test(migrations = "../postgres/migrations")] +async fn search_thoughts_viewer_context(pool: sqlx::PgPool) { + use domain::models::social::Like; + use domain::ports::{LikeRepository, UserWriter}; + use domain::value_objects::LikeId; + use postgres::{like::PgLikeRepository, user::PgUserRepository}; + + let (alice, thought) = seed_thought(&pool, "alice", "hello world").await; + + // alice likes her own thought + let like_repo = PgLikeRepository::new(pool.clone()); + like_repo + .save(&Like { + id: LikeId::new(), + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: chrono::Utc::now(), + }) + .await + .unwrap(); + + let repo = PgSearchRepository::new(pool); + + // with viewer — should see liked = true + let authed = repo + .search_thoughts( + "hello", + &PageParams { + page: 1, + per_page: 20, + }, + Some(&alice.id), + ) + .await + .unwrap(); + assert_eq!(authed.items.len(), 1); + let ctx = authed.items[0] + .viewer + .as_ref() + .expect("viewer context present"); + assert!(ctx.liked, "alice should see the thought as liked"); + assert!(!ctx.boosted); + + // without viewer — viewer should be None + let anon = repo + .search_thoughts( + "hello", + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); + assert_eq!(anon.items.len(), 1); + assert!( + anon.items[0].viewer.is_none(), + "anonymous request has no viewer context" + ); +} diff --git a/crates/adapters/postgres/src/activitypub.rs b/crates/adapters/postgres/src/activitypub/mod.rs similarity index 83% rename from crates/adapters/postgres/src/activitypub.rs rename to crates/adapters/postgres/src/activitypub/mod.rs index 39bb1ab..fb74e64 100644 --- a/crates/adapters/postgres/src/activitypub.rs +++ b/crates/adapters/postgres/src/activitypub/mod.rs @@ -334,73 +334,4 @@ impl ActivityPubRepository for PgActivityPubRepository { } #[cfg(test)] -mod tests { - use super::*; - use activitypub_base::ActivityPubRepository; - - #[sqlx::test(migrations = "./migrations")] - async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) { - let repo = PgActivityPubRepository::new(pool); - let url = "https://mastodon.social/users/alice"; - let id1 = repo.intern_remote_actor(url).await.unwrap(); - let id2 = repo.intern_remote_actor(url).await.unwrap(); - assert_eq!(id1, id2); - } - - #[sqlx::test(migrations = "./migrations")] - async fn accept_and_retract_note(pool: sqlx::PgPool) { - let repo = PgActivityPubRepository::new(pool); - let actor_url = "https://remote.example/users/bob"; - let ap_id = "https://remote.example/notes/1"; - let author = repo.intern_remote_actor(actor_url).await.unwrap(); - repo.accept_note( - ap_id, - &author, - "hello from remote", - chrono::Utc::now(), - false, - None, - "public", - None, - ) - .await - .unwrap(); - repo.retract_note(ap_id).await.unwrap(); - } - - #[sqlx::test(migrations = "./migrations")] - async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) { - let repo = PgActivityPubRepository::new(pool); - assert_eq!(repo.count_local_notes().await.unwrap(), 0); - } - - #[sqlx::test(migrations = "./migrations")] - async fn accept_note_returns_thought_id(pool: sqlx::PgPool) { - let repo = PgActivityPubRepository::new(pool.clone()); - let actor_user_id = repo - .intern_remote_actor("https://remote.example/users/alice") - .await - .unwrap(); - - let thought_id = repo - .accept_note( - "https://remote.example/notes/1", - &actor_user_id, - "Hello #rust world", - chrono::Utc::now(), - false, - None, - "public", - None, - ) - .await - .unwrap(); - - let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1") - .bind("https://remote.example/notes/1") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(thought_id.as_uuid(), row.0); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/activitypub/tests.rs b/crates/adapters/postgres/src/activitypub/tests.rs new file mode 100644 index 0000000..9da0b57 --- /dev/null +++ b/crates/adapters/postgres/src/activitypub/tests.rs @@ -0,0 +1,68 @@ + use super::*; + use activitypub_base::ActivityPubRepository; + + #[sqlx::test(migrations = "./migrations")] + async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) { + let repo = PgActivityPubRepository::new(pool); + let url = "https://mastodon.social/users/alice"; + let id1 = repo.intern_remote_actor(url).await.unwrap(); + let id2 = repo.intern_remote_actor(url).await.unwrap(); + assert_eq!(id1, id2); + } + + #[sqlx::test(migrations = "./migrations")] + async fn accept_and_retract_note(pool: sqlx::PgPool) { + let repo = PgActivityPubRepository::new(pool); + let actor_url = "https://remote.example/users/bob"; + let ap_id = "https://remote.example/notes/1"; + let author = repo.intern_remote_actor(actor_url).await.unwrap(); + repo.accept_note( + ap_id, + &author, + "hello from remote", + chrono::Utc::now(), + false, + None, + "public", + None, + ) + .await + .unwrap(); + repo.retract_note(ap_id).await.unwrap(); + } + + #[sqlx::test(migrations = "./migrations")] + async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) { + let repo = PgActivityPubRepository::new(pool); + assert_eq!(repo.count_local_notes().await.unwrap(), 0); + } + + #[sqlx::test(migrations = "./migrations")] + async fn accept_note_returns_thought_id(pool: sqlx::PgPool) { + let repo = PgActivityPubRepository::new(pool.clone()); + let actor_user_id = repo + .intern_remote_actor("https://remote.example/users/alice") + .await + .unwrap(); + + let thought_id = repo + .accept_note( + "https://remote.example/notes/1", + &actor_user_id, + "Hello #rust world", + chrono::Utc::now(), + false, + None, + "public", + None, + ) + .await + .unwrap(); + + let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1") + .bind("https://remote.example/notes/1") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(thought_id.as_uuid(), row.0); + } diff --git a/crates/adapters/postgres/src/api_key.rs b/crates/adapters/postgres/src/api_key/mod.rs similarity index 62% rename from crates/adapters/postgres/src/api_key.rs rename to crates/adapters/postgres/src/api_key/mod.rs index 18eadc5..e9e0b87 100644 --- a/crates/adapters/postgres/src/api_key.rs +++ b/crates/adapters/postgres/src/api_key/mod.rs @@ -89,54 +89,4 @@ impl ApiKeyRepository for PgApiKeyRepository { } #[cfg(test)] -mod tests { - use super::*; - use crate::user::PgUserRepository; - use chrono::Utc; - use domain::ports::UserWriter; - use domain::{models::user::User, value_objects::*}; - - async fn seed_user(pool: &sqlx::PgPool) -> User { - let repo = PgUserRepository::new(pool.clone()); - let u = User::new_local( - UserId::new(), - Username::new("alice").unwrap(), - Email::new("alice@ex.com").unwrap(), - PasswordHash("h".into()), - ); - repo.save(&u).await.unwrap(); - u - } - - #[sqlx::test(migrations = "./migrations")] - async fn save_and_find_by_hash(pool: sqlx::PgPool) { - let user = seed_user(&pool).await; - let repo = PgApiKeyRepository::new(pool); - let key = ApiKey { - id: ApiKeyId::new(), - user_id: user.id.clone(), - key_hash: "abc123".into(), - name: "test".into(), - created_at: Utc::now(), - }; - repo.save(&key).await.unwrap(); - let found = repo.find_by_hash("abc123").await.unwrap().unwrap(); - assert_eq!(found.name, "test"); - } - - #[sqlx::test(migrations = "./migrations")] - async fn delete_key(pool: sqlx::PgPool) { - let user = seed_user(&pool).await; - let repo = PgApiKeyRepository::new(pool); - let key = ApiKey { - id: ApiKeyId::new(), - user_id: user.id.clone(), - key_hash: "def456".into(), - name: "key2".into(), - created_at: Utc::now(), - }; - repo.save(&key).await.unwrap(); - repo.delete(&key.id, &user.id).await.unwrap(); - assert!(repo.find_by_hash("def456").await.unwrap().is_none()); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/api_key/tests.rs b/crates/adapters/postgres/src/api_key/tests.rs new file mode 100644 index 0000000..703a710 --- /dev/null +++ b/crates/adapters/postgres/src/api_key/tests.rs @@ -0,0 +1,49 @@ + use super::*; + use crate::user::PgUserRepository; + use chrono::Utc; + use domain::ports::UserWriter; + use domain::{models::user::User, value_objects::*}; + + async fn seed_user(pool: &sqlx::PgPool) -> User { + let repo = PgUserRepository::new(pool.clone()); + let u = User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ); + repo.save(&u).await.unwrap(); + u + } + + #[sqlx::test(migrations = "./migrations")] + async fn save_and_find_by_hash(pool: sqlx::PgPool) { + let user = seed_user(&pool).await; + let repo = PgApiKeyRepository::new(pool); + let key = ApiKey { + id: ApiKeyId::new(), + user_id: user.id.clone(), + key_hash: "abc123".into(), + name: "test".into(), + created_at: Utc::now(), + }; + repo.save(&key).await.unwrap(); + let found = repo.find_by_hash("abc123").await.unwrap().unwrap(); + assert_eq!(found.name, "test"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn delete_key(pool: sqlx::PgPool) { + let user = seed_user(&pool).await; + let repo = PgApiKeyRepository::new(pool); + let key = ApiKey { + id: ApiKeyId::new(), + user_id: user.id.clone(), + key_hash: "def456".into(), + name: "key2".into(), + created_at: Utc::now(), + }; + repo.save(&key).await.unwrap(); + repo.delete(&key.id, &user.id).await.unwrap(); + assert!(repo.find_by_hash("def456").await.unwrap().is_none()); + } diff --git a/crates/adapters/postgres/src/block.rs b/crates/adapters/postgres/src/block/mod.rs similarity index 54% rename from crates/adapters/postgres/src/block.rs rename to crates/adapters/postgres/src/block/mod.rs index 53be416..3a50768 100644 --- a/crates/adapters/postgres/src/block.rs +++ b/crates/adapters/postgres/src/block/mod.rs @@ -52,39 +52,4 @@ impl BlockRepository for PgBlockRepository { } #[cfg(test)] -mod tests { - use super::*; - use crate::test_helpers::seed_user; - use chrono::Utc; - use domain::value_objects::*; - - #[sqlx::test(migrations = "./migrations")] - async fn block_exists(pool: sqlx::PgPool) { - let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgBlockRepository::new(pool); - let block = Block { - blocker_id: alice.id.clone(), - blocked_id: bob.id.clone(), - created_at: Utc::now(), - }; - repo.save(&block).await.unwrap(); - assert!(repo.exists(&alice.id, &bob.id).await.unwrap()); - assert!(!repo.exists(&bob.id, &alice.id).await.unwrap()); - } - - #[sqlx::test(migrations = "./migrations")] - async fn unblock(pool: sqlx::PgPool) { - let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgBlockRepository::new(pool); - let block = Block { - blocker_id: alice.id.clone(), - blocked_id: bob.id.clone(), - created_at: Utc::now(), - }; - repo.save(&block).await.unwrap(); - repo.delete(&alice.id, &bob.id).await.unwrap(); - assert!(!repo.exists(&alice.id, &bob.id).await.unwrap()); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/block/tests.rs b/crates/adapters/postgres/src/block/tests.rs new file mode 100644 index 0000000..9473974 --- /dev/null +++ b/crates/adapters/postgres/src/block/tests.rs @@ -0,0 +1,34 @@ + use super::*; + use crate::test_helpers::seed_user; + use chrono::Utc; + use domain::value_objects::*; + + #[sqlx::test(migrations = "./migrations")] + async fn block_exists(pool: sqlx::PgPool) { + let alice = seed_user(&pool, "alice", "alice@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgBlockRepository::new(pool); + let block = Block { + blocker_id: alice.id.clone(), + blocked_id: bob.id.clone(), + created_at: Utc::now(), + }; + repo.save(&block).await.unwrap(); + assert!(repo.exists(&alice.id, &bob.id).await.unwrap()); + assert!(!repo.exists(&bob.id, &alice.id).await.unwrap()); + } + + #[sqlx::test(migrations = "./migrations")] + async fn unblock(pool: sqlx::PgPool) { + let alice = seed_user(&pool, "alice", "alice@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgBlockRepository::new(pool); + let block = Block { + blocker_id: alice.id.clone(), + blocked_id: bob.id.clone(), + created_at: Utc::now(), + }; + repo.save(&block).await.unwrap(); + repo.delete(&alice.id, &bob.id).await.unwrap(); + assert!(!repo.exists(&alice.id, &bob.id).await.unwrap()); + } diff --git a/crates/adapters/postgres/src/boost.rs b/crates/adapters/postgres/src/boost/mod.rs similarity index 65% rename from crates/adapters/postgres/src/boost.rs rename to crates/adapters/postgres/src/boost/mod.rs index bc1e8b9..234748a 100644 --- a/crates/adapters/postgres/src/boost.rs +++ b/crates/adapters/postgres/src/boost/mod.rs @@ -71,40 +71,4 @@ impl BoostRepository for PgBoostRepository { } #[cfg(test)] -mod tests { - use super::*; - use crate::test_helpers::seed_user_and_thought; - use chrono::Utc; - use domain::value_objects::*; - - #[sqlx::test(migrations = "./migrations")] - async fn boost_and_count(pool: sqlx::PgPool) { - let (user, thought) = seed_user_and_thought(&pool).await; - let repo = PgBoostRepository::new(pool); - let boost = Boost { - id: BoostId::new(), - user_id: user.id.clone(), - thought_id: thought.id.clone(), - ap_id: None, - created_at: Utc::now(), - }; - repo.save(&boost).await.unwrap(); - assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); - } - - #[sqlx::test(migrations = "./migrations")] - async fn unboost(pool: sqlx::PgPool) { - let (user, thought) = seed_user_and_thought(&pool).await; - let repo = PgBoostRepository::new(pool); - let boost = Boost { - id: BoostId::new(), - user_id: user.id.clone(), - thought_id: thought.id.clone(), - ap_id: None, - created_at: Utc::now(), - }; - repo.save(&boost).await.unwrap(); - repo.delete(&user.id, &thought.id).await.unwrap(); - assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/boost/tests.rs b/crates/adapters/postgres/src/boost/tests.rs new file mode 100644 index 0000000..f93291a --- /dev/null +++ b/crates/adapters/postgres/src/boost/tests.rs @@ -0,0 +1,35 @@ + use super::*; + use crate::test_helpers::seed_user_and_thought; + use chrono::Utc; + use domain::value_objects::*; + + #[sqlx::test(migrations = "./migrations")] + async fn boost_and_count(pool: sqlx::PgPool) { + let (user, thought) = seed_user_and_thought(&pool).await; + let repo = PgBoostRepository::new(pool); + let boost = Boost { + id: BoostId::new(), + user_id: user.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: Utc::now(), + }; + repo.save(&boost).await.unwrap(); + assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); + } + + #[sqlx::test(migrations = "./migrations")] + async fn unboost(pool: sqlx::PgPool) { + let (user, thought) = seed_user_and_thought(&pool).await; + let repo = PgBoostRepository::new(pool); + let boost = Boost { + id: BoostId::new(), + user_id: user.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: Utc::now(), + }; + repo.save(&boost).await.unwrap(); + repo.delete(&user.id, &thought.id).await.unwrap(); + assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); + } diff --git a/crates/adapters/postgres/src/feed.rs b/crates/adapters/postgres/src/feed/mod.rs similarity index 84% rename from crates/adapters/postgres/src/feed.rs rename to crates/adapters/postgres/src/feed/mod.rs index d335e84..a4016fc 100644 --- a/crates/adapters/postgres/src/feed.rs +++ b/crates/adapters/postgres/src/feed/mod.rs @@ -326,74 +326,4 @@ impl FeedRepository for PgFeedRepository { } #[cfg(test)] -mod tests { - use super::*; - use crate::{thought::PgThoughtRepository, user::PgUserRepository}; - use domain::{ - models::{ - feed::PageParams, - thought::{Thought, Visibility}, - user::User, - }, - ports::{FeedQuery, ThoughtRepository, UserWriter}, - value_objects::*, - }; - - async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { - let urepo = PgUserRepository::new(pool.clone()); - let trepo = PgThoughtRepository::new(pool.clone()); - let u = User::new_local( - UserId::new(), - Username::new(username).unwrap(), - Email::new(format!("{username}@ex.com")).unwrap(), - PasswordHash("h".into()), - ); - urepo.save(&u).await.unwrap(); - let t = Thought::new_local( - ThoughtId::new(), - u.id.clone(), - Content::new_local(content).unwrap(), - None, - Visibility::Public, - None, - false, - ); - trepo.save(&t).await.unwrap(); - (u, t) - } - - #[sqlx::test(migrations = "./migrations")] - async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) { - let (_, _) = seed(&pool, "alice", "hello").await; - let repo = PgFeedRepository::new(pool); - let result = repo - .query(&FeedQuery::public( - PageParams { page: 1, per_page: 20 }, - None, - )) - .await - .unwrap(); - assert_eq!(result.total, 1); - assert_eq!(result.items[0].thought.content.as_str(), "hello"); - } - - #[sqlx::test(migrations = "./migrations")] - async fn search_returns_matching_thoughts(pool: sqlx::PgPool) { - let (_, _) = seed(&pool, "alice", "hello world").await; - let (_, _) = seed(&pool, "bob", "goodbye world").await; - let repo = PgFeedRepository::new(pool); - let result = repo - .query(&FeedQuery::search( - "hello world", - PageParams { page: 1, per_page: 20 }, - None, - )) - .await - .unwrap(); - assert!(result.total >= 1); - assert!(result - .items - .iter() - .any(|e| e.thought.content.as_str() == "hello world")); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/feed/tests.rs b/crates/adapters/postgres/src/feed/tests.rs new file mode 100644 index 0000000..9193601 --- /dev/null +++ b/crates/adapters/postgres/src/feed/tests.rs @@ -0,0 +1,69 @@ + use super::*; + use crate::{thought::PgThoughtRepository, user::PgUserRepository}; + use domain::{ + models::{ + feed::PageParams, + thought::{Thought, Visibility}, + user::User, + }, + ports::{FeedQuery, ThoughtRepository, UserWriter}, + value_objects::*, + }; + + async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { + let urepo = PgUserRepository::new(pool.clone()); + let trepo = PgThoughtRepository::new(pool.clone()); + let u = User::new_local( + UserId::new(), + Username::new(username).unwrap(), + Email::new(format!("{username}@ex.com")).unwrap(), + PasswordHash("h".into()), + ); + urepo.save(&u).await.unwrap(); + let t = Thought::new_local( + ThoughtId::new(), + u.id.clone(), + Content::new_local(content).unwrap(), + None, + Visibility::Public, + None, + false, + ); + trepo.save(&t).await.unwrap(); + (u, t) + } + + #[sqlx::test(migrations = "./migrations")] + async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) { + let (_, _) = seed(&pool, "alice", "hello").await; + let repo = PgFeedRepository::new(pool); + let result = repo + .query(&FeedQuery::public( + PageParams { page: 1, per_page: 20 }, + None, + )) + .await + .unwrap(); + assert_eq!(result.total, 1); + assert_eq!(result.items[0].thought.content.as_str(), "hello"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn search_returns_matching_thoughts(pool: sqlx::PgPool) { + let (_, _) = seed(&pool, "alice", "hello world").await; + let (_, _) = seed(&pool, "bob", "goodbye world").await; + let repo = PgFeedRepository::new(pool); + let result = repo + .query(&FeedQuery::search( + "hello world", + PageParams { page: 1, per_page: 20 }, + None, + )) + .await + .unwrap(); + assert!(result.total >= 1); + assert!(result + .items + .iter() + .any(|e| e.thought.content.as_str() == "hello world")); + } diff --git a/crates/adapters/postgres/src/follow.rs b/crates/adapters/postgres/src/follow/mod.rs similarity index 72% rename from crates/adapters/postgres/src/follow.rs rename to crates/adapters/postgres/src/follow/mod.rs index 6bd2286..512ce19 100644 --- a/crates/adapters/postgres/src/follow.rs +++ b/crates/adapters/postgres/src/follow/mod.rs @@ -190,63 +190,4 @@ impl FollowRepository for PgFollowRepository { } #[cfg(test)] -mod tests { - use super::*; - use crate::test_helpers::seed_user; - use chrono::Utc; - use domain::value_objects::*; - - #[sqlx::test(migrations = "./migrations")] - async fn save_and_find_follow(pool: sqlx::PgPool) { - let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgFollowRepository::new(pool); - let follow = Follow { - follower_id: alice.id.clone(), - following_id: bob.id.clone(), - state: FollowState::Accepted, - ap_id: None, - created_at: Utc::now(), - }; - repo.save(&follow).await.unwrap(); - let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); - assert_eq!(found.state, FollowState::Accepted); - } - - #[sqlx::test(migrations = "./migrations")] - async fn update_state(pool: sqlx::PgPool) { - let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgFollowRepository::new(pool); - let follow = Follow { - follower_id: alice.id.clone(), - following_id: bob.id.clone(), - state: FollowState::Pending, - ap_id: None, - created_at: Utc::now(), - }; - repo.save(&follow).await.unwrap(); - repo.update_state(&alice.id, &bob.id, &FollowState::Accepted) - .await - .unwrap(); - let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); - assert_eq!(found.state, FollowState::Accepted); - } - - #[sqlx::test(migrations = "./migrations")] - async fn get_accepted_following_ids(pool: sqlx::PgPool) { - let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgFollowRepository::new(pool); - let follow = Follow { - follower_id: alice.id.clone(), - following_id: bob.id.clone(), - state: FollowState::Accepted, - ap_id: None, - created_at: Utc::now(), - }; - repo.save(&follow).await.unwrap(); - let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap(); - assert_eq!(ids, vec![bob.id]); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/follow/tests.rs b/crates/adapters/postgres/src/follow/tests.rs new file mode 100644 index 0000000..05ba499 --- /dev/null +++ b/crates/adapters/postgres/src/follow/tests.rs @@ -0,0 +1,58 @@ + use super::*; + use crate::test_helpers::seed_user; + use chrono::Utc; + use domain::value_objects::*; + + #[sqlx::test(migrations = "./migrations")] + async fn save_and_find_follow(pool: sqlx::PgPool) { + let alice = seed_user(&pool, "alice", "alice@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgFollowRepository::new(pool); + let follow = Follow { + follower_id: alice.id.clone(), + following_id: bob.id.clone(), + state: FollowState::Accepted, + ap_id: None, + created_at: Utc::now(), + }; + repo.save(&follow).await.unwrap(); + let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); + assert_eq!(found.state, FollowState::Accepted); + } + + #[sqlx::test(migrations = "./migrations")] + async fn update_state(pool: sqlx::PgPool) { + let alice = seed_user(&pool, "alice", "alice@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgFollowRepository::new(pool); + let follow = Follow { + follower_id: alice.id.clone(), + following_id: bob.id.clone(), + state: FollowState::Pending, + ap_id: None, + created_at: Utc::now(), + }; + repo.save(&follow).await.unwrap(); + repo.update_state(&alice.id, &bob.id, &FollowState::Accepted) + .await + .unwrap(); + let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); + assert_eq!(found.state, FollowState::Accepted); + } + + #[sqlx::test(migrations = "./migrations")] + async fn get_accepted_following_ids(pool: sqlx::PgPool) { + let alice = seed_user(&pool, "alice", "alice@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgFollowRepository::new(pool); + let follow = Follow { + follower_id: alice.id.clone(), + following_id: bob.id.clone(), + state: FollowState::Accepted, + ap_id: None, + created_at: Utc::now(), + }; + repo.save(&follow).await.unwrap(); + let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap(); + assert_eq!(ids, vec![bob.id]); + } diff --git a/crates/adapters/postgres/src/like.rs b/crates/adapters/postgres/src/like/mod.rs similarity index 66% rename from crates/adapters/postgres/src/like.rs rename to crates/adapters/postgres/src/like/mod.rs index 43d5d59..d01c415 100644 --- a/crates/adapters/postgres/src/like.rs +++ b/crates/adapters/postgres/src/like/mod.rs @@ -71,40 +71,4 @@ impl LikeRepository for PgLikeRepository { } #[cfg(test)] -mod tests { - use super::*; - use crate::test_helpers::seed_user_and_thought; - use chrono::Utc; - use domain::value_objects::*; - - #[sqlx::test(migrations = "./migrations")] - async fn like_and_count(pool: sqlx::PgPool) { - let (user, thought) = seed_user_and_thought(&pool).await; - let repo = PgLikeRepository::new(pool); - let like = Like { - id: LikeId::new(), - user_id: user.id.clone(), - thought_id: thought.id.clone(), - ap_id: None, - created_at: Utc::now(), - }; - repo.save(&like).await.unwrap(); - assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); - } - - #[sqlx::test(migrations = "./migrations")] - async fn unlike(pool: sqlx::PgPool) { - let (user, thought) = seed_user_and_thought(&pool).await; - let repo = PgLikeRepository::new(pool); - let like = Like { - id: LikeId::new(), - user_id: user.id.clone(), - thought_id: thought.id.clone(), - ap_id: None, - created_at: Utc::now(), - }; - repo.save(&like).await.unwrap(); - repo.delete(&user.id, &thought.id).await.unwrap(); - assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/like/tests.rs b/crates/adapters/postgres/src/like/tests.rs new file mode 100644 index 0000000..1106d67 --- /dev/null +++ b/crates/adapters/postgres/src/like/tests.rs @@ -0,0 +1,35 @@ + use super::*; + use crate::test_helpers::seed_user_and_thought; + use chrono::Utc; + use domain::value_objects::*; + + #[sqlx::test(migrations = "./migrations")] + async fn like_and_count(pool: sqlx::PgPool) { + let (user, thought) = seed_user_and_thought(&pool).await; + let repo = PgLikeRepository::new(pool); + let like = Like { + id: LikeId::new(), + user_id: user.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: Utc::now(), + }; + repo.save(&like).await.unwrap(); + assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); + } + + #[sqlx::test(migrations = "./migrations")] + async fn unlike(pool: sqlx::PgPool) { + let (user, thought) = seed_user_and_thought(&pool).await; + let repo = PgLikeRepository::new(pool); + let like = Like { + id: LikeId::new(), + user_id: user.id.clone(), + thought_id: thought.id.clone(), + ap_id: None, + created_at: Utc::now(), + }; + repo.save(&like).await.unwrap(); + repo.delete(&user.id, &thought.id).await.unwrap(); + assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); + } diff --git a/crates/adapters/postgres/src/notification.rs b/crates/adapters/postgres/src/notification/mod.rs similarity index 69% rename from crates/adapters/postgres/src/notification.rs rename to crates/adapters/postgres/src/notification/mod.rs index 77b4cbb..c33df38 100644 --- a/crates/adapters/postgres/src/notification.rs +++ b/crates/adapters/postgres/src/notification/mod.rs @@ -159,72 +159,4 @@ impl NotificationRepository for PgNotificationRepository { } #[cfg(test)] -mod tests { - use super::*; - use crate::test_helpers; - use chrono::Utc; - use domain::{ - models::{notification::NotificationKind, user::User}, - value_objects::*, - }; - - #[sqlx::test(migrations = "./migrations")] - async fn save_and_list(pool: sqlx::PgPool) { - let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; - let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgNotificationRepository::new(pool); - use domain::models::feed::PageParams; - let n = Notification { - id: NotificationId::new(), - user_id: user.id.clone(), - kind: NotificationKind::Follow { - from_user_id: from_user.id.clone(), - }, - read: false, - created_at: Utc::now(), - }; - repo.save(&n).await.unwrap(); - let page = repo - .list_for_user( - &user.id, - &PageParams { - page: 1, - per_page: 20, - }, - ) - .await - .unwrap(); - assert_eq!(page.total, 1); - assert!(!page.items[0].read); - } - - #[sqlx::test(migrations = "./migrations")] - async fn mark_all_read(pool: sqlx::PgPool) { - let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; - let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgNotificationRepository::new(pool); - use domain::models::feed::PageParams; - let n = Notification { - id: NotificationId::new(), - user_id: user.id.clone(), - kind: NotificationKind::Follow { - from_user_id: from_user.id.clone(), - }, - read: false, - created_at: Utc::now(), - }; - repo.save(&n).await.unwrap(); - repo.mark_all_read(&user.id).await.unwrap(); - let page = repo - .list_for_user( - &user.id, - &PageParams { - page: 1, - per_page: 20, - }, - ) - .await - .unwrap(); - assert!(page.items[0].read); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/notification/tests.rs b/crates/adapters/postgres/src/notification/tests.rs new file mode 100644 index 0000000..9a9caf8 --- /dev/null +++ b/crates/adapters/postgres/src/notification/tests.rs @@ -0,0 +1,67 @@ + use super::*; + use crate::test_helpers; + use chrono::Utc; + use domain::{ + models::{notification::NotificationKind, user::User}, + value_objects::*, + }; + + #[sqlx::test(migrations = "./migrations")] + async fn save_and_list(pool: sqlx::PgPool) { + let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; + let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgNotificationRepository::new(pool); + use domain::models::feed::PageParams; + let n = Notification { + id: NotificationId::new(), + user_id: user.id.clone(), + kind: NotificationKind::Follow { + from_user_id: from_user.id.clone(), + }, + read: false, + created_at: Utc::now(), + }; + repo.save(&n).await.unwrap(); + let page = repo + .list_for_user( + &user.id, + &PageParams { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); + assert_eq!(page.total, 1); + assert!(!page.items[0].read); + } + + #[sqlx::test(migrations = "./migrations")] + async fn mark_all_read(pool: sqlx::PgPool) { + let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; + let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgNotificationRepository::new(pool); + use domain::models::feed::PageParams; + let n = Notification { + id: NotificationId::new(), + user_id: user.id.clone(), + kind: NotificationKind::Follow { + from_user_id: from_user.id.clone(), + }, + read: false, + created_at: Utc::now(), + }; + repo.save(&n).await.unwrap(); + repo.mark_all_read(&user.id).await.unwrap(); + let page = repo + .list_for_user( + &user.id, + &PageParams { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); + assert!(page.items[0].read); + } diff --git a/crates/adapters/postgres/src/tag.rs b/crates/adapters/postgres/src/tag/mod.rs similarity index 70% rename from crates/adapters/postgres/src/tag.rs rename to crates/adapters/postgres/src/tag/mod.rs index ab692a9..e93a8d2 100644 --- a/crates/adapters/postgres/src/tag.rs +++ b/crates/adapters/postgres/src/tag/mod.rs @@ -132,53 +132,4 @@ impl TagRepository for PgTagRepository { } #[cfg(test)] -mod tests { - use super::*; - use crate::{thought::PgThoughtRepository, user::PgUserRepository}; - use domain::ports::{ThoughtRepository, UserWriter}; - use domain::{ - models::{ - thought::{Thought, Visibility}, - user::User, - }, - value_objects::*, - }; - - #[sqlx::test(migrations = "./migrations")] - async fn find_or_create_tag(pool: sqlx::PgPool) { - let repo = PgTagRepository::new(pool); - let t1 = repo.find_or_create("rust").await.unwrap(); - let t2 = repo.find_or_create("rust").await.unwrap(); - assert_eq!(t1.id, t2.id); - assert_eq!(t1.name, "rust"); - } - - #[sqlx::test(migrations = "./migrations")] - async fn attach_and_list(pool: sqlx::PgPool) { - let urepo = PgUserRepository::new(pool.clone()); - let trepo = PgThoughtRepository::new(pool.clone()); - let u = User::new_local( - UserId::new(), - Username::new("alice").unwrap(), - Email::new("alice@ex.com").unwrap(), - PasswordHash("h".into()), - ); - urepo.save(&u).await.unwrap(); - let t = Thought::new_local( - ThoughtId::new(), - u.id.clone(), - Content::new_local("hi").unwrap(), - None, - Visibility::Public, - None, - false, - ); - trepo.save(&t).await.unwrap(); - let repo = PgTagRepository::new(pool); - let tag = repo.find_or_create("greetings").await.unwrap(); - repo.attach_to_thought(&t.id, tag.id).await.unwrap(); - let tags = repo.list_for_thought(&t.id).await.unwrap(); - assert_eq!(tags.len(), 1); - assert_eq!(tags[0].name, "greetings"); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/tag/tests.rs b/crates/adapters/postgres/src/tag/tests.rs new file mode 100644 index 0000000..0cb5512 --- /dev/null +++ b/crates/adapters/postgres/src/tag/tests.rs @@ -0,0 +1,48 @@ + use super::*; + use crate::{thought::PgThoughtRepository, user::PgUserRepository}; + use domain::ports::{ThoughtRepository, UserWriter}; + use domain::{ + models::{ + thought::{Thought, Visibility}, + user::User, + }, + value_objects::*, + }; + + #[sqlx::test(migrations = "./migrations")] + async fn find_or_create_tag(pool: sqlx::PgPool) { + let repo = PgTagRepository::new(pool); + let t1 = repo.find_or_create("rust").await.unwrap(); + let t2 = repo.find_or_create("rust").await.unwrap(); + assert_eq!(t1.id, t2.id); + assert_eq!(t1.name, "rust"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn attach_and_list(pool: sqlx::PgPool) { + let urepo = PgUserRepository::new(pool.clone()); + let trepo = PgThoughtRepository::new(pool.clone()); + let u = User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ); + urepo.save(&u).await.unwrap(); + let t = Thought::new_local( + ThoughtId::new(), + u.id.clone(), + Content::new_local("hi").unwrap(), + None, + Visibility::Public, + None, + false, + ); + trepo.save(&t).await.unwrap(); + let repo = PgTagRepository::new(pool); + let tag = repo.find_or_create("greetings").await.unwrap(); + repo.attach_to_thought(&t.id, tag.id).await.unwrap(); + let tags = repo.list_for_thought(&t.id).await.unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].name, "greetings"); + } diff --git a/crates/adapters/postgres/src/thought.rs b/crates/adapters/postgres/src/thought/mod.rs similarity index 64% rename from crates/adapters/postgres/src/thought.rs rename to crates/adapters/postgres/src/thought/mod.rs index 3a0cf7d..beec7be 100644 --- a/crates/adapters/postgres/src/thought.rs +++ b/crates/adapters/postgres/src/thought/mod.rs @@ -168,95 +168,4 @@ impl ThoughtRepository for PgThoughtRepository { } #[cfg(test)] -mod tests { - use super::*; - use crate::test_helpers::seed_user; - use domain::{ - models::thought::{Thought, Visibility}, - value_objects::*, - }; - - #[sqlx::test(migrations = "./migrations")] - async fn save_and_find_thought(pool: sqlx::PgPool) { - let user = seed_user(&pool, "alice", "alice@ex.com").await; - let repo = PgThoughtRepository::new(pool); - let t = Thought::new_local( - ThoughtId::new(), - user.id.clone(), - Content::new_local("hello world").unwrap(), - None, - Visibility::Public, - None, - false, - ); - repo.save(&t).await.unwrap(); - let found = repo.find_by_id(&t.id).await.unwrap().unwrap(); - assert_eq!(found.content.as_str(), "hello world"); - assert!(found.local); - } - - #[sqlx::test(migrations = "./migrations")] - async fn delete_thought(pool: sqlx::PgPool) { - let user = seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgThoughtRepository::new(pool); - let t = Thought::new_local( - ThoughtId::new(), - user.id.clone(), - Content::new_local("bye").unwrap(), - None, - Visibility::Public, - None, - false, - ); - repo.save(&t).await.unwrap(); - repo.delete(&t.id, &user.id).await.unwrap(); - assert!(repo.find_by_id(&t.id).await.unwrap().is_none()); - } - - #[sqlx::test(migrations = "./migrations")] - async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) { - let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgThoughtRepository::new(pool); - let t = Thought::new_local( - ThoughtId::new(), - alice.id.clone(), - Content::new_local("secret").unwrap(), - None, - Visibility::Public, - None, - false, - ); - repo.save(&t).await.unwrap(); - let err = repo.delete(&t.id, &bob.id).await.unwrap_err(); - assert!(matches!(err, DomainError::NotFound)); - } - - #[sqlx::test(migrations = "./migrations")] - async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) { - let user = seed_user(&pool, "charlie", "charlie@ex.com").await; - let repo = PgThoughtRepository::new(pool); - let root = Thought::new_local( - ThoughtId::new(), - user.id.clone(), - Content::new_local("root").unwrap(), - None, - Visibility::Public, - None, - false, - ); - let reply = Thought::new_local( - ThoughtId::new(), - user.id.clone(), - Content::new_local("reply").unwrap(), - Some(root.id.clone()), - Visibility::Public, - None, - false, - ); - repo.save(&root).await.unwrap(); - repo.save(&reply).await.unwrap(); - let thread = repo.get_thread(&root.id).await.unwrap(); - assert_eq!(thread.len(), 2); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/thought/tests.rs b/crates/adapters/postgres/src/thought/tests.rs new file mode 100644 index 0000000..c227c4d --- /dev/null +++ b/crates/adapters/postgres/src/thought/tests.rs @@ -0,0 +1,90 @@ + use super::*; + use crate::test_helpers::seed_user; + use domain::{ + models::thought::{Thought, Visibility}, + value_objects::*, + }; + + #[sqlx::test(migrations = "./migrations")] + async fn save_and_find_thought(pool: sqlx::PgPool) { + let user = seed_user(&pool, "alice", "alice@ex.com").await; + let repo = PgThoughtRepository::new(pool); + let t = Thought::new_local( + ThoughtId::new(), + user.id.clone(), + Content::new_local("hello world").unwrap(), + None, + Visibility::Public, + None, + false, + ); + repo.save(&t).await.unwrap(); + let found = repo.find_by_id(&t.id).await.unwrap().unwrap(); + assert_eq!(found.content.as_str(), "hello world"); + assert!(found.local); + } + + #[sqlx::test(migrations = "./migrations")] + async fn delete_thought(pool: sqlx::PgPool) { + let user = seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgThoughtRepository::new(pool); + let t = Thought::new_local( + ThoughtId::new(), + user.id.clone(), + Content::new_local("bye").unwrap(), + None, + Visibility::Public, + None, + false, + ); + repo.save(&t).await.unwrap(); + repo.delete(&t.id, &user.id).await.unwrap(); + assert!(repo.find_by_id(&t.id).await.unwrap().is_none()); + } + + #[sqlx::test(migrations = "./migrations")] + async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) { + let alice = seed_user(&pool, "alice", "alice@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgThoughtRepository::new(pool); + let t = Thought::new_local( + ThoughtId::new(), + alice.id.clone(), + Content::new_local("secret").unwrap(), + None, + Visibility::Public, + None, + false, + ); + repo.save(&t).await.unwrap(); + let err = repo.delete(&t.id, &bob.id).await.unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); + } + + #[sqlx::test(migrations = "./migrations")] + async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) { + let user = seed_user(&pool, "charlie", "charlie@ex.com").await; + let repo = PgThoughtRepository::new(pool); + let root = Thought::new_local( + ThoughtId::new(), + user.id.clone(), + Content::new_local("root").unwrap(), + None, + Visibility::Public, + None, + false, + ); + let reply = Thought::new_local( + ThoughtId::new(), + user.id.clone(), + Content::new_local("reply").unwrap(), + Some(root.id.clone()), + Visibility::Public, + None, + false, + ); + repo.save(&root).await.unwrap(); + repo.save(&reply).await.unwrap(); + let thread = repo.get_thread(&root.id).await.unwrap(); + assert_eq!(thread.len(), 2); + } diff --git a/crates/adapters/postgres/src/top_friend.rs b/crates/adapters/postgres/src/top_friend/mod.rs similarity index 65% rename from crates/adapters/postgres/src/top_friend.rs rename to crates/adapters/postgres/src/top_friend/mod.rs index 0528e18..769553a 100644 --- a/crates/adapters/postgres/src/top_friend.rs +++ b/crates/adapters/postgres/src/top_friend/mod.rs @@ -104,52 +104,4 @@ impl TopFriendRepository for PgTopFriendRepository { } #[cfg(test)] -mod tests { - use super::*; - use crate::user::PgUserRepository; - use domain::ports::UserWriter; - use domain::{models::user::User, value_objects::*}; - - async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { - let repo = PgUserRepository::new(pool.clone()); - let u = User::new_local( - UserId::new(), - Username::new(username).unwrap(), - Email::new(email).unwrap(), - PasswordHash("h".into()), - ); - repo.save(&u).await.unwrap(); - u - } - - #[sqlx::test(migrations = "./migrations")] - async fn set_and_list_top_friends(pool: sqlx::PgPool) { - let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; - let repo = PgTopFriendRepository::new(pool); - repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]) - .await - .unwrap(); - let friends = repo.list_for_user(&alice.id).await.unwrap(); - assert_eq!(friends.len(), 1); - assert_eq!(friends[0].0.position, 1); - assert_eq!(friends[0].1.username.as_str(), "bob"); - } - - #[sqlx::test(migrations = "./migrations")] - async fn replace_top_friends(pool: sqlx::PgPool) { - let alice = seed_user(&pool, "alice", "alice@ex.com").await; - let bob = seed_user(&pool, "bob", "bob@ex.com").await; - let carol = seed_user(&pool, "carol", "carol@ex.com").await; - let repo = PgTopFriendRepository::new(pool); - repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]) - .await - .unwrap(); - repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)]) - .await - .unwrap(); - let friends = repo.list_for_user(&alice.id).await.unwrap(); - assert_eq!(friends.len(), 1); - assert_eq!(friends[0].1.username.as_str(), "carol"); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/top_friend/tests.rs b/crates/adapters/postgres/src/top_friend/tests.rs new file mode 100644 index 0000000..8e14acc --- /dev/null +++ b/crates/adapters/postgres/src/top_friend/tests.rs @@ -0,0 +1,47 @@ + use super::*; + use crate::user::PgUserRepository; + use domain::ports::UserWriter; + use domain::{models::user::User, value_objects::*}; + + async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { + let repo = PgUserRepository::new(pool.clone()); + let u = User::new_local( + UserId::new(), + Username::new(username).unwrap(), + Email::new(email).unwrap(), + PasswordHash("h".into()), + ); + repo.save(&u).await.unwrap(); + u + } + + #[sqlx::test(migrations = "./migrations")] + async fn set_and_list_top_friends(pool: sqlx::PgPool) { + let alice = seed_user(&pool, "alice", "alice@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let repo = PgTopFriendRepository::new(pool); + repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]) + .await + .unwrap(); + let friends = repo.list_for_user(&alice.id).await.unwrap(); + assert_eq!(friends.len(), 1); + assert_eq!(friends[0].0.position, 1); + assert_eq!(friends[0].1.username.as_str(), "bob"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn replace_top_friends(pool: sqlx::PgPool) { + let alice = seed_user(&pool, "alice", "alice@ex.com").await; + let bob = seed_user(&pool, "bob", "bob@ex.com").await; + let carol = seed_user(&pool, "carol", "carol@ex.com").await; + let repo = PgTopFriendRepository::new(pool); + repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]) + .await + .unwrap(); + repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)]) + .await + .unwrap(); + let friends = repo.list_for_user(&alice.id).await.unwrap(); + assert_eq!(friends.len(), 1); + assert_eq!(friends[0].1.username.as_str(), "carol"); + } diff --git a/crates/adapters/postgres/src/user.rs b/crates/adapters/postgres/src/user/mod.rs similarity index 80% rename from crates/adapters/postgres/src/user.rs rename to crates/adapters/postgres/src/user/mod.rs index 9019b16..1f68bd7 100644 --- a/crates/adapters/postgres/src/user.rs +++ b/crates/adapters/postgres/src/user/mod.rs @@ -279,74 +279,4 @@ impl UserWriter for PgUserRepository { } #[cfg(test)] -mod tests { - use super::*; - use domain::{models::user::User, value_objects::*}; - - #[sqlx::test(migrations = "./migrations")] - async fn save_and_find_by_id(pool: sqlx::PgPool) { - let repo = PgUserRepository::new(pool); - let user = User::new_local( - UserId::new(), - Username::new("alice").unwrap(), - Email::new("alice@ex.com").unwrap(), - PasswordHash("hash".into()), - ); - repo.save(&user).await.unwrap(); - let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); - assert_eq!(found.username.as_str(), "alice"); - assert_eq!(found.email.as_str(), "alice@ex.com"); - } - - #[sqlx::test(migrations = "./migrations")] - async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) { - let repo = PgUserRepository::new(pool); - let result = repo - .find_by_username(&Username::new("ghost").unwrap()) - .await - .unwrap(); - assert!(result.is_none()); - } - - #[sqlx::test(migrations = "./migrations")] - async fn find_by_email(pool: sqlx::PgPool) { - let repo = PgUserRepository::new(pool); - let user = User::new_local( - UserId::new(), - Username::new("bob").unwrap(), - Email::new("bob@ex.com").unwrap(), - PasswordHash("hash".into()), - ); - repo.save(&user).await.unwrap(); - let found = repo - .find_by_email(&Email::new("bob@ex.com").unwrap()) - .await - .unwrap(); - assert!(found.is_some()); - } - - #[sqlx::test(migrations = "./migrations")] - async fn update_profile_changes_fields(pool: sqlx::PgPool) { - let repo = PgUserRepository::new(pool); - let user = User::new_local( - UserId::new(), - Username::new("charlie").unwrap(), - Email::new("charlie@ex.com").unwrap(), - PasswordHash("hash".into()), - ); - repo.save(&user).await.unwrap(); - repo.update_profile( - &user.id, - Some("Charlie".into()), - Some("bio".into()), - None, - None, - None, - ) - .await - .unwrap(); - let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); - assert_eq!(found.display_name.as_deref(), Some("Charlie")); - assert_eq!(found.bio.as_deref(), Some("bio")); - } -} +mod tests; diff --git a/crates/adapters/postgres/src/user/tests.rs b/crates/adapters/postgres/src/user/tests.rs new file mode 100644 index 0000000..b7cf915 --- /dev/null +++ b/crates/adapters/postgres/src/user/tests.rs @@ -0,0 +1,69 @@ + use super::*; + use domain::{models::user::User, value_objects::*}; + + #[sqlx::test(migrations = "./migrations")] + async fn save_and_find_by_id(pool: sqlx::PgPool) { + let repo = PgUserRepository::new(pool); + let user = User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("hash".into()), + ); + repo.save(&user).await.unwrap(); + let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); + assert_eq!(found.username.as_str(), "alice"); + assert_eq!(found.email.as_str(), "alice@ex.com"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) { + let repo = PgUserRepository::new(pool); + let result = repo + .find_by_username(&Username::new("ghost").unwrap()) + .await + .unwrap(); + assert!(result.is_none()); + } + + #[sqlx::test(migrations = "./migrations")] + async fn find_by_email(pool: sqlx::PgPool) { + let repo = PgUserRepository::new(pool); + let user = User::new_local( + UserId::new(), + Username::new("bob").unwrap(), + Email::new("bob@ex.com").unwrap(), + PasswordHash("hash".into()), + ); + repo.save(&user).await.unwrap(); + let found = repo + .find_by_email(&Email::new("bob@ex.com").unwrap()) + .await + .unwrap(); + assert!(found.is_some()); + } + + #[sqlx::test(migrations = "./migrations")] + async fn update_profile_changes_fields(pool: sqlx::PgPool) { + let repo = PgUserRepository::new(pool); + let user = User::new_local( + UserId::new(), + Username::new("charlie").unwrap(), + Email::new("charlie@ex.com").unwrap(), + PasswordHash("hash".into()), + ); + repo.save(&user).await.unwrap(); + repo.update_profile( + &user.id, + Some("Charlie".into()), + Some("bio".into()), + None, + None, + None, + ) + .await + .unwrap(); + let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); + assert_eq!(found.display_name.as_deref(), Some("Charlie")); + assert_eq!(found.bio.as_deref(), Some("bio")); + } diff --git a/crates/application/src/services/federation_event.rs b/crates/application/src/services/federation_event.rs deleted file mode 100644 index 4c5ecb7..0000000 --- a/crates/application/src/services/federation_event.rs +++ /dev/null @@ -1,787 +0,0 @@ -use activitypub_base::{ActivityPubRepository, OutboundFederationPort}; -use domain::{ - errors::DomainError, - events::DomainEvent, - models::thought::Visibility, - ports::{ThoughtRepository, UserReader}, - value_objects::ThoughtId, -}; -use std::sync::Arc; - -pub struct FederationEventService { - pub thoughts: Arc, - pub users: Arc, - pub ap: Arc, - pub base_url: String, - pub ap_repo: Arc, -} - -impl FederationEventService { - async fn object_ap_id(&self, thought_id: &ThoughtId) -> Result { - if let Some(ap_id) = self.ap_repo.get_thought_ap_id(thought_id).await? { - return Ok(ap_id); - } - Ok(format!("{}/thoughts/{}", self.base_url, thought_id)) - } - - pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { - match event { - DomainEvent::ThoughtCreated { - thought_id, - user_id, - .. - } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) - if t.local - && matches!( - t.visibility, - Visibility::Public | Visibility::Unlisted - ) => - { - t - } - _ => return Ok(()), - }; - let user = match self.users.find_by_id(user_id).await? { - Some(u) => u, - None => return Ok(()), - }; - // Resolve in_reply_to_url for the parent thought via AP repo. - let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id { - let ap_id = self - .ap_repo - .get_thought_ap_id(reply_id) - .await? - .unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, reply_id)); - Some(ap_id) - } else { - None - }; - self.ap - .broadcast_create( - user_id, - &thought, - user.username.as_str(), - in_reply_to_url.as_deref(), - ) - .await - } - - DomainEvent::ThoughtDeleted { - thought_id, - user_id, - } => { - // No DB lookup — thought is already deleted when this event fires. - // No locality guard: delete commands only reach local thoughts via the use case. - let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id); - self.ap.broadcast_delete(user_id, &ap_id).await - } - - DomainEvent::ThoughtUpdated { - thought_id, - user_id, - } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) - if t.local - && matches!( - t.visibility, - Visibility::Public | Visibility::Unlisted - ) => - { - t - } - _ => return Ok(()), - }; - let user = match self.users.find_by_id(user_id).await? { - Some(u) => u, - None => return Ok(()), - }; - let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id { - self.ap_repo - .get_thought_ap_id(reply_id) - .await? - .or_else(|| Some(format!("{}/thoughts/{}", self.base_url, reply_id))) - } else { - None - }; - self.ap - .broadcast_update( - user_id, - &thought, - user.username.as_str(), - in_reply_to_url.as_deref(), - ) - .await - } - - DomainEvent::BoostAdded { - boost_id: _, - user_id, - thought_id, - } => { - // Only fan-out if the booster is a local user. Remote boosts must not be re-broadcast. - let booster = match self.users.find_by_id(user_id).await? { - Some(u) if u.local => u, - _ => return Ok(()), - }; - let _ = booster; - if self.thoughts.find_by_id(thought_id).await?.is_none() { - return Ok(()); - } - let object_ap_id = self.object_ap_id(thought_id).await?; - self.ap.broadcast_announce(user_id, &object_ap_id).await - } - - DomainEvent::BoostRemoved { - user_id, - thought_id, - } => { - if self.thoughts.find_by_id(thought_id).await?.is_none() { - return Ok(()); - } - let object_ap_id = self.object_ap_id(thought_id).await?; - self.ap - .broadcast_undo_announce(user_id, &object_ap_id) - .await - } - - DomainEvent::LikeAdded { - like_id: _, - user_id, - thought_id, - } => { - // Only federate: local liker + remote thought (has ap_id) + author has inbox. - let liker = match self.users.find_by_id(user_id).await? { - Some(u) if u.local => u, - _ => return Ok(()), - }; - let _ = liker; - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) => t, - _ => return Ok(()), - }; - let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? { - Some(id) => id, - None => return Ok(()), // local thought — no federation needed - }; - let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? { - Some(u) => u, - None => return Ok(()), - }; - self.ap - .broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url) - .await - } - - DomainEvent::LikeRemoved { - user_id, - thought_id, - } => { - let liker = match self.users.find_by_id(user_id).await? { - Some(u) if u.local => u, - _ => return Ok(()), - }; - let _ = liker; - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) => t, - _ => return Ok(()), - }; - let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? { - Some(id) => id, - None => return Ok(()), - }; - let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? { - Some(u) => u, - None => return Ok(()), - }; - self.ap - .broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url) - .await - } - - DomainEvent::ProfileUpdated { user_id } => { - self.ap.broadcast_actor_update(user_id).await - } - - _ => Ok(()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use activitypub_base::{ActorApUrls, OutboundFederationPort}; - use async_trait::async_trait; - use crate::testing::TestApRepo; - use domain::{ - errors::DomainError, - events::DomainEvent, - models::thought::{Thought, Visibility}, - models::user::User, - testing::TestStore, - value_objects::*, - }; - use std::sync::{Arc, Mutex}; - - // ── Spy port ───────────────────────────────────────────────────────────── - - #[derive(Default)] - struct SpyPort { - created: Mutex>, - deleted: Mutex>, - updated: Mutex>, - announced: Mutex>, - undo_announced: Mutex>, - liked: Mutex>, - undo_liked: Mutex>, - actor_updated: Mutex>, - } - - #[async_trait] - impl OutboundFederationPort for SpyPort { - async fn broadcast_create( - &self, - _: &UserId, - thought: &Thought, - _: &str, - _in_reply_to_url: Option<&str>, - ) -> Result<(), DomainError> { - self.created.lock().unwrap().push(thought.id.clone()); - Ok(()) - } - async fn broadcast_delete(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> { - self.deleted.lock().unwrap().push(ap_id.to_string()); - Ok(()) - } - async fn broadcast_update( - &self, - _: &UserId, - thought: &Thought, - _: &str, - _in_reply_to_url: Option<&str>, - ) -> Result<(), DomainError> { - self.updated.lock().unwrap().push(thought.id.clone()); - Ok(()) - } - async fn broadcast_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> { - self.announced.lock().unwrap().push(ap_id.to_string()); - Ok(()) - } - async fn broadcast_undo_announce( - &self, - _: &UserId, - ap_id: &str, - ) -> Result<(), DomainError> { - self.undo_announced.lock().unwrap().push(ap_id.to_string()); - Ok(()) - } - - async fn broadcast_like( - &self, - _: &UserId, - ap_id: &str, - _: &str, - ) -> Result<(), DomainError> { - self.liked.lock().unwrap().push(ap_id.to_string()); - Ok(()) - } - - async fn broadcast_undo_like( - &self, - _: &UserId, - ap_id: &str, - _: &str, - ) -> Result<(), DomainError> { - self.undo_liked.lock().unwrap().push(ap_id.to_string()); - Ok(()) - } - - async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError> { - self.actor_updated.lock().unwrap().push(user_id.clone()); - Ok(()) - } - } - - fn alice() -> User { - User::new_local( - UserId::new(), - Username::new("alice").unwrap(), - Email::new("alice@ex.com").unwrap(), - PasswordHash("h".into()), - ) - } - - fn local_thought(author_id: UserId) -> Thought { - Thought::new_local( - ThoughtId::new(), - author_id, - Content::new_local("hello").unwrap(), - None, - Visibility::Public, - None, - false, - ) - } - - fn svc(store: &TestStore, spy: Arc) -> FederationEventService { - let ap_repo = TestApRepo::new(store.clone()); - FederationEventService { - thoughts: Arc::new(store.clone()), - users: Arc::new(store.clone()), - ap: spy, - base_url: "https://example.com".to_string(), - ap_repo: Arc::new(ap_repo), - } - } - - fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc) -> FederationEventService { - FederationEventService { - thoughts: Arc::new(store.clone()), - users: Arc::new(store.clone()), - ap: spy, - base_url: "https://example.com".to_string(), - ap_repo: Arc::new(ap_repo), - } - } - - #[tokio::test] - async fn thought_created_broadcasts_create() { - let store = TestStore::default(); - let alice = alice(); - let thought = local_thought(alice.id.clone()); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtCreated { - thought_id: thought.id.clone(), - user_id: alice.id.clone(), - in_reply_to_id: None, - }) - .await - .unwrap(); - - assert_eq!(spy.created.lock().unwrap().len(), 1); - assert_eq!(spy.created.lock().unwrap()[0], thought.id); - } - - #[tokio::test] - async fn remote_thought_created_does_not_broadcast() { - let store = TestStore::default(); - let alice = alice(); - // Remote thought: local = false - let mut thought = local_thought(alice.id.clone()); - thought.local = false; - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtCreated { - thought_id: thought.id.clone(), - user_id: alice.id.clone(), - in_reply_to_id: None, - }) - .await - .unwrap(); - - assert!(spy.created.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn thought_deleted_broadcasts_delete_with_constructed_ap_id() { - let store = TestStore::default(); - let alice = alice(); - let tid = ThoughtId::new(); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtDeleted { - thought_id: tid.clone(), - user_id: alice.id.clone(), - }) - .await - .unwrap(); - - let deleted = spy.deleted.lock().unwrap(); - assert_eq!(deleted.len(), 1); - assert_eq!(deleted[0], format!("https://example.com/thoughts/{}", tid)); - } - - #[tokio::test] - async fn thought_updated_broadcasts_update() { - let store = TestStore::default(); - let alice = alice(); - let thought = local_thought(alice.id.clone()); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtUpdated { - thought_id: thought.id.clone(), - user_id: alice.id.clone(), - }) - .await - .unwrap(); - - assert_eq!(spy.updated.lock().unwrap().len(), 1); - assert_eq!(spy.updated.lock().unwrap()[0], thought.id); - } - - #[tokio::test] - async fn boost_of_local_thought_announces_constructed_url() { - let store = TestStore::default(); - let alice = alice(); - let thought = local_thought(alice.id.clone()); // ap_id = None - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::BoostAdded { - boost_id: BoostId::new(), - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - }) - .await - .unwrap(); - - let announced = spy.announced.lock().unwrap(); - assert_eq!(announced.len(), 1); - assert_eq!( - announced[0], - format!("https://example.com/thoughts/{}", thought.id) - ); - } - - #[tokio::test] - async fn boost_of_remote_thought_announces_remote_ap_id() { - let store = TestStore::default(); - let alice = alice(); - let mut thought = local_thought(alice.id.clone()); - thought.local = false; - let ap_repo = TestApRepo::new(store.clone()); - ap_repo.inner.thought_ap_ids.lock().unwrap().insert( - thought.id.clone(), - "https://mastodon.social/users/bob/statuses/123".into(), - ); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc_with_ap(&store, ap_repo, spy.clone()) - .process(&DomainEvent::BoostAdded { - boost_id: BoostId::new(), - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - }) - .await - .unwrap(); - - let announced = spy.announced.lock().unwrap(); - assert_eq!( - announced[0], - "https://mastodon.social/users/bob/statuses/123" - ); - } - - #[tokio::test] - async fn direct_thought_created_does_not_broadcast() { - let store = TestStore::default(); - let alice = alice(); - let thought = Thought::new_local( - ThoughtId::new(), - alice.id.clone(), - Content::new_local("private").unwrap(), - None, - Visibility::Direct, - None, - false, - ); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtCreated { - thought_id: thought.id.clone(), - user_id: alice.id.clone(), - in_reply_to_id: None, - }) - .await - .unwrap(); - - assert!(spy.created.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn followers_only_thought_does_not_broadcast_publicly() { - let store = TestStore::default(); - let alice = alice(); - let thought = Thought::new_local( - ThoughtId::new(), - alice.id.clone(), - Content::new_local("for followers").unwrap(), - None, - Visibility::Followers, - None, - false, - ); - store.users.lock().unwrap().push(alice.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtCreated { - thought_id: thought.id.clone(), - user_id: alice.id.clone(), - in_reply_to_id: None, - }) - .await - .unwrap(); - - assert!(spy.created.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn unrelated_events_are_noop() { - let store = TestStore::default(); - let spy = Arc::new(SpyPort::default()); - let svc = svc(&store, spy.clone()); - - svc.process(&DomainEvent::UserBlocked { - blocker_id: UserId::new(), - blocked_id: UserId::new(), - }) - .await - .unwrap(); - - assert!(spy.created.lock().unwrap().is_empty()); - assert!(spy.deleted.lock().unwrap().is_empty()); - assert!(spy.updated.lock().unwrap().is_empty()); - assert!(spy.announced.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn thought_created_does_not_broadcast_if_user_missing() { - let store = TestStore::default(); - let alice = alice(); - let thought = local_thought(alice.id.clone()); - // Don't push alice into users — simulates user deleted before handler runs - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtCreated { - thought_id: thought.id.clone(), - user_id: alice.id.clone(), - in_reply_to_id: None, - }) - .await - .unwrap(); - - assert!(spy.created.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn boost_removed_sends_undo_announce_for_local_thought() { - let store = TestStore::default(); - let alice = alice(); - let thought = local_thought(alice.id.clone()); // ap_id = None → constructed URL - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::BoostRemoved { - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - }) - .await - .unwrap(); - - let undo_announced = spy.undo_announced.lock().unwrap(); - assert_eq!(undo_announced.len(), 1); - assert_eq!( - undo_announced[0], - format!("https://example.com/thoughts/{}", thought.id) - ); - } - - #[tokio::test] - async fn boost_removed_sends_undo_announce_for_remote_thought() { - let store = TestStore::default(); - let alice = alice(); - let mut thought = local_thought(alice.id.clone()); - thought.local = false; - let ap_repo = TestApRepo::new(store.clone()); - ap_repo.inner.thought_ap_ids.lock().unwrap().insert( - thought.id.clone(), - "https://mastodon.social/users/bob/statuses/456".into(), - ); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc_with_ap(&store, ap_repo, spy.clone()) - .process(&DomainEvent::BoostRemoved { - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - }) - .await - .unwrap(); - - let undo_announced = spy.undo_announced.lock().unwrap(); - assert_eq!(undo_announced.len(), 1); - assert_eq!( - undo_announced[0], - "https://mastodon.social/users/bob/statuses/456" - ); - } - - #[tokio::test] - async fn boost_removed_does_not_broadcast_if_thought_missing() { - let store = TestStore::default(); - let alice = alice(); - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::BoostRemoved { - user_id: alice.id.clone(), - thought_id: ThoughtId::new(), // doesn't exist in store - }) - .await - .unwrap(); - assert!(spy.undo_announced.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn thought_updated_does_not_broadcast_if_user_missing() { - let store = TestStore::default(); - let alice = alice(); - let thought = local_thought(alice.id.clone()); - // Don't push alice into users - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::ThoughtUpdated { - thought_id: thought.id.clone(), - user_id: alice.id.clone(), - }) - .await - .unwrap(); - - assert!(spy.updated.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn like_added_local_user_remote_thought_broadcasts_like() { - let store = TestStore::default(); - - let mut author = User::new_local( - UserId::new(), - Username::new("remote_author").unwrap(), - Email::new("r@remote.example").unwrap(), - PasswordHash("h".into()), - ); - author.local = false; - let thought = local_thought(author.id.clone()); - let liker = alice(); - - store.users.lock().unwrap().push(author.clone()); - store.users.lock().unwrap().push(liker.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let ap_repo = TestApRepo::new(store.clone()); - ap_repo.actor_ap_urls.lock().unwrap().insert( - author.id.clone(), - ActorApUrls { - ap_id: "https://mastodon.social/users/author".into(), - inbox_url: "https://mastodon.social/users/author/inbox".into(), - }, - ); - ap_repo.inner.thought_ap_ids.lock().unwrap().insert( - thought.id.clone(), - "https://mastodon.social/posts/123".into(), - ); - - let spy = Arc::new(SpyPort::default()); - svc_with_ap(&store, ap_repo, spy.clone()) - .process(&DomainEvent::LikeAdded { - like_id: LikeId::new(), - user_id: liker.id, - thought_id: thought.id, - }) - .await - .unwrap(); - - assert_eq!(spy.liked.lock().unwrap().len(), 1); - } - - #[tokio::test] - async fn like_added_remote_user_skips_broadcast() { - let store = TestStore::default(); - - let author = alice(); - let thought = local_thought(author.id.clone()); // local thought — no ap_id - - let mut remote_liker = User::new_local( - UserId::new(), - Username::new("bob").unwrap(), - Email::new("bob@remote").unwrap(), - PasswordHash("h".into()), - ); - remote_liker.local = false; - - store.users.lock().unwrap().push(author); - store.users.lock().unwrap().push(remote_liker.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::LikeAdded { - like_id: LikeId::new(), - user_id: remote_liker.id, - thought_id: thought.id, - }) - .await - .unwrap(); - - assert!(spy.liked.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn boost_added_remote_user_skips_broadcast() { - let store = TestStore::default(); - - let author = alice(); - let thought = local_thought(author.id.clone()); - - let mut remote_booster = User::new_local( - UserId::new(), - Username::new("bob").unwrap(), - Email::new("bob@remote").unwrap(), - PasswordHash("h".into()), - ); - remote_booster.local = false; - - store.users.lock().unwrap().push(author); - store.users.lock().unwrap().push(remote_booster.clone()); - store.thoughts.lock().unwrap().push(thought.clone()); - - let spy = Arc::new(SpyPort::default()); - svc(&store, spy.clone()) - .process(&DomainEvent::BoostAdded { - boost_id: BoostId::new(), - user_id: remote_booster.id, - thought_id: thought.id, - }) - .await - .unwrap(); - - assert!(spy.announced.lock().unwrap().is_empty()); - } -} diff --git a/crates/application/src/services/federation_event/mod.rs b/crates/application/src/services/federation_event/mod.rs new file mode 100644 index 0000000..b6ab6c1 --- /dev/null +++ b/crates/application/src/services/federation_event/mod.rs @@ -0,0 +1,214 @@ +use activitypub_base::{ActivityPubRepository, OutboundFederationPort}; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::thought::Visibility, + ports::{ThoughtRepository, UserReader}, + value_objects::ThoughtId, +}; +use std::sync::Arc; + +pub struct FederationEventService { + pub thoughts: Arc, + pub users: Arc, + pub ap: Arc, + pub base_url: String, + pub ap_repo: Arc, +} + +impl FederationEventService { + async fn object_ap_id(&self, thought_id: &ThoughtId) -> Result { + if let Some(ap_id) = self.ap_repo.get_thought_ap_id(thought_id).await? { + return Ok(ap_id); + } + Ok(format!("{}/thoughts/{}", self.base_url, thought_id)) + } + + pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { + match event { + DomainEvent::ThoughtCreated { + thought_id, + user_id, + .. + } => { + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) + if t.local + && matches!( + t.visibility, + Visibility::Public | Visibility::Unlisted + ) => + { + t + } + _ => return Ok(()), + }; + let user = match self.users.find_by_id(user_id).await? { + Some(u) => u, + None => return Ok(()), + }; + // Resolve in_reply_to_url for the parent thought via AP repo. + let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id { + let ap_id = self + .ap_repo + .get_thought_ap_id(reply_id) + .await? + .unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, reply_id)); + Some(ap_id) + } else { + None + }; + self.ap + .broadcast_create( + user_id, + &thought, + user.username.as_str(), + in_reply_to_url.as_deref(), + ) + .await + } + + DomainEvent::ThoughtDeleted { + thought_id, + user_id, + } => { + // No DB lookup — thought is already deleted when this event fires. + // No locality guard: delete commands only reach local thoughts via the use case. + let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id); + self.ap.broadcast_delete(user_id, &ap_id).await + } + + DomainEvent::ThoughtUpdated { + thought_id, + user_id, + } => { + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) + if t.local + && matches!( + t.visibility, + Visibility::Public | Visibility::Unlisted + ) => + { + t + } + _ => return Ok(()), + }; + let user = match self.users.find_by_id(user_id).await? { + Some(u) => u, + None => return Ok(()), + }; + let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id { + self.ap_repo + .get_thought_ap_id(reply_id) + .await? + .or_else(|| Some(format!("{}/thoughts/{}", self.base_url, reply_id))) + } else { + None + }; + self.ap + .broadcast_update( + user_id, + &thought, + user.username.as_str(), + in_reply_to_url.as_deref(), + ) + .await + } + + DomainEvent::BoostAdded { + boost_id: _, + user_id, + thought_id, + } => { + // Only fan-out if the booster is a local user. Remote boosts must not be re-broadcast. + let booster = match self.users.find_by_id(user_id).await? { + Some(u) if u.local => u, + _ => return Ok(()), + }; + let _ = booster; + if self.thoughts.find_by_id(thought_id).await?.is_none() { + return Ok(()); + } + let object_ap_id = self.object_ap_id(thought_id).await?; + self.ap.broadcast_announce(user_id, &object_ap_id).await + } + + DomainEvent::BoostRemoved { + user_id, + thought_id, + } => { + if self.thoughts.find_by_id(thought_id).await?.is_none() { + return Ok(()); + } + let object_ap_id = self.object_ap_id(thought_id).await?; + self.ap + .broadcast_undo_announce(user_id, &object_ap_id) + .await + } + + DomainEvent::LikeAdded { + like_id: _, + user_id, + thought_id, + } => { + // Only federate: local liker + remote thought (has ap_id) + author has inbox. + let liker = match self.users.find_by_id(user_id).await? { + Some(u) if u.local => u, + _ => return Ok(()), + }; + let _ = liker; + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) => t, + _ => return Ok(()), + }; + let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? { + Some(id) => id, + None => return Ok(()), // local thought — no federation needed + }; + let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? { + Some(u) => u, + None => return Ok(()), + }; + self.ap + .broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url) + .await + } + + DomainEvent::LikeRemoved { + user_id, + thought_id, + } => { + let liker = match self.users.find_by_id(user_id).await? { + Some(u) if u.local => u, + _ => return Ok(()), + }; + let _ = liker; + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) => t, + _ => return Ok(()), + }; + let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? { + Some(id) => id, + None => return Ok(()), + }; + let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? { + Some(u) => u, + None => return Ok(()), + }; + self.ap + .broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url) + .await + } + + DomainEvent::ProfileUpdated { user_id } => { + self.ap.broadcast_actor_update(user_id).await + } + + _ => Ok(()), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/application/src/services/federation_event/tests.rs b/crates/application/src/services/federation_event/tests.rs new file mode 100644 index 0000000..9a407d8 --- /dev/null +++ b/crates/application/src/services/federation_event/tests.rs @@ -0,0 +1,572 @@ +use super::*; +use activitypub_base::{ActorApUrls, OutboundFederationPort}; +use async_trait::async_trait; +use crate::testing::TestApRepo; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::thought::{Thought, Visibility}, + models::user::User, + testing::TestStore, + value_objects::*, +}; +use std::sync::{Arc, Mutex}; + +// ── Spy port ───────────────────────────────────────────────────────────── + +#[derive(Default)] +struct SpyPort { + created: Mutex>, + deleted: Mutex>, + updated: Mutex>, + announced: Mutex>, + undo_announced: Mutex>, + liked: Mutex>, + undo_liked: Mutex>, + actor_updated: Mutex>, +} + +#[async_trait] +impl OutboundFederationPort for SpyPort { + async fn broadcast_create( + &self, + _: &UserId, + thought: &Thought, + _: &str, + _in_reply_to_url: Option<&str>, + ) -> Result<(), DomainError> { + self.created.lock().unwrap().push(thought.id.clone()); + Ok(()) + } + async fn broadcast_delete(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> { + self.deleted.lock().unwrap().push(ap_id.to_string()); + Ok(()) + } + async fn broadcast_update( + &self, + _: &UserId, + thought: &Thought, + _: &str, + _in_reply_to_url: Option<&str>, + ) -> Result<(), DomainError> { + self.updated.lock().unwrap().push(thought.id.clone()); + Ok(()) + } + async fn broadcast_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> { + self.announced.lock().unwrap().push(ap_id.to_string()); + Ok(()) + } + async fn broadcast_undo_announce( + &self, + _: &UserId, + ap_id: &str, + ) -> Result<(), DomainError> { + self.undo_announced.lock().unwrap().push(ap_id.to_string()); + Ok(()) + } + + async fn broadcast_like( + &self, + _: &UserId, + ap_id: &str, + _: &str, + ) -> Result<(), DomainError> { + self.liked.lock().unwrap().push(ap_id.to_string()); + Ok(()) + } + + async fn broadcast_undo_like( + &self, + _: &UserId, + ap_id: &str, + _: &str, + ) -> Result<(), DomainError> { + self.undo_liked.lock().unwrap().push(ap_id.to_string()); + Ok(()) + } + + async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError> { + self.actor_updated.lock().unwrap().push(user_id.clone()); + Ok(()) + } +} + +fn alice() -> User { + User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ) +} + +fn local_thought(author_id: UserId) -> Thought { + Thought::new_local( + ThoughtId::new(), + author_id, + Content::new_local("hello").unwrap(), + None, + Visibility::Public, + None, + false, + ) +} + +fn svc(store: &TestStore, spy: Arc) -> FederationEventService { + let ap_repo = TestApRepo::new(store.clone()); + FederationEventService { + thoughts: Arc::new(store.clone()), + users: Arc::new(store.clone()), + ap: spy, + base_url: "https://example.com".to_string(), + ap_repo: Arc::new(ap_repo), + } +} + +fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc) -> FederationEventService { + FederationEventService { + thoughts: Arc::new(store.clone()), + users: Arc::new(store.clone()), + ap: spy, + base_url: "https://example.com".to_string(), + ap_repo: Arc::new(ap_repo), + } +} + +#[tokio::test] +async fn thought_created_broadcasts_create() { + let store = TestStore::default(); + let alice = alice(); + let thought = local_thought(alice.id.clone()); + store.users.lock().unwrap().push(alice.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtCreated { + thought_id: thought.id.clone(), + user_id: alice.id.clone(), + in_reply_to_id: None, + }) + .await + .unwrap(); + + assert_eq!(spy.created.lock().unwrap().len(), 1); + assert_eq!(spy.created.lock().unwrap()[0], thought.id); +} + +#[tokio::test] +async fn remote_thought_created_does_not_broadcast() { + let store = TestStore::default(); + let alice = alice(); + // Remote thought: local = false + let mut thought = local_thought(alice.id.clone()); + thought.local = false; + store.users.lock().unwrap().push(alice.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtCreated { + thought_id: thought.id.clone(), + user_id: alice.id.clone(), + in_reply_to_id: None, + }) + .await + .unwrap(); + + assert!(spy.created.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn thought_deleted_broadcasts_delete_with_constructed_ap_id() { + let store = TestStore::default(); + let alice = alice(); + let tid = ThoughtId::new(); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtDeleted { + thought_id: tid.clone(), + user_id: alice.id.clone(), + }) + .await + .unwrap(); + + let deleted = spy.deleted.lock().unwrap(); + assert_eq!(deleted.len(), 1); + assert_eq!(deleted[0], format!("https://example.com/thoughts/{}", tid)); +} + +#[tokio::test] +async fn thought_updated_broadcasts_update() { + let store = TestStore::default(); + let alice = alice(); + let thought = local_thought(alice.id.clone()); + store.users.lock().unwrap().push(alice.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtUpdated { + thought_id: thought.id.clone(), + user_id: alice.id.clone(), + }) + .await + .unwrap(); + + assert_eq!(spy.updated.lock().unwrap().len(), 1); + assert_eq!(spy.updated.lock().unwrap()[0], thought.id); +} + +#[tokio::test] +async fn boost_of_local_thought_announces_constructed_url() { + let store = TestStore::default(); + let alice = alice(); + let thought = local_thought(alice.id.clone()); // ap_id = None + store.users.lock().unwrap().push(alice.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::BoostAdded { + boost_id: BoostId::new(), + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }) + .await + .unwrap(); + + let announced = spy.announced.lock().unwrap(); + assert_eq!(announced.len(), 1); + assert_eq!( + announced[0], + format!("https://example.com/thoughts/{}", thought.id) + ); +} + +#[tokio::test] +async fn boost_of_remote_thought_announces_remote_ap_id() { + let store = TestStore::default(); + let alice = alice(); + let mut thought = local_thought(alice.id.clone()); + thought.local = false; + let ap_repo = TestApRepo::new(store.clone()); + ap_repo.inner.thought_ap_ids.lock().unwrap().insert( + thought.id.clone(), + "https://mastodon.social/users/bob/statuses/123".into(), + ); + store.users.lock().unwrap().push(alice.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc_with_ap(&store, ap_repo, spy.clone()) + .process(&DomainEvent::BoostAdded { + boost_id: BoostId::new(), + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }) + .await + .unwrap(); + + let announced = spy.announced.lock().unwrap(); + assert_eq!( + announced[0], + "https://mastodon.social/users/bob/statuses/123" + ); +} + +#[tokio::test] +async fn direct_thought_created_does_not_broadcast() { + let store = TestStore::default(); + let alice = alice(); + let thought = Thought::new_local( + ThoughtId::new(), + alice.id.clone(), + Content::new_local("private").unwrap(), + None, + Visibility::Direct, + None, + false, + ); + store.users.lock().unwrap().push(alice.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtCreated { + thought_id: thought.id.clone(), + user_id: alice.id.clone(), + in_reply_to_id: None, + }) + .await + .unwrap(); + + assert!(spy.created.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn followers_only_thought_does_not_broadcast_publicly() { + let store = TestStore::default(); + let alice = alice(); + let thought = Thought::new_local( + ThoughtId::new(), + alice.id.clone(), + Content::new_local("for followers").unwrap(), + None, + Visibility::Followers, + None, + false, + ); + store.users.lock().unwrap().push(alice.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtCreated { + thought_id: thought.id.clone(), + user_id: alice.id.clone(), + in_reply_to_id: None, + }) + .await + .unwrap(); + + assert!(spy.created.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn unrelated_events_are_noop() { + let store = TestStore::default(); + let spy = Arc::new(SpyPort::default()); + let svc = svc(&store, spy.clone()); + + svc.process(&DomainEvent::UserBlocked { + blocker_id: UserId::new(), + blocked_id: UserId::new(), + }) + .await + .unwrap(); + + assert!(spy.created.lock().unwrap().is_empty()); + assert!(spy.deleted.lock().unwrap().is_empty()); + assert!(spy.updated.lock().unwrap().is_empty()); + assert!(spy.announced.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn thought_created_does_not_broadcast_if_user_missing() { + let store = TestStore::default(); + let alice = alice(); + let thought = local_thought(alice.id.clone()); + // Don't push alice into users — simulates user deleted before handler runs + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtCreated { + thought_id: thought.id.clone(), + user_id: alice.id.clone(), + in_reply_to_id: None, + }) + .await + .unwrap(); + + assert!(spy.created.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn boost_removed_sends_undo_announce_for_local_thought() { + let store = TestStore::default(); + let alice = alice(); + let thought = local_thought(alice.id.clone()); // ap_id = None → constructed URL + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::BoostRemoved { + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }) + .await + .unwrap(); + + let undo_announced = spy.undo_announced.lock().unwrap(); + assert_eq!(undo_announced.len(), 1); + assert_eq!( + undo_announced[0], + format!("https://example.com/thoughts/{}", thought.id) + ); +} + +#[tokio::test] +async fn boost_removed_sends_undo_announce_for_remote_thought() { + let store = TestStore::default(); + let alice = alice(); + let mut thought = local_thought(alice.id.clone()); + thought.local = false; + let ap_repo = TestApRepo::new(store.clone()); + ap_repo.inner.thought_ap_ids.lock().unwrap().insert( + thought.id.clone(), + "https://mastodon.social/users/bob/statuses/456".into(), + ); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc_with_ap(&store, ap_repo, spy.clone()) + .process(&DomainEvent::BoostRemoved { + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }) + .await + .unwrap(); + + let undo_announced = spy.undo_announced.lock().unwrap(); + assert_eq!(undo_announced.len(), 1); + assert_eq!( + undo_announced[0], + "https://mastodon.social/users/bob/statuses/456" + ); +} + +#[tokio::test] +async fn boost_removed_does_not_broadcast_if_thought_missing() { + let store = TestStore::default(); + let alice = alice(); + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::BoostRemoved { + user_id: alice.id.clone(), + thought_id: ThoughtId::new(), // doesn't exist in store + }) + .await + .unwrap(); + assert!(spy.undo_announced.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn thought_updated_does_not_broadcast_if_user_missing() { + let store = TestStore::default(); + let alice = alice(); + let thought = local_thought(alice.id.clone()); + // Don't push alice into users + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::ThoughtUpdated { + thought_id: thought.id.clone(), + user_id: alice.id.clone(), + }) + .await + .unwrap(); + + assert!(spy.updated.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn like_added_local_user_remote_thought_broadcasts_like() { + let store = TestStore::default(); + + let mut author = User::new_local( + UserId::new(), + Username::new("remote_author").unwrap(), + Email::new("r@remote.example").unwrap(), + PasswordHash("h".into()), + ); + author.local = false; + let thought = local_thought(author.id.clone()); + let liker = alice(); + + store.users.lock().unwrap().push(author.clone()); + store.users.lock().unwrap().push(liker.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let ap_repo = TestApRepo::new(store.clone()); + ap_repo.actor_ap_urls.lock().unwrap().insert( + author.id.clone(), + ActorApUrls { + ap_id: "https://mastodon.social/users/author".into(), + inbox_url: "https://mastodon.social/users/author/inbox".into(), + }, + ); + ap_repo.inner.thought_ap_ids.lock().unwrap().insert( + thought.id.clone(), + "https://mastodon.social/posts/123".into(), + ); + + let spy = Arc::new(SpyPort::default()); + svc_with_ap(&store, ap_repo, spy.clone()) + .process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: liker.id, + thought_id: thought.id, + }) + .await + .unwrap(); + + assert_eq!(spy.liked.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn like_added_remote_user_skips_broadcast() { + let store = TestStore::default(); + + let author = alice(); + let thought = local_thought(author.id.clone()); // local thought — no ap_id + + let mut remote_liker = User::new_local( + UserId::new(), + Username::new("bob").unwrap(), + Email::new("bob@remote").unwrap(), + PasswordHash("h".into()), + ); + remote_liker.local = false; + + store.users.lock().unwrap().push(author); + store.users.lock().unwrap().push(remote_liker.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: remote_liker.id, + thought_id: thought.id, + }) + .await + .unwrap(); + + assert!(spy.liked.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn boost_added_remote_user_skips_broadcast() { + let store = TestStore::default(); + + let author = alice(); + let thought = local_thought(author.id.clone()); + + let mut remote_booster = User::new_local( + UserId::new(), + Username::new("bob").unwrap(), + Email::new("bob@remote").unwrap(), + PasswordHash("h".into()), + ); + remote_booster.local = false; + + store.users.lock().unwrap().push(author); + store.users.lock().unwrap().push(remote_booster.clone()); + store.thoughts.lock().unwrap().push(thought.clone()); + + let spy = Arc::new(SpyPort::default()); + svc(&store, spy.clone()) + .process(&DomainEvent::BoostAdded { + boost_id: BoostId::new(), + user_id: remote_booster.id, + thought_id: thought.id, + }) + .await + .unwrap(); + + assert!(spy.announced.lock().unwrap().is_empty()); +} diff --git a/crates/application/src/services/notification_event.rs b/crates/application/src/services/notification_event.rs deleted file mode 100644 index fc4992d..0000000 --- a/crates/application/src/services/notification_event.rs +++ /dev/null @@ -1,332 +0,0 @@ -use chrono::Utc; -use domain::{ - errors::DomainError, - events::DomainEvent, - models::notification::{Notification, NotificationKind}, - ports::{NotificationRepository, ThoughtRepository}, - value_objects::NotificationId, -}; -use std::sync::Arc; - -pub struct NotificationEventService { - pub thoughts: Arc, - pub notifications: Arc, -} - -fn is_self_action( - thought_author: &domain::value_objects::UserId, - actor: &domain::value_objects::UserId, -) -> bool { - thought_author == actor -} - -impl NotificationEventService { - pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { - match event { - DomainEvent::LikeAdded { - like_id: _, - user_id, - thought_id, - } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) => t, - None => return Ok(()), - }; - if is_self_action(&thought.user_id, user_id) { - return Ok(()); - } - self.notifications - .save(&Notification { - id: NotificationId::new(), - user_id: thought.user_id, - kind: NotificationKind::Like { - thought_id: thought_id.clone(), - from_user_id: user_id.clone(), - }, - read: false, - created_at: Utc::now(), - }) - .await - } - DomainEvent::BoostAdded { - boost_id: _, - user_id, - thought_id, - } => { - let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) => t, - None => return Ok(()), - }; - if is_self_action(&thought.user_id, user_id) { - return Ok(()); - } - self.notifications - .save(&Notification { - id: NotificationId::new(), - user_id: thought.user_id, - kind: NotificationKind::Boost { - thought_id: thought_id.clone(), - from_user_id: user_id.clone(), - }, - read: false, - created_at: Utc::now(), - }) - .await - } - DomainEvent::FollowAccepted { - follower_id, - following_id, - } => { - self.notifications - .save(&Notification { - id: NotificationId::new(), - user_id: following_id.clone(), - kind: NotificationKind::Follow { - from_user_id: follower_id.clone(), - }, - read: false, - created_at: Utc::now(), - }) - .await - } - DomainEvent::ThoughtCreated { - thought_id, - user_id, - in_reply_to_id, - } => { - let reply_to_id = match in_reply_to_id { - Some(id) => id, - None => return Ok(()), - }; - let original = match self.thoughts.find_by_id(reply_to_id).await? { - Some(t) => t, - None => return Ok(()), - }; - if is_self_action(&original.user_id, user_id) { - return Ok(()); - } - self.notifications - .save(&Notification { - id: NotificationId::new(), - user_id: original.user_id, - kind: NotificationKind::Reply { - thought_id: thought_id.clone(), - from_user_id: user_id.clone(), - }, - read: false, - created_at: Utc::now(), - }) - .await - } - DomainEvent::MentionReceived { - thought_id, - mentioned_user_id, - author_user_id, - } => { - self.notifications - .save(&Notification { - id: NotificationId::new(), - user_id: mentioned_user_id.clone(), - kind: NotificationKind::Mention { - thought_id: thought_id.clone(), - from_user_id: author_user_id.clone(), - }, - read: false, - created_at: Utc::now(), - }) - .await - } - _ => Ok(()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use domain::{ - models::{ - notification::NotificationKind, - thought::{Thought, Visibility}, - user::User, - }, - testing::TestStore, - value_objects::*, - }; - use std::sync::Arc; - - fn alice() -> User { - User::new_local( - UserId::new(), - Username::new("alice").unwrap(), - Email::new("alice@ex.com").unwrap(), - PasswordHash("h".into()), - ) - } - - #[tokio::test] - async fn like_creates_notification_for_thought_author() { - let store = TestStore::default(); - let alice = alice(); - let bob_id = UserId::new(); - let thought = Thought::new_local( - ThoughtId::new(), - alice.id.clone(), - Content::new_local("hello").unwrap(), - None, - Visibility::Public, - None, - false, - ); - store.thoughts.lock().unwrap().push(thought.clone()); - let svc = NotificationEventService { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - svc.process(&DomainEvent::LikeAdded { - like_id: LikeId::new(), - user_id: bob_id, - thought_id: thought.id.clone(), - }) - .await - .unwrap(); - let notifs = store.notifications.lock().unwrap(); - assert_eq!(notifs.len(), 1); - assert!(matches!(notifs[0].kind, NotificationKind::Like { .. })); - } - - #[tokio::test] - async fn self_like_creates_no_notification() { - let store = TestStore::default(); - let alice = alice(); - let thought = Thought::new_local( - ThoughtId::new(), - alice.id.clone(), - Content::new_local("hello").unwrap(), - None, - Visibility::Public, - None, - false, - ); - store.thoughts.lock().unwrap().push(thought.clone()); - let svc = NotificationEventService { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - svc.process(&DomainEvent::LikeAdded { - like_id: LikeId::new(), - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - }) - .await - .unwrap(); - assert!(store.notifications.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn follow_accepted_creates_notification() { - let store = TestStore::default(); - let alice = alice(); - let bob_id = UserId::new(); - let svc = NotificationEventService { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - svc.process(&DomainEvent::FollowAccepted { - follower_id: bob_id, - following_id: alice.id.clone(), - }) - .await - .unwrap(); - let notifs = store.notifications.lock().unwrap(); - assert_eq!(notifs.len(), 1); - assert!(matches!(notifs[0].kind, NotificationKind::Follow { .. })); - } - - #[tokio::test] - async fn reply_creates_notification_for_original_author() { - let store = TestStore::default(); - let alice = alice(); - let bob_id = UserId::new(); - let original = Thought::new_local( - ThoughtId::new(), - alice.id.clone(), - Content::new_local("original").unwrap(), - None, - Visibility::Public, - None, - false, - ); - store.thoughts.lock().unwrap().push(original.clone()); - let svc = NotificationEventService { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - svc.process(&DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: bob_id, - in_reply_to_id: Some(original.id.clone()), - }) - .await - .unwrap(); - let notifs = store.notifications.lock().unwrap(); - assert_eq!(notifs.len(), 1); - assert!(matches!(notifs[0].kind, NotificationKind::Reply { .. })); - } - - #[tokio::test] - async fn self_reply_creates_no_notification() { - let store = TestStore::default(); - let alice = alice(); - let original = Thought::new_local( - ThoughtId::new(), - alice.id.clone(), - Content::new_local("original").unwrap(), - None, - Visibility::Public, - None, - false, - ); - store.thoughts.lock().unwrap().push(original.clone()); - let svc = NotificationEventService { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - svc.process(&DomainEvent::ThoughtCreated { - thought_id: ThoughtId::new(), - user_id: alice.id.clone(), - in_reply_to_id: Some(original.id.clone()), - }) - .await - .unwrap(); - assert!(store.notifications.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn self_boost_creates_no_notification() { - let store = TestStore::default(); - let alice = alice(); - let thought = Thought::new_local( - ThoughtId::new(), - alice.id.clone(), - Content::new_local("hello").unwrap(), - None, - Visibility::Public, - None, - false, - ); - store.thoughts.lock().unwrap().push(thought.clone()); - let svc = NotificationEventService { - thoughts: Arc::new(store.clone()), - notifications: Arc::new(store.clone()), - }; - svc.process(&DomainEvent::BoostAdded { - boost_id: BoostId::new(), - user_id: alice.id.clone(), - thought_id: thought.id.clone(), - }) - .await - .unwrap(); - assert!(store.notifications.lock().unwrap().is_empty()); - } -} diff --git a/crates/application/src/services/notification_event/mod.rs b/crates/application/src/services/notification_event/mod.rs new file mode 100644 index 0000000..53011e1 --- /dev/null +++ b/crates/application/src/services/notification_event/mod.rs @@ -0,0 +1,145 @@ +use chrono::Utc; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::notification::{Notification, NotificationKind}, + ports::{NotificationRepository, ThoughtRepository}, + value_objects::NotificationId, +}; +use std::sync::Arc; + +pub struct NotificationEventService { + pub thoughts: Arc, + pub notifications: Arc, +} + +fn is_self_action( + thought_author: &domain::value_objects::UserId, + actor: &domain::value_objects::UserId, +) -> bool { + thought_author == actor +} + +impl NotificationEventService { + pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { + match event { + DomainEvent::LikeAdded { + like_id: _, + user_id, + thought_id, + } => { + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) => t, + None => return Ok(()), + }; + if is_self_action(&thought.user_id, user_id) { + return Ok(()); + } + self.notifications + .save(&Notification { + id: NotificationId::new(), + user_id: thought.user_id, + kind: NotificationKind::Like { + thought_id: thought_id.clone(), + from_user_id: user_id.clone(), + }, + read: false, + created_at: Utc::now(), + }) + .await + } + DomainEvent::BoostAdded { + boost_id: _, + user_id, + thought_id, + } => { + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) => t, + None => return Ok(()), + }; + if is_self_action(&thought.user_id, user_id) { + return Ok(()); + } + self.notifications + .save(&Notification { + id: NotificationId::new(), + user_id: thought.user_id, + kind: NotificationKind::Boost { + thought_id: thought_id.clone(), + from_user_id: user_id.clone(), + }, + read: false, + created_at: Utc::now(), + }) + .await + } + DomainEvent::FollowAccepted { + follower_id, + following_id, + } => { + self.notifications + .save(&Notification { + id: NotificationId::new(), + user_id: following_id.clone(), + kind: NotificationKind::Follow { + from_user_id: follower_id.clone(), + }, + read: false, + created_at: Utc::now(), + }) + .await + } + DomainEvent::ThoughtCreated { + thought_id, + user_id, + in_reply_to_id, + } => { + let reply_to_id = match in_reply_to_id { + Some(id) => id, + None => return Ok(()), + }; + let original = match self.thoughts.find_by_id(reply_to_id).await? { + Some(t) => t, + None => return Ok(()), + }; + if is_self_action(&original.user_id, user_id) { + return Ok(()); + } + self.notifications + .save(&Notification { + id: NotificationId::new(), + user_id: original.user_id, + kind: NotificationKind::Reply { + thought_id: thought_id.clone(), + from_user_id: user_id.clone(), + }, + read: false, + created_at: Utc::now(), + }) + .await + } + DomainEvent::MentionReceived { + thought_id, + mentioned_user_id, + author_user_id, + } => { + self.notifications + .save(&Notification { + id: NotificationId::new(), + user_id: mentioned_user_id.clone(), + kind: NotificationKind::Mention { + thought_id: thought_id.clone(), + from_user_id: author_user_id.clone(), + }, + read: false, + created_at: Utc::now(), + }) + .await + } + _ => Ok(()), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/application/src/services/notification_event/tests.rs b/crates/application/src/services/notification_event/tests.rs new file mode 100644 index 0000000..15658da --- /dev/null +++ b/crates/application/src/services/notification_event/tests.rs @@ -0,0 +1,186 @@ +use super::*; +use domain::{ + models::{ + notification::NotificationKind, + thought::{Thought, Visibility}, + user::User, + }, + testing::TestStore, + value_objects::*, +}; +use std::sync::Arc; + +fn alice() -> User { + User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ) +} + +#[tokio::test] +async fn like_creates_notification_for_thought_author() { + let store = TestStore::default(); + let alice = alice(); + let bob_id = UserId::new(); + let thought = Thought::new_local( + ThoughtId::new(), + alice.id.clone(), + Content::new_local("hello").unwrap(), + None, + Visibility::Public, + None, + false, + ); + store.thoughts.lock().unwrap().push(thought.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: bob_id, + thought_id: thought.id.clone(), + }) + .await + .unwrap(); + let notifs = store.notifications.lock().unwrap(); + assert_eq!(notifs.len(), 1); + assert!(matches!(notifs[0].kind, NotificationKind::Like { .. })); +} + +#[tokio::test] +async fn self_like_creates_no_notification() { + let store = TestStore::default(); + let alice = alice(); + let thought = Thought::new_local( + ThoughtId::new(), + alice.id.clone(), + Content::new_local("hello").unwrap(), + None, + Visibility::Public, + None, + false, + ); + store.thoughts.lock().unwrap().push(thought.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }) + .await + .unwrap(); + assert!(store.notifications.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn follow_accepted_creates_notification() { + let store = TestStore::default(); + let alice = alice(); + let bob_id = UserId::new(); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::FollowAccepted { + follower_id: bob_id, + following_id: alice.id.clone(), + }) + .await + .unwrap(); + let notifs = store.notifications.lock().unwrap(); + assert_eq!(notifs.len(), 1); + assert!(matches!(notifs[0].kind, NotificationKind::Follow { .. })); +} + +#[tokio::test] +async fn reply_creates_notification_for_original_author() { + let store = TestStore::default(); + let alice = alice(); + let bob_id = UserId::new(); + let original = Thought::new_local( + ThoughtId::new(), + alice.id.clone(), + Content::new_local("original").unwrap(), + None, + Visibility::Public, + None, + false, + ); + store.thoughts.lock().unwrap().push(original.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::ThoughtCreated { + thought_id: ThoughtId::new(), + user_id: bob_id, + in_reply_to_id: Some(original.id.clone()), + }) + .await + .unwrap(); + let notifs = store.notifications.lock().unwrap(); + assert_eq!(notifs.len(), 1); + assert!(matches!(notifs[0].kind, NotificationKind::Reply { .. })); +} + +#[tokio::test] +async fn self_reply_creates_no_notification() { + let store = TestStore::default(); + let alice = alice(); + let original = Thought::new_local( + ThoughtId::new(), + alice.id.clone(), + Content::new_local("original").unwrap(), + None, + Visibility::Public, + None, + false, + ); + store.thoughts.lock().unwrap().push(original.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::ThoughtCreated { + thought_id: ThoughtId::new(), + user_id: alice.id.clone(), + in_reply_to_id: Some(original.id.clone()), + }) + .await + .unwrap(); + assert!(store.notifications.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn self_boost_creates_no_notification() { + let store = TestStore::default(); + let alice = alice(); + let thought = Thought::new_local( + ThoughtId::new(), + alice.id.clone(), + Content::new_local("hello").unwrap(), + None, + Visibility::Public, + None, + false, + ); + store.thoughts.lock().unwrap().push(thought.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::BoostAdded { + boost_id: BoostId::new(), + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }) + .await + .unwrap(); + assert!(store.notifications.lock().unwrap().is_empty()); +} diff --git a/crates/application/src/use_cases/api_keys.rs b/crates/application/src/use_cases/api_keys.rs deleted file mode 100644 index 17e1716..0000000 --- a/crates/application/src/use_cases/api_keys.rs +++ /dev/null @@ -1,101 +0,0 @@ -use chrono::Utc; -use domain::{ - errors::DomainError, - models::api_key::ApiKey, - ports::ApiKeyRepository, - value_objects::{ApiKeyId, UserId}, -}; - -pub async fn list_api_keys( - keys: &dyn ApiKeyRepository, - user_id: &UserId, -) -> Result, DomainError> { - keys.list_for_user(user_id).await -} - -pub async fn create_api_key( - keys: &dyn ApiKeyRepository, - user_id: &UserId, - name: String, -) -> Result<(ApiKey, String), DomainError> { - let raw_key = uuid::Uuid::new_v4().to_string().replace('-', ""); - let key_hash = sha256_hex(&raw_key); - let key = ApiKey { - id: ApiKeyId::new(), - user_id: user_id.clone(), - key_hash, - name, - created_at: Utc::now(), - }; - keys.save(&key).await?; - Ok((key, raw_key)) -} - -pub async fn delete_api_key( - keys: &dyn ApiKeyRepository, - user_id: &UserId, - key_id: &ApiKeyId, -) -> Result<(), DomainError> { - keys.delete(key_id, user_id).await -} - -fn sha256_hex(s: &str) -> String { - use sha2::{Digest, Sha256}; - let hash = Sha256::digest(s.as_bytes()); - hex::encode(hash) -} - -#[cfg(test)] -mod tests { - use super::*; - use domain::{testing::TestStore, value_objects::UserId}; - - #[tokio::test] - async fn create_key_saves_hashed_not_raw() { - let store = TestStore::default(); - let uid = UserId::new(); - let (key, raw) = create_api_key(&store, &uid, "my-key".to_string()) - .await - .unwrap(); - assert_ne!(key.key_hash, raw, "stored hash must differ from raw key"); - assert!(!key.key_hash.is_empty()); - assert_eq!(key.name, "my-key"); - assert_eq!(key.user_id, uid); - assert_eq!(store.api_keys.lock().unwrap().len(), 1); - } - - #[tokio::test] - async fn raw_key_verifies_against_stored_hash() { - use sha2::{Digest, Sha256}; - let store = TestStore::default(); - let uid = UserId::new(); - let (key, raw) = create_api_key(&store, &uid, "test".to_string()) - .await - .unwrap(); - let expected_hash = hex::encode(Sha256::digest(raw.as_bytes())); - assert_eq!(key.key_hash, expected_hash); - } - - #[tokio::test] - async fn delete_key_removes_it() { - let store = TestStore::default(); - let uid = UserId::new(); - let (key, _) = create_api_key(&store, &uid, "k".to_string()).await.unwrap(); - delete_api_key(&store, &uid, &key.id).await.unwrap(); - assert!(store.api_keys.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn list_keys_returns_only_own_keys() { - let store = TestStore::default(); - let alice = UserId::new(); - let bob = UserId::new(); - create_api_key(&store, &alice, "a".to_string()) - .await - .unwrap(); - create_api_key(&store, &bob, "b".to_string()).await.unwrap(); - let alice_keys = list_api_keys(&store, &alice).await.unwrap(); - assert_eq!(alice_keys.len(), 1); - assert_eq!(alice_keys[0].user_id, alice); - } -} diff --git a/crates/application/src/use_cases/api_keys/mod.rs b/crates/application/src/use_cases/api_keys/mod.rs new file mode 100644 index 0000000..b83639e --- /dev/null +++ b/crates/application/src/use_cases/api_keys/mod.rs @@ -0,0 +1,49 @@ +use chrono::Utc; +use domain::{ + errors::DomainError, + models::api_key::ApiKey, + ports::ApiKeyRepository, + value_objects::{ApiKeyId, UserId}, +}; + +pub async fn list_api_keys( + keys: &dyn ApiKeyRepository, + user_id: &UserId, +) -> Result, DomainError> { + keys.list_for_user(user_id).await +} + +pub async fn create_api_key( + keys: &dyn ApiKeyRepository, + user_id: &UserId, + name: String, +) -> Result<(ApiKey, String), DomainError> { + let raw_key = uuid::Uuid::new_v4().to_string().replace('-', ""); + let key_hash = sha256_hex(&raw_key); + let key = ApiKey { + id: ApiKeyId::new(), + user_id: user_id.clone(), + key_hash, + name, + created_at: Utc::now(), + }; + keys.save(&key).await?; + Ok((key, raw_key)) +} + +pub async fn delete_api_key( + keys: &dyn ApiKeyRepository, + user_id: &UserId, + key_id: &ApiKeyId, +) -> Result<(), DomainError> { + keys.delete(key_id, user_id).await +} + +fn sha256_hex(s: &str) -> String { + use sha2::{Digest, Sha256}; + let hash = Sha256::digest(s.as_bytes()); + hex::encode(hash) +} + +#[cfg(test)] +mod tests; diff --git a/crates/application/src/use_cases/api_keys/tests.rs b/crates/application/src/use_cases/api_keys/tests.rs new file mode 100644 index 0000000..d5e1afe --- /dev/null +++ b/crates/application/src/use_cases/api_keys/tests.rs @@ -0,0 +1,51 @@ +use super::*; +use domain::{testing::TestStore, value_objects::UserId}; + +#[tokio::test] +async fn create_key_saves_hashed_not_raw() { + let store = TestStore::default(); + let uid = UserId::new(); + let (key, raw) = create_api_key(&store, &uid, "my-key".to_string()) + .await + .unwrap(); + assert_ne!(key.key_hash, raw, "stored hash must differ from raw key"); + assert!(!key.key_hash.is_empty()); + assert_eq!(key.name, "my-key"); + assert_eq!(key.user_id, uid); + assert_eq!(store.api_keys.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn raw_key_verifies_against_stored_hash() { + use sha2::{Digest, Sha256}; + let store = TestStore::default(); + let uid = UserId::new(); + let (key, raw) = create_api_key(&store, &uid, "test".to_string()) + .await + .unwrap(); + let expected_hash = hex::encode(Sha256::digest(raw.as_bytes())); + assert_eq!(key.key_hash, expected_hash); +} + +#[tokio::test] +async fn delete_key_removes_it() { + let store = TestStore::default(); + let uid = UserId::new(); + let (key, _) = create_api_key(&store, &uid, "k".to_string()).await.unwrap(); + delete_api_key(&store, &uid, &key.id).await.unwrap(); + assert!(store.api_keys.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn list_keys_returns_only_own_keys() { + let store = TestStore::default(); + let alice = UserId::new(); + let bob = UserId::new(); + create_api_key(&store, &alice, "a".to_string()) + .await + .unwrap(); + create_api_key(&store, &bob, "b".to_string()).await.unwrap(); + let alice_keys = list_api_keys(&store, &alice).await.unwrap(); + assert_eq!(alice_keys.len(), 1); + assert_eq!(alice_keys[0].user_id, alice); +} diff --git a/crates/application/src/use_cases/auth.rs b/crates/application/src/use_cases/auth.rs deleted file mode 100644 index 54d50dd..0000000 --- a/crates/application/src/use_cases/auth.rs +++ /dev/null @@ -1,388 +0,0 @@ -use domain::{ - errors::DomainError, - events::DomainEvent, - models::user::User, - ports::{AuthService, EventPublisher, PasswordHasher, UserReader, UserRepository}, - value_objects::{Email, UserId, Username}, -}; - -pub struct RegisterInput { - pub username: String, - pub email: String, - pub password: String, -} -#[derive(Debug)] -pub struct RegisterOutput { - pub user: User, - pub token: String, -} - -pub async fn register( - users: &dyn UserRepository, - hasher: &dyn PasswordHasher, - auth: &dyn AuthService, - events: &dyn EventPublisher, - input: RegisterInput, -) -> Result { - let username = Username::new(input.username)?; - let email = Email::new(input.email)?; - if users.find_by_username(&username).await?.is_some() { - return Err(DomainError::Conflict("username taken".into())); - } - if users.find_by_email(&email).await?.is_some() { - return Err(DomainError::Conflict("email taken".into())); - } - let hash = hasher.hash(&input.password).await?; - let user = User::new_local(UserId::new(), username, email, hash); - users - .save(&user) - .await - .map_err(|e| match e { - DomainError::UniqueViolation { field: "username" } => { - DomainError::Conflict("username taken".into()) - } - DomainError::UniqueViolation { field: "email" } => { - DomainError::Conflict("email taken".into()) - } - DomainError::UniqueViolation { .. } => { - DomainError::Conflict("already exists".into()) - } - other => other, - })?; - events - .publish(&DomainEvent::UserRegistered { - user_id: user.id.clone(), - }) - .await?; - let token = auth.generate_token(&user.id)?; - Ok(RegisterOutput { - user, - token: token.token, - }) -} - -pub struct LoginInput { - pub email: String, - pub password: String, -} -#[derive(Debug)] -pub struct LoginOutput { - pub user: User, - pub token: String, -} - -pub async fn login( - users: &dyn UserReader, - hasher: &dyn PasswordHasher, - auth: &dyn AuthService, - input: LoginInput, -) -> Result { - let email = Email::new(input.email)?; - let user = users.find_by_email(&email).await?; - if user.is_none() { - // Timing equalization — prevents email enumeration via response-time oracle. - // Running the hasher on a miss makes "no such user" take the same time as - // "wrong password", so attackers cannot distinguish the two cases. - let _ = hasher.hash(&input.password).await; - return Err(DomainError::Unauthorized); - } - let user = user.unwrap(); - if !hasher.verify(&input.password, &user.password_hash).await? { - return Err(DomainError::Unauthorized); - } - let token = auth.generate_token(&user.id)?; - Ok(LoginOutput { - user, - token: token.token, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use domain::{ - errors::DomainError, - events::DomainEvent, - models::{feed::{PageParams, Paginated, UserSummary}, user::User}, - ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter}, - testing::{NoOpEventPublisher, TestStore}, - value_objects::{Email, PasswordHash, UserId, Username}, - }; - - /// Simulates a concurrent registration that slips past the pre-checks and - /// hits the DB unique constraint — exactly what happens in the TOCTOU window. - struct ConflictOnSaveStore(TestStore); - struct EmailConflictOnSaveStore(TestStore); - - #[async_trait] - impl UserReader for ConflictOnSaveStore { - async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { - self.0.find_by_id(id).await - } - async fn find_by_username( - &self, - username: &Username, - ) -> Result, DomainError> { - self.0.find_by_username(username).await - } - async fn find_by_email(&self, email: &Email) -> Result, DomainError> { - self.0.find_by_email(email).await - } - async fn list_with_stats(&self) -> Result, DomainError> { - self.0.list_with_stats().await - } - async fn count(&self) -> Result { - self.0.count().await - } - async fn list_paginated(&self, page: PageParams) -> Result, DomainError> { - self.0.list_paginated(page).await - } - async fn find_by_ids(&self, ids: &[UserId]) -> Result, DomainError> { - self.0.find_by_ids(ids).await - } - } - - #[async_trait] - impl UserWriter for ConflictOnSaveStore { - async fn save(&self, _user: &User) -> Result<(), DomainError> { - Err(DomainError::UniqueViolation { field: "username" }) - } - async fn update_profile( - &self, - user_id: &UserId, - display_name: Option, - bio: Option, - avatar_url: Option, - header_url: Option, - custom_css: Option, - ) -> Result<(), DomainError> { - self.0 - .update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css) - .await - } - } - - #[async_trait] - impl UserReader for EmailConflictOnSaveStore { - async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { - self.0.find_by_id(id).await - } - async fn find_by_username( - &self, - username: &Username, - ) -> Result, DomainError> { - self.0.find_by_username(username).await - } - async fn find_by_email(&self, email: &Email) -> Result, DomainError> { - self.0.find_by_email(email).await - } - async fn list_with_stats(&self) -> Result, DomainError> { - self.0.list_with_stats().await - } - async fn count(&self) -> Result { - self.0.count().await - } - async fn list_paginated(&self, page: PageParams) -> Result, DomainError> { - self.0.list_paginated(page).await - } - async fn find_by_ids(&self, ids: &[UserId]) -> Result, DomainError> { - self.0.find_by_ids(ids).await - } - } - - #[async_trait] - impl UserWriter for EmailConflictOnSaveStore { - async fn save(&self, _user: &User) -> Result<(), DomainError> { - Err(DomainError::UniqueViolation { field: "email" }) - } - async fn update_profile( - &self, - user_id: &UserId, - display_name: Option, - bio: Option, - avatar_url: Option, - header_url: Option, - custom_css: Option, - ) -> Result<(), DomainError> { - self.0 - .update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css) - .await - } - } - - struct FakeHasher; - #[async_trait] - impl PasswordHasher for FakeHasher { - async fn hash(&self, plain: &str) -> Result { - 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)); - } - - #[tokio::test] - async fn register_publishes_user_registered_event() { - let store = TestStore::default(); - register(&store, &FakeHasher, &FakeAuth, &store, input()) - .await - .unwrap(); - let events = store.events.lock().unwrap(); - assert_eq!(events.len(), 1); - assert!(matches!(events[0], DomainEvent::UserRegistered { .. })); - } - - #[tokio::test] - async fn login_fails_for_nonexistent_user() { - let store = TestStore::default(); - let err = login( - &store, - &FakeHasher, - &FakeAuth, - LoginInput { - email: "ghost@ex.com".into(), - password: "pass".into(), - }, - ) - .await - .unwrap_err(); - assert!(matches!(err, DomainError::Unauthorized)); - } - - #[tokio::test] - async fn register_rejects_duplicate_email() { - let store = TestStore::default(); - register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) - .await - .unwrap(); - let err = register( - &store, - &FakeHasher, - &FakeAuth, - &NoOpEventPublisher, - RegisterInput { - username: "alice2".into(), - email: "alice@ex.com".into(), - password: "pass2".into(), - }, - ) - .await - .unwrap_err(); - assert!(matches!(err, DomainError::Conflict(_))); - } - - /// TOCTOU: a concurrent registration slips past the pre-checks and the DB - /// unique constraint fires on save. The map_err must convert it to a - /// human-readable Conflict, not bubble up a raw constraint name. - #[tokio::test] - async fn register_maps_db_conflict_on_username_to_conflict() { - let store = ConflictOnSaveStore(TestStore::default()); - let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) - .await - .unwrap_err(); - assert!( - matches!(err, DomainError::Conflict(ref m) if m == "username taken"), - "expected 'username taken', got: {:?}", - err - ); - } - - #[tokio::test] - async fn register_maps_db_conflict_on_email_to_conflict() { - let store = EmailConflictOnSaveStore(TestStore::default()); - let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) - .await - .unwrap_err(); - assert!( - matches!(err, DomainError::Conflict(ref m) if m == "email taken"), - "expected 'email taken', got: {:?}", - err - ); - } -} diff --git a/crates/application/src/use_cases/auth/mod.rs b/crates/application/src/use_cases/auth/mod.rs new file mode 100644 index 0000000..be41385 --- /dev/null +++ b/crates/application/src/use_cases/auth/mod.rs @@ -0,0 +1,101 @@ +use domain::{ + errors::DomainError, + events::DomainEvent, + models::user::User, + ports::{AuthService, EventPublisher, PasswordHasher, UserReader, UserRepository}, + value_objects::{Email, UserId, Username}, +}; + +pub struct RegisterInput { + pub username: String, + pub email: String, + pub password: String, +} +#[derive(Debug)] +pub struct RegisterOutput { + pub user: User, + pub token: String, +} + +pub async fn register( + users: &dyn UserRepository, + hasher: &dyn PasswordHasher, + auth: &dyn AuthService, + events: &dyn EventPublisher, + input: RegisterInput, +) -> Result { + let username = Username::new(input.username)?; + let email = Email::new(input.email)?; + if users.find_by_username(&username).await?.is_some() { + return Err(DomainError::Conflict("username taken".into())); + } + if users.find_by_email(&email).await?.is_some() { + return Err(DomainError::Conflict("email taken".into())); + } + let hash = hasher.hash(&input.password).await?; + let user = User::new_local(UserId::new(), username, email, hash); + users + .save(&user) + .await + .map_err(|e| match e { + DomainError::UniqueViolation { field: "username" } => { + DomainError::Conflict("username taken".into()) + } + DomainError::UniqueViolation { field: "email" } => { + DomainError::Conflict("email taken".into()) + } + DomainError::UniqueViolation { .. } => { + DomainError::Conflict("already exists".into()) + } + other => other, + })?; + events + .publish(&DomainEvent::UserRegistered { + user_id: user.id.clone(), + }) + .await?; + let token = auth.generate_token(&user.id)?; + Ok(RegisterOutput { + user, + token: token.token, + }) +} + +pub struct LoginInput { + pub email: String, + pub password: String, +} +#[derive(Debug)] +pub struct LoginOutput { + pub user: User, + pub token: String, +} + +pub async fn login( + users: &dyn UserReader, + hasher: &dyn PasswordHasher, + auth: &dyn AuthService, + input: LoginInput, +) -> Result { + let email = Email::new(input.email)?; + let user = users.find_by_email(&email).await?; + if user.is_none() { + // Timing equalization — prevents email enumeration via response-time oracle. + // Running the hasher on a miss makes "no such user" take the same time as + // "wrong password", so attackers cannot distinguish the two cases. + let _ = hasher.hash(&input.password).await; + return Err(DomainError::Unauthorized); + } + let user = user.unwrap(); + if !hasher.verify(&input.password, &user.password_hash).await? { + return Err(DomainError::Unauthorized); + } + let token = auth.generate_token(&user.id)?; + Ok(LoginOutput { + user, + token: token.token, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/application/src/use_cases/auth/tests.rs b/crates/application/src/use_cases/auth/tests.rs new file mode 100644 index 0000000..86e4d1b --- /dev/null +++ b/crates/application/src/use_cases/auth/tests.rs @@ -0,0 +1,286 @@ +use super::*; +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::{feed::{PageParams, Paginated, UserSummary}, user::User}, + ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter}, + testing::{NoOpEventPublisher, TestStore}, + value_objects::{Email, PasswordHash, UserId, Username}, +}; + +/// Simulates a concurrent registration that slips past the pre-checks and +/// hits the DB unique constraint — exactly what happens in the TOCTOU window. +struct ConflictOnSaveStore(TestStore); +struct EmailConflictOnSaveStore(TestStore); + +#[async_trait] +impl UserReader for ConflictOnSaveStore { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + self.0.find_by_id(id).await + } + async fn find_by_username( + &self, + username: &Username, + ) -> Result, DomainError> { + self.0.find_by_username(username).await + } + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + self.0.find_by_email(email).await + } + async fn list_with_stats(&self) -> Result, DomainError> { + self.0.list_with_stats().await + } + async fn count(&self) -> Result { + self.0.count().await + } + async fn list_paginated(&self, page: PageParams) -> Result, DomainError> { + self.0.list_paginated(page).await + } + async fn find_by_ids(&self, ids: &[UserId]) -> Result, DomainError> { + self.0.find_by_ids(ids).await + } +} + +#[async_trait] +impl UserWriter for ConflictOnSaveStore { + async fn save(&self, _user: &User) -> Result<(), DomainError> { + Err(DomainError::UniqueViolation { field: "username" }) + } + async fn update_profile( + &self, + user_id: &UserId, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + ) -> Result<(), DomainError> { + self.0 + .update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css) + .await + } +} + +#[async_trait] +impl UserReader for EmailConflictOnSaveStore { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + self.0.find_by_id(id).await + } + async fn find_by_username( + &self, + username: &Username, + ) -> Result, DomainError> { + self.0.find_by_username(username).await + } + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + self.0.find_by_email(email).await + } + async fn list_with_stats(&self) -> Result, DomainError> { + self.0.list_with_stats().await + } + async fn count(&self) -> Result { + self.0.count().await + } + async fn list_paginated(&self, page: PageParams) -> Result, DomainError> { + self.0.list_paginated(page).await + } + async fn find_by_ids(&self, ids: &[UserId]) -> Result, DomainError> { + self.0.find_by_ids(ids).await + } +} + +#[async_trait] +impl UserWriter for EmailConflictOnSaveStore { + async fn save(&self, _user: &User) -> Result<(), DomainError> { + Err(DomainError::UniqueViolation { field: "email" }) + } + async fn update_profile( + &self, + user_id: &UserId, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + ) -> Result<(), DomainError> { + self.0 + .update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css) + .await + } +} + +struct FakeHasher; +#[async_trait] +impl PasswordHasher for FakeHasher { + async fn hash(&self, plain: &str) -> Result { + 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)); +} + +#[tokio::test] +async fn register_publishes_user_registered_event() { + let store = TestStore::default(); + register(&store, &FakeHasher, &FakeAuth, &store, input()) + .await + .unwrap(); + let events = store.events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], DomainEvent::UserRegistered { .. })); +} + +#[tokio::test] +async fn login_fails_for_nonexistent_user() { + let store = TestStore::default(); + let err = login( + &store, + &FakeHasher, + &FakeAuth, + LoginInput { + email: "ghost@ex.com".into(), + password: "pass".into(), + }, + ) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Unauthorized)); +} + +#[tokio::test] +async fn register_rejects_duplicate_email() { + let store = TestStore::default(); + register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) + .await + .unwrap(); + let err = register( + &store, + &FakeHasher, + &FakeAuth, + &NoOpEventPublisher, + RegisterInput { + username: "alice2".into(), + email: "alice@ex.com".into(), + password: "pass2".into(), + }, + ) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Conflict(_))); +} + +/// TOCTOU: a concurrent registration slips past the pre-checks and the DB +/// unique constraint fires on save. The map_err must convert it to a +/// human-readable Conflict, not bubble up a raw constraint name. +#[tokio::test] +async fn register_maps_db_conflict_on_username_to_conflict() { + let store = ConflictOnSaveStore(TestStore::default()); + let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::Conflict(ref m) if m == "username taken"), + "expected 'username taken', got: {:?}", + err + ); +} + +#[tokio::test] +async fn register_maps_db_conflict_on_email_to_conflict() { + let store = EmailConflictOnSaveStore(TestStore::default()); + let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) + .await + .unwrap_err(); + assert!( + matches!(err, DomainError::Conflict(ref m) if m == "email taken"), + "expected 'email taken', got: {:?}", + err + ); +} diff --git a/crates/application/src/use_cases/federation_management.rs b/crates/application/src/use_cases/federation_management/mod.rs similarity index 71% rename from crates/application/src/use_cases/federation_management.rs rename to crates/application/src/use_cases/federation_management/mod.rs index f7eed3d..a9f2771 100644 --- a/crates/application/src/use_cases/federation_management.rs +++ b/crates/application/src/use_cases/federation_management/mod.rs @@ -134,58 +134,4 @@ pub async fn get_actor_connections_page( } #[cfg(test)] -mod tests { - use super::*; - use domain::testing::TestStore; - - #[tokio::test] - async fn list_pending_returns_empty_by_default() { - let store = TestStore::default(); - let uid = UserId::new(); - let result = list_pending_requests(&store, &uid).await.unwrap(); - assert!(result.is_empty()); - } - - #[tokio::test] - async fn accept_follow_request_returns_ok() { - let store = TestStore::default(); - let uid = UserId::new(); - accept_follow_request(&store, &uid, "https://mastodon.social/users/alice") - .await - .unwrap(); - } - - #[tokio::test] - async fn reject_follow_request_returns_ok() { - let store = TestStore::default(); - let uid = UserId::new(); - reject_follow_request(&store, &uid, "https://mastodon.social/users/alice") - .await - .unwrap(); - } - - #[tokio::test] - async fn list_remote_followers_returns_empty_by_default() { - let store = TestStore::default(); - let uid = UserId::new(); - let result = list_remote_followers(&store, &uid).await.unwrap(); - assert!(result.is_empty()); - } - - #[tokio::test] - async fn remove_remote_follower_returns_ok() { - let store = TestStore::default(); - let uid = UserId::new(); - remove_remote_follower(&store, &uid, "https://mastodon.social/users/alice") - .await - .unwrap(); - } - - #[tokio::test] - async fn list_remote_following_returns_empty_by_default() { - let store = TestStore::default(); - let uid = UserId::new(); - let result = list_remote_following(&store, &uid).await.unwrap(); - assert!(result.is_empty()); - } -} +mod tests; diff --git a/crates/application/src/use_cases/federation_management/tests.rs b/crates/application/src/use_cases/federation_management/tests.rs new file mode 100644 index 0000000..63fd366 --- /dev/null +++ b/crates/application/src/use_cases/federation_management/tests.rs @@ -0,0 +1,53 @@ +use super::*; +use domain::testing::TestStore; + +#[tokio::test] +async fn list_pending_returns_empty_by_default() { + let store = TestStore::default(); + let uid = UserId::new(); + let result = list_pending_requests(&store, &uid).await.unwrap(); + assert!(result.is_empty()); +} + +#[tokio::test] +async fn accept_follow_request_returns_ok() { + let store = TestStore::default(); + let uid = UserId::new(); + accept_follow_request(&store, &uid, "https://mastodon.social/users/alice") + .await + .unwrap(); +} + +#[tokio::test] +async fn reject_follow_request_returns_ok() { + let store = TestStore::default(); + let uid = UserId::new(); + reject_follow_request(&store, &uid, "https://mastodon.social/users/alice") + .await + .unwrap(); +} + +#[tokio::test] +async fn list_remote_followers_returns_empty_by_default() { + let store = TestStore::default(); + let uid = UserId::new(); + let result = list_remote_followers(&store, &uid).await.unwrap(); + assert!(result.is_empty()); +} + +#[tokio::test] +async fn remove_remote_follower_returns_ok() { + let store = TestStore::default(); + let uid = UserId::new(); + remove_remote_follower(&store, &uid, "https://mastodon.social/users/alice") + .await + .unwrap(); +} + +#[tokio::test] +async fn list_remote_following_returns_empty_by_default() { + let store = TestStore::default(); + let uid = UserId::new(); + let result = list_remote_following(&store, &uid).await.unwrap(); + assert!(result.is_empty()); +} diff --git a/crates/application/src/use_cases/profile.rs b/crates/application/src/use_cases/profile/mod.rs similarity index 53% rename from crates/application/src/use_cases/profile.rs rename to crates/application/src/use_cases/profile/mod.rs index dbf2262..a308ac6 100644 --- a/crates/application/src/use_cases/profile.rs +++ b/crates/application/src/use_cases/profile/mod.rs @@ -93,71 +93,4 @@ pub async fn set_top_friends( } #[cfg(test)] -mod tests { - use super::*; - use domain::{ - errors::DomainError, - models::user::User, - testing::TestStore, - value_objects::{Email, PasswordHash, UserId, Username}, - }; - - fn make_user() -> User { - User::new_local( - UserId::new(), - Username::new("alice").unwrap(), - Email::new("alice@ex.com").unwrap(), - PasswordHash("h".into()), - ) - } - - #[tokio::test] - async fn set_top_friends_rejects_more_than_eight() { - let store = TestStore::default(); - let uid = UserId::new(); - let friends: Vec = (0..9).map(|_| UserId::new()).collect(); - let err = set_top_friends(&store, &uid, friends).await.unwrap_err(); - assert!(matches!(err, DomainError::InvalidInput(_))); - } - - #[tokio::test] - async fn set_top_friends_assigns_sequential_positions() { - let store = TestStore::default(); - let uid = UserId::new(); - let f1 = UserId::new(); - let f2 = UserId::new(); - let f3 = UserId::new(); - set_top_friends(&store, &uid, vec![f1.clone(), f2.clone(), f3.clone()]) - .await - .unwrap(); - let tf = store.top_friends.lock().unwrap(); - assert_eq!(tf.len(), 3); - let pos_f1 = tf - .iter() - .find(|t| t.friend_id == f1) - .map(|t| t.position) - .unwrap(); - let pos_f2 = tf - .iter() - .find(|t| t.friend_id == f2) - .map(|t| t.position) - .unwrap(); - assert!(pos_f1 < pos_f2, "f1 should come before f2"); - } - - #[tokio::test] - async fn get_user_by_username_returns_not_found_for_missing_user() { - let store = TestStore::default(); - let err = get_user_by_username(&store, "nobody").await.unwrap_err(); - assert!(matches!(err, DomainError::NotFound)); - } - - #[tokio::test] - async fn get_user_by_username_returns_correct_user() { - let store = TestStore::default(); - let user = make_user(); - store.users.lock().unwrap().push(user.clone()); - let found = get_user_by_username(&store, "alice").await.unwrap(); - assert_eq!(found.id, user.id); - } -} +mod tests; diff --git a/crates/application/src/use_cases/profile/tests.rs b/crates/application/src/use_cases/profile/tests.rs new file mode 100644 index 0000000..9f0fdd4 --- /dev/null +++ b/crates/application/src/use_cases/profile/tests.rs @@ -0,0 +1,66 @@ +use super::*; +use domain::{ + errors::DomainError, + models::user::User, + testing::TestStore, + value_objects::{Email, PasswordHash, UserId, Username}, +}; + +fn make_user() -> User { + User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ) +} + +#[tokio::test] +async fn set_top_friends_rejects_more_than_eight() { + let store = TestStore::default(); + let uid = UserId::new(); + let friends: Vec = (0..9).map(|_| UserId::new()).collect(); + let err = set_top_friends(&store, &uid, friends).await.unwrap_err(); + assert!(matches!(err, DomainError::InvalidInput(_))); +} + +#[tokio::test] +async fn set_top_friends_assigns_sequential_positions() { + let store = TestStore::default(); + let uid = UserId::new(); + let f1 = UserId::new(); + let f2 = UserId::new(); + let f3 = UserId::new(); + set_top_friends(&store, &uid, vec![f1.clone(), f2.clone(), f3.clone()]) + .await + .unwrap(); + let tf = store.top_friends.lock().unwrap(); + assert_eq!(tf.len(), 3); + let pos_f1 = tf + .iter() + .find(|t| t.friend_id == f1) + .map(|t| t.position) + .unwrap(); + let pos_f2 = tf + .iter() + .find(|t| t.friend_id == f2) + .map(|t| t.position) + .unwrap(); + assert!(pos_f1 < pos_f2, "f1 should come before f2"); +} + +#[tokio::test] +async fn get_user_by_username_returns_not_found_for_missing_user() { + let store = TestStore::default(); + let err = get_user_by_username(&store, "nobody").await.unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); +} + +#[tokio::test] +async fn get_user_by_username_returns_correct_user() { + let store = TestStore::default(); + let user = make_user(); + store.users.lock().unwrap().push(user.clone()); + let found = get_user_by_username(&store, "alice").await.unwrap(); + assert_eq!(found.id, user.id); +} diff --git a/crates/application/src/use_cases/social.rs b/crates/application/src/use_cases/social/mod.rs similarity index 54% rename from crates/application/src/use_cases/social.rs rename to crates/application/src/use_cases/social/mod.rs index e53bc03..a02a64a 100644 --- a/crates/application/src/use_cases/social.rs +++ b/crates/application/src/use_cases/social/mod.rs @@ -281,207 +281,4 @@ pub async fn unblock_user( } #[cfg(test)] -mod tests { - use super::*; - use domain::{ - models::{ - thought::{Thought, Visibility}, - user::User, - }, - testing::TestStore, - value_objects::*, - }; - - fn user(name: &str) -> User { - User::new_local( - UserId::new(), - Username::new(name).unwrap(), - Email::new(format!("{name}@ex.com")).unwrap(), - PasswordHash("h".into()), - ) - } - - #[tokio::test] - async fn like_and_unlike() { - let store = TestStore::default(); - let alice = user("alice"); - let tid = ThoughtId::new(); - store.thoughts.lock().unwrap().push(Thought::new_local( - tid.clone(), - alice.id.clone(), - Content::new_local("hi").unwrap(), - None, - Visibility::Public, - None, - false, - )); - like_thought(&store, &store, &alice.id, &tid).await.unwrap(); - assert_eq!(store.likes.lock().unwrap().len(), 1); - unlike_thought(&store, &store, &alice.id, &tid) - .await - .unwrap(); - assert!(store.likes.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn follow_and_unfollow() { - let store = TestStore::default(); - let alice = user("alice"); - let bob = user("bob"); - follow_user(&store, &store, &alice.id, &bob.id) - .await - .unwrap(); - assert_eq!(store.follows.lock().unwrap().len(), 1); - unfollow_user(&store, &store, &alice.id, &bob.id) - .await - .unwrap(); - assert!(store.follows.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn cannot_follow_self() { - let store = TestStore::default(); - let alice = user("alice"); - let err = follow_user(&store, &store, &alice.id, &alice.id) - .await - .unwrap_err(); - assert!(matches!(err, DomainError::InvalidInput(_))); - } - - #[tokio::test] - async fn unblock_user_publishes_event() { - let store = TestStore::default(); - let alice = user("alice"); - let bob = user("bob"); - block_user(&store, &store, &alice.id, &bob.id) - .await - .unwrap(); - store.events.lock().unwrap().clear(); - unblock_user(&store, &store, &alice.id, &bob.id) - .await - .unwrap(); - let events = store.events.lock().unwrap(); - assert_eq!(events.len(), 1); - assert!(matches!(events[0], DomainEvent::UserUnblocked { .. })); - } - - #[tokio::test] - async fn block_user_saves_block_and_publishes_event() { - let store = TestStore::default(); - let alice = user("alice"); - let bob = user("bob"); - block_user(&store, &store, &alice.id, &bob.id) - .await - .unwrap(); - assert_eq!(store.blocks.lock().unwrap().len(), 1); - let events = store.events.lock().unwrap(); - assert!(events.iter().any( - |e| matches!(e, DomainEvent::UserBlocked { blocker_id, .. } if blocker_id == &alice.id) - )); - } - - #[tokio::test] - async fn cannot_block_self() { - let store = TestStore::default(); - let alice = user("alice"); - let err = block_user(&store, &store, &alice.id, &alice.id) - .await - .unwrap_err(); - assert!(matches!(err, DomainError::InvalidInput(_))); - } - - #[tokio::test] - async fn follow_actor_local_routes_to_follow_user() { - let store = TestStore::default(); - let alice = user("alice"); - let bob = user("bob"); - store.users.lock().unwrap().push(bob.clone()); - follow_actor(&store, &store, &store, &store, &alice.id, "bob") - .await - .unwrap(); - assert_eq!(store.follows.lock().unwrap().len(), 1); - } - - #[tokio::test] - async fn follow_actor_remote_routes_to_federation() { - let store = TestStore::default(); - let alice = user("alice"); - follow_actor( - &store, - &store, - &store, - &store, - &alice.id, - "@bob@example.com", - ) - .await - .unwrap(); - // TestStore.follow_remote is a no-op that returns Ok(()) - // no local follow should be recorded - assert!(store.follows.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn unfollow_actor_local_routes_to_unfollow_user() { - let store = TestStore::default(); - let alice = user("alice"); - let bob = user("bob"); - store.users.lock().unwrap().push(bob.clone()); - // Create an existing follow first - store - .follows - .lock() - .unwrap() - .push(domain::models::social::Follow { - follower_id: alice.id.clone(), - following_id: bob.id.clone(), - state: domain::models::social::FollowState::Accepted, - ap_id: None, - created_at: chrono::Utc::now(), - }); - unfollow_actor(&store, &store, &store, &store, &alice.id, "bob") - .await - .unwrap(); - assert!(store.follows.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn unfollow_actor_remote_routes_to_federation() { - let store = TestStore::default(); - let alice = user("alice"); - unfollow_actor( - &store, - &store, - &store, - &store, - &alice.id, - "@bob@example.com", - ) - .await - .unwrap(); - // TestStore.unfollow_remote is a no-op — just verify it doesn't error - assert!(store.follows.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn boost_and_unboost() { - let store = TestStore::default(); - let alice = user("alice"); - let tid = ThoughtId::new(); - boost_thought(&store, &store, &alice.id, &tid) - .await - .unwrap(); - assert_eq!(store.boosts.lock().unwrap().len(), 1); - unboost_thought(&store, &store, &alice.id, &tid) - .await - .unwrap(); - assert!(store.boosts.lock().unwrap().is_empty()); - let events = store.events.lock().unwrap(); - assert!(events - .iter() - .any(|e| matches!(e, DomainEvent::BoostAdded { .. }))); - assert!(events - .iter() - .any(|e| matches!(e, DomainEvent::BoostRemoved { .. }))); - } -} +mod tests; diff --git a/crates/application/src/use_cases/social/tests.rs b/crates/application/src/use_cases/social/tests.rs new file mode 100644 index 0000000..aabf855 --- /dev/null +++ b/crates/application/src/use_cases/social/tests.rs @@ -0,0 +1,202 @@ +use super::*; +use domain::{ + models::{ + thought::{Thought, Visibility}, + user::User, + }, + testing::TestStore, + value_objects::*, +}; + +fn user(name: &str) -> User { + User::new_local( + UserId::new(), + Username::new(name).unwrap(), + Email::new(format!("{name}@ex.com")).unwrap(), + PasswordHash("h".into()), + ) +} + +#[tokio::test] +async fn like_and_unlike() { + let store = TestStore::default(); + let alice = user("alice"); + let tid = ThoughtId::new(); + store.thoughts.lock().unwrap().push(Thought::new_local( + tid.clone(), + alice.id.clone(), + Content::new_local("hi").unwrap(), + None, + Visibility::Public, + None, + false, + )); + like_thought(&store, &store, &alice.id, &tid).await.unwrap(); + assert_eq!(store.likes.lock().unwrap().len(), 1); + unlike_thought(&store, &store, &alice.id, &tid) + .await + .unwrap(); + assert!(store.likes.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn follow_and_unfollow() { + let store = TestStore::default(); + let alice = user("alice"); + let bob = user("bob"); + follow_user(&store, &store, &alice.id, &bob.id) + .await + .unwrap(); + assert_eq!(store.follows.lock().unwrap().len(), 1); + unfollow_user(&store, &store, &alice.id, &bob.id) + .await + .unwrap(); + assert!(store.follows.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn cannot_follow_self() { + let store = TestStore::default(); + let alice = user("alice"); + let err = follow_user(&store, &store, &alice.id, &alice.id) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::InvalidInput(_))); +} + +#[tokio::test] +async fn unblock_user_publishes_event() { + let store = TestStore::default(); + let alice = user("alice"); + let bob = user("bob"); + block_user(&store, &store, &alice.id, &bob.id) + .await + .unwrap(); + store.events.lock().unwrap().clear(); + unblock_user(&store, &store, &alice.id, &bob.id) + .await + .unwrap(); + let events = store.events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], DomainEvent::UserUnblocked { .. })); +} + +#[tokio::test] +async fn block_user_saves_block_and_publishes_event() { + let store = TestStore::default(); + let alice = user("alice"); + let bob = user("bob"); + block_user(&store, &store, &alice.id, &bob.id) + .await + .unwrap(); + assert_eq!(store.blocks.lock().unwrap().len(), 1); + let events = store.events.lock().unwrap(); + assert!(events.iter().any( + |e| matches!(e, DomainEvent::UserBlocked { blocker_id, .. } if blocker_id == &alice.id) + )); +} + +#[tokio::test] +async fn cannot_block_self() { + let store = TestStore::default(); + let alice = user("alice"); + let err = block_user(&store, &store, &alice.id, &alice.id) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::InvalidInput(_))); +} + +#[tokio::test] +async fn follow_actor_local_routes_to_follow_user() { + let store = TestStore::default(); + let alice = user("alice"); + let bob = user("bob"); + store.users.lock().unwrap().push(bob.clone()); + follow_actor(&store, &store, &store, &store, &alice.id, "bob") + .await + .unwrap(); + assert_eq!(store.follows.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn follow_actor_remote_routes_to_federation() { + let store = TestStore::default(); + let alice = user("alice"); + follow_actor( + &store, + &store, + &store, + &store, + &alice.id, + "@bob@example.com", + ) + .await + .unwrap(); + // TestStore.follow_remote is a no-op that returns Ok(()) + // no local follow should be recorded + assert!(store.follows.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn unfollow_actor_local_routes_to_unfollow_user() { + let store = TestStore::default(); + let alice = user("alice"); + let bob = user("bob"); + store.users.lock().unwrap().push(bob.clone()); + // Create an existing follow first + store + .follows + .lock() + .unwrap() + .push(domain::models::social::Follow { + follower_id: alice.id.clone(), + following_id: bob.id.clone(), + state: domain::models::social::FollowState::Accepted, + ap_id: None, + created_at: chrono::Utc::now(), + }); + unfollow_actor(&store, &store, &store, &store, &alice.id, "bob") + .await + .unwrap(); + assert!(store.follows.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn unfollow_actor_remote_routes_to_federation() { + let store = TestStore::default(); + let alice = user("alice"); + unfollow_actor( + &store, + &store, + &store, + &store, + &alice.id, + "@bob@example.com", + ) + .await + .unwrap(); + // TestStore.unfollow_remote is a no-op — just verify it doesn't error + assert!(store.follows.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn boost_and_unboost() { + let store = TestStore::default(); + let alice = user("alice"); + let tid = ThoughtId::new(); + boost_thought(&store, &store, &alice.id, &tid) + .await + .unwrap(); + assert_eq!(store.boosts.lock().unwrap().len(), 1); + unboost_thought(&store, &store, &alice.id, &tid) + .await + .unwrap(); + assert!(store.boosts.lock().unwrap().is_empty()); + let events = store.events.lock().unwrap(); + assert!(events + .iter() + .any(|e| matches!(e, DomainEvent::BoostAdded { .. }))); + assert!(events + .iter() + .any(|e| matches!(e, DomainEvent::BoostRemoved { .. }))); +} diff --git a/crates/application/src/use_cases/thoughts.rs b/crates/application/src/use_cases/thoughts.rs deleted file mode 100644 index 1fc1654..0000000 --- a/crates/application/src/use_cases/thoughts.rs +++ /dev/null @@ -1,456 +0,0 @@ -use domain::{ - errors::DomainError, - events::DomainEvent, - models::{ - feed::{EngagementStats, FeedEntry}, - thought::{Thought, Visibility}, - }, - ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader}, - value_objects::{Content, ThoughtId, UserId}, -}; - -fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> { - if thought.user_id != *user_id { - return Err(DomainError::NotFound); - } - Ok(()) -} - -pub struct CreateThoughtInput { - pub user_id: UserId, - pub content: String, - pub in_reply_to_id: Option, - 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 UserReader, - tags: &dyn TagRepository, - _events: &dyn EventPublisher, - outbox: &dyn OutboxWriter, - input: CreateThoughtInput, -) -> Result { - let content = Content::new_local(input.content)?; - let visibility = match input.visibility.as_deref() { - Some("followers") => Visibility::Followers, - Some("unlisted") => Visibility::Unlisted, - Some("direct") => Visibility::Direct, - _ => Visibility::Public, - }; - let thought = Thought::new_local( - ThoughtId::new(), - input.user_id, - content.clone(), - input.in_reply_to_id.clone(), - visibility, - input.content_warning, - input.sensitive, - ); - thoughts.save(&thought).await?; - - // Extract and attach hashtags from content. - for h in domain::hashtag::extract(content.as_str()) { - if let Ok(tag) = tags.find_or_create(&h.normalized).await { - let _ = tags.attach_to_thought(&thought.id, tag.id).await; - } - } - - outbox - .append(&DomainEvent::ThoughtCreated { - thought_id: thought.id.clone(), - user_id: thought.user_id.clone(), - in_reply_to_id: input.in_reply_to_id, - }) - .await?; - Ok(CreateThoughtOutput { thought }) -} - -pub async fn delete_thought( - thoughts: &dyn ThoughtRepository, - _events: &dyn EventPublisher, - outbox: &dyn OutboxWriter, - id: &ThoughtId, - user_id: &UserId, -) -> Result<(), DomainError> { - let thought = thoughts - .find_by_id(id) - .await? - .ok_or(DomainError::NotFound)?; - require_owner(&thought, user_id)?; - thoughts.delete(id, user_id).await?; - outbox - .append(&DomainEvent::ThoughtDeleted { - thought_id: id.clone(), - user_id: user_id.clone(), - }) - .await?; - Ok(()) -} - -pub async fn edit_thought( - thoughts: &dyn ThoughtRepository, - events: &dyn EventPublisher, - id: &ThoughtId, - user_id: &UserId, - new_content: String, -) -> Result<(), DomainError> { - let thought = thoughts - .find_by_id(id) - .await? - .ok_or(DomainError::NotFound)?; - require_owner(&thought, user_id)?; - let content = Content::new_local(new_content)?; - thoughts.update_content(id, &content).await?; - events - .publish(&DomainEvent::ThoughtUpdated { - thought_id: id.clone(), - user_id: user_id.clone(), - }) - .await?; - Ok(()) -} - -/// Fetches a single thought enriched with author + real engagement stats. -pub async fn get_thought_view( - thoughts: &dyn ThoughtRepository, - users: &dyn UserReader, - engagement: &dyn EngagementRepository, - id: &ThoughtId, - viewer: Option<&UserId>, -) -> Result { - let thought = thoughts - .find_by_id(id) - .await? - .ok_or(DomainError::NotFound)?; - let author = users - .find_by_id(&thought.user_id) - .await? - .ok_or(DomainError::NotFound)?; - let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?; - let (stats, viewer_ctx) = map.remove(id).unwrap_or( - (EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) - ); - Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx }) -} - -/// Fetches a thread (root + replies) enriched with authors + real engagement stats. -/// Batches all DB lookups — one query per resource type regardless of thread length. -pub async fn get_thread_views( - thoughts: &dyn ThoughtRepository, - users: &dyn UserReader, - engagement: &dyn EngagementRepository, - root_id: &ThoughtId, - viewer: Option<&UserId>, -) -> Result, DomainError> { - let thread = thoughts.get_thread(root_id).await?; - if thread.is_empty() { - return Ok(vec![]); - } - - let thought_ids: Vec = thread.iter().map(|t| t.id.clone()).collect(); - let user_ids: Vec = thread.iter().map(|t| t.user_id.clone()).collect(); - - let (authors_map, engagement_map) = tokio::join!( - users.find_by_ids(&user_ids), - engagement.get_for_thoughts(&thought_ids, viewer), - ); - let authors_map = authors_map?; - let mut engagement_map = engagement_map?; - - let mut entries = Vec::with_capacity(thread.len()); - for thought in thread { - let author = authors_map - .get(&thought.user_id) - .cloned() - .ok_or(DomainError::NotFound)?; - let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or( - (EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) - ); - entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx }); - } - Ok(entries) -} - -#[cfg(test)] -mod tests { - use super::*; - use domain::{ - models::user::User, - testing::{NoOpEventPublisher, NoOpOutboxWriter, TestOutbox, TestStore}, - value_objects::*, - }; - - fn user() -> User { - User::new_local( - UserId::new(), - Username::new("alice").unwrap(), - Email::new("alice@ex.com").unwrap(), - PasswordHash("h".into()), - ) - } - - fn input(uid: UserId) -> CreateThoughtInput { - CreateThoughtInput { - user_id: uid, - content: "hello".into(), - in_reply_to_id: None, - visibility: None, - content_warning: None, - sensitive: false, - } - } - - #[tokio::test] - async fn create_thought_saves_and_stages_outbox_event() { - let store = TestStore::default(); - let outbox = TestOutbox::default(); - let u = user(); - store.users.lock().unwrap().push(u.clone()); - let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &outbox, input(u.id.clone())) - .await - .unwrap(); - assert_eq!(out.thought.content.as_str(), "hello"); - let staged = outbox.staged(); - assert_eq!(staged.len(), 1); - assert!(matches!(staged[0], DomainEvent::ThoughtCreated { .. })); - } - - #[tokio::test] - async fn delete_thought_stages_outbox_event() { - let store = TestStore::default(); - let outbox = TestOutbox::default(); - let u = user(); - store.users.lock().unwrap().push(u.clone()); - let out = create_thought( - &store, - &store, - &store, - &NoOpEventPublisher, - &NoOpOutboxWriter, - input(u.id.clone()), - ) - .await - .unwrap(); - let tid = out.thought.id.clone(); - - delete_thought(&store, &NoOpEventPublisher, &outbox, &tid, &u.id) - .await - .unwrap(); - - let staged = outbox.staged(); - assert_eq!(staged.len(), 1); - assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid)); - } - - #[tokio::test] - async fn delete_own_thought_succeeds() { - let store = TestStore::default(); - let u = user(); - store.users.lock().unwrap().push(u.clone()); - let out = create_thought( - &store, - &store, - &store, - &NoOpEventPublisher, - &NoOpOutboxWriter, - input(u.id.clone()), - ) - .await - .unwrap(); - delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &u.id) - .await - .unwrap(); - assert!(store.thoughts.lock().unwrap().is_empty()); - } - - #[tokio::test] - async fn delete_other_thought_returns_not_found() { - let store = TestStore::default(); - let alice = user(); - let bob = User::new_local( - UserId::new(), - Username::new("bob").unwrap(), - Email::new("bob@ex.com").unwrap(), - PasswordHash("h".into()), - ); - store - .users - .lock() - .unwrap() - .extend([alice.clone(), bob.clone()]); - let out = create_thought( - &store, - &store, - &store, - &NoOpEventPublisher, - &NoOpOutboxWriter, - input(alice.id.clone()), - ) - .await - .unwrap(); - let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &bob.id) - .await - .unwrap_err(); - assert!(matches!(err, DomainError::NotFound)); - } - - #[tokio::test] - async fn edit_thought_changes_content_and_emits_event() { - let store = TestStore::default(); - let alice = user(); - store.users.lock().unwrap().push(alice.clone()); - let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &NoOpOutboxWriter, input(alice.id.clone())) - .await - .unwrap(); - let tid = out.thought.id.clone(); - - edit_thought(&store, &store, &tid, &alice.id, "updated".to_string()) - .await - .unwrap(); - - let saved = store - .thoughts - .lock() - .unwrap() - .iter() - .find(|t| t.id == tid) - .unwrap() - .clone(); - assert_eq!(saved.content.as_str(), "updated"); - - let events = store.events.lock().unwrap(); - assert!(events.iter().any( - |e| matches!(e, DomainEvent::ThoughtUpdated { thought_id, .. } if thought_id == &tid) - )); - } - - #[tokio::test] - async fn create_reply_sets_in_reply_to_id() { - let store = TestStore::default(); - let alice = user(); - store.users.lock().unwrap().push(alice.clone()); - let original = create_thought( - &store, - &store, - &store, - &NoOpEventPublisher, - &NoOpOutboxWriter, - input(alice.id.clone()), - ) - .await - .unwrap() - .thought; - - create_thought( - &store, - &store, - &store, - &NoOpEventPublisher, - &NoOpOutboxWriter, - CreateThoughtInput { - user_id: alice.id.clone(), - content: "reply".into(), - in_reply_to_id: Some(original.id.clone()), - visibility: None, - content_warning: None, - sensitive: false, - }, - ) - .await - .unwrap(); - - let thoughts = store.thoughts.lock().unwrap(); - let reply = thoughts - .iter() - .find(|t| t.content.as_str() == "reply") - .unwrap(); - assert_eq!(reply.in_reply_to_id, Some(original.id.clone())); - } -} - -#[cfg(test)] -mod enrichment_tests { - use super::*; - use domain::testing::TestStore; - use domain::models::user::User; - use domain::models::thought::{Thought, Visibility}; - use domain::value_objects::*; - use domain::ports::{ThoughtRepository, UserWriter}; - - fn make_user() -> User { - User::new_local( - UserId::new(), - Username::new("alice").unwrap(), - Email::new("a@a.com").unwrap(), - PasswordHash("h".into()), - ) - } - - fn make_thought(user_id: UserId) -> Thought { - Thought::new_local( - ThoughtId::new(), - user_id, - Content::new_local(String::from("hello")).unwrap(), - None, - Visibility::Public, - None, - false, - ) - } - - #[tokio::test] - async fn get_thought_view_returns_feed_entry() { - let store = TestStore::default(); - let user = make_user(); - ::save(&store, &user).await.unwrap(); - let thought = make_thought(user.id.clone()); - ::save(&store, &thought).await.unwrap(); - - let entry = get_thought_view(&store, &store, &store, &thought.id, None) - .await - .unwrap(); - assert_eq!(entry.thought.id, thought.id); - assert_eq!(entry.author.id, user.id); - assert_eq!(entry.stats.like_count, 0); - assert!(entry.viewer.is_none()); - } - - #[tokio::test] - async fn get_thought_view_returns_not_found_for_missing_thought() { - let store = TestStore::default(); - let err = get_thought_view(&store, &store, &store, &ThoughtId::new(), None) - .await - .unwrap_err(); - assert!(matches!(err, DomainError::NotFound)); - } - - #[tokio::test] - async fn get_thread_views_batches_correctly() { - let store = TestStore::default(); - let user = make_user(); - ::save(&store, &user).await.unwrap(); - let root = make_thought(user.id.clone()); - ::save(&store, &root).await.unwrap(); - let reply = Thought::new_local( - ThoughtId::new(), - user.id.clone(), - Content::new_local(String::from("reply")).unwrap(), - Some(root.id.clone()), - Visibility::Public, - None, - false, - ); - ::save(&store, &reply).await.unwrap(); - - let entries = get_thread_views(&store, &store, &store, &root.id, None) - .await - .unwrap(); - assert_eq!(entries.len(), 2); - } -} diff --git a/crates/application/src/use_cases/thoughts/mod.rs b/crates/application/src/use_cases/thoughts/mod.rs new file mode 100644 index 0000000..6249131 --- /dev/null +++ b/crates/application/src/use_cases/thoughts/mod.rs @@ -0,0 +1,181 @@ +use domain::{ + errors::DomainError, + events::DomainEvent, + models::{ + feed::{EngagementStats, FeedEntry}, + thought::{Thought, Visibility}, + }, + ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader}, + value_objects::{Content, ThoughtId, UserId}, +}; + +fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> { + if thought.user_id != *user_id { + return Err(DomainError::NotFound); + } + Ok(()) +} + +pub struct CreateThoughtInput { + pub user_id: UserId, + pub content: String, + pub in_reply_to_id: Option, + 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 UserReader, + tags: &dyn TagRepository, + _events: &dyn EventPublisher, + outbox: &dyn OutboxWriter, + input: CreateThoughtInput, +) -> Result { + let content = Content::new_local(input.content)?; + let visibility = match input.visibility.as_deref() { + Some("followers") => Visibility::Followers, + Some("unlisted") => Visibility::Unlisted, + Some("direct") => Visibility::Direct, + _ => Visibility::Public, + }; + let thought = Thought::new_local( + ThoughtId::new(), + input.user_id, + content.clone(), + input.in_reply_to_id.clone(), + visibility, + input.content_warning, + input.sensitive, + ); + thoughts.save(&thought).await?; + + // Extract and attach hashtags from content. + for h in domain::hashtag::extract(content.as_str()) { + if let Ok(tag) = tags.find_or_create(&h.normalized).await { + let _ = tags.attach_to_thought(&thought.id, tag.id).await; + } + } + + outbox + .append(&DomainEvent::ThoughtCreated { + thought_id: thought.id.clone(), + user_id: thought.user_id.clone(), + in_reply_to_id: input.in_reply_to_id, + }) + .await?; + Ok(CreateThoughtOutput { thought }) +} + +pub async fn delete_thought( + thoughts: &dyn ThoughtRepository, + _events: &dyn EventPublisher, + outbox: &dyn OutboxWriter, + id: &ThoughtId, + user_id: &UserId, +) -> Result<(), DomainError> { + let thought = thoughts + .find_by_id(id) + .await? + .ok_or(DomainError::NotFound)?; + require_owner(&thought, user_id)?; + thoughts.delete(id, user_id).await?; + outbox + .append(&DomainEvent::ThoughtDeleted { + thought_id: id.clone(), + user_id: user_id.clone(), + }) + .await?; + Ok(()) +} + +pub async fn edit_thought( + thoughts: &dyn ThoughtRepository, + events: &dyn EventPublisher, + id: &ThoughtId, + user_id: &UserId, + new_content: String, +) -> Result<(), DomainError> { + let thought = thoughts + .find_by_id(id) + .await? + .ok_or(DomainError::NotFound)?; + require_owner(&thought, user_id)?; + let content = Content::new_local(new_content)?; + thoughts.update_content(id, &content).await?; + events + .publish(&DomainEvent::ThoughtUpdated { + thought_id: id.clone(), + user_id: user_id.clone(), + }) + .await?; + Ok(()) +} + +/// Fetches a single thought enriched with author + real engagement stats. +pub async fn get_thought_view( + thoughts: &dyn ThoughtRepository, + users: &dyn UserReader, + engagement: &dyn EngagementRepository, + id: &ThoughtId, + viewer: Option<&UserId>, +) -> Result { + let thought = thoughts + .find_by_id(id) + .await? + .ok_or(DomainError::NotFound)?; + let author = users + .find_by_id(&thought.user_id) + .await? + .ok_or(DomainError::NotFound)?; + let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?; + let (stats, viewer_ctx) = map.remove(id).unwrap_or( + (EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) + ); + Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx }) +} + +/// Fetches a thread (root + replies) enriched with authors + real engagement stats. +/// Batches all DB lookups — one query per resource type regardless of thread length. +pub async fn get_thread_views( + thoughts: &dyn ThoughtRepository, + users: &dyn UserReader, + engagement: &dyn EngagementRepository, + root_id: &ThoughtId, + viewer: Option<&UserId>, +) -> Result, DomainError> { + let thread = thoughts.get_thread(root_id).await?; + if thread.is_empty() { + return Ok(vec![]); + } + + let thought_ids: Vec = thread.iter().map(|t| t.id.clone()).collect(); + let user_ids: Vec = thread.iter().map(|t| t.user_id.clone()).collect(); + + let (authors_map, engagement_map) = tokio::join!( + users.find_by_ids(&user_ids), + engagement.get_for_thoughts(&thought_ids, viewer), + ); + let authors_map = authors_map?; + let mut engagement_map = engagement_map?; + + let mut entries = Vec::with_capacity(thread.len()); + for thought in thread { + let author = authors_map + .get(&thought.user_id) + .cloned() + .ok_or(DomainError::NotFound)?; + let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or( + (EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) + ); + entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx }); + } + Ok(entries) +} + +#[cfg(test)] +mod tests; diff --git a/crates/application/src/use_cases/thoughts/tests.rs b/crates/application/src/use_cases/thoughts/tests.rs new file mode 100644 index 0000000..6d69b04 --- /dev/null +++ b/crates/application/src/use_cases/thoughts/tests.rs @@ -0,0 +1,269 @@ +use super::*; +use domain::{ + models::user::User, + testing::{NoOpEventPublisher, NoOpOutboxWriter, TestOutbox, TestStore}, + value_objects::*, +}; + +fn user() -> User { + User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ) +} + +fn input(uid: UserId) -> CreateThoughtInput { + CreateThoughtInput { + user_id: uid, + content: "hello".into(), + in_reply_to_id: None, + visibility: None, + content_warning: None, + sensitive: false, + } +} + +#[tokio::test] +async fn create_thought_saves_and_stages_outbox_event() { + let store = TestStore::default(); + let outbox = TestOutbox::default(); + let u = user(); + store.users.lock().unwrap().push(u.clone()); + let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &outbox, input(u.id.clone())) + .await + .unwrap(); + assert_eq!(out.thought.content.as_str(), "hello"); + let staged = outbox.staged(); + assert_eq!(staged.len(), 1); + assert!(matches!(staged[0], DomainEvent::ThoughtCreated { .. })); +} + +#[tokio::test] +async fn delete_thought_stages_outbox_event() { + let store = TestStore::default(); + let outbox = TestOutbox::default(); + let u = user(); + store.users.lock().unwrap().push(u.clone()); + let out = create_thought( + &store, + &store, + &store, + &NoOpEventPublisher, + &NoOpOutboxWriter, + input(u.id.clone()), + ) + .await + .unwrap(); + let tid = out.thought.id.clone(); + + delete_thought(&store, &NoOpEventPublisher, &outbox, &tid, &u.id) + .await + .unwrap(); + + let staged = outbox.staged(); + assert_eq!(staged.len(), 1); + assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid)); +} + +#[tokio::test] +async fn delete_own_thought_succeeds() { + let store = TestStore::default(); + let u = user(); + store.users.lock().unwrap().push(u.clone()); + let out = create_thought( + &store, + &store, + &store, + &NoOpEventPublisher, + &NoOpOutboxWriter, + input(u.id.clone()), + ) + .await + .unwrap(); + delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &u.id) + .await + .unwrap(); + assert!(store.thoughts.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn delete_other_thought_returns_not_found() { + let store = TestStore::default(); + let alice = user(); + let bob = User::new_local( + UserId::new(), + Username::new("bob").unwrap(), + Email::new("bob@ex.com").unwrap(), + PasswordHash("h".into()), + ); + store + .users + .lock() + .unwrap() + .extend([alice.clone(), bob.clone()]); + let out = create_thought( + &store, + &store, + &store, + &NoOpEventPublisher, + &NoOpOutboxWriter, + input(alice.id.clone()), + ) + .await + .unwrap(); + let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &bob.id) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); +} + +#[tokio::test] +async fn edit_thought_changes_content_and_emits_event() { + let store = TestStore::default(); + let alice = user(); + store.users.lock().unwrap().push(alice.clone()); + let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &NoOpOutboxWriter, input(alice.id.clone())) + .await + .unwrap(); + let tid = out.thought.id.clone(); + + edit_thought(&store, &store, &tid, &alice.id, "updated".to_string()) + .await + .unwrap(); + + let saved = store + .thoughts + .lock() + .unwrap() + .iter() + .find(|t| t.id == tid) + .unwrap() + .clone(); + assert_eq!(saved.content.as_str(), "updated"); + + let events = store.events.lock().unwrap(); + assert!(events.iter().any( + |e| matches!(e, DomainEvent::ThoughtUpdated { thought_id, .. } if thought_id == &tid) + )); +} + +#[tokio::test] +async fn create_reply_sets_in_reply_to_id() { + let store = TestStore::default(); + let alice = user(); + store.users.lock().unwrap().push(alice.clone()); + let original = create_thought( + &store, + &store, + &store, + &NoOpEventPublisher, + &NoOpOutboxWriter, + input(alice.id.clone()), + ) + .await + .unwrap() + .thought; + + create_thought( + &store, + &store, + &store, + &NoOpEventPublisher, + &NoOpOutboxWriter, + CreateThoughtInput { + user_id: alice.id.clone(), + content: "reply".into(), + in_reply_to_id: Some(original.id.clone()), + visibility: None, + content_warning: None, + sensitive: false, + }, + ) + .await + .unwrap(); + + let thoughts = store.thoughts.lock().unwrap(); + let reply = thoughts + .iter() + .find(|t| t.content.as_str() == "reply") + .unwrap(); + assert_eq!(reply.in_reply_to_id, Some(original.id.clone())); +} + +// enrichment_tests (combined from second cfg(test) block) + +use domain::models::thought::{Thought, Visibility}; +use domain::ports::{ThoughtRepository, UserWriter}; + +fn make_user() -> User { + User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("a@a.com").unwrap(), + PasswordHash("h".into()), + ) +} + +fn make_thought(user_id: UserId) -> Thought { + Thought::new_local( + ThoughtId::new(), + user_id, + Content::new_local(String::from("hello")).unwrap(), + None, + Visibility::Public, + None, + false, + ) +} + +#[tokio::test] +async fn get_thought_view_returns_feed_entry() { + let store = TestStore::default(); + let user = make_user(); + ::save(&store, &user).await.unwrap(); + let thought = make_thought(user.id.clone()); + ::save(&store, &thought).await.unwrap(); + + let entry = get_thought_view(&store, &store, &store, &thought.id, None) + .await + .unwrap(); + assert_eq!(entry.thought.id, thought.id); + assert_eq!(entry.author.id, user.id); + assert_eq!(entry.stats.like_count, 0); + assert!(entry.viewer.is_none()); +} + +#[tokio::test] +async fn get_thought_view_returns_not_found_for_missing_thought() { + let store = TestStore::default(); + let err = get_thought_view(&store, &store, &store, &ThoughtId::new(), None) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); +} + +#[tokio::test] +async fn get_thread_views_batches_correctly() { + let store = TestStore::default(); + let user = make_user(); + ::save(&store, &user).await.unwrap(); + let root = make_thought(user.id.clone()); + ::save(&store, &root).await.unwrap(); + let reply = Thought::new_local( + ThoughtId::new(), + user.id.clone(), + Content::new_local(String::from("reply")).unwrap(), + Some(root.id.clone()), + Visibility::Public, + None, + false, + ); + ::save(&store, &reply).await.unwrap(); + + let entries = get_thread_views(&store, &store, &store, &root.id, None) + .await + .unwrap(); + assert_eq!(entries.len(), 2); +} diff --git a/crates/domain/src/hashtag.rs b/crates/domain/src/hashtag.rs deleted file mode 100644 index 7988a36..0000000 --- a/crates/domain/src/hashtag.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::collections::HashSet; - -/// A hashtag extracted from content. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Hashtag { - /// Original casing, e.g. "Rust" - pub raw: String, - /// Lowercased, e.g. "rust" — used for DB lookups - pub normalized: String, - /// "tags/rust" — callers prepend base_url - pub url_slug: String, - /// "#rust" — used directly in AP tag array - pub ap_name: String, -} - -/// Extract hashtags from content using a char-by-char scan. -/// -/// Rules: -/// - Tag starts after a bare `#` followed immediately by an alphanumeric char. -/// - Tag chars: `[A-Za-z0-9_]`. -/// - Deduplicated case-insensitively; first occurrence wins. -/// - Returned in order of first appearance. -pub fn extract(content: &str) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut tags: Vec = Vec::new(); - let mut chars = content.char_indices().peekable(); - - while let Some((_, c)) = chars.next() { - if c == '#' - && chars - .peek() - .map(|(_, nc)| nc.is_alphanumeric()) - .unwrap_or(false) - { - let raw: String = chars - .by_ref() - .take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_') - .map(|(_, nc)| nc) - .collect(); - - if raw.is_empty() { - continue; - } - - let normalized = raw.to_lowercase(); - if seen.insert(normalized.clone()) { - tags.push(Hashtag { - url_slug: format!("tags/{}", normalized), - ap_name: format!("#{}", normalized), - raw, - normalized, - }); - } - } - } - - tags -} - -#[cfg(test)] -mod tests { - use super::*; - - fn names(tags: &[Hashtag]) -> Vec<&str> { - tags.iter().map(|h| h.normalized.as_str()).collect() - } - - #[test] - fn basic() { - let tags = extract("Hello #world and #Rust!"); - assert_eq!(names(&tags), ["world", "rust"]); - } - - #[test] - fn fields() { - let tags = extract("#Rust"); - assert_eq!(tags.len(), 1); - let h = &tags[0]; - assert_eq!(h.raw, "Rust"); - assert_eq!(h.normalized, "rust"); - assert_eq!(h.url_slug, "tags/rust"); - assert_eq!(h.ap_name, "#rust"); - } - - #[test] - fn dedup_case_insensitive() { - let tags = extract("#rust #Rust #RUST"); - assert_eq!(names(&tags), ["rust"]); - assert_eq!(tags[0].raw, "rust"); // first occurrence wins - } - - #[test] - fn deduplicates_non_adjacent() { - // The old algorithm used Vec::dedup() which only removes adjacent duplicates. - // Using HashSet silently fixed this bug. This test documents the fix. - let tags = extract("#a #b #a"); - assert_eq!(tags.len(), 2); - assert_eq!(tags[0].normalized, "a"); - assert_eq!(tags[1].normalized, "b"); - } - - #[test] - fn mid_word_extracted() { - // `text#tag` — `#` not preceded by whitespace is still matched by the - // char-by-char scan (the old algorithm didn't require whitespace before `#`). - // This test documents the authoritative behaviour: mid-word tags ARE extracted. - let tags = extract("text#tag"); - assert_eq!(names(&tags), ["tag"]); - } - - #[test] - fn hash_only_ignored() { - assert!(extract("# lone hash").is_empty()); - } - - #[test] - fn trailing_punctuation_excluded() { - // punctuation after tag terminates the tag, not included - let tags = extract("#rust."); - assert_eq!(names(&tags), ["rust"]); - } - - #[test] - fn underscore_allowed() { - let tags = extract("#hello_world"); - assert_eq!(names(&tags), ["hello_world"]); - } - - #[test] - fn empty_content() { - assert!(extract("").is_empty()); - } - - #[test] - fn order_of_appearance() { - let tags = extract("#b #a #c"); - assert_eq!(names(&tags), ["b", "a", "c"]); - } -} diff --git a/crates/domain/src/hashtag/mod.rs b/crates/domain/src/hashtag/mod.rs new file mode 100644 index 0000000..f4b78e5 --- /dev/null +++ b/crates/domain/src/hashtag/mod.rs @@ -0,0 +1,61 @@ +use std::collections::HashSet; + +/// A hashtag extracted from content. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Hashtag { + /// Original casing, e.g. "Rust" + pub raw: String, + /// Lowercased, e.g. "rust" — used for DB lookups + pub normalized: String, + /// "tags/rust" — callers prepend base_url + pub url_slug: String, + /// "#rust" — used directly in AP tag array + pub ap_name: String, +} + +/// Extract hashtags from content using a char-by-char scan. +/// +/// Rules: +/// - Tag starts after a bare `#` followed immediately by an alphanumeric char. +/// - Tag chars: `[A-Za-z0-9_]`. +/// - Deduplicated case-insensitively; first occurrence wins. +/// - Returned in order of first appearance. +pub fn extract(content: &str) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut tags: Vec = Vec::new(); + let mut chars = content.char_indices().peekable(); + + while let Some((_, c)) = chars.next() { + if c == '#' + && chars + .peek() + .map(|(_, nc)| nc.is_alphanumeric()) + .unwrap_or(false) + { + let raw: String = chars + .by_ref() + .take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_') + .map(|(_, nc)| nc) + .collect(); + + if raw.is_empty() { + continue; + } + + let normalized = raw.to_lowercase(); + if seen.insert(normalized.clone()) { + tags.push(Hashtag { + url_slug: format!("tags/{}", normalized), + ap_name: format!("#{}", normalized), + raw, + normalized, + }); + } + } + } + + tags +} + +#[cfg(test)] +mod tests; diff --git a/crates/domain/src/hashtag/tests.rs b/crates/domain/src/hashtag/tests.rs new file mode 100644 index 0000000..666f08a --- /dev/null +++ b/crates/domain/src/hashtag/tests.rs @@ -0,0 +1,71 @@ +use super::*; + +fn names(tags: &[Hashtag]) -> Vec<&str> { + tags.iter().map(|h| h.normalized.as_str()).collect() +} + +#[test] +fn basic() { + let tags = extract("Hello #world and #Rust!"); + assert_eq!(names(&tags), ["world", "rust"]); +} + +#[test] +fn fields() { + let tags = extract("#Rust"); + assert_eq!(tags.len(), 1); + let h = &tags[0]; + assert_eq!(h.raw, "Rust"); + assert_eq!(h.normalized, "rust"); + assert_eq!(h.url_slug, "tags/rust"); + assert_eq!(h.ap_name, "#rust"); +} + +#[test] +fn dedup_case_insensitive() { + let tags = extract("#rust #Rust #RUST"); + assert_eq!(names(&tags), ["rust"]); + assert_eq!(tags[0].raw, "rust"); // first occurrence wins +} + +#[test] +fn deduplicates_non_adjacent() { + let tags = extract("#a #b #a"); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0].normalized, "a"); + assert_eq!(tags[1].normalized, "b"); +} + +#[test] +fn mid_word_extracted() { + let tags = extract("text#tag"); + assert_eq!(names(&tags), ["tag"]); +} + +#[test] +fn hash_only_ignored() { + assert!(extract("# lone hash").is_empty()); +} + +#[test] +fn trailing_punctuation_excluded() { + let tags = extract("#rust."); + assert_eq!(names(&tags), ["rust"]); +} + +#[test] +fn underscore_allowed() { + let tags = extract("#hello_world"); + assert_eq!(names(&tags), ["hello_world"]); +} + +#[test] +fn empty_content() { + assert!(extract("").is_empty()); +} + +#[test] +fn order_of_appearance() { + let tags = extract("#b #a #c"); + assert_eq!(names(&tags), ["b", "a", "c"]); +} diff --git a/crates/domain/src/testing.rs b/crates/domain/src/testing/mod.rs similarity index 89% rename from crates/domain/src/testing.rs rename to crates/domain/src/testing/mod.rs index 11193f5..adc528c 100644 --- a/crates/domain/src/testing.rs +++ b/crates/domain/src/testing/mod.rs @@ -802,7 +802,6 @@ impl SearchPort for TestStore { } } - #[async_trait] impl FederationSchedulerPort for TestStore { async fn schedule_actor_posts_fetch(&self, _: &str, _: &str) -> Result<(), DomainError> { @@ -863,102 +862,4 @@ impl OutboxWriter for NoOpOutboxWriter { } #[cfg(test)] -mod federation_port_tests { - use super::*; - use crate::value_objects::UserId; - - fn uid() -> UserId { - UserId::new() - } - - #[tokio::test] - async fn test_store_lookup_returns_not_found() { - let store = TestStore::default(); - let err = store.lookup_actor("@alice@example.com").await.unwrap_err(); - assert!(matches!(err, DomainError::NotFound)); - } - - #[tokio::test] - async fn test_store_follow_remote_is_noop_ok() { - let store = TestStore::default(); - store - .follow_remote(&uid(), "@alice@example.com") - .await - .unwrap(); - } - - #[tokio::test] - async fn test_store_actor_json_returns_not_found() { - let store = TestStore::default(); - let err = store.actor_json(&UserId::new()).await.unwrap_err(); - assert!(matches!(err, DomainError::NotFound)); - } - - #[tokio::test] - async fn test_store_fetch_outbox_returns_empty() { - let store = TestStore::default(); - let notes = store - .fetch_outbox_page("https://example.com/outbox", 1) - .await - .unwrap(); - assert!(notes.is_empty()); - } - - #[tokio::test] - async fn test_store_resolve_actor_profiles_returns_empty() { - let store = TestStore::default(); - let result = store - .resolve_actor_profiles(vec!["https://example.com/users/alice".into()]) - .await; - assert!(result.is_empty()); - } - - #[tokio::test] - async fn test_store_fetch_collection_urls_returns_empty() { - let store = TestStore::default(); - let urls = store - .fetch_actor_urls_from_collection("https://example.com/users/alice/followers") - .await - .unwrap(); - assert!(urls.is_empty()); - } -} - -#[cfg(test)] -mod search_tests { - use super::*; - use crate::models::feed::PageParams; - - #[tokio::test] - async fn test_store_search_thoughts_returns_empty() { - let store = TestStore::default(); - let result = store - .search_thoughts( - "hello", - &PageParams { - page: 1, - per_page: 20, - }, - None, - ) - .await - .unwrap(); - assert_eq!(result.total, 0); - } - - #[tokio::test] - async fn test_store_search_users_returns_empty() { - let store = TestStore::default(); - let result = store - .search_users( - "alice", - &PageParams { - page: 1, - per_page: 20, - }, - ) - .await - .unwrap(); - assert_eq!(result.total, 0); - } -} +mod tests; diff --git a/crates/domain/src/testing/tests.rs b/crates/domain/src/testing/tests.rs new file mode 100644 index 0000000..85146d9 --- /dev/null +++ b/crates/domain/src/testing/tests.rs @@ -0,0 +1,98 @@ +mod federation_port_tests { + use super::super::*; + use crate::value_objects::UserId; + + fn uid() -> UserId { + UserId::new() + } + + #[tokio::test] + async fn test_store_lookup_returns_not_found() { + let store = TestStore::default(); + let err = store.lookup_actor("@alice@example.com").await.unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); + } + + #[tokio::test] + async fn test_store_follow_remote_is_noop_ok() { + let store = TestStore::default(); + store + .follow_remote(&uid(), "@alice@example.com") + .await + .unwrap(); + } + + #[tokio::test] + async fn test_store_actor_json_returns_not_found() { + let store = TestStore::default(); + let err = store.actor_json(&UserId::new()).await.unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); + } + + #[tokio::test] + async fn test_store_fetch_outbox_returns_empty() { + let store = TestStore::default(); + let notes = store + .fetch_outbox_page("https://example.com/outbox", 1) + .await + .unwrap(); + assert!(notes.is_empty()); + } + + #[tokio::test] + async fn test_store_resolve_actor_profiles_returns_empty() { + let store = TestStore::default(); + let result = store + .resolve_actor_profiles(vec!["https://example.com/users/alice".into()]) + .await; + assert!(result.is_empty()); + } + + #[tokio::test] + async fn test_store_fetch_collection_urls_returns_empty() { + let store = TestStore::default(); + let urls = store + .fetch_actor_urls_from_collection("https://example.com/users/alice/followers") + .await + .unwrap(); + assert!(urls.is_empty()); + } +} + +mod search_tests { + use super::super::*; + use crate::models::feed::PageParams; + + #[tokio::test] + async fn test_store_search_thoughts_returns_empty() { + let store = TestStore::default(); + let result = store + .search_thoughts( + "hello", + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); + assert_eq!(result.total, 0); + } + + #[tokio::test] + async fn test_store_search_users_returns_empty() { + let store = TestStore::default(); + let result = store + .search_users( + "alice", + &PageParams { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); + assert_eq!(result.total, 0); + } +} diff --git a/crates/domain/src/value_objects.rs b/crates/domain/src/value_objects/mod.rs similarity index 80% rename from crates/domain/src/value_objects.rs rename to crates/domain/src/value_objects/mod.rs index 7d3f3a8..04129fb 100644 --- a/crates/domain/src/value_objects.rs +++ b/crates/domain/src/value_objects/mod.rs @@ -116,35 +116,4 @@ impl std::fmt::Display for Content { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn username_rejects_empty() { - assert!(Username::new("").is_err()); - } - #[test] - fn username_rejects_too_long() { - assert!(Username::new("a".repeat(33)).is_err()); - } - #[test] - fn username_rejects_invalid_chars() { - assert!(Username::new("hello world").is_err()); - } - #[test] - fn username_accepts_valid() { - assert!(Username::new("hello_123").is_ok()); - } - #[test] - fn content_local_rejects_over_128() { - assert!(Content::new_local("a".repeat(129)).is_err()); - } - #[test] - fn content_local_accepts_128() { - assert!(Content::new_local("a".repeat(128)).is_ok()); - } - #[test] - fn email_rejects_no_at() { - assert!(Email::new("notanemail").is_err()); - } -} +mod tests; diff --git a/crates/domain/src/value_objects/tests.rs b/crates/domain/src/value_objects/tests.rs new file mode 100644 index 0000000..afef5a4 --- /dev/null +++ b/crates/domain/src/value_objects/tests.rs @@ -0,0 +1,30 @@ +use super::*; + +#[test] +fn username_rejects_empty() { + assert!(Username::new("").is_err()); +} +#[test] +fn username_rejects_too_long() { + assert!(Username::new("a".repeat(33)).is_err()); +} +#[test] +fn username_rejects_invalid_chars() { + assert!(Username::new("hello world").is_err()); +} +#[test] +fn username_accepts_valid() { + assert!(Username::new("hello_123").is_ok()); +} +#[test] +fn content_local_rejects_over_128() { + assert!(Content::new_local("a".repeat(129)).is_err()); +} +#[test] +fn content_local_accepts_128() { + assert!(Content::new_local("a".repeat(128)).is_ok()); +} +#[test] +fn email_rejects_no_at() { + assert!(Email::new("notanemail").is_err()); +} diff --git a/crates/presentation/src/handlers/federation_actors.rs b/crates/presentation/src/handlers/federation_actors/mod.rs similarity index 81% rename from crates/presentation/src/handlers/federation_actors.rs rename to crates/presentation/src/handlers/federation_actors/mod.rs index 8d15177..a84fe94 100644 --- a/crates/presentation/src/handlers/federation_actors.rs +++ b/crates/presentation/src/handlers/federation_actors/mod.rs @@ -120,32 +120,4 @@ async fn actor_connections_handler( } #[cfg(test)] -mod tests { - use super::*; - use crate::testing::make_state; - use axum::{body::Body, http::Request, routing::get, Router}; - use tower::ServiceExt; - - fn app() -> Router { - Router::new() - .route( - "/federation/actors/{handle}/posts", - get(remote_actor_posts_handler), - ) - .with_state(make_state()) - } - - #[tokio::test] - async fn unknown_actor_returns_404() { - let resp = app() - .oneshot( - Request::builder() - .uri("/federation/actors/%40alice%40example.com/posts") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 404); - } -} +mod tests; diff --git a/crates/presentation/src/handlers/federation_actors/tests.rs b/crates/presentation/src/handlers/federation_actors/tests.rs new file mode 100644 index 0000000..b699db5 --- /dev/null +++ b/crates/presentation/src/handlers/federation_actors/tests.rs @@ -0,0 +1,27 @@ +use super::*; +use crate::testing::make_state; +use axum::{body::Body, http::Request, routing::get, Router}; +use tower::ServiceExt; + +fn app() -> Router { + Router::new() + .route( + "/federation/actors/{handle}/posts", + get(remote_actor_posts_handler), + ) + .with_state(make_state()) +} + +#[tokio::test] +async fn unknown_actor_returns_404() { + let resp = app() + .oneshot( + Request::builder() + .uri("/federation/actors/%40alice%40example.com/posts") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); +} diff --git a/crates/presentation/src/handlers/notifications.rs b/crates/presentation/src/handlers/notifications/mod.rs similarity index 61% rename from crates/presentation/src/handlers/notifications.rs rename to crates/presentation/src/handlers/notifications/mod.rs index 833cecf..2374c7f 100644 --- a/crates/presentation/src/handlers/notifications.rs +++ b/crates/presentation/src/handlers/notifications/mod.rs @@ -67,53 +67,4 @@ pub async fn mark_all_read( } #[cfg(test)] -mod tests { - use super::*; - use crate::testing::make_state; - use axum::{ - body::Body, - http::{header, Request}, - routing::{get, patch}, - Router, - }; - use tower::ServiceExt; - - fn app() -> Router { - Router::new() - .route("/notifications", patch(mark_all_read)) - .route("/notifications/{id}", patch(mark_notification_read)) - .with_state(make_state()) - } - - #[tokio::test] - async fn patch_notification_without_auth_returns_401() { - let resp = app() - .oneshot( - Request::builder() - .method("PATCH") - .uri("/notifications/00000000-0000-0000-0000-000000000001") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"read":true}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 401); - } - - #[tokio::test] - async fn patch_all_without_auth_returns_401() { - let resp = app() - .oneshot( - Request::builder() - .method("PATCH") - .uri("/notifications") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"read":true}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 401); - } -} +mod tests; diff --git a/crates/presentation/src/handlers/notifications/tests.rs b/crates/presentation/src/handlers/notifications/tests.rs new file mode 100644 index 0000000..b3e19a2 --- /dev/null +++ b/crates/presentation/src/handlers/notifications/tests.rs @@ -0,0 +1,48 @@ +use super::*; +use crate::testing::make_state; +use axum::{ + body::Body, + http::{header, Request}, + routing::{get, patch}, + Router, +}; +use tower::ServiceExt; + +fn app() -> Router { + Router::new() + .route("/notifications", patch(mark_all_read)) + .route("/notifications/{id}", patch(mark_notification_read)) + .with_state(make_state()) +} + +#[tokio::test] +async fn patch_notification_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/notifications/00000000-0000-0000-0000-000000000001") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"read":true}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); +} + +#[tokio::test] +async fn patch_all_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/notifications") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"read":true}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); +} diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social/mod.rs similarity index 82% rename from crates/presentation/src/handlers/social.rs rename to crates/presentation/src/handlers/social/mod.rs index 9cb9c4b..0958dde 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/presentation/src/handlers/social/mod.rs @@ -155,53 +155,4 @@ pub async fn get_top_friends_handler( } #[cfg(test)] -mod tests { - use super::*; - use crate::testing::make_state; - use axum::{ - body::Body, - http::Request, - routing::{delete, post}, - Router, - }; - use tower::ServiceExt; - - fn app() -> Router { - Router::new() - .route( - "/users/{username}/follow", - post(post_follow).delete(delete_follow), - ) - .with_state(make_state()) - } - - #[tokio::test] - async fn follow_without_auth_returns_401() { - let resp = app() - .oneshot( - Request::builder() - .method("POST") - .uri("/users/alice/follow") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 401); - } - - #[tokio::test] - async fn unfollow_remote_without_auth_returns_401() { - let resp = app() - .oneshot( - Request::builder() - .method("DELETE") - .uri("/users/alice@example.com/follow") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 401); - } -} +mod tests; diff --git a/crates/presentation/src/handlers/social/tests.rs b/crates/presentation/src/handlers/social/tests.rs new file mode 100644 index 0000000..ef61d51 --- /dev/null +++ b/crates/presentation/src/handlers/social/tests.rs @@ -0,0 +1,48 @@ +use super::*; +use crate::testing::make_state; +use axum::{ + body::Body, + http::Request, + routing::{delete, post}, + Router, +}; +use tower::ServiceExt; + +fn app() -> Router { + Router::new() + .route( + "/users/{username}/follow", + post(post_follow).delete(delete_follow), + ) + .with_state(make_state()) +} + +#[tokio::test] +async fn follow_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/alice/follow") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); +} + +#[tokio::test] +async fn unfollow_remote_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/alice@example.com/follow") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); +} diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users/mod.rs similarity index 80% rename from crates/presentation/src/handlers/users.rs rename to crates/presentation/src/handlers/users/mod.rs index 0360b4e..95560ce 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users/mod.rs @@ -228,64 +228,4 @@ pub async fn lookup_handler( } #[cfg(test)] -mod tests { - use super::*; - use crate::testing::make_state; - use axum::{ - body::Body, - http::{header, Request}, - routing::get, - Router, - }; - use tower::ServiceExt; - - fn app() -> Router { - Router::new() - .route("/users/{username}", get(get_user)) - .route("/users/lookup", get(lookup_handler)) - .with_state(make_state()) - } - - #[tokio::test] - async fn get_unknown_user_returns_404() { - let resp = app() - .oneshot( - Request::builder() - .uri("/users/nobody") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 404); - } - - #[tokio::test] - async fn get_user_with_ap_accept_returns_404_when_actor_not_found() { - let resp = app() - .oneshot( - Request::builder() - .uri("/users/nobody") - .header(header::ACCEPT, "application/activity+json") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 404); - } - - #[tokio::test] - async fn lookup_unknown_handle_returns_404() { - let resp = app() - .oneshot( - Request::builder() - .uri("/users/lookup?handle=%40alice%40example.com") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), 404); - } -} +mod tests; diff --git a/crates/presentation/src/handlers/users/tests.rs b/crates/presentation/src/handlers/users/tests.rs new file mode 100644 index 0000000..d03ebf3 --- /dev/null +++ b/crates/presentation/src/handlers/users/tests.rs @@ -0,0 +1,59 @@ +use super::*; +use crate::testing::make_state; +use axum::{ + body::Body, + http::{header, Request}, + routing::get, + Router, +}; +use tower::ServiceExt; + +fn app() -> Router { + Router::new() + .route("/users/{username}", get(get_user)) + .route("/users/lookup", get(lookup_handler)) + .with_state(make_state()) +} + +#[tokio::test] +async fn get_unknown_user_returns_404() { + let resp = app() + .oneshot( + Request::builder() + .uri("/users/nobody") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); +} + +#[tokio::test] +async fn get_user_with_ap_accept_returns_404_when_actor_not_found() { + let resp = app() + .oneshot( + Request::builder() + .uri("/users/nobody") + .header(header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); +} + +#[tokio::test] +async fn lookup_unknown_handle_returns_404() { + let resp = app() + .oneshot( + Request::builder() + .uri("/users/lookup?handle=%40alice%40example.com") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); +}