diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 4669625..c0a7a83 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -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"] } diff --git a/crates/application/src/use_cases/auth.rs b/crates/application/src/use_cases/auth.rs index d59001c..54d50dd 100644 --- a/crates/application/src/use_cases/auth.rs +++ b/crates/application/src/use_cases/auth.rs @@ -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 { self.0.count().await } + async fn list_paginated(&self, page: PageParams) -> Result, DomainError> { + self.0.list_paginated(page).await + } + async fn find_by_ids(&self, ids: &[UserId]) -> Result, DomainError> { + self.0.find_by_ids(ids).await + } } #[async_trait] @@ -177,6 +183,12 @@ mod tests { async fn count(&self) -> Result { self.0.count().await } + async fn list_paginated(&self, page: PageParams) -> Result, DomainError> { + self.0.list_paginated(page).await + } + async fn find_by_ids(&self, ids: &[UserId]) -> Result, DomainError> { + self.0.find_by_ids(ids).await + } } #[async_trait] diff --git a/crates/application/src/use_cases/thoughts.rs b/crates/application/src/use_cases/thoughts.rs index abb8b98..0a7435b 100644 --- a/crates/application/src/use_cases/thoughts.rs +++ b/crates/application/src/use_cases/thoughts.rs @@ -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 { + 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, DomainError> { + let thread = thoughts.get_thread(root_id).await?; + if thread.is_empty() { + return Ok(vec![]); + } + + let thought_ids: Vec = thread.iter().map(|t| t.id.clone()).collect(); + let user_ids: Vec = thread.iter().map(|t| t.user_id.clone()).collect(); + + let (authors_map, engagement_map) = tokio::join!( + users.find_by_ids(&user_ids), + engagement.get_for_thoughts(&thought_ids, viewer), + ); + let authors_map = authors_map?; + let mut engagement_map = engagement_map?; + + let mut entries = Vec::with_capacity(thread.len()); + for thought in thread { + let author = authors_map + .get(&thought.user_id) + .cloned() + .ok_or(DomainError::NotFound)?; + let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or_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(); + ::save(&store, &user).await.unwrap(); + let thought = make_thought(user.id.clone()); + ::save(&store, &thought).await.unwrap(); + + let entry = get_thought_view(&store, &store, &store, &thought.id, None) + .await + .unwrap(); + assert_eq!(entry.thought.id, thought.id); + assert_eq!(entry.author.id, user.id); + assert_eq!(entry.stats.like_count, 0); + assert!(entry.viewer.is_none()); + } + + #[tokio::test] + async fn get_thought_view_returns_not_found_for_missing_thought() { + let store = TestStore::default(); + let err = get_thought_view(&store, &store, &store, &ThoughtId::new(), None) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::NotFound)); + } + + #[tokio::test] + async fn get_thread_views_batches_correctly() { + let store = TestStore::default(); + let user = make_user(); + ::save(&store, &user).await.unwrap(); + let root = make_thought(user.id.clone()); + ::save(&store, &root).await.unwrap(); + let reply = Thought::new_local( + ThoughtId::new(), + user.id.clone(), + Content::new_local(String::from("reply")).unwrap(), + Some(root.id.clone()), + Visibility::Public, + None, + false, + ); + ::save(&store, &reply).await.unwrap(); + + let entries = get_thread_views(&store, &store, &store, &root.id, None) + .await + .unwrap(); + assert_eq!(entries.len(), 2); + } +}