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