add 400+ unit tests for domain and application layers
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:
2026-06-09 02:07:26 +02:00
parent 30a6200b5b
commit d867a14b28
122 changed files with 7033 additions and 151 deletions

View 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());
}

View 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);
}

View 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);
}

View 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);
}

View File

@@ -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),

View 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"
);
}