feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready (#1)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled

This commit was merged in pull request #1.
This commit is contained in:
2026-05-16 09:42:40 +00:00
parent 071809bc3f
commit 9aee4ceb6d
224 changed files with 35418 additions and 1469 deletions

View File

@@ -0,0 +1,101 @@
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);
}
}

View File

@@ -0,0 +1,388 @@
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
);
}
}

View File

@@ -0,0 +1,191 @@
use activitypub_base::ActivityPubRepository;
use domain::{
errors::DomainError,
models::{
actor_connection_summary::ActorConnectionSummary,
feed::{FeedEntry, PageParams, Paginated},
remote_actor::RemoteActor,
},
ports::{
EventPublisher, FederationActionPort, FederationFollowPort,
FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository,
FollowRepository, RemoteActorConnectionRepository, UserReader,
},
value_objects::UserId,
};
use super::social;
pub async fn list_pending_requests(
federation: &dyn FederationFollowRequestPort,
user_id: &UserId,
) -> Result<Vec<RemoteActor>, DomainError> {
federation.get_pending_followers(user_id).await
}
pub async fn accept_follow_request(
federation: &dyn FederationFollowRequestPort,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
federation.accept_follow_request(user_id, actor_url).await
}
pub async fn reject_follow_request(
federation: &dyn FederationFollowRequestPort,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
federation.reject_follow_request(user_id, actor_url).await
}
pub async fn list_remote_followers(
federation: &dyn FederationFollowRequestPort,
user_id: &UserId,
) -> Result<Vec<RemoteActor>, DomainError> {
federation.get_remote_followers(user_id).await
}
pub async fn remove_remote_follower(
federation: &dyn FederationFollowRequestPort,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
federation.remove_remote_follower(user_id, actor_url).await
}
pub async fn list_remote_following(
federation: &dyn FederationFollowPort,
user_id: &UserId,
) -> Result<Vec<RemoteActor>, DomainError> {
federation.get_remote_following(user_id).await
}
pub async fn remove_remote_following(
follows: &dyn FollowRepository,
users: &dyn UserReader,
federation: &dyn FederationFollowPort,
events: &dyn EventPublisher,
user_id: &UserId,
handle: &str,
) -> Result<(), DomainError> {
social::unfollow_actor(follows, users, federation, events, user_id, handle).await
}
pub async fn get_remote_actor_posts(
federation: &dyn FederationActionPort,
ap_repo: &dyn ActivityPubRepository,
feed: &dyn FeedRepository,
scheduler: &dyn FederationSchedulerPort,
handle: &str,
page: PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let actor = federation.lookup_actor(handle).await?;
let author_id = match ap_repo.find_remote_actor_id(&actor.url).await? {
Some(id) => id,
None => ap_repo.intern_remote_actor(&actor.url).await?,
};
let result = feed.query(&FeedQuery::user(author_id, page.clone(), viewer_id.cloned())).await?;
if let Some(outbox_url) = actor.outbox_url {
let _ = scheduler
.schedule_actor_posts_fetch(&actor.url, &outbox_url)
.await;
}
Ok(result)
}
const ACTOR_CONNECTIONS_CACHE_TTL_SECS: i64 = 3600;
pub async fn get_actor_connections_page(
federation: &dyn FederationActionPort,
connections: &dyn RemoteActorConnectionRepository,
scheduler: &dyn FederationSchedulerPort,
handle: &str,
connection_type: &str,
page: u32,
) -> Result<(Vec<ActorConnectionSummary>, bool), DomainError> {
const PAGE_SIZE: usize = 20;
let actor = federation.lookup_actor(handle).await?;
let collection_url = match connection_type {
"followers" => actor.followers_url.ok_or(DomainError::NotFound)?,
_ => actor.following_url.ok_or(DomainError::NotFound)?,
};
let items = connections
.list_connections(&actor.url, connection_type, page)
.await?;
let stale = match connections
.connection_page_age(&actor.url, connection_type, page)
.await?
{
None => true,
Some(age) => {
chrono::Utc::now().signed_duration_since(age).num_seconds()
> ACTOR_CONNECTIONS_CACHE_TTL_SECS
}
};
if stale {
let _ = scheduler
.schedule_connections_fetch(&actor.url, &collection_url, connection_type, page)
.await;
}
let has_more = items.len() >= PAGE_SIZE;
Ok((items, has_more))
}
#[cfg(test)]
mod tests {
use super::*;
use domain::testing::TestStore;
#[tokio::test]
async fn list_pending_returns_empty_by_default() {
let store = TestStore::default();
let uid = UserId::new();
let result = list_pending_requests(&store, &uid).await.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn accept_follow_request_returns_ok() {
let store = TestStore::default();
let uid = UserId::new();
accept_follow_request(&store, &uid, "https://mastodon.social/users/alice")
.await
.unwrap();
}
#[tokio::test]
async fn reject_follow_request_returns_ok() {
let store = TestStore::default();
let uid = UserId::new();
reject_follow_request(&store, &uid, "https://mastodon.social/users/alice")
.await
.unwrap();
}
#[tokio::test]
async fn list_remote_followers_returns_empty_by_default() {
let store = TestStore::default();
let uid = UserId::new();
let result = list_remote_followers(&store, &uid).await.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn remove_remote_follower_returns_ok() {
let store = TestStore::default();
let uid = UserId::new();
remove_remote_follower(&store, &uid, "https://mastodon.social/users/alice")
.await
.unwrap();
}
#[tokio::test]
async fn list_remote_following_returns_empty_by_default() {
let store = TestStore::default();
let uid = UserId::new();
let result = list_remote_following(&store, &uid).await.unwrap();
assert!(result.is_empty());
}
}

View File

@@ -0,0 +1,17 @@
use domain::{
errors::DomainError,
models::feed::{FeedEntry, PageParams, Paginated},
ports::{FeedQuery, FeedRepository, FollowRepository},
value_objects::UserId,
};
pub async fn get_home_feed(
feed: &dyn FeedRepository,
follows: &dyn FollowRepository,
user_id: &UserId,
page: PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
following_ids.push(user_id.clone());
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)).await
}

View File

@@ -0,0 +1,8 @@
pub mod api_keys;
pub mod auth;
pub mod federation_management;
pub mod feed;
pub mod notifications;
pub mod profile;
pub mod social;
pub mod thoughts;

View File

@@ -0,0 +1,47 @@
use domain::{
errors::DomainError,
models::feed::{PageParams, Paginated},
models::notification::Notification,
ports::NotificationRepository,
value_objects::{NotificationId, UserId},
};
pub async fn list_notifications(
repo: &dyn NotificationRepository,
user_id: &UserId,
page: PageParams,
) -> Result<Paginated<Notification>, DomainError> {
repo.list_for_user(user_id, &page).await
}
pub async fn count_unread_notifications(
repo: &dyn NotificationRepository,
user_id: &UserId,
) -> Result<u64, DomainError> {
repo.count_unread(user_id).await
}
pub async fn mark_notification_read(
repo: &dyn NotificationRepository,
id: &NotificationId,
user_id: &UserId,
is_read: bool,
) -> Result<(), DomainError> {
if is_read {
repo.mark_read(id, user_id).await
} else {
Ok(())
}
}
pub async fn mark_all_notifications_read(
repo: &dyn NotificationRepository,
user_id: &UserId,
is_read: bool,
) -> Result<(), DomainError> {
if is_read {
repo.mark_all_read(user_id).await
} else {
Ok(())
}
}

View File

@@ -0,0 +1,163 @@
const MAX_TOP_FRIENDS: usize = 8;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{top_friend::TopFriend, user::User},
ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter},
value_objects::{UserId, Username},
};
pub async fn get_user(users: &dyn UserReader, user_id: &UserId) -> Result<User, DomainError> {
users
.find_by_id(user_id)
.await?
.ok_or(DomainError::NotFound)
}
pub async fn get_user_by_username(
users: &dyn UserReader,
username: &str,
) -> Result<User, DomainError> {
let username = Username::new(username).map_err(|_| DomainError::NotFound)?;
users
.find_by_username(&username)
.await?
.ok_or(DomainError::NotFound)
}
/// Resolve a path segment that is either a UUID (AP actor URL) or a username.
pub async fn get_user_by_id_or_username(
users: &dyn UserReader,
id_or_username: &str,
) -> Result<User, DomainError> {
if let Ok(uuid) = uuid::Uuid::parse_str(id_or_username) {
users
.find_by_id(&UserId::from_uuid(uuid))
.await?
.ok_or(DomainError::NotFound)
} else {
get_user_by_username(users, id_or_username).await
}
}
#[allow(clippy::too_many_arguments)]
pub async fn update_profile(
users: &dyn UserWriter,
events: &dyn EventPublisher,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> {
users
.update_profile(
user_id,
display_name,
bio,
avatar_url,
header_url,
custom_css,
)
.await?;
events
.publish(&DomainEvent::ProfileUpdated {
user_id: user_id.clone(),
})
.await
}
pub async fn get_top_friends(
top_friends: &dyn TopFriendRepository,
user_id: &UserId,
) -> Result<Vec<(TopFriend, User)>, DomainError> {
top_friends.list_for_user(user_id).await
}
pub async fn set_top_friends(
top_friends: &dyn TopFriendRepository,
user_id: &UserId,
friend_ids: Vec<UserId>,
) -> Result<(), DomainError> {
if friend_ids.len() > MAX_TOP_FRIENDS {
return Err(DomainError::InvalidInput("top friends: max 8".into()));
}
let friends: Vec<(UserId, i16)> = friend_ids
.into_iter()
.enumerate()
.map(|(i, id)| (id, (i + 1) as i16))
.collect();
top_friends.set_top_friends(user_id, friends).await
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{
errors::DomainError,
models::user::User,
testing::TestStore,
value_objects::{Email, PasswordHash, UserId, Username},
};
fn make_user() -> User {
User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
)
}
#[tokio::test]
async fn set_top_friends_rejects_more_than_eight() {
let store = TestStore::default();
let uid = UserId::new();
let friends: Vec<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);
}
}

View File

@@ -0,0 +1,487 @@
use chrono::Utc;
use domain::{
errors::DomainError,
events::DomainEvent,
models::social::{Block, Boost, Follow, FollowState, Like},
ports::{
BlockRepository, BoostRepository, EventPublisher, FederationFollowPort, FollowRepository,
LikeRepository, UserReader,
},
value_objects::{BoostId, LikeId, ThoughtId, UserId, Username},
};
pub async fn like_thought(
likes: &dyn LikeRepository,
events: &dyn EventPublisher,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<(), DomainError> {
let like = Like {
id: LikeId::new(),
user_id: user_id.clone(),
thought_id: thought_id.clone(),
ap_id: None,
created_at: Utc::now(),
};
likes.save(&like).await?;
events
.publish(&DomainEvent::LikeAdded {
like_id: like.id,
user_id: user_id.clone(),
thought_id: thought_id.clone(),
})
.await?;
Ok(())
}
pub async fn unlike_thought(
likes: &dyn LikeRepository,
events: &dyn EventPublisher,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<(), DomainError> {
likes.delete(user_id, thought_id).await?;
events
.publish(&DomainEvent::LikeRemoved {
user_id: user_id.clone(),
thought_id: thought_id.clone(),
})
.await?;
Ok(())
}
pub async fn boost_thought(
boosts: &dyn BoostRepository,
events: &dyn EventPublisher,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<(), DomainError> {
let boost = Boost {
id: BoostId::new(),
user_id: user_id.clone(),
thought_id: thought_id.clone(),
ap_id: None,
created_at: Utc::now(),
};
boosts.save(&boost).await?;
events
.publish(&DomainEvent::BoostAdded {
boost_id: boost.id,
user_id: user_id.clone(),
thought_id: thought_id.clone(),
})
.await?;
Ok(())
}
pub async fn unboost_thought(
boosts: &dyn BoostRepository,
events: &dyn EventPublisher,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<(), DomainError> {
boosts.delete(user_id, thought_id).await?;
events
.publish(&DomainEvent::BoostRemoved {
user_id: user_id.clone(),
thought_id: thought_id.clone(),
})
.await?;
Ok(())
}
pub async fn follow_actor(
follows: &dyn FollowRepository,
users: &dyn UserReader,
federation: &dyn FederationFollowPort,
events: &dyn EventPublisher,
follower_id: &UserId,
username: &str,
) -> Result<(), DomainError> {
if username.contains('@') {
federation.follow_remote(follower_id, username).await
} else {
let uname = Username::new(username)
.map_err(|_| DomainError::InvalidInput("invalid username".into()))?;
let target = users
.find_by_username(&uname)
.await?
.ok_or(DomainError::NotFound)?;
follow_user(follows, events, follower_id, &target.id).await
}
}
pub async fn follow_user(
follows: &dyn FollowRepository,
events: &dyn EventPublisher,
follower_id: &UserId,
following_id: &UserId,
) -> Result<(), DomainError> {
if follower_id == following_id {
return Err(DomainError::InvalidInput("cannot follow yourself".into()));
}
let follow = Follow {
follower_id: follower_id.clone(),
following_id: following_id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
};
follows.save(&follow).await?;
events
.publish(&DomainEvent::FollowAccepted {
follower_id: follower_id.clone(),
following_id: following_id.clone(),
})
.await?;
Ok(())
}
pub async fn unfollow_actor(
follows: &dyn FollowRepository,
users: &dyn UserReader,
federation: &dyn FederationFollowPort,
events: &dyn EventPublisher,
follower_id: &UserId,
username: &str,
) -> Result<(), DomainError> {
if username.contains('@') {
federation.unfollow_remote(follower_id, username).await
} else {
let uname = Username::new(username)
.map_err(|_| DomainError::InvalidInput("invalid username".into()))?;
let target = users
.find_by_username(&uname)
.await?
.ok_or(DomainError::NotFound)?;
unfollow_user(follows, events, follower_id, &target.id).await
}
}
pub async fn unfollow_user(
follows: &dyn FollowRepository,
events: &dyn EventPublisher,
follower_id: &UserId,
following_id: &UserId,
) -> Result<(), DomainError> {
follows.delete(follower_id, following_id).await?;
events
.publish(&DomainEvent::Unfollowed {
follower_id: follower_id.clone(),
following_id: following_id.clone(),
})
.await?;
Ok(())
}
pub async fn accept_follow(
follows: &dyn FollowRepository,
events: &dyn EventPublisher,
follower_id: &UserId,
following_id: &UserId,
) -> Result<(), DomainError> {
follows
.update_state(follower_id, following_id, &FollowState::Accepted)
.await?;
events
.publish(&DomainEvent::FollowAccepted {
follower_id: follower_id.clone(),
following_id: following_id.clone(),
})
.await?;
Ok(())
}
pub async fn reject_follow(
follows: &dyn FollowRepository,
events: &dyn EventPublisher,
follower_id: &UserId,
following_id: &UserId,
) -> Result<(), DomainError> {
follows
.update_state(follower_id, following_id, &FollowState::Rejected)
.await?;
events
.publish(&DomainEvent::FollowRejected {
follower_id: follower_id.clone(),
following_id: following_id.clone(),
})
.await?;
Ok(())
}
pub async fn block_by_username(
blocks: &dyn BlockRepository,
users: &dyn UserReader,
events: &dyn EventPublisher,
blocker_id: &UserId,
username: &str,
) -> Result<(), DomainError> {
let uname = Username::new(username).map_err(|_| DomainError::NotFound)?;
let target = users
.find_by_username(&uname)
.await?
.ok_or(DomainError::NotFound)?;
block_user(blocks, events, blocker_id, &target.id).await
}
pub async fn unblock_by_username(
blocks: &dyn BlockRepository,
users: &dyn UserReader,
events: &dyn EventPublisher,
blocker_id: &UserId,
username: &str,
) -> Result<(), DomainError> {
let uname = Username::new(username).map_err(|_| DomainError::NotFound)?;
let target = users
.find_by_username(&uname)
.await?
.ok_or(DomainError::NotFound)?;
unblock_user(blocks, events, blocker_id, &target.id).await
}
pub async fn block_user(
blocks: &dyn BlockRepository,
events: &dyn EventPublisher,
blocker_id: &UserId,
blocked_id: &UserId,
) -> Result<(), DomainError> {
if blocker_id == blocked_id {
return Err(DomainError::InvalidInput("cannot block yourself".into()));
}
let block = Block {
blocker_id: blocker_id.clone(),
blocked_id: blocked_id.clone(),
created_at: Utc::now(),
};
blocks.save(&block).await?;
events
.publish(&DomainEvent::UserBlocked {
blocker_id: blocker_id.clone(),
blocked_id: blocked_id.clone(),
})
.await?;
Ok(())
}
pub async fn unblock_user(
blocks: &dyn BlockRepository,
events: &dyn EventPublisher,
blocker_id: &UserId,
blocked_id: &UserId,
) -> Result<(), DomainError> {
blocks.delete(blocker_id, blocked_id).await?;
events
.publish(&DomainEvent::UserUnblocked {
blocker_id: blocker_id.clone(),
blocked_id: blocked_id.clone(),
})
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
testing::TestStore,
value_objects::*,
};
fn user(name: &str) -> User {
User::new_local(
UserId::new(),
Username::new(name).unwrap(),
Email::new(format!("{name}@ex.com")).unwrap(),
PasswordHash("h".into()),
)
}
#[tokio::test]
async fn like_and_unlike() {
let store = TestStore::default();
let alice = user("alice");
let tid = ThoughtId::new();
store.thoughts.lock().unwrap().push(Thought::new_local(
tid.clone(),
alice.id.clone(),
Content::new_local("hi").unwrap(),
None,
Visibility::Public,
None,
false,
));
like_thought(&store, &store, &alice.id, &tid).await.unwrap();
assert_eq!(store.likes.lock().unwrap().len(), 1);
unlike_thought(&store, &store, &alice.id, &tid)
.await
.unwrap();
assert!(store.likes.lock().unwrap().is_empty());
}
#[tokio::test]
async fn follow_and_unfollow() {
let store = TestStore::default();
let alice = user("alice");
let bob = user("bob");
follow_user(&store, &store, &alice.id, &bob.id)
.await
.unwrap();
assert_eq!(store.follows.lock().unwrap().len(), 1);
unfollow_user(&store, &store, &alice.id, &bob.id)
.await
.unwrap();
assert!(store.follows.lock().unwrap().is_empty());
}
#[tokio::test]
async fn cannot_follow_self() {
let store = TestStore::default();
let alice = user("alice");
let err = follow_user(&store, &store, &alice.id, &alice.id)
.await
.unwrap_err();
assert!(matches!(err, DomainError::InvalidInput(_)));
}
#[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 { .. })));
}
}

View File

@@ -0,0 +1,456 @@
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);
}
}