feat(application): add get_thought_view + get_thread_views use cases with real engagement stats
This commit is contained in:
@@ -14,6 +14,7 @@ sha2 = "0.10"
|
|||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ mod tests {
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{feed::UserSummary, user::User},
|
models::{feed::{PageParams, Paginated, UserSummary}, user::User},
|
||||||
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
|
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
|
||||||
testing::{NoOpEventPublisher, TestStore},
|
testing::{NoOpEventPublisher, TestStore},
|
||||||
value_objects::{Email, PasswordHash, UserId, Username},
|
value_objects::{Email, PasswordHash, UserId, Username},
|
||||||
@@ -135,6 +135,12 @@ mod tests {
|
|||||||
async fn count(&self) -> Result<i64, DomainError> {
|
async fn count(&self) -> Result<i64, DomainError> {
|
||||||
self.0.count().await
|
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]
|
#[async_trait]
|
||||||
@@ -177,6 +183,12 @@ mod tests {
|
|||||||
async fn count(&self) -> Result<i64, DomainError> {
|
async fn count(&self) -> Result<i64, DomainError> {
|
||||||
self.0.count().await
|
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]
|
#[async_trait]
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::thought::{Thought, Visibility},
|
models::{
|
||||||
ports::{EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
|
feed::{EngagementStats, FeedEntry},
|
||||||
|
thought::{Thought, Visibility},
|
||||||
|
},
|
||||||
|
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
|
||||||
value_objects::{Content, ThoughtId, UserId},
|
value_objects::{Content, ThoughtId, UserId},
|
||||||
};
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> {
|
fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
if thought.user_id != *user_id {
|
if thought.user_id != *user_id {
|
||||||
@@ -127,6 +131,67 @@ pub async fn get_thread(
|
|||||||
thoughts.get_thread(id).await
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -323,3 +388,84 @@ mod tests {
|
|||||||
assert_eq!(reply.in_reply_to_id, Some(original.id.clone()));
|
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