add 400+ unit tests for domain and application layers
Some checks failed
CI / Check / Test (push) Has been cancelled
Some checks failed
CI / Check / Test (push) Has been cancelled
Extract ReviewLogger trait to decouple import/integrations from diary::log_review (cross-module coupling smell). Add in-memory fakes for all repository ports, enabling isolated testing of every use case module without a database. Coverage: domain+application 22% → 80%, 427 tests.
This commit is contained in:
@@ -64,3 +64,7 @@ async fn build_following_filter(
|
||||
remote_actor_urls: remote_urls,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_activity_feed.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -27,3 +27,7 @@ pub async fn execute(
|
||||
|
||||
ctx.repos.diary.query_diary(&filter).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_diary.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -43,3 +43,7 @@ pub async fn execute(
|
||||
profile,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_movie_social_page.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -21,3 +21,7 @@ pub async fn execute(
|
||||
|
||||
Ok((history, trend))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_review_history.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,98 +1,11 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Movie, Review},
|
||||
value_objects::{Comment, MovieId, Rating, UserId},
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::{
|
||||
context::AppContext,
|
||||
diary::commands::LogReviewCommand,
|
||||
diary::movie_resolver::{MovieResolver, MovieResolverDeps},
|
||||
};
|
||||
use crate::{context::AppContext, diary::commands::LogReviewCommand};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), DomainError> {
|
||||
let rating = Rating::new(cmd.rating)?;
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
|
||||
|
||||
let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id {
|
||||
let movie_id = MovieId::from_uuid(id);
|
||||
let movie = ctx
|
||||
.repos
|
||||
.movie
|
||||
.get_movie_by_id(&movie_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
|
||||
(movie, false)
|
||||
} else {
|
||||
let deps = MovieResolverDeps {
|
||||
repository: ctx.repos.movie.as_ref(),
|
||||
metadata_client: ctx.services.metadata.as_ref(),
|
||||
};
|
||||
MovieResolver::default_pipeline()
|
||||
.resolve(&cmd.input, &deps)
|
||||
.await?
|
||||
};
|
||||
|
||||
ctx.repos.movie.upsert_movie(&movie).await?;
|
||||
|
||||
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
|
||||
let review_event = ctx.repos.review.save_review(&review).await?;
|
||||
|
||||
let was_on_watchlist = ctx
|
||||
.repos
|
||||
.watchlist
|
||||
.remove_if_present(review.user_id(), review.movie_id())
|
||||
.await?;
|
||||
if was_on_watchlist {
|
||||
let _ = ctx
|
||||
.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::WatchlistEntryRemoved {
|
||||
user_id: review.user_id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
publish_events(ctx, &movie, is_new_movie, review_event).await?;
|
||||
|
||||
Ok(())
|
||||
ctx.services.review_logger.log_review(cmd).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/log_review.rs"]
|
||||
mod tests;
|
||||
|
||||
async fn publish_events(
|
||||
ctx: &AppContext,
|
||||
movie: &Movie,
|
||||
is_new_movie: bool,
|
||||
review_event: DomainEvent,
|
||||
) -> Result<(), DomainError> {
|
||||
if is_new_movie && let Some(ext_id) = movie.external_metadata_id() {
|
||||
let discovery_event = DomainEvent::MovieDiscovered {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
};
|
||||
ctx.services
|
||||
.event_publisher
|
||||
.publish(&discovery_event)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
let enrichment_event = DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.value().to_string(),
|
||||
};
|
||||
ctx.services
|
||||
.event_publisher
|
||||
.publish(&enrichment_event)
|
||||
.await?;
|
||||
}
|
||||
|
||||
ctx.services.event_publisher.publish(&review_event).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod get_review_history;
|
||||
pub mod log_review;
|
||||
pub mod movie_resolver;
|
||||
pub mod queries;
|
||||
pub mod review_logger;
|
||||
|
||||
121
crates/application/src/diary/review_logger.rs
Normal file
121
crates/application/src/diary/review_logger.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Movie, Review},
|
||||
ports::{
|
||||
EventPublisher, MetadataClient, MovieRepository, ReviewRepository, WatchlistRepository,
|
||||
},
|
||||
value_objects::{Comment, MovieId, Rating, UserId},
|
||||
};
|
||||
|
||||
use crate::diary::commands::LogReviewCommand;
|
||||
use crate::diary::movie_resolver::{MovieResolver, MovieResolverDeps};
|
||||
use crate::ports::ReviewLogger;
|
||||
|
||||
pub struct DefaultReviewLogger {
|
||||
movie_repo: Arc<dyn MovieRepository>,
|
||||
review_repo: Arc<dyn ReviewRepository>,
|
||||
watchlist_repo: Arc<dyn WatchlistRepository>,
|
||||
metadata_client: Arc<dyn MetadataClient>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
impl DefaultReviewLogger {
|
||||
pub fn new(
|
||||
movie_repo: Arc<dyn MovieRepository>,
|
||||
review_repo: Arc<dyn ReviewRepository>,
|
||||
watchlist_repo: Arc<dyn WatchlistRepository>,
|
||||
metadata_client: Arc<dyn MetadataClient>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
movie_repo,
|
||||
review_repo,
|
||||
watchlist_repo,
|
||||
metadata_client,
|
||||
event_publisher,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ReviewLogger for DefaultReviewLogger {
|
||||
async fn log_review(&self, cmd: LogReviewCommand) -> Result<(), DomainError> {
|
||||
let rating = Rating::new(cmd.rating)?;
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
|
||||
|
||||
let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id {
|
||||
let movie_id = MovieId::from_uuid(id);
|
||||
let movie = self
|
||||
.movie_repo
|
||||
.get_movie_by_id(&movie_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
|
||||
(movie, false)
|
||||
} else {
|
||||
let deps = MovieResolverDeps {
|
||||
repository: self.movie_repo.as_ref(),
|
||||
metadata_client: self.metadata_client.as_ref(),
|
||||
};
|
||||
MovieResolver::default_pipeline()
|
||||
.resolve(&cmd.input, &deps)
|
||||
.await?
|
||||
};
|
||||
|
||||
self.movie_repo.upsert_movie(&movie).await?;
|
||||
|
||||
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
|
||||
let review_event = self.review_repo.save_review(&review).await?;
|
||||
|
||||
let was_on_watchlist = self
|
||||
.watchlist_repo
|
||||
.remove_if_present(review.user_id(), review.movie_id())
|
||||
.await?;
|
||||
if was_on_watchlist {
|
||||
let _ = self
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::WatchlistEntryRemoved {
|
||||
user_id: review.user_id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
publish_events(&self.event_publisher, &movie, is_new_movie, review_event).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/review_logger.rs"]
|
||||
mod tests;
|
||||
|
||||
async fn publish_events(
|
||||
publisher: &Arc<dyn EventPublisher>,
|
||||
movie: &Movie,
|
||||
is_new_movie: bool,
|
||||
review_event: DomainEvent,
|
||||
) -> Result<(), DomainError> {
|
||||
if is_new_movie && let Some(ext_id) = movie.external_metadata_id() {
|
||||
publisher
|
||||
.publish(&DomainEvent::MovieDiscovered {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
publisher
|
||||
.publish(&DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.value().to_string(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
publisher.publish(&review_event).await
|
||||
}
|
||||
139
crates/application/src/diary/tests/get_activity_feed.rs
Normal file
139
crates/application/src/diary/tests/get_activity_feed.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::{
|
||||
diary::get_activity_feed, diary::queries::GetActivityFeedQuery,
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_feed() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_activity_feed::execute(
|
||||
&ctx,
|
||||
GetActivityFeedQuery {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
viewer_user_id: None,
|
||||
filter_following: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_feed_with_following_filter() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
|
||||
let result = get_activity_feed::execute(
|
||||
&ctx,
|
||||
GetActivityFeedQuery {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
viewer_user_id: Some(viewer),
|
||||
filter_following: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// NoopSocialQueryPort returns empty following, so FollowingFilter
|
||||
// contains only the viewer's id. Feed is empty but the code path is hit.
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
struct FakeSocialWithFollowing(Vec<String>);
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::SocialQueryPort for FakeSocialWithFollowing {
|
||||
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
) -> Result<Vec<domain::ports::PendingFollowerInfo>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn list_all_followed_remote_actors(
|
||||
&self,
|
||||
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn following_filter_parses_local_and_remote_urls() {
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
let local_friend = uuid::Uuid::new_v4();
|
||||
|
||||
let following_urls = vec![
|
||||
format!("http://localhost:3000/users/{}", local_friend),
|
||||
"https://remote.example/actor/1".to_string(),
|
||||
];
|
||||
|
||||
let social = Arc::new(FakeSocialWithFollowing(following_urls));
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_social_query(social as _)
|
||||
.build();
|
||||
|
||||
let result = get_activity_feed::execute(
|
||||
&ctx,
|
||||
GetActivityFeedQuery {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
viewer_user_id: Some(viewer),
|
||||
filter_following: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Feed is empty (no data seeded), but the build_following_filter code path
|
||||
// with actual URL parsing ran without errors.
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn following_filter_without_viewer_returns_none() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_activity_feed::execute(
|
||||
&ctx,
|
||||
GetActivityFeedQuery {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
viewer_user_id: None,
|
||||
filter_following: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// filter_following=true but viewer_user_id=None → build_following_filter returns None
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
22
crates/application/src/diary/tests/get_diary.rs
Normal file
22
crates/application/src/diary/tests/get_diary.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use crate::{diary::get_diary, diary::queries::GetDiaryQuery, test_helpers::TestContextBuilder};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_page() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_diary::execute(
|
||||
&ctx,
|
||||
GetDiaryQuery {
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: None,
|
||||
movie_id: None,
|
||||
user_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total_count, 0);
|
||||
}
|
||||
65
crates/application/src/diary/tests/get_movie_social_page.rs
Normal file
65
crates/application/src/diary/tests/get_movie_social_page.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{
|
||||
models::Movie,
|
||||
ports::MovieRepository,
|
||||
testing::InMemoryMovieRepository,
|
||||
value_objects::{MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
diary::get_movie_social_page, diary::queries::GetMovieSocialPageQuery,
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_movie_not_found() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_movie_social_page::execute(
|
||||
&ctx,
|
||||
GetMovieSocialPageQuery {
|
||||
movie_id: Uuid::new_v4(),
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_movie_social_page() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Social Movie".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let movie_uuid = movie.id().value();
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(&movies) as _)
|
||||
.build();
|
||||
|
||||
let result = get_movie_social_page::execute(
|
||||
&ctx,
|
||||
GetMovieSocialPageQuery {
|
||||
movie_id: movie_uuid,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.movie.title().value(), "Social Movie");
|
||||
assert_eq!(result.reviews.items.len(), 0);
|
||||
}
|
||||
34
crates/application/src/diary/tests/get_review_history.rs
Normal file
34
crates/application/src/diary/tests/get_review_history.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use domain::{
|
||||
models::Movie,
|
||||
services::review_history::Trend,
|
||||
value_objects::{MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
diary::get_review_history, diary::queries::GetReviewHistoryQuery,
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_history() {
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Test".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let movie_id = movie.id().value();
|
||||
|
||||
let diary = domain::testing::FakeDiaryRepository::new();
|
||||
diary.seed_history(movie, vec![]);
|
||||
|
||||
let ctx = TestContextBuilder::new().with_diary(diary as _).build();
|
||||
|
||||
let (history, trend) = get_review_history::execute(&ctx, GetReviewHistoryQuery { movie_id })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(history.viewings().is_empty());
|
||||
assert_eq!(trend, Trend::Neutral);
|
||||
}
|
||||
@@ -13,9 +13,30 @@ use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEve
|
||||
use crate::{
|
||||
diary::commands::{LogReviewCommand, MovieInput},
|
||||
diary::log_review,
|
||||
diary::review_logger::DefaultReviewLogger,
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
fn build_ctx_with_real_logger(
|
||||
movies: &Arc<InMemoryMovieRepository>,
|
||||
reviews: &Arc<InMemoryReviewRepository>,
|
||||
events: &Arc<NoopEventPublisher>,
|
||||
) -> crate::context::AppContext {
|
||||
let logger = Arc::new(DefaultReviewLogger::new(
|
||||
Arc::clone(movies) as _,
|
||||
Arc::clone(reviews) as _,
|
||||
crate::test_helpers::TestContextBuilder::new().watchlist_repo,
|
||||
Arc::new(domain::testing::FakeMetadataClient) as _,
|
||||
Arc::clone(events) as _,
|
||||
));
|
||||
TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(movies) as _)
|
||||
.with_reviews(Arc::clone(reviews) as _)
|
||||
.with_event_publisher(Arc::clone(events) as _)
|
||||
.with_review_logger(logger)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn movie_input_manual(title: &str, year: u16) -> MovieInput {
|
||||
MovieInput {
|
||||
movie_id: None,
|
||||
@@ -41,11 +62,7 @@ async fn test_log_review_creates_movie_and_review() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(&movies) as _)
|
||||
.with_reviews(Arc::clone(&reviews) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events);
|
||||
|
||||
let user_id = uuid::Uuid::new_v4();
|
||||
let cmd = LogReviewCommand {
|
||||
@@ -77,10 +94,8 @@ async fn test_log_review_reuses_existing_movie() {
|
||||
let movie_uuid = existing_movie.id().value();
|
||||
movies.upsert_movie(&existing_movie).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(&movies) as _)
|
||||
.with_reviews(Arc::clone(&reviews) as _)
|
||||
.build();
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events);
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: uuid::Uuid::new_v4(),
|
||||
@@ -98,7 +113,10 @@ async fn test_log_review_reuses_existing_movie() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_review_with_invalid_rating_fails() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events);
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: uuid::Uuid::new_v4(),
|
||||
input: movie_input_manual("Some Film", 2000),
|
||||
|
||||
310
crates/application/src/diary/tests/review_logger.rs
Normal file
310
crates/application/src/diary/tests/review_logger.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::Movie,
|
||||
models::WatchlistEntry,
|
||||
ports::{MetadataClient, MetadataSearchCriteria, MovieRepository, WatchlistRepository},
|
||||
testing::{
|
||||
FakeMetadataClient, InMemoryMovieRepository, InMemoryReviewRepository,
|
||||
InMemoryWatchlistRepository, NoopEventPublisher,
|
||||
},
|
||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::diary::commands::{LogReviewCommand, MovieInput};
|
||||
use crate::diary::review_logger::DefaultReviewLogger;
|
||||
use crate::ports::ReviewLogger;
|
||||
|
||||
fn make_logger(
|
||||
movies: &Arc<InMemoryMovieRepository>,
|
||||
reviews: &Arc<InMemoryReviewRepository>,
|
||||
watchlist: &Arc<InMemoryWatchlistRepository>,
|
||||
events: &Arc<NoopEventPublisher>,
|
||||
) -> DefaultReviewLogger {
|
||||
DefaultReviewLogger::new(
|
||||
Arc::clone(movies) as _,
|
||||
Arc::clone(reviews) as _,
|
||||
Arc::clone(watchlist) as _,
|
||||
Arc::new(FakeMetadataClient) as _,
|
||||
Arc::clone(events) as _,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logs_review_with_manual_movie() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
let uid = Uuid::new_v4();
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: uid,
|
||||
input: MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: None,
|
||||
manual_title: Some("Test Film".into()),
|
||||
manual_release_year: Some(2024),
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
assert_eq!(movies.count(), 1);
|
||||
assert_eq!(reviews.count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removes_from_watchlist_on_review() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
let uid = Uuid::new_v4();
|
||||
let user_id = UserId::from_uuid(uid);
|
||||
|
||||
// Create and store movie
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Watchlisted Film".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let movie_id = movie.id().value();
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
// Add to watchlist
|
||||
let entry = WatchlistEntry::new(user_id.clone(), MovieId::from_uuid(movie_id));
|
||||
watchlist.add(&entry).await.unwrap();
|
||||
assert_eq!(watchlist.count(), 1);
|
||||
|
||||
// Log review for same movie
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: uid,
|
||||
input: MovieInput {
|
||||
movie_id: Some(movie_id),
|
||||
external_metadata_id: None,
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 5,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
assert_eq!(watchlist.count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logs_review_with_existing_movie_by_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Existing Film".into()).unwrap(),
|
||||
ReleaseYear::new(2020).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let movie_uuid = movie.id().value();
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
input: MovieInput {
|
||||
movie_id: Some(movie_uuid),
|
||||
external_metadata_id: None,
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 3,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
assert_eq!(movies.count(), 1);
|
||||
assert_eq!(reviews.count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_movie_not_found_returns_error() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
input: MovieInput {
|
||||
movie_id: Some(Uuid::new_v4()),
|
||||
external_metadata_id: None,
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
assert!(logger.log_review(cmd).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_rating_returns_error() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
input: MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: None,
|
||||
manual_title: Some("Film".into()),
|
||||
manual_release_year: Some(2024),
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 6,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
let result = logger.log_review(cmd).await;
|
||||
assert!(result.is_err());
|
||||
// No repo calls should have happened
|
||||
assert_eq!(movies.count(), 0);
|
||||
assert_eq!(reviews.count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watchlist_not_present_does_not_publish_removed() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
input: MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: None,
|
||||
manual_title: Some("No Watchlist Film".into()),
|
||||
manual_release_year: Some(2024),
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
!published
|
||||
.iter()
|
||||
.any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })),
|
||||
"should not publish WatchlistEntryRemoved when not on watchlist"
|
||||
);
|
||||
}
|
||||
|
||||
/// A metadata client that returns a movie with an external_metadata_id,
|
||||
/// triggering the MovieDiscovered event path.
|
||||
struct MetadataClientWithExternalId;
|
||||
|
||||
#[async_trait]
|
||||
impl MetadataClient for MetadataClientWithExternalId {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
_criteria: &MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError> {
|
||||
Ok(Movie::new(
|
||||
Some(ExternalMetadataId::new("tmdb:99999".into()).unwrap()),
|
||||
MovieTitle::new("Discovered Film".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publishes_movie_discovered_for_new_movie_with_external_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let logger = DefaultReviewLogger::new(
|
||||
Arc::clone(&movies) as _,
|
||||
Arc::clone(&reviews) as _,
|
||||
Arc::clone(&watchlist) as _,
|
||||
Arc::new(MetadataClientWithExternalId) as _,
|
||||
Arc::clone(&events) as _,
|
||||
);
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
input: MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: None,
|
||||
manual_title: Some("Discovered Film".into()),
|
||||
manual_release_year: Some(2024),
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 5,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, domain::events::DomainEvent::MovieDiscovered { .. })),
|
||||
"should publish MovieDiscovered for new movie with external_metadata_id"
|
||||
);
|
||||
assert!(
|
||||
published.iter().any(|e| matches!(
|
||||
e,
|
||||
domain::events::DomainEvent::MovieEnrichmentRequested { .. }
|
||||
)),
|
||||
"should publish MovieEnrichmentRequested"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user