feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1
@@ -14,6 +14,7 @@ sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
@@ -104,7 +104,7 @@ mod tests {
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{feed::UserSummary, user::User},
|
||||
models::{feed::{PageParams, Paginated, UserSummary}, user::User},
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
|
||||
testing::{NoOpEventPublisher, TestStore},
|
||||
value_objects::{Email, PasswordHash, UserId, Username},
|
||||
@@ -135,6 +135,12 @@ mod tests {
|
||||
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]
|
||||
@@ -177,6 +183,12 @@ mod tests {
|
||||
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]
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::thought::{Thought, Visibility},
|
||||
ports::{EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
|
||||
models::{
|
||||
feed::{EngagementStats, FeedEntry},
|
||||
thought::{Thought, Visibility},
|
||||
},
|
||||
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
|
||||
value_objects::{Content, ThoughtId, UserId},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> {
|
||||
if thought.user_id != *user_id {
|
||||
@@ -127,6 +131,67 @@ pub async fn get_thread(
|
||||
thoughts.get_thread(id).await
|
||||
}
|
||||
|
||||
/// 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_else(|| {
|
||||
(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_else(|| {
|
||||
(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::*;
|
||||
@@ -323,3 +388,84 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user