diff --git a/README.md b/README.md index e13cd12..27fc1ba 100644 --- a/README.md +++ b/README.md @@ -190,11 +190,13 @@ make test # cargo test ## Test ```bash -cargo test # full workspace (requires DATABASE_URL for sqlx offline checks) -cargo test -p application # domain-level unit tests only — no database required +cargo test # full workspace (requires DATABASE_URL for sqlx offline checks) +cargo test -p application # business logic tests only — no database required +cargo test -p domain # domain model + value object tests +cargo llvm-cov -p application -p domain # line coverage report (requires cargo-llvm-cov) ``` -The `application` crate has unit tests for core use cases backed by in-memory fakes from `domain`'s `test-helpers` feature. These run without a database and are the fastest feedback loop for business logic changes. +The `application` and `domain` crates have 400+ unit tests covering all use case modules (auth, diary, goals, import, integrations, movies, person, search, users, watchlist, wrapup) backed by in-memory fakes from `domain`'s `test-helpers` feature. These run without a database and are the fastest feedback loop for business logic changes. ## Docker diff --git a/crates/application/src/auth/register_and_login.rs b/crates/application/src/auth/register_and_login.rs index 7d76c4c..b5031d4 100644 --- a/crates/application/src/auth/register_and_login.rs +++ b/crates/application/src/auth/register_and_login.rs @@ -30,3 +30,7 @@ pub async fn execute( ) .await } + +#[cfg(test)] +#[path = "tests/register_and_login.rs"] +mod tests; diff --git a/crates/application/src/auth/tests/register_and_login.rs b/crates/application/src/auth/tests/register_and_login.rs new file mode 100644 index 0000000..c9c8a5d --- /dev/null +++ b/crates/application/src/auth/tests/register_and_login.rs @@ -0,0 +1,22 @@ +use crate::auth::commands::RegisterAndLoginCommand; +use crate::auth::register_and_login; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn registers_and_returns_token() { + let ctx = TestContextBuilder::new().build(); + + let result = register_and_login::execute( + &ctx, + RegisterAndLoginCommand { + email: "new@example.com".into(), + username: "newuser".into(), + password: "password123".into(), + }, + ) + .await + .unwrap(); + + assert!(!result.token.is_empty()); + assert_eq!(result.email, "new@example.com"); +} diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index f28236c..4543ca8 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -11,6 +11,7 @@ use domain::ports::{ }; use crate::config::AppConfig; +use crate::ports::ReviewLogger; #[derive(Clone)] pub struct Repositories { @@ -49,6 +50,7 @@ pub struct Services { pub event_publisher: Arc, pub diary_exporter: Arc, pub document_parser: Arc, + pub review_logger: Arc, } #[derive(Clone)] diff --git a/crates/application/src/diary/get_activity_feed.rs b/crates/application/src/diary/get_activity_feed.rs index aaf351d..5bff643 100644 --- a/crates/application/src/diary/get_activity_feed.rs +++ b/crates/application/src/diary/get_activity_feed.rs @@ -64,3 +64,7 @@ async fn build_following_filter( remote_actor_urls: remote_urls, }) } + +#[cfg(test)] +#[path = "tests/get_activity_feed.rs"] +mod tests; diff --git a/crates/application/src/diary/get_diary.rs b/crates/application/src/diary/get_diary.rs index 712c996..5d26f79 100644 --- a/crates/application/src/diary/get_diary.rs +++ b/crates/application/src/diary/get_diary.rs @@ -27,3 +27,7 @@ pub async fn execute( ctx.repos.diary.query_diary(&filter).await } + +#[cfg(test)] +#[path = "tests/get_diary.rs"] +mod tests; diff --git a/crates/application/src/diary/get_movie_social_page.rs b/crates/application/src/diary/get_movie_social_page.rs index 6105e01..9e3f42a 100644 --- a/crates/application/src/diary/get_movie_social_page.rs +++ b/crates/application/src/diary/get_movie_social_page.rs @@ -43,3 +43,7 @@ pub async fn execute( profile, }) } + +#[cfg(test)] +#[path = "tests/get_movie_social_page.rs"] +mod tests; diff --git a/crates/application/src/diary/get_review_history.rs b/crates/application/src/diary/get_review_history.rs index aa2c7ed..b3b515d 100644 --- a/crates/application/src/diary/get_review_history.rs +++ b/crates/application/src/diary/get_review_history.rs @@ -21,3 +21,7 @@ pub async fn execute( Ok((history, trend)) } + +#[cfg(test)] +#[path = "tests/get_review_history.rs"] +mod tests; diff --git a/crates/application/src/diary/log_review.rs b/crates/application/src/diary/log_review.rs index d7a505c..01f1aea 100644 --- a/crates/application/src/diary/log_review.rs +++ b/crates/application/src/diary/log_review.rs @@ -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(()) -} diff --git a/crates/application/src/diary/mod.rs b/crates/application/src/diary/mod.rs index 2a46996..3faa83e 100644 --- a/crates/application/src/diary/mod.rs +++ b/crates/application/src/diary/mod.rs @@ -8,3 +8,4 @@ pub mod get_review_history; pub mod log_review; pub mod movie_resolver; pub mod queries; +pub mod review_logger; diff --git a/crates/application/src/diary/review_logger.rs b/crates/application/src/diary/review_logger.rs new file mode 100644 index 0000000..749909d --- /dev/null +++ b/crates/application/src/diary/review_logger.rs @@ -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, + review_repo: Arc, + watchlist_repo: Arc, + metadata_client: Arc, + event_publisher: Arc, +} + +impl DefaultReviewLogger { + pub fn new( + movie_repo: Arc, + review_repo: Arc, + watchlist_repo: Arc, + metadata_client: Arc, + event_publisher: Arc, + ) -> 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, + 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 +} diff --git a/crates/application/src/diary/tests/get_activity_feed.rs b/crates/application/src/diary/tests/get_activity_feed.rs new file mode 100644 index 0000000..38a32f7 --- /dev/null +++ b/crates/application/src/diary/tests/get_activity_feed.rs @@ -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); + +#[async_trait] +impl domain::ports::SocialQueryPort for FakeSocialWithFollowing { + async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { + Ok(self.0.clone()) + } + async fn count_following(&self, _: uuid::Uuid) -> Result { + Ok(0) + } + async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result { + Ok(0) + } + async fn get_pending_followers( + &self, + _: uuid::Uuid, + ) -> Result, DomainError> { + Ok(vec![]) + } + async fn list_all_followed_remote_actors( + &self, + ) -> Result, 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()); +} diff --git a/crates/application/src/diary/tests/get_diary.rs b/crates/application/src/diary/tests/get_diary.rs new file mode 100644 index 0000000..4390039 --- /dev/null +++ b/crates/application/src/diary/tests/get_diary.rs @@ -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); +} diff --git a/crates/application/src/diary/tests/get_movie_social_page.rs b/crates/application/src/diary/tests/get_movie_social_page.rs new file mode 100644 index 0000000..3063787 --- /dev/null +++ b/crates/application/src/diary/tests/get_movie_social_page.rs @@ -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); +} diff --git a/crates/application/src/diary/tests/get_review_history.rs b/crates/application/src/diary/tests/get_review_history.rs new file mode 100644 index 0000000..dfbe38f --- /dev/null +++ b/crates/application/src/diary/tests/get_review_history.rs @@ -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); +} diff --git a/crates/application/src/diary/tests/log_review.rs b/crates/application/src/diary/tests/log_review.rs index b509cb2..39f6363 100644 --- a/crates/application/src/diary/tests/log_review.rs +++ b/crates/application/src/diary/tests/log_review.rs @@ -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, + reviews: &Arc, + events: &Arc, +) -> 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), diff --git a/crates/application/src/diary/tests/review_logger.rs b/crates/application/src/diary/tests/review_logger.rs new file mode 100644 index 0000000..2b29476 --- /dev/null +++ b/crates/application/src/diary/tests/review_logger.rs @@ -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, + reviews: &Arc, + watchlist: &Arc, + events: &Arc, +) -> 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 { + 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, 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" + ); +} diff --git a/crates/application/src/goals/create.rs b/crates/application/src/goals/create.rs index 66d4412..bbb217c 100644 --- a/crates/application/src/goals/create.rs +++ b/crates/application/src/goals/create.rs @@ -54,3 +54,7 @@ pub async fn execute( current_count, }) } + +#[cfg(test)] +#[path = "tests/create.rs"] +mod tests; diff --git a/crates/application/src/goals/delete.rs b/crates/application/src/goals/delete.rs index f5709d0..5aa1852 100644 --- a/crates/application/src/goals/delete.rs +++ b/crates/application/src/goals/delete.rs @@ -26,3 +26,7 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteGoalCommand) -> Result<(), Dom Ok(()) } + +#[cfg(test)] +#[path = "tests/delete.rs"] +mod tests; diff --git a/crates/application/src/goals/get.rs b/crates/application/src/goals/get.rs index 3de6637..bafefb5 100644 --- a/crates/application/src/goals/get.rs +++ b/crates/application/src/goals/get.rs @@ -28,3 +28,7 @@ pub async fn execute( current_count, })) } + +#[cfg(test)] +#[path = "tests/get.rs"] +mod tests; diff --git a/crates/application/src/goals/list.rs b/crates/application/src/goals/list.rs index 0b1428b..b243c3e 100644 --- a/crates/application/src/goals/list.rs +++ b/crates/application/src/goals/list.rs @@ -25,3 +25,7 @@ pub async fn execute( Ok(result) } + +#[cfg(test)] +#[path = "tests/list.rs"] +mod tests; diff --git a/crates/application/src/goals/tests/create.rs b/crates/application/src/goals/tests/create.rs new file mode 100644 index 0000000..ed98e23 --- /dev/null +++ b/crates/application/src/goals/tests/create.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; + +use domain::events::DomainEvent; +use domain::testing::{InMemoryGoalRepository, NoopEventPublisher}; +use uuid::Uuid; + +use crate::goals::{commands::CreateGoalCommand, create}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn creates_goal_and_returns_progress() { + let goals = InMemoryGoalRepository::new(); + goals.set_review_count(Uuid::nil(), 2025, 5); + let events = NoopEventPublisher::new(); + let ctx = TestContextBuilder::new() + .with_goal(Arc::clone(&goals) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let result = create::execute( + &ctx, + CreateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 50, + }, + ) + .await + .unwrap(); + + assert_eq!(result.goal.year(), 2025); + assert_eq!(result.goal.target_count(), 50); + assert_eq!(result.current_count, 5); + assert_eq!(goals.count(), 1); +} + +#[tokio::test] +async fn emits_goal_created_event() { + let events = NoopEventPublisher::new(); + let ctx = TestContextBuilder::new() + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + create::execute( + &ctx, + CreateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 10, + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!( + published + .iter() + .any(|e| matches!(e, DomainEvent::GoalCreated { year: 2025, .. })) + ); +} + +#[tokio::test] +async fn rejects_duplicate_year() { + let ctx = TestContextBuilder::new().build(); + let cmd = CreateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 10, + }; + + create::execute(&ctx, cmd).await.unwrap(); + + let result = create::execute( + &ctx, + CreateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 20, + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn rejects_year_before_2020() { + let ctx = TestContextBuilder::new().build(); + let result = create::execute( + &ctx, + CreateGoalCommand { + user_id: Uuid::nil(), + year: 2019, + target_count: 10, + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn rejects_zero_target() { + let ctx = TestContextBuilder::new().build(); + let result = create::execute( + &ctx, + CreateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 0, + }, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/goals/tests/delete.rs b/crates/application/src/goals/tests/delete.rs new file mode 100644 index 0000000..b068eb4 --- /dev/null +++ b/crates/application/src/goals/tests/delete.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use domain::testing::{InMemoryGoalRepository, NoopEventPublisher}; +use uuid::Uuid; + +use crate::goals::{ + commands::{CreateGoalCommand, DeleteGoalCommand}, + create, delete, +}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn deletes_existing_goal() { + let goals = InMemoryGoalRepository::new(); + let events = NoopEventPublisher::new(); + let ctx = TestContextBuilder::new() + .with_goal(Arc::clone(&goals) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + create::execute( + &ctx, + CreateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 10, + }, + ) + .await + .unwrap(); + assert_eq!(goals.count(), 1); + + delete::execute( + &ctx, + DeleteGoalCommand { + user_id: Uuid::nil(), + year: 2025, + }, + ) + .await + .unwrap(); + + assert_eq!(goals.count(), 0); +} + +#[tokio::test] +async fn fails_when_not_found() { + let ctx = TestContextBuilder::new().build(); + let result = delete::execute( + &ctx, + DeleteGoalCommand { + user_id: Uuid::nil(), + year: 2025, + }, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/goals/tests/get.rs b/crates/application/src/goals/tests/get.rs new file mode 100644 index 0000000..96fbf0d --- /dev/null +++ b/crates/application/src/goals/tests/get.rs @@ -0,0 +1,48 @@ +use uuid::Uuid; + +use crate::goals::{commands::CreateGoalCommand, create, get, queries::GetGoalQuery}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn returns_goal_when_exists() { + let ctx = TestContextBuilder::new().build(); + create::execute( + &ctx, + CreateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 50, + }, + ) + .await + .unwrap(); + + let result = get::execute( + &ctx, + GetGoalQuery { + user_id: Uuid::nil(), + year: 2025, + }, + ) + .await + .unwrap(); + + assert!(result.is_some()); + assert_eq!(result.unwrap().goal.target_count(), 50); +} + +#[tokio::test] +async fn returns_none_when_missing() { + let ctx = TestContextBuilder::new().build(); + let result = get::execute( + &ctx, + GetGoalQuery { + user_id: Uuid::nil(), + year: 2025, + }, + ) + .await + .unwrap(); + + assert!(result.is_none()); +} diff --git a/crates/application/src/goals/tests/list.rs b/crates/application/src/goals/tests/list.rs new file mode 100644 index 0000000..6d64213 --- /dev/null +++ b/crates/application/src/goals/tests/list.rs @@ -0,0 +1,47 @@ +use uuid::Uuid; + +use crate::goals::{commands::CreateGoalCommand, create, list, queries::ListGoalsQuery}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn returns_empty_when_no_goals() { + let ctx = TestContextBuilder::new().build(); + let result = list::execute( + &ctx, + ListGoalsQuery { + user_id: Uuid::nil(), + }, + ) + .await + .unwrap(); + + assert!(result.is_empty()); +} + +#[tokio::test] +async fn returns_all_goals_for_user() { + let ctx = TestContextBuilder::new().build(); + for year in [2023, 2024, 2025] { + create::execute( + &ctx, + CreateGoalCommand { + user_id: Uuid::nil(), + year, + target_count: 10, + }, + ) + .await + .unwrap(); + } + + let result = list::execute( + &ctx, + ListGoalsQuery { + user_id: Uuid::nil(), + }, + ) + .await + .unwrap(); + + assert_eq!(result.len(), 3); +} diff --git a/crates/application/src/goals/tests/update.rs b/crates/application/src/goals/tests/update.rs new file mode 100644 index 0000000..de856a5 --- /dev/null +++ b/crates/application/src/goals/tests/update.rs @@ -0,0 +1,78 @@ +use uuid::Uuid; + +use crate::goals::{ + commands::{CreateGoalCommand, UpdateGoalCommand}, + create, update, +}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn updates_target_count() { + let ctx = TestContextBuilder::new().build(); + create::execute( + &ctx, + CreateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 10, + }, + ) + .await + .unwrap(); + + let result = update::execute( + &ctx, + UpdateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 100, + }, + ) + .await + .unwrap(); + + assert_eq!(result.goal.target_count(), 100); +} + +#[tokio::test] +async fn fails_when_goal_not_found() { + let ctx = TestContextBuilder::new().build(); + let result = update::execute( + &ctx, + UpdateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 10, + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn rejects_zero_target() { + let ctx = TestContextBuilder::new().build(); + create::execute( + &ctx, + CreateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 10, + }, + ) + .await + .unwrap(); + + let result = update::execute( + &ctx, + UpdateGoalCommand { + user_id: Uuid::nil(), + year: 2025, + target_count: 0, + }, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/goals/update.rs b/crates/application/src/goals/update.rs index 5127b15..963ecd5 100644 --- a/crates/application/src/goals/update.rs +++ b/crates/application/src/goals/update.rs @@ -42,3 +42,7 @@ pub async fn execute( current_count, }) } + +#[cfg(test)] +#[path = "tests/update.rs"] +mod tests; diff --git a/crates/application/src/import/apply_mapping.rs b/crates/application/src/import/apply_mapping.rs index 0cd9762..f709bf0 100644 --- a/crates/application/src/import/apply_mapping.rs +++ b/crates/application/src/import/apply_mapping.rs @@ -89,3 +89,7 @@ async fn mark_duplicates(ctx: &AppContext, rows: &mut [AnnotatedRow]) -> Result< Ok(()) } + +#[cfg(test)] +#[path = "tests/apply_mapping.rs"] +mod tests; diff --git a/crates/application/src/import/apply_profile.rs b/crates/application/src/import/apply_profile.rs index 7fa7f26..02a36b7 100644 --- a/crates/application/src/import/apply_profile.rs +++ b/crates/application/src/import/apply_profile.rs @@ -27,3 +27,7 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportProfileCommand) -> Result session.row_results = None; ctx.repos.import_session.update(&session).await } + +#[cfg(test)] +#[path = "tests/apply_profile.rs"] +mod tests; diff --git a/crates/application/src/import/cleanup.rs b/crates/application/src/import/cleanup.rs index f3cc726..f98ce65 100644 --- a/crates/application/src/import/cleanup.rs +++ b/crates/application/src/import/cleanup.rs @@ -4,3 +4,7 @@ use domain::errors::DomainError; pub async fn execute(ctx: &AppContext) -> Result { ctx.repos.import_session.delete_expired().await } + +#[cfg(test)] +#[path = "tests/cleanup.rs"] +mod tests; diff --git a/crates/application/src/import/create_session.rs b/crates/application/src/import/create_session.rs index 0b0e033..0bc9cc4 100644 --- a/crates/application/src/import/create_session.rs +++ b/crates/application/src/import/create_session.rs @@ -45,3 +45,7 @@ pub async fn execute( sample_rows, }) } + +#[cfg(test)] +#[path = "tests/create_session.rs"] +mod tests; diff --git a/crates/application/src/import/delete_profile.rs b/crates/application/src/import/delete_profile.rs index edf3bb6..5a793c3 100644 --- a/crates/application/src/import/delete_profile.rs +++ b/crates/application/src/import/delete_profile.rs @@ -15,3 +15,7 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteImportProfileCommand) -> Resul .ok_or_else(|| DomainError::NotFound("import profile".into()))?; ctx.repos.import_profile.delete(&profile_id).await } + +#[cfg(test)] +#[path = "tests/delete_profile.rs"] +mod tests; diff --git a/crates/application/src/import/execute.rs b/crates/application/src/import/execute.rs index dc79657..f207a94 100644 --- a/crates/application/src/import/execute.rs +++ b/crates/application/src/import/execute.rs @@ -9,7 +9,6 @@ use uuid::Uuid; use crate::{ context::AppContext, diary::commands::{LogReviewCommand, MovieInput}, - diary::log_review, import::commands::ExecuteImportCommand, }; @@ -47,7 +46,7 @@ pub async fn execute( } match annotated.result { RowResult::Valid(row) => match row_to_command(&row, user_id.value()) { - Ok(cmd) => match log_review::execute(ctx, cmd).await { + Ok(cmd) => match ctx.services.review_logger.log_review(cmd).await { Ok(_) => imported += 1, Err(e) => failed.push((idx, e.to_string())), }, @@ -68,6 +67,10 @@ pub async fn execute( }) } +#[cfg(test)] +#[path = "tests/execute.rs"] +mod tests; + fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result { let rating = row .rating diff --git a/crates/application/src/import/list_profiles.rs b/crates/application/src/import/list_profiles.rs index 72af997..9ac0d11 100644 --- a/crates/application/src/import/list_profiles.rs +++ b/crates/application/src/import/list_profiles.rs @@ -7,3 +7,7 @@ pub async fn execute( ) -> Result, DomainError> { ctx.repos.import_profile.list_for_user(user_id).await } + +#[cfg(test)] +#[path = "tests/list_profiles.rs"] +mod tests; diff --git a/crates/application/src/import/save_profile.rs b/crates/application/src/import/save_profile.rs index 9e1a790..77a097f 100644 --- a/crates/application/src/import/save_profile.rs +++ b/crates/application/src/import/save_profile.rs @@ -33,3 +33,7 @@ pub async fn execute( ctx.repos.import_profile.save(&profile).await?; Ok(id) } + +#[cfg(test)] +#[path = "tests/save_profile.rs"] +mod tests; diff --git a/crates/application/src/import/tests/apply_mapping.rs b/crates/application/src/import/tests/apply_mapping.rs new file mode 100644 index 0000000..6fa6fb4 --- /dev/null +++ b/crates/application/src/import/tests/apply_mapping.rs @@ -0,0 +1,208 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use domain::{ + models::{ + AnnotatedRow, Movie, + import::{ImportRow, ParsedFile, RowResult}, + }, + ports::{DocumentParser, MovieRepository}, + testing::InMemoryMovieRepository, + value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear}, +}; + +use crate::import::{ + apply_mapping, + commands::{ApplyImportMappingCommand, CreateImportSessionCommand}, + create_session, +}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn applies_mapping_to_session() { + let ctx = TestContextBuilder::new().build(); + let user_id = Uuid::new_v4(); + + let session = create_session::execute( + &ctx, + CreateImportSessionCommand { + user_id, + bytes: b"title\nTest".to_vec(), + format: domain::models::FileFormat::Csv, + }, + ) + .await + .unwrap(); + + let rows = apply_mapping::execute( + &ctx, + ApplyImportMappingCommand { + user_id, + session_id: session.session_id.value(), + mappings: vec![], + }, + ) + .await + .unwrap(); + + assert!(!rows.is_empty()); +} + +#[tokio::test] +async fn fails_when_session_not_found() { + let ctx = TestContextBuilder::new().build(); + + let result = apply_mapping::execute( + &ctx, + ApplyImportMappingCommand { + user_id: Uuid::new_v4(), + session_id: Uuid::new_v4(), + mappings: vec![], + }, + ) + .await; + + assert!(result.is_err()); +} + +/// A document parser that returns rows with specific field values for testing +/// the mark_duplicates logic. +struct DuplicateTestParser { + rows: Vec, +} + +impl DocumentParser for DuplicateTestParser { + fn parse( + &self, + _: &[u8], + _: domain::models::FileFormat, + ) -> Result { + Ok(ParsedFile { + columns: vec!["title".into()], + rows: vec![vec!["x".into()]], + }) + } + + fn apply_mapping( + &self, + _: &ParsedFile, + _: &[domain::models::FieldMapping], + ) -> Vec { + self.rows + .iter() + .map(|r| AnnotatedRow { + result: RowResult::Valid(r.clone()), + is_duplicate: false, + }) + .collect() + } +} + +#[tokio::test] +async fn marks_duplicate_by_external_id() { + let movies = InMemoryMovieRepository::new(); + + let ext_id = ExternalMetadataId::new("tt1234567".into()).unwrap(); + let movie = Movie::new( + Some(ext_id), + MovieTitle::new("Known Movie".into()).unwrap(), + ReleaseYear::new(2020).unwrap(), + None, + None, + ); + movies.upsert_movie(&movie).await.unwrap(); + + let parser = DuplicateTestParser { + rows: vec![ImportRow { + title: Some("Known Movie".into()), + release_year: Some("2020".into()), + external_metadata_id: Some("tt1234567".into()), + ..ImportRow::default() + }], + }; + + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .with_document_parser(Arc::new(parser) as _) + .build(); + let user_id = Uuid::new_v4(); + + let session = create_session::execute( + &ctx, + CreateImportSessionCommand { + user_id, + bytes: b"title\nKnown Movie".to_vec(), + format: domain::models::FileFormat::Csv, + }, + ) + .await + .unwrap(); + + let rows = apply_mapping::execute( + &ctx, + ApplyImportMappingCommand { + user_id, + session_id: session.session_id.value(), + mappings: vec![], + }, + ) + .await + .unwrap(); + + let has_dup = rows.iter().any(|r| r.is_duplicate); + assert!(has_dup, "row with matching external_id should be duplicate"); +} + +#[tokio::test] +async fn marks_duplicate_by_title_and_year() { + let movies = InMemoryMovieRepository::new(); + + let movie = Movie::new( + None, + MovieTitle::new("Duplicate Film".into()).unwrap(), + ReleaseYear::new(2022).unwrap(), + None, + None, + ); + movies.upsert_movie(&movie).await.unwrap(); + + let parser = DuplicateTestParser { + rows: vec![ImportRow { + title: Some("Duplicate Film".into()), + release_year: Some("2022".into()), + ..ImportRow::default() + }], + }; + + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .with_document_parser(Arc::new(parser) as _) + .build(); + let user_id = Uuid::new_v4(); + + let session = create_session::execute( + &ctx, + CreateImportSessionCommand { + user_id, + bytes: b"title\nDuplicate Film".to_vec(), + format: domain::models::FileFormat::Csv, + }, + ) + .await + .unwrap(); + + let rows = apply_mapping::execute( + &ctx, + ApplyImportMappingCommand { + user_id, + session_id: session.session_id.value(), + mappings: vec![], + }, + ) + .await + .unwrap(); + + let has_dup = rows.iter().any(|r| r.is_duplicate); + assert!(has_dup, "row with matching title+year should be duplicate"); +} diff --git a/crates/application/src/import/tests/apply_profile.rs b/crates/application/src/import/tests/apply_profile.rs new file mode 100644 index 0000000..6e8c6d6 --- /dev/null +++ b/crates/application/src/import/tests/apply_profile.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +use chrono::Utc; +use domain::models::ImportProfile; +use domain::ports::{ImportProfileRepository, ImportSessionRepository}; +use domain::testing::InMemoryImportProfileRepository; +use domain::value_objects::{ImportProfileId, UserId}; +use uuid::Uuid; + +use crate::import::{apply_profile, commands::ApplyImportProfileCommand}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn fails_when_profile_not_found() { + let ctx = TestContextBuilder::new().build(); + + let result = apply_profile::execute( + &ctx, + ApplyImportProfileCommand { + user_id: Uuid::new_v4(), + session_id: Uuid::new_v4(), + profile_id: Uuid::new_v4(), + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn fails_when_session_not_found() { + let profiles = InMemoryImportProfileRepository::new(); + let user_id = Uuid::new_v4(); + + let profile = ImportProfile::new( + ImportProfileId::generate(), + UserId::from_uuid(user_id), + "test".into(), + vec![], + Utc::now().naive_utc(), + ); + let profile_id = profile.id.clone(); + profiles.save(&profile).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_profiles(Arc::clone(&profiles) as _) + .build(); + + let result = apply_profile::execute( + &ctx, + ApplyImportProfileCommand { + user_id, + session_id: Uuid::new_v4(), + profile_id: profile_id.value(), + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn applies_profile_mappings_to_session() { + let profiles = InMemoryImportProfileRepository::new(); + let sessions = domain::testing::InMemoryImportSessionRepository::new(); + let user_id = Uuid::new_v4(); + + let profile = ImportProfile::new( + ImportProfileId::generate(), + UserId::from_uuid(user_id), + "letterboxd".into(), + vec![domain::models::FieldMapping { + source_column: "Name".into(), + domain_field: domain::models::import::DomainField::Title, + transform: domain::models::import::Transform::Identity, + }], + Utc::now().naive_utc(), + ); + let profile_id = profile.id.clone(); + profiles.save(&profile).await.unwrap(); + + let session = domain::models::ImportSession::new( + domain::value_objects::ImportSessionId::generate(), + UserId::from_uuid(user_id), + Utc::now().naive_utc(), + ); + let session_id = session.id.clone(); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_profiles(Arc::clone(&profiles) as _) + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + apply_profile::execute( + &ctx, + ApplyImportProfileCommand { + user_id, + session_id: session_id.value(), + profile_id: profile_id.value(), + }, + ) + .await + .unwrap(); + + // Verify the session got updated with field_mappings and row_results cleared + let updated = sessions + .get(&session_id, &UserId::from_uuid(user_id)) + .await + .unwrap() + .unwrap(); + assert!(updated.field_mappings.is_some()); + assert_eq!(updated.field_mappings.unwrap().len(), 1); + assert!(updated.row_results.is_none()); +} diff --git a/crates/application/src/import/tests/cleanup.rs b/crates/application/src/import/tests/cleanup.rs new file mode 100644 index 0000000..5550367 --- /dev/null +++ b/crates/application/src/import/tests/cleanup.rs @@ -0,0 +1,18 @@ +use std::sync::Arc; + +use domain::testing::InMemoryImportSessionRepository; + +use crate::import::cleanup; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn returns_zero_when_nothing_expired() { + let sessions = InMemoryImportSessionRepository::new(); + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = cleanup::execute(&ctx).await.unwrap(); + + assert_eq!(result, 0); +} diff --git a/crates/application/src/import/tests/create_session.rs b/crates/application/src/import/tests/create_session.rs new file mode 100644 index 0000000..c899b3f --- /dev/null +++ b/crates/application/src/import/tests/create_session.rs @@ -0,0 +1,22 @@ +use uuid::Uuid; + +use crate::import::{commands::CreateImportSessionCommand, create_session}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn creates_session_with_parsed_file() { + let ctx = TestContextBuilder::new().build(); + + let result = create_session::execute( + &ctx, + CreateImportSessionCommand { + user_id: Uuid::new_v4(), + bytes: b"col1\nval1".to_vec(), + format: domain::models::FileFormat::Csv, + }, + ) + .await + .unwrap(); + + assert!(!result.columns.is_empty()); +} diff --git a/crates/application/src/import/tests/delete_profile.rs b/crates/application/src/import/tests/delete_profile.rs new file mode 100644 index 0000000..a0bfca1 --- /dev/null +++ b/crates/application/src/import/tests/delete_profile.rs @@ -0,0 +1,26 @@ +use std::sync::Arc; + +use domain::testing::InMemoryImportProfileRepository; +use uuid::Uuid; + +use crate::import::{commands::DeleteImportProfileCommand, delete_profile}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn fails_when_profile_not_found() { + let profiles = InMemoryImportProfileRepository::new(); + let ctx = TestContextBuilder::new() + .with_import_profiles(Arc::clone(&profiles) as _) + .build(); + + let result = delete_profile::execute( + &ctx, + DeleteImportProfileCommand { + user_id: Uuid::new_v4(), + profile_id: Uuid::new_v4(), + }, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/import/tests/execute.rs b/crates/application/src/import/tests/execute.rs new file mode 100644 index 0000000..c6565bb --- /dev/null +++ b/crates/application/src/import/tests/execute.rs @@ -0,0 +1,558 @@ +use std::sync::Arc; + +use chrono::Utc; +use domain::models::{AnnotatedRow, ImportSession, import::RowResult}; +use domain::ports::ImportSessionRepository; +use domain::testing::InMemoryImportSessionRepository; +use domain::value_objects::{ImportSessionId, UserId}; +use uuid::Uuid; + +use crate::import::commands::ExecuteImportCommand; +use crate::import::execute; +use crate::test_helpers::TestContextBuilder; + +fn make_session_with_rows(user_id: UserId, session_id: ImportSessionId) -> ImportSession { + let now = Utc::now().naive_utc(); + let mut session = ImportSession::new(session_id, user_id, now); + session.row_results = Some(vec![ + AnnotatedRow { + result: RowResult::Valid(domain::models::ImportRow { + title: Some("Test Movie".into()), + release_year: Some("2024".into()), + rating: Some("4".into()), + watched_at: Some("2024-06-01".into()), + external_metadata_id: None, + director: None, + comment: None, + }), + is_duplicate: false, + }, + AnnotatedRow { + result: RowResult::Valid(domain::models::ImportRow { + title: Some("Another".into()), + release_year: Some("2023".into()), + rating: Some("3".into()), + watched_at: Some("2024-07-01".into()), + external_metadata_id: None, + director: None, + comment: None, + }), + is_duplicate: false, + }, + ]); + session +} + +#[tokio::test] +async fn imports_confirmed_rows() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let session = make_session_with_rows(UserId::from_uuid(uid), sid.clone()); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0, 1], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 2); + assert_eq!(result.skipped_duplicates, 0); + assert!(result.failed.is_empty()); +} + +#[tokio::test] +async fn skips_unconfirmed_rows() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let session = make_session_with_rows(UserId::from_uuid(uid), sid.clone()); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 1); + assert_eq!(result.skipped_duplicates, 1); +} + +#[tokio::test] +async fn fails_when_session_not_found() { + let ctx = TestContextBuilder::new().build(); + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: Uuid::new_v4(), + session_id: Uuid::new_v4(), + confirmed_indices: vec![], + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn handles_datetime_format() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let now = Utc::now().naive_utc(); + let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); + session.row_results = Some(vec![AnnotatedRow { + result: RowResult::Valid(domain::models::ImportRow { + title: Some("DateTime Movie".into()), + release_year: Some("2024".into()), + rating: Some("5".into()), + watched_at: Some("2024-06-01T12:30:00".into()), + external_metadata_id: None, + director: None, + comment: None, + }), + is_duplicate: false, + }]); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 1); + assert!(result.failed.is_empty()); +} + +#[tokio::test] +async fn fails_on_invalid_rating() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let now = Utc::now().naive_utc(); + let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); + session.row_results = Some(vec![AnnotatedRow { + result: RowResult::Valid(domain::models::ImportRow { + title: Some("Bad Rating Movie".into()), + release_year: Some("2024".into()), + rating: Some("not_a_number".into()), + watched_at: Some("2024-06-01".into()), + external_metadata_id: None, + director: None, + comment: None, + }), + is_duplicate: false, + }]); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 0); + assert_eq!(result.failed.len(), 1); +} + +#[tokio::test] +async fn fails_on_missing_watched_at() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let now = Utc::now().naive_utc(); + let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); + session.row_results = Some(vec![AnnotatedRow { + result: RowResult::Valid(domain::models::ImportRow { + title: Some("No Date Movie".into()), + release_year: Some("2024".into()), + rating: Some("4".into()), + watched_at: None, + external_metadata_id: None, + director: None, + comment: None, + }), + is_duplicate: false, + }]); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 0); + assert_eq!(result.failed.len(), 1); +} + +#[tokio::test] +async fn imports_row_with_external_metadata_id() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let now = Utc::now().naive_utc(); + let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); + session.row_results = Some(vec![AnnotatedRow { + result: RowResult::Valid(domain::models::ImportRow { + title: Some("TMDB Movie".into()), + release_year: Some("2024".into()), + rating: Some("5".into()), + watched_at: Some("2024-06-01".into()), + external_metadata_id: Some("tt9999999".into()), + director: None, + comment: None, + }), + is_duplicate: false, + }]); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 1); + assert!(result.failed.is_empty()); +} + +#[tokio::test] +async fn imports_row_with_director_and_comment() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let now = Utc::now().naive_utc(); + let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); + session.row_results = Some(vec![AnnotatedRow { + result: RowResult::Valid(domain::models::ImportRow { + title: Some("Directed Movie".into()), + release_year: Some("2022".into()), + rating: Some("4".into()), + watched_at: Some("2024-06-01".into()), + external_metadata_id: None, + director: Some("John Director".into()), + comment: Some("A great film".into()), + }), + is_duplicate: false, + }]); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 1); + assert!(result.failed.is_empty()); +} + +#[tokio::test] +async fn handles_space_separated_datetime_format() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let now = Utc::now().naive_utc(); + let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); + session.row_results = Some(vec![AnnotatedRow { + result: RowResult::Valid(domain::models::ImportRow { + title: Some("Space DateTime".into()), + release_year: Some("2024".into()), + rating: Some("3".into()), + watched_at: Some("2024-06-01 14:30:00".into()), + external_metadata_id: None, + director: None, + comment: None, + }), + is_duplicate: false, + }]); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 1); + assert!(result.failed.is_empty()); +} + +#[tokio::test] +async fn reports_invalid_row_result_errors() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let now = Utc::now().naive_utc(); + let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); + session.row_results = Some(vec![AnnotatedRow { + result: RowResult::Invalid { + errors: vec!["missing title".into(), "bad year".into()], + raw: vec![("col1".into(), "val1".into())], + }, + is_duplicate: false, + }]); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 0); + assert_eq!(result.failed.len(), 1); + assert!(result.failed[0].1.contains("missing title")); + assert!(result.failed[0].1.contains("bad year")); +} + +#[tokio::test] +async fn fails_on_missing_rating() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let now = Utc::now().naive_utc(); + let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); + session.row_results = Some(vec![AnnotatedRow { + result: RowResult::Valid(domain::models::ImportRow { + title: Some("No Rating Movie".into()), + release_year: Some("2024".into()), + rating: None, + watched_at: Some("2024-06-01".into()), + external_metadata_id: None, + director: None, + comment: None, + }), + is_duplicate: false, + }]); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 0); + assert_eq!(result.failed.len(), 1); + assert!(result.failed[0].1.contains("missing rating")); +} + +#[tokio::test] +async fn fails_on_unparseable_date() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let now = Utc::now().naive_utc(); + let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); + session.row_results = Some(vec![AnnotatedRow { + result: RowResult::Valid(domain::models::ImportRow { + title: Some("Bad Date Movie".into()), + release_year: Some("2024".into()), + rating: Some("3".into()), + watched_at: Some("not-a-date".into()), + external_metadata_id: None, + director: None, + comment: None, + }), + is_duplicate: false, + }]); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 0); + assert_eq!(result.failed.len(), 1); + assert!(result.failed[0].1.contains("cannot parse watched_at")); +} + +#[tokio::test] +async fn imports_row_without_release_year() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let now = Utc::now().naive_utc(); + let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); + session.row_results = Some(vec![AnnotatedRow { + result: RowResult::Valid(domain::models::ImportRow { + title: Some("No Year Movie".into()), + release_year: None, + rating: Some("4".into()), + watched_at: Some("2024-06-01".into()), + external_metadata_id: None, + director: None, + comment: None, + }), + is_duplicate: false, + }]); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!(result.imported, 1); + assert!(result.failed.is_empty()); +} + +#[tokio::test] +async fn deletes_session_after_import() { + let sessions = InMemoryImportSessionRepository::new(); + let uid = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let session = make_session_with_rows(UserId::from_uuid(uid), sid.clone()); + sessions.create(&session).await.unwrap(); + assert_eq!(sessions.count(), 1); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + execute::execute( + &ctx, + ExecuteImportCommand { + user_id: uid, + session_id: sid.value(), + confirmed_indices: vec![0], + }, + ) + .await + .unwrap(); + + assert_eq!( + sessions.count(), + 0, + "session should be deleted after import" + ); +} diff --git a/crates/application/src/import/tests/list_profiles.rs b/crates/application/src/import/tests/list_profiles.rs new file mode 100644 index 0000000..fe65fc0 --- /dev/null +++ b/crates/application/src/import/tests/list_profiles.rs @@ -0,0 +1,21 @@ +use std::sync::Arc; + +use domain::testing::InMemoryImportProfileRepository; +use domain::value_objects::UserId; +use uuid::Uuid; + +use crate::import::list_profiles; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn returns_empty_when_no_profiles() { + let profiles = InMemoryImportProfileRepository::new(); + let ctx = TestContextBuilder::new() + .with_import_profiles(Arc::clone(&profiles) as _) + .build(); + + let user_id = UserId::from_uuid(Uuid::new_v4()); + let result = list_profiles::execute(&ctx, &user_id).await.unwrap(); + + assert!(result.is_empty()); +} diff --git a/crates/application/src/import/tests/save_profile.rs b/crates/application/src/import/tests/save_profile.rs new file mode 100644 index 0000000..73768a0 --- /dev/null +++ b/crates/application/src/import/tests/save_profile.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use chrono::Utc; +use domain::models::ImportSession; +use domain::ports::ImportSessionRepository; +use domain::testing::InMemoryImportSessionRepository; +use domain::value_objects::{ImportSessionId, UserId}; +use uuid::Uuid; + +use crate::import::{commands::SaveImportProfileCommand, save_profile}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn fails_when_session_not_found() { + let sessions = InMemoryImportSessionRepository::new(); + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = save_profile::execute( + &ctx, + SaveImportProfileCommand { + user_id: Uuid::new_v4(), + session_id: Uuid::new_v4(), + name: "my profile".into(), + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn saves_profile_from_session() { + let sessions = InMemoryImportSessionRepository::new(); + let user_id = Uuid::new_v4(); + let sid = ImportSessionId::generate(); + + let mut session = ImportSession::new( + sid.clone(), + UserId::from_uuid(user_id), + Utc::now().naive_utc(), + ); + session.field_mappings = Some(vec![]); + sessions.create(&session).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_import_sessions(Arc::clone(&sessions) as _) + .build(); + + let result = save_profile::execute( + &ctx, + SaveImportProfileCommand { + user_id, + session_id: sid.value(), + name: "my profile".into(), + }, + ) + .await; + + assert!(result.is_ok()); +} diff --git a/crates/application/src/integrations/cleanup.rs b/crates/application/src/integrations/cleanup.rs index a31ea12..8675d41 100644 --- a/crates/application/src/integrations/cleanup.rs +++ b/crates/application/src/integrations/cleanup.rs @@ -10,3 +10,7 @@ pub async fn execute(ctx: &AppContext) -> Result { .delete_non_pending_older_than(cutoff) .await } + +#[cfg(test)] +#[path = "tests/cleanup.rs"] +mod tests; diff --git a/crates/application/src/integrations/confirm.rs b/crates/application/src/integrations/confirm.rs index acc66f1..058a315 100644 --- a/crates/application/src/integrations/confirm.rs +++ b/crates/application/src/integrations/confirm.rs @@ -7,7 +7,6 @@ use domain::{ use crate::{ context::AppContext, diary::commands::{LogReviewCommand, MovieInput}, - diary::log_review, integrations::commands::ConfirmWatchEventsCommand, }; @@ -54,7 +53,7 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result watched_at: *event.watched_at(), }; - log_review::execute(ctx, review_cmd).await?; + ctx.services.review_logger.log_review(review_cmd).await?; ctx.repos .watch_event @@ -66,3 +65,7 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result Ok(confirmed) } + +#[cfg(test)] +#[path = "tests/confirm.rs"] +mod tests; diff --git a/crates/application/src/integrations/dismiss.rs b/crates/application/src/integrations/dismiss.rs index 8b6288b..54dc88d 100644 --- a/crates/application/src/integrations/dismiss.rs +++ b/crates/application/src/integrations/dismiss.rs @@ -39,3 +39,7 @@ pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result Ok(count as u32) } + +#[cfg(test)] +#[path = "tests/dismiss.rs"] +mod tests; diff --git a/crates/application/src/integrations/generate_token.rs b/crates/application/src/integrations/generate_token.rs index 393ec9e..62ff1e5 100644 --- a/crates/application/src/integrations/generate_token.rs +++ b/crates/application/src/integrations/generate_token.rs @@ -36,3 +36,7 @@ pub fn hash_token(plaintext: &str) -> String { hasher.update(plaintext.as_bytes()); hex::encode(hasher.finalize()) } + +#[cfg(test)] +#[path = "tests/generate_token.rs"] +mod tests; diff --git a/crates/application/src/integrations/get_queue.rs b/crates/application/src/integrations/get_queue.rs index 20a06d8..3a5e164 100644 --- a/crates/application/src/integrations/get_queue.rs +++ b/crates/application/src/integrations/get_queue.rs @@ -9,3 +9,7 @@ pub async fn execute( let user_id = UserId::from_uuid(query.user_id); ctx.repos.watch_event.list_pending(&user_id).await } + +#[cfg(test)] +#[path = "tests/get_queue.rs"] +mod tests; diff --git a/crates/application/src/integrations/get_tokens.rs b/crates/application/src/integrations/get_tokens.rs index 3c75260..d912255 100644 --- a/crates/application/src/integrations/get_tokens.rs +++ b/crates/application/src/integrations/get_tokens.rs @@ -9,3 +9,7 @@ pub async fn execute( let user_id = UserId::from_uuid(query.user_id); ctx.repos.webhook_token.list_by_user(&user_id).await } + +#[cfg(test)] +#[path = "tests/get_tokens.rs"] +mod tests; diff --git a/crates/application/src/integrations/ingest.rs b/crates/application/src/integrations/ingest.rs index 1197011..fc1a1fd 100644 --- a/crates/application/src/integrations/ingest.rs +++ b/crates/application/src/integrations/ingest.rs @@ -69,3 +69,7 @@ pub async fn execute( Ok(()) } + +#[cfg(test)] +#[path = "tests/ingest.rs"] +mod tests; diff --git a/crates/application/src/integrations/revoke_token.rs b/crates/application/src/integrations/revoke_token.rs index 731f459..23ed200 100644 --- a/crates/application/src/integrations/revoke_token.rs +++ b/crates/application/src/integrations/revoke_token.rs @@ -10,3 +10,7 @@ pub async fn execute(ctx: &AppContext, cmd: RevokeWebhookTokenCommand) -> Result let token_id = WebhookTokenId::from_uuid(cmd.token_id); ctx.repos.webhook_token.delete(&token_id, &user_id).await } + +#[cfg(test)] +#[path = "tests/revoke_token.rs"] +mod tests; diff --git a/crates/application/src/integrations/tests/cleanup.rs b/crates/application/src/integrations/tests/cleanup.rs new file mode 100644 index 0000000..d4f5cf2 --- /dev/null +++ b/crates/application/src/integrations/tests/cleanup.rs @@ -0,0 +1,11 @@ +use crate::integrations::cleanup; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn returns_zero_when_nothing_to_clean() { + let ctx = TestContextBuilder::new().build(); + + let count = cleanup::execute(&ctx).await.unwrap(); + + assert_eq!(count, 0); +} diff --git a/crates/application/src/integrations/tests/confirm.rs b/crates/application/src/integrations/tests/confirm.rs new file mode 100644 index 0000000..d075323 --- /dev/null +++ b/crates/application/src/integrations/tests/confirm.rs @@ -0,0 +1,372 @@ +use std::sync::Arc; + +use domain::models::{WatchEvent, WatchEventSource}; +use domain::ports::{MovieRepository, WatchEventRepository}; +use domain::testing::{InMemoryWatchEventRepository, NoopEventPublisher}; +use domain::value_objects::UserId; +use uuid::Uuid; + +use crate::integrations::commands::{ConfirmWatchEventsCommand, WatchEventConfirmation}; +use crate::integrations::confirm; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn confirms_watch_event_via_review_logger() { + let watch_events = InMemoryWatchEventRepository::new(); + let events = NoopEventPublisher::new(); + let uid = Uuid::new_v4(); + + let event = WatchEvent::new( + UserId::from_uuid(uid), + "Test Movie".into(), + Some(2024), + None, + WatchEventSource::Jellyfin, + chrono::Utc::now().naive_utc(), + None, + ); + let event_id = event.id().value(); + watch_events.save(&event).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_watch_events(Arc::clone(&watch_events) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let result = confirm::execute( + &ctx, + ConfirmWatchEventsCommand { + user_id: uid, + confirmations: vec![WatchEventConfirmation { + watch_event_id: event_id, + rating: 4, + comment: None, + }], + }, + ) + .await + .unwrap(); + + assert_eq!(result, 1); +} + +#[tokio::test] +async fn empty_confirmations_returns_zero() { + let ctx = TestContextBuilder::new().build(); + + let result = confirm::execute( + &ctx, + ConfirmWatchEventsCommand { + user_id: Uuid::new_v4(), + confirmations: vec![], + }, + ) + .await + .unwrap(); + + assert_eq!(result, 0); +} + +#[tokio::test] +async fn confirms_event_with_external_metadata_id_and_no_movie_id() { + let watch_events = InMemoryWatchEventRepository::new(); + let events = NoopEventPublisher::new(); + let uid = Uuid::new_v4(); + + let event = WatchEvent::new( + UserId::from_uuid(uid), + "External Movie".into(), + Some(2023), + Some("tt1234567".into()), + WatchEventSource::Jellyfin, + chrono::Utc::now().naive_utc(), + None, + ); + let event_id = event.id().value(); + watch_events.save(&event).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_watch_events(Arc::clone(&watch_events) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let result = confirm::execute( + &ctx, + ConfirmWatchEventsCommand { + user_id: uid, + confirmations: vec![WatchEventConfirmation { + watch_event_id: event_id, + rating: 3, + comment: Some("Great film".into()), + }], + }, + ) + .await + .unwrap(); + + assert_eq!(result, 1); +} + +#[tokio::test] +async fn rejects_other_users_event() { + let watch_events = InMemoryWatchEventRepository::new(); + let owner = Uuid::new_v4(); + let intruder = Uuid::new_v4(); + + let event = WatchEvent::new( + UserId::from_uuid(owner), + "Movie".into(), + None, + None, + WatchEventSource::Jellyfin, + chrono::Utc::now().naive_utc(), + None, + ); + let event_id = event.id().value(); + watch_events.save(&event).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_watch_events(Arc::clone(&watch_events) as _) + .build(); + + let result = confirm::execute( + &ctx, + ConfirmWatchEventsCommand { + user_id: intruder, + confirmations: vec![WatchEventConfirmation { + watch_event_id: event_id, + rating: 3, + comment: None, + }], + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn fails_when_event_not_found() { + let ctx = TestContextBuilder::new().build(); + + let result = confirm::execute( + &ctx, + ConfirmWatchEventsCommand { + user_id: Uuid::new_v4(), + confirmations: vec![WatchEventConfirmation { + watch_event_id: Uuid::new_v4(), + rating: 4, + comment: None, + }], + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn confirms_event_with_movie_id() { + let watch_events = InMemoryWatchEventRepository::new(); + let events = NoopEventPublisher::new(); + let uid = Uuid::new_v4(); + let movie_uuid = Uuid::new_v4(); + + let event = WatchEvent::new( + UserId::from_uuid(uid), + "Movie With Id".into(), + Some(2024), + None, + WatchEventSource::Jellyfin, + chrono::Utc::now().naive_utc(), + Some(domain::value_objects::MovieId::from_uuid(movie_uuid)), + ); + let event_id = event.id().value(); + watch_events.save(&event).await.unwrap(); + + // Also seed movie repo so review_logger can find it + let movies = domain::testing::InMemoryMovieRepository::new(); + let movie = domain::models::Movie::from_persistence( + domain::value_objects::MovieId::from_uuid(movie_uuid), + None, + domain::value_objects::MovieTitle::new("Movie With Id".into()).unwrap(), + domain::value_objects::ReleaseYear::new(2024).unwrap(), + None, + None, + ); + movies.upsert_movie(&movie).await.unwrap(); + + // Build a real review logger + let reviews = domain::testing::InMemoryReviewRepository::new(); + let watchlist = domain::testing::InMemoryWatchlistRepository::new(); + let review_logger = std::sync::Arc::new(crate::diary::review_logger::DefaultReviewLogger::new( + std::sync::Arc::clone(&movies) as _, + std::sync::Arc::clone(&reviews) as _, + std::sync::Arc::clone(&watchlist) as _, + std::sync::Arc::new(domain::testing::FakeMetadataClient) as _, + std::sync::Arc::clone(&events) as _, + )); + + let ctx = TestContextBuilder::new() + .with_watch_events(std::sync::Arc::clone(&watch_events) as _) + .with_event_publisher(std::sync::Arc::clone(&events) as _) + .with_movies(std::sync::Arc::clone(&movies) as _) + .with_review_logger(review_logger as _) + .build(); + + let result = confirm::execute( + &ctx, + ConfirmWatchEventsCommand { + user_id: uid, + confirmations: vec![WatchEventConfirmation { + watch_event_id: event_id, + rating: 4, + comment: None, + }], + }, + ) + .await + .unwrap(); + + assert_eq!(result, 1); +} + +#[tokio::test] +async fn confirms_event_without_movie_id_and_without_external_metadata_id() { + let watch_events = InMemoryWatchEventRepository::new(); + let events = NoopEventPublisher::new(); + let uid = Uuid::new_v4(); + + let event = WatchEvent::new( + UserId::from_uuid(uid), + "Title Only Movie".into(), + Some(2022), + None, + WatchEventSource::Jellyfin, + chrono::Utc::now().naive_utc(), + None, + ); + let event_id = event.id().value(); + watch_events.save(&event).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_watch_events(Arc::clone(&watch_events) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let result = confirm::execute( + &ctx, + ConfirmWatchEventsCommand { + user_id: uid, + confirmations: vec![WatchEventConfirmation { + watch_event_id: event_id, + rating: 5, + comment: Some("Amazing".into()), + }], + }, + ) + .await + .unwrap(); + + assert_eq!(result, 1); +} + +#[tokio::test] +async fn confirms_multiple_events() { + let watch_events = InMemoryWatchEventRepository::new(); + let events = NoopEventPublisher::new(); + let uid = Uuid::new_v4(); + + let event1 = WatchEvent::new( + UserId::from_uuid(uid), + "Movie One".into(), + Some(2020), + None, + WatchEventSource::Jellyfin, + chrono::Utc::now().naive_utc(), + None, + ); + let id1 = event1.id().value(); + + let event2 = WatchEvent::new( + UserId::from_uuid(uid), + "Movie Two".into(), + Some(2021), + None, + WatchEventSource::Jellyfin, + chrono::Utc::now().naive_utc(), + None, + ); + let id2 = event2.id().value(); + + watch_events.save(&event1).await.unwrap(); + watch_events.save(&event2).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_watch_events(Arc::clone(&watch_events) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let result = confirm::execute( + &ctx, + ConfirmWatchEventsCommand { + user_id: uid, + confirmations: vec![ + WatchEventConfirmation { + watch_event_id: id1, + rating: 3, + comment: None, + }, + WatchEventConfirmation { + watch_event_id: id2, + rating: 4, + comment: None, + }, + ], + }, + ) + .await + .unwrap(); + + assert_eq!(result, 2); +} + +#[tokio::test] +async fn confirms_event_without_year() { + let watch_events = InMemoryWatchEventRepository::new(); + let events = NoopEventPublisher::new(); + let uid = Uuid::new_v4(); + + let event = WatchEvent::new( + UserId::from_uuid(uid), + "No Year Movie".into(), + None, // no year + None, + WatchEventSource::Jellyfin, + chrono::Utc::now().naive_utc(), + None, + ); + let event_id = event.id().value(); + watch_events.save(&event).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_watch_events(Arc::clone(&watch_events) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let result = confirm::execute( + &ctx, + ConfirmWatchEventsCommand { + user_id: uid, + confirmations: vec![WatchEventConfirmation { + watch_event_id: event_id, + rating: 3, + comment: None, + }], + }, + ) + .await + .unwrap(); + + assert_eq!(result, 1); +} diff --git a/crates/application/src/integrations/tests/dismiss.rs b/crates/application/src/integrations/tests/dismiss.rs new file mode 100644 index 0000000..da9c87b --- /dev/null +++ b/crates/application/src/integrations/tests/dismiss.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use domain::models::{WatchEvent, WatchEventSource}; +use domain::ports::WatchEventRepository; +use domain::testing::InMemoryWatchEventRepository; +use domain::value_objects::UserId; +use uuid::Uuid; + +use crate::integrations::{commands::DismissWatchEventsCommand, dismiss}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn dismisses_empty_list_returns_zero() { + let events = InMemoryWatchEventRepository::new(); + let ctx = TestContextBuilder::new() + .with_watch_events(Arc::clone(&events) as _) + .build(); + + let result = dismiss::execute( + &ctx, + DismissWatchEventsCommand { + user_id: Uuid::new_v4(), + event_ids: vec![], + }, + ) + .await + .unwrap(); + + assert_eq!(result, 0); +} + +#[tokio::test] +async fn fails_when_event_not_found() { + let events = InMemoryWatchEventRepository::new(); + let ctx = TestContextBuilder::new() + .with_watch_events(Arc::clone(&events) as _) + .build(); + + let result = dismiss::execute( + &ctx, + DismissWatchEventsCommand { + user_id: Uuid::new_v4(), + event_ids: vec![Uuid::new_v4()], + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn dismisses_existing_events() { + let watch_events = InMemoryWatchEventRepository::new(); + let uid = Uuid::new_v4(); + let user_id = UserId::from_uuid(uid); + + let e1 = WatchEvent::new( + user_id.clone(), + "Movie A".into(), + Some(2024), + None, + WatchEventSource::Jellyfin, + chrono::Utc::now().naive_utc(), + None, + ); + let e2 = WatchEvent::new( + user_id, + "Movie B".into(), + Some(2023), + None, + WatchEventSource::Jellyfin, + chrono::Utc::now().naive_utc(), + None, + ); + let id1 = e1.id().value(); + let id2 = e2.id().value(); + watch_events.save(&e1).await.unwrap(); + watch_events.save(&e2).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_watch_events(Arc::clone(&watch_events) as _) + .build(); + + let result = dismiss::execute( + &ctx, + DismissWatchEventsCommand { + user_id: uid, + event_ids: vec![id1, id2], + }, + ) + .await + .unwrap(); + + assert_eq!(result, 2); +} diff --git a/crates/application/src/integrations/tests/generate_token.rs b/crates/application/src/integrations/tests/generate_token.rs new file mode 100644 index 0000000..b2b02d9 --- /dev/null +++ b/crates/application/src/integrations/tests/generate_token.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; + +use domain::models::WatchEventSource; +use domain::testing::InMemoryWebhookTokenRepository; +use uuid::Uuid; + +use crate::integrations::{commands::GenerateWebhookTokenCommand, generate_token}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn generates_token_and_saves() { + let tokens = InMemoryWebhookTokenRepository::new(); + let ctx = TestContextBuilder::new() + .with_webhook_tokens(Arc::clone(&tokens) as _) + .build(); + + let user_id = Uuid::new_v4(); + let result = generate_token::execute( + &ctx, + GenerateWebhookTokenCommand { + user_id, + provider: WatchEventSource::Jellyfin, + label: None, + }, + ) + .await + .unwrap(); + + assert!(!result.token_plaintext.is_empty()); + + let saved = ctx + .repos + .webhook_token + .list_by_user(&domain::value_objects::UserId::from_uuid(user_id)) + .await + .unwrap(); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].id().value(), result.token.id().value()); +} diff --git a/crates/application/src/integrations/tests/get_queue.rs b/crates/application/src/integrations/tests/get_queue.rs new file mode 100644 index 0000000..302b233 --- /dev/null +++ b/crates/application/src/integrations/tests/get_queue.rs @@ -0,0 +1,56 @@ +use std::sync::Arc; + +use chrono::Utc; +use domain::models::{WatchEvent, WatchEventSource}; +use domain::ports::WatchEventRepository; +use domain::testing::InMemoryWatchEventRepository; +use domain::value_objects::UserId; +use uuid::Uuid; + +use crate::integrations::{get_queue, queries::GetWatchQueueQuery}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn returns_empty_when_no_events() { + let events = InMemoryWatchEventRepository::new(); + let ctx = TestContextBuilder::new() + .with_watch_events(Arc::clone(&events) as _) + .build(); + + let result = get_queue::execute( + &ctx, + GetWatchQueueQuery { + user_id: Uuid::new_v4(), + }, + ) + .await + .unwrap(); + + assert!(result.is_empty()); +} + +#[tokio::test] +async fn returns_pending_events() { + let events = InMemoryWatchEventRepository::new(); + let ctx = TestContextBuilder::new() + .with_watch_events(Arc::clone(&events) as _) + .build(); + + let user_id = Uuid::new_v4(); + let event = WatchEvent::new( + UserId::from_uuid(user_id), + "Blade Runner 2049".into(), + Some(2017), + None, + WatchEventSource::Jellyfin, + Utc::now().naive_utc(), + None, + ); + events.save(&event).await.unwrap(); + + let result = get_queue::execute(&ctx, GetWatchQueueQuery { user_id }) + .await + .unwrap(); + + assert_eq!(result.len(), 1); +} diff --git a/crates/application/src/integrations/tests/get_tokens.rs b/crates/application/src/integrations/tests/get_tokens.rs new file mode 100644 index 0000000..81d13ec --- /dev/null +++ b/crates/application/src/integrations/tests/get_tokens.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use domain::models::WatchEventSource; +use domain::testing::InMemoryWebhookTokenRepository; +use uuid::Uuid; + +use crate::integrations::{ + commands::GenerateWebhookTokenCommand, generate_token, get_tokens, + queries::GetWebhookTokensQuery, +}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn returns_empty_when_no_tokens() { + let tokens = InMemoryWebhookTokenRepository::new(); + let ctx = TestContextBuilder::new() + .with_webhook_tokens(Arc::clone(&tokens) as _) + .build(); + + let result = get_tokens::execute( + &ctx, + GetWebhookTokensQuery { + user_id: Uuid::new_v4(), + }, + ) + .await + .unwrap(); + + assert!(result.is_empty()); +} + +#[tokio::test] +async fn returns_tokens_after_generate() { + let tokens = InMemoryWebhookTokenRepository::new(); + let ctx = TestContextBuilder::new() + .with_webhook_tokens(Arc::clone(&tokens) as _) + .build(); + + let user_id = Uuid::new_v4(); + + generate_token::execute( + &ctx, + GenerateWebhookTokenCommand { + user_id, + provider: WatchEventSource::Jellyfin, + label: None, + }, + ) + .await + .unwrap(); + + generate_token::execute( + &ctx, + GenerateWebhookTokenCommand { + user_id, + provider: WatchEventSource::Plex, + label: Some("living room".into()), + }, + ) + .await + .unwrap(); + + let result = get_tokens::execute(&ctx, GetWebhookTokensQuery { user_id }) + .await + .unwrap(); + + assert_eq!(result.len(), 2); +} diff --git a/crates/application/src/integrations/tests/ingest.rs b/crates/application/src/integrations/tests/ingest.rs new file mode 100644 index 0000000..66eb49a --- /dev/null +++ b/crates/application/src/integrations/tests/ingest.rs @@ -0,0 +1,76 @@ +use std::sync::Arc; + +use domain::models::WatchEventSource; +use domain::testing::InMemoryWebhookTokenRepository; +use uuid::Uuid; + +use crate::integrations::commands::GenerateWebhookTokenCommand; +use crate::integrations::{commands::IngestWatchEventCommand, generate_token, ingest}; +use crate::test_helpers::TestContextBuilder; + +struct FakeParser; + +impl domain::ports::MediaServerParser for FakeParser { + fn parse_playback_event( + &self, + _: &[u8], + ) -> Result, domain::errors::DomainError> { + Ok(Some(domain::models::ParsedPlaybackEvent { + title: "Test".into(), + year: Some(2024), + tmdb_id: None, + imdb_id: None, + })) + } +} + +#[tokio::test] +async fn ingests_watch_event() { + let tokens = InMemoryWebhookTokenRepository::new(); + let ctx = TestContextBuilder::new() + .with_webhook_tokens(Arc::clone(&tokens) as _) + .build(); + + let user_id = Uuid::new_v4(); + let generated = generate_token::execute( + &ctx, + GenerateWebhookTokenCommand { + user_id, + provider: WatchEventSource::Jellyfin, + label: None, + }, + ) + .await + .unwrap(); + + let result = ingest::execute( + &ctx, + IngestWatchEventCommand { + token: generated.token_plaintext, + raw_payload: vec![], + source: WatchEventSource::Jellyfin, + }, + &FakeParser, + ) + .await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn rejects_invalid_token() { + let ctx = TestContextBuilder::new().build(); + + let result = ingest::execute( + &ctx, + IngestWatchEventCommand { + token: "bad-token".into(), + raw_payload: vec![], + source: WatchEventSource::Jellyfin, + }, + &FakeParser, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/integrations/tests/revoke_token.rs b/crates/application/src/integrations/tests/revoke_token.rs new file mode 100644 index 0000000..9cfbc5d --- /dev/null +++ b/crates/application/src/integrations/tests/revoke_token.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use domain::models::WatchEventSource; +use domain::testing::InMemoryWebhookTokenRepository; +use uuid::Uuid; + +use crate::integrations::{ + commands::{GenerateWebhookTokenCommand, RevokeWebhookTokenCommand}, + generate_token, get_tokens, + queries::GetWebhookTokensQuery, + revoke_token, +}; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn revokes_existing_token() { + let tokens = InMemoryWebhookTokenRepository::new(); + let ctx = TestContextBuilder::new() + .with_webhook_tokens(Arc::clone(&tokens) as _) + .build(); + + let user_id = Uuid::new_v4(); + + let generated = generate_token::execute( + &ctx, + GenerateWebhookTokenCommand { + user_id, + provider: WatchEventSource::Jellyfin, + label: None, + }, + ) + .await + .unwrap(); + + let token_id = generated.token.id().value(); + + revoke_token::execute(&ctx, RevokeWebhookTokenCommand { user_id, token_id }) + .await + .unwrap(); + + let remaining = get_tokens::execute(&ctx, GetWebhookTokensQuery { user_id }) + .await + .unwrap(); + + assert!(remaining.is_empty()); +} diff --git a/crates/application/src/movies/enrich_movie.rs b/crates/application/src/movies/enrich_movie.rs index d8fd819..b118802 100644 --- a/crates/application/src/movies/enrich_movie.rs +++ b/crates/application/src/movies/enrich_movie.rs @@ -92,3 +92,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec { seen.into_values().collect() } + +#[cfg(test)] +#[path = "tests/enrich_movie.rs"] +mod tests; diff --git a/crates/application/src/movies/get_movie_profile.rs b/crates/application/src/movies/get_movie_profile.rs index 2ddf828..8c40dea 100644 --- a/crates/application/src/movies/get_movie_profile.rs +++ b/crates/application/src/movies/get_movie_profile.rs @@ -76,3 +76,7 @@ pub async fn execute( } })) } + +#[cfg(test)] +#[path = "tests/get_movie_profile.rs"] +mod tests; diff --git a/crates/application/src/movies/get_movies.rs b/crates/application/src/movies/get_movies.rs index e73dafd..b5ed2e2 100644 --- a/crates/application/src/movies/get_movies.rs +++ b/crates/application/src/movies/get_movies.rs @@ -18,3 +18,7 @@ pub async fn execute( }; ctx.repos.movie.list_movies(&page, &filter).await } + +#[cfg(test)] +#[path = "tests/get_movies.rs"] +mod tests; diff --git a/crates/application/src/movies/request_enrichment.rs b/crates/application/src/movies/request_enrichment.rs index 95faf7b..bb25d69 100644 --- a/crates/application/src/movies/request_enrichment.rs +++ b/crates/application/src/movies/request_enrichment.rs @@ -42,3 +42,7 @@ pub async fn fetch_if_stale( Err(e) => Err(e), } } + +#[cfg(test)] +#[path = "tests/request_enrichment.rs"] +mod tests; diff --git a/crates/application/src/movies/sync_poster.rs b/crates/application/src/movies/sync_poster.rs index 9a4d166..eb99dd7 100644 --- a/crates/application/src/movies/sync_poster.rs +++ b/crates/application/src/movies/sync_poster.rs @@ -96,3 +96,7 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom Ok(()) } + +#[cfg(test)] +#[path = "tests/sync_poster.rs"] +mod tests; diff --git a/crates/application/src/movies/tests/enrich_movie.rs b/crates/application/src/movies/tests/enrich_movie.rs new file mode 100644 index 0000000..cc77bb4 --- /dev/null +++ b/crates/application/src/movies/tests/enrich_movie.rs @@ -0,0 +1,151 @@ +use std::sync::Arc; + +use chrono::Utc; +use domain::{ + models::{Movie, MovieProfile}, + ports::MovieRepository, + testing::{ + FakeSearchCommand, InMemoryMovieProfileRepository, InMemoryMovieRepository, + PanicPersonCommand, + }, + value_objects::{MovieId, MovieTitle, ReleaseYear}, +}; + +use crate::movies::{commands::EnrichMovieCommand, enrich_movie}; + +#[tokio::test] +async fn stores_profile_and_indexes() { + let movie_repo = InMemoryMovieRepository::new(); + let profile_repo = InMemoryMovieProfileRepository::new(); + let search_cmd: Arc = Arc::new(FakeSearchCommand); + // PanicPersonCommand is safe here — empty cast/crew means upsert_batch is never called + let person_cmd: Arc = Arc::new(PanicPersonCommand); + + let movie = Movie::new( + None, + MovieTitle::new("Test".into()).unwrap(), + ReleaseYear::new(2024).unwrap(), + None, + None, + ); + let movie_id = MovieId::from_uuid(movie.id().value()); + movie_repo.upsert_movie(&movie).await.unwrap(); + + let profile = MovieProfile { + movie_id: movie_id.clone(), + tmdb_id: 999, + imdb_id: None, + overview: Some("A test movie".into()), + tagline: None, + runtime_minutes: Some(120), + budget_usd: None, + revenue_usd: None, + vote_average: Some(7.5), + vote_count: Some(100), + original_language: Some("en".into()), + collection_name: None, + genres: vec![], + keywords: vec![], + cast: vec![], + crew: vec![], + enriched_at: Utc::now(), + }; + + enrich_movie::execute( + &(movie_repo as Arc<_>), + &(profile_repo.clone() as Arc<_>), + &person_cmd, + &search_cmd, + EnrichMovieCommand { + movie_id: movie_id.clone(), + profile, + }, + ) + .await + .unwrap(); + + assert_eq!(profile_repo.count(), 1); +} + +struct NoopPersonCommand; + +#[async_trait::async_trait] +impl domain::ports::PersonCommand for NoopPersonCommand { + async fn upsert_batch( + &self, + _: &[domain::models::Person], + ) -> Result<(), domain::errors::DomainError> { + Ok(()) + } + async fn backfill_from_credits_batch( + &self, + _: u32, + ) -> Result<(u64, bool), domain::errors::DomainError> { + Ok((0, false)) + } +} + +#[tokio::test] +async fn extracts_and_indexes_persons() { + let movie_repo = InMemoryMovieRepository::new(); + let profile_repo = InMemoryMovieProfileRepository::new(); + let search_cmd: Arc = Arc::new(FakeSearchCommand); + let person_cmd: Arc = Arc::new(NoopPersonCommand); + + let movie = Movie::new( + None, + MovieTitle::new("Cast Movie".into()).unwrap(), + ReleaseYear::new(2024).unwrap(), + None, + None, + ); + let movie_id = MovieId::from_uuid(movie.id().value()); + movie_repo.upsert_movie(&movie).await.unwrap(); + + let profile = MovieProfile { + movie_id: movie_id.clone(), + tmdb_id: 1001, + imdb_id: None, + overview: None, + tagline: None, + runtime_minutes: None, + budget_usd: None, + revenue_usd: None, + vote_average: None, + vote_count: None, + original_language: None, + collection_name: None, + genres: vec![], + keywords: vec![], + cast: vec![domain::models::CastMember { + tmdb_person_id: 42, + name: "Actor One".into(), + character: "Hero".into(), + billing_order: 0, + profile_path: None, + }], + crew: vec![domain::models::CrewMember { + tmdb_person_id: 99, + name: "Director One".into(), + job: "Director".into(), + department: "Directing".into(), + profile_path: None, + }], + enriched_at: Utc::now(), + }; + + enrich_movie::execute( + &(movie_repo as Arc<_>), + &(profile_repo.clone() as Arc<_>), + &person_cmd, + &search_cmd, + EnrichMovieCommand { + movie_id: movie_id.clone(), + profile, + }, + ) + .await + .unwrap(); + + assert_eq!(profile_repo.count(), 1); +} diff --git a/crates/application/src/movies/tests/get_movie_profile.rs b/crates/application/src/movies/tests/get_movie_profile.rs new file mode 100644 index 0000000..1bd2a87 --- /dev/null +++ b/crates/application/src/movies/tests/get_movie_profile.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; + +use chrono::Utc; +use uuid::Uuid; + +use domain::{ + models::{CastMember, CrewMember, MovieProfile}, + ports::MovieProfileRepository, + testing::InMemoryMovieProfileRepository, + value_objects::MovieId, +}; + +use crate::{ + movies::get_movie_profile::{self, GetMovieProfileQuery}, + test_helpers::TestContextBuilder, +}; + +#[tokio::test] +async fn returns_none_when_no_profile() { + let ctx = TestContextBuilder::new().build(); + + let result = get_movie_profile::execute( + &ctx, + GetMovieProfileQuery { + movie_id: Uuid::new_v4(), + }, + ) + .await + .unwrap(); + + assert!(result.is_none()); +} + +#[tokio::test] +async fn returns_profile_with_cast_and_crew() { + let profile_repo = InMemoryMovieProfileRepository::new(); + let movie_id = MovieId::generate(); + + let profile = MovieProfile { + movie_id: movie_id.clone(), + tmdb_id: 42, + imdb_id: Some("tt1234567".into()), + overview: Some("A great movie".into()), + tagline: None, + runtime_minutes: Some(120), + budget_usd: None, + revenue_usd: None, + vote_average: Some(8.0), + vote_count: Some(500), + original_language: Some("en".into()), + collection_name: None, + genres: vec![], + keywords: vec![], + cast: vec![CastMember { + tmdb_person_id: 1, + name: "Alice".into(), + character: "Hero".into(), + billing_order: 0, + profile_path: None, + }], + crew: vec![CrewMember { + tmdb_person_id: 2, + name: "Bob".into(), + job: "Director".into(), + department: "Directing".into(), + profile_path: None, + }], + enriched_at: Utc::now(), + }; + profile_repo.upsert(&profile).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_movie_profiles(Arc::clone(&profile_repo) as _) + .build(); + + let result = get_movie_profile::execute( + &ctx, + GetMovieProfileQuery { + movie_id: movie_id.value(), + }, + ) + .await + .unwrap(); + + let res = result.expect("profile should be present"); + assert_eq!(res.cast.len(), 1); + assert_eq!(res.cast[0].name, "Alice"); + assert_eq!(res.cast[0].character, "Hero"); + assert_eq!(res.crew.len(), 1); + assert_eq!(res.crew[0].name, "Bob"); + assert_eq!(res.crew[0].job, "Director"); +} diff --git a/crates/application/src/movies/tests/get_movies.rs b/crates/application/src/movies/tests/get_movies.rs new file mode 100644 index 0000000..e2917a2 --- /dev/null +++ b/crates/application/src/movies/tests/get_movies.rs @@ -0,0 +1,24 @@ +use crate::{ + movies::{get_movies, queries::GetMoviesQuery}, + test_helpers::TestContextBuilder, +}; + +#[tokio::test] +async fn returns_empty_when_no_movies() { + let ctx = TestContextBuilder::new().build(); + + let result = get_movies::execute( + &ctx, + GetMoviesQuery { + limit: None, + offset: None, + search: None, + genre: None, + language: None, + }, + ) + .await + .unwrap(); + + assert!(result.items.is_empty()); +} diff --git a/crates/application/src/movies/tests/request_enrichment.rs b/crates/application/src/movies/tests/request_enrichment.rs new file mode 100644 index 0000000..1de0be2 --- /dev/null +++ b/crates/application/src/movies/tests/request_enrichment.rs @@ -0,0 +1,103 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use domain::{ + errors::DomainError, + models::MovieProfile, + ports::{MovieEnrichmentClient, MovieProfileRepository}, + testing::{FakeMovieEnrichmentClient, InMemoryMovieProfileRepository}, + value_objects::MovieId, +}; + +use crate::movies::request_enrichment; + +#[tokio::test] +async fn returns_profile_when_none_cached() { + let enrichment = FakeMovieEnrichmentClient; + let profile_repo = InMemoryMovieProfileRepository::new(); + let movie_id = MovieId::generate(); + + let result = request_enrichment::fetch_if_stale( + &enrichment, + &(profile_repo as Arc<_>), + movie_id.clone(), + "tmdb:12345", + ) + .await + .unwrap(); + + assert!(result.is_some()); + assert_eq!(result.unwrap().movie_id, movie_id); +} + +#[tokio::test] +async fn returns_none_when_profile_is_fresh() { + let enrichment = FakeMovieEnrichmentClient; + let profile_repo = InMemoryMovieProfileRepository::new(); + let movie_id = MovieId::generate(); + + // Seed a fresh profile (enriched_at = now) + let fresh_profile = MovieProfile { + movie_id: movie_id.clone(), + tmdb_id: 12345, + imdb_id: None, + overview: None, + tagline: None, + runtime_minutes: None, + budget_usd: None, + revenue_usd: None, + vote_average: None, + vote_count: None, + original_language: None, + collection_name: None, + genres: vec![], + keywords: vec![], + cast: vec![], + crew: vec![], + enriched_at: Utc::now(), + }; + profile_repo.upsert(&fresh_profile).await.unwrap(); + + let result = request_enrichment::fetch_if_stale( + &enrichment, + &(Arc::clone(&profile_repo) as Arc<_>), + movie_id, + "tmdb:12345", + ) + .await + .unwrap(); + + assert!(result.is_none(), "fresh profile should be skipped"); +} + +struct NotFoundEnrichmentClient; + +#[async_trait] +impl MovieEnrichmentClient for NotFoundEnrichmentClient { + async fn fetch_profile( + &self, + _movie_id: MovieId, + _external_metadata_id: &str, + ) -> Result { + Err(DomainError::NotFound("not found in TMDb".into())) + } +} + +#[tokio::test] +async fn returns_none_on_not_found_from_client() { + let enrichment = NotFoundEnrichmentClient; + let profile_repo = InMemoryMovieProfileRepository::new(); + let movie_id = MovieId::generate(); + + let result = request_enrichment::fetch_if_stale( + &enrichment, + &(profile_repo as Arc<_>), + movie_id, + "tmdb:99999", + ) + .await + .unwrap(); + + assert!(result.is_none(), "NotFound should return Ok(None), not Err"); +} diff --git a/crates/application/src/movies/tests/sync_poster.rs b/crates/application/src/movies/tests/sync_poster.rs new file mode 100644 index 0000000..d06e92f --- /dev/null +++ b/crates/application/src/movies/tests/sync_poster.rs @@ -0,0 +1,103 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use domain::{ + errors::DomainError, + models::Movie, + ports::{MetadataClient, MovieRepository}, + testing::InMemoryMovieRepository, + value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear}, +}; + +use crate::{ + diary::commands::SyncPosterCommand, movies::sync_poster, test_helpers::TestContextBuilder, +}; + +#[tokio::test] +async fn fails_when_movie_not_found() { + let ctx = TestContextBuilder::new().build(); + + let result = sync_poster::execute( + &ctx, + SyncPosterCommand { + movie_id: Uuid::new_v4(), + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn fails_when_no_external_id() { + let movies = InMemoryMovieRepository::new(); + let movie = Movie::new( + None, + MovieTitle::new("Test".into()).unwrap(), + ReleaseYear::new(2024).unwrap(), + None, + None, + ); + let movie_id = movie.id().value(); + movies.upsert_movie(&movie).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .build(); + + let result = sync_poster::execute(&ctx, SyncPosterCommand { movie_id }).await; + + assert!(result.is_err()); +} + +struct FakeMetaWithPoster; + +#[async_trait::async_trait] +impl MetadataClient for FakeMetaWithPoster { + async fn fetch_movie_metadata( + &self, + _: &domain::ports::MetadataSearchCriteria, + ) -> Result { + unimplemented!() + } + async fn get_poster_url( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + Ok(Some( + PosterUrl::new("https://example.com/poster.jpg".into()).unwrap(), + )) + } +} + +#[tokio::test] +async fn syncs_poster_for_movie_with_external_id() { + let movies = InMemoryMovieRepository::new(); + let ext_id = ExternalMetadataId::new("tmdb:999".into()).unwrap(); + let movie = Movie::new( + Some(ext_id), + MovieTitle::new("Poster Movie".into()).unwrap(), + ReleaseYear::new(2024).unwrap(), + None, + None, + ); + let movie_id = movie.id().value(); + movies.upsert_movie(&movie).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .with_metadata_client(Arc::new(FakeMetaWithPoster) as _) + .build(); + + sync_poster::execute(&ctx, SyncPosterCommand { movie_id }) + .await + .unwrap(); + + let updated = movies + .get_movie_by_id(&domain::value_objects::MovieId::from_uuid(movie_id)) + .await + .unwrap() + .unwrap(); + assert!(updated.poster_path().is_some()); +} diff --git a/crates/application/src/person/get.rs b/crates/application/src/person/get.rs index a4177bb..1199b45 100644 --- a/crates/application/src/person/get.rs +++ b/crates/application/src/person/get.rs @@ -7,3 +7,7 @@ use domain::{ pub async fn execute(ctx: &AppContext, id: PersonId) -> Result, DomainError> { ctx.repos.person_query.get_by_id(&id).await } + +#[cfg(test)] +#[path = "tests/get.rs"] +mod tests; diff --git a/crates/application/src/person/get_credits.rs b/crates/application/src/person/get_credits.rs index a3843db..6bb6863 100644 --- a/crates/application/src/person/get_credits.rs +++ b/crates/application/src/person/get_credits.rs @@ -7,3 +7,7 @@ use domain::{ pub async fn execute(ctx: &AppContext, id: PersonId) -> Result { ctx.repos.person_query.get_credits(&id).await } + +#[cfg(test)] +#[path = "tests/get_credits.rs"] +mod tests; diff --git a/crates/application/src/person/tests/get.rs b/crates/application/src/person/tests/get.rs new file mode 100644 index 0000000..bf7c723 --- /dev/null +++ b/crates/application/src/person/tests/get.rs @@ -0,0 +1,16 @@ +use domain::models::PersonId; +use uuid::Uuid; + +use crate::person::get; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn returns_none_for_unknown_person() { + let ctx = TestContextBuilder::new().build(); + + let result = get::execute(&ctx, PersonId::from_uuid(Uuid::new_v4())) + .await + .unwrap(); + + assert!(result.is_none()); +} diff --git a/crates/application/src/person/tests/get_credits.rs b/crates/application/src/person/tests/get_credits.rs new file mode 100644 index 0000000..4bddb6d --- /dev/null +++ b/crates/application/src/person/tests/get_credits.rs @@ -0,0 +1,17 @@ +use domain::models::PersonId; +use uuid::Uuid; + +use crate::person::get_credits; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn returns_empty_credits() { + let ctx = TestContextBuilder::new().build(); + + let result = get_credits::execute(&ctx, PersonId::from_uuid(Uuid::new_v4())) + .await + .unwrap(); + + assert!(result.cast.is_empty()); + assert!(result.crew.is_empty()); +} diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index aed3f40..a11aad9 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -1,7 +1,16 @@ +use async_trait::async_trait; use uuid::Uuid; +use domain::errors::DomainError; use domain::models::DiaryEntry; +use crate::diary::commands::LogReviewCommand; + +#[async_trait] +pub trait ReviewLogger: Send + Sync { + async fn log_review(&self, cmd: LogReviewCommand) -> Result<(), DomainError>; +} + pub struct HtmlPageContext { pub user_email: Option, pub user_id: Option, diff --git a/crates/application/src/search/execute.rs b/crates/application/src/search/execute.rs index ebea467..36de83e 100644 --- a/crates/application/src/search/execute.rs +++ b/crates/application/src/search/execute.rs @@ -7,3 +7,7 @@ use domain::{ pub async fn execute(ctx: &AppContext, query: SearchQuery) -> Result { ctx.repos.search_port.search(&query).await } + +#[cfg(test)] +#[path = "tests/execute.rs"] +mod tests; diff --git a/crates/application/src/search/tests/execute.rs b/crates/application/src/search/tests/execute.rs new file mode 100644 index 0000000..10888b2 --- /dev/null +++ b/crates/application/src/search/tests/execute.rs @@ -0,0 +1,16 @@ +use domain::models::SearchQuery; + +use crate::search::execute; +use crate::test_helpers::TestContextBuilder; + +#[tokio::test] +async fn returns_empty_results() { + let ctx = TestContextBuilder::new().build(); + + let result = execute::execute(&ctx, SearchQuery::default()) + .await + .unwrap(); + + assert!(result.movies.items.is_empty()); + assert!(result.people.items.is_empty()); +} diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index 92e0846..5f43729 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -1,34 +1,49 @@ use std::sync::Arc; use domain::testing::{ - InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopRemoteWatchlistRepository, - NoopSocialQueryPort, + InMemoryGoalRepository, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, + NoopRemoteWatchlistRepository, NoopSocialQueryPort, }; use domain::{ ports::{ AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, - ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository, - MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, - PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository, - UserProfileFieldsRepository, UserRepository, WatchEventRepository, WatchlistRepository, - WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery, + GoalRepository, ImportProfileRepository, ImportSessionRepository, MetadataClient, + MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, + PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, + StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository, + WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository, + WrapUpStatsQuery, }, testing::{ - FakeAuthService, FakeMetadataClient, FakePasswordHasher, InMemoryMovieRepository, - InMemoryReviewRepository, InMemoryUserRepository, InMemoryWatchlistRepository, - NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicDiaryRepository, - PanicDocumentParser, PanicImportProfileRepository, PanicImportSessionRepository, - PanicMovieProfileRepository, PanicPersonCommand, PanicPersonQuery, PanicPosterFetcher, - PanicProfileFieldsRepo, PanicSearchCommand, PanicSearchPort, PanicStatsRepository, - PanicWatchEventRepository, PanicWebhookTokenRepository, + FakeAuthService, FakeDiaryRepository, FakeDocumentParser, FakeMetadataClient, + FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort, + FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository, + InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo, + InMemoryReviewRepository, InMemoryUserRepository, InMemoryUserSettingsRepository, + InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository, + NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicPersonCommand, }, }; +use async_trait::async_trait; +use domain::errors::DomainError; + use crate::{ config::AppConfig, context::{AppContext, Repositories, Services}, + diary::commands::LogReviewCommand, + ports::ReviewLogger, }; +pub struct NoopReviewLogger; + +#[async_trait] +impl ReviewLogger for NoopReviewLogger { + async fn log_review(&self, _cmd: LogReviewCommand) -> Result<(), DomainError> { + Ok(()) + } +} + pub struct TestContextBuilder { pub movie_repo: Arc, pub review_repo: Arc, @@ -56,6 +71,10 @@ pub struct TestContextBuilder { pub search_command: Arc, pub wrapup_stats: Arc, pub wrapup_repo: Arc, + pub goal_repo: Arc, + pub user_settings_repo: Arc, + pub review_logger: Arc, + pub social_query: Arc, pub config: AppConfig, } @@ -70,30 +89,34 @@ impl TestContextBuilder { Self { movie_repo: InMemoryMovieRepository::new(), review_repo: InMemoryReviewRepository::new(), - diary_repo: Arc::new(PanicDiaryRepository), + diary_repo: FakeDiaryRepository::new(), diary_exporter: Arc::new(PanicDiaryExporter), - document_parser: Arc::new(PanicDocumentParser), - stats_repo: Arc::new(PanicStatsRepository), + document_parser: Arc::new(FakeDocumentParser), + stats_repo: Arc::new(FakeStatsRepository), metadata_client: Arc::new(FakeMetadataClient), - poster_fetcher: Arc::new(PanicPosterFetcher), + poster_fetcher: Arc::new(FakePosterFetcher), object_storage: Arc::new(NoopObjectStorage), event_publisher: NoopEventPublisher::new(), auth_service: Arc::new(FakeAuthService), password_hasher: Arc::new(FakePasswordHasher), user_repo: InMemoryUserRepository::new(), - import_session_repo: Arc::new(PanicImportSessionRepository), - import_profile_repo: Arc::new(PanicImportProfileRepository), - movie_profile_repo: Arc::new(PanicMovieProfileRepository), + import_session_repo: InMemoryImportSessionRepository::new(), + import_profile_repo: InMemoryImportProfileRepository::new(), + movie_profile_repo: InMemoryMovieProfileRepository::new(), watchlist_repo: InMemoryWatchlistRepository::new(), - watch_event_repo: Arc::new(PanicWatchEventRepository), - webhook_token_repo: Arc::new(PanicWebhookTokenRepository), - profile_fields_repo: Arc::new(PanicProfileFieldsRepo), + watch_event_repo: InMemoryWatchEventRepository::new(), + webhook_token_repo: InMemoryWebhookTokenRepository::new(), + profile_fields_repo: InMemoryProfileFieldsRepo::new(), person_command: Arc::new(PanicPersonCommand), - person_query: Arc::new(PanicPersonQuery), - search_port: Arc::new(PanicSearchPort), - search_command: Arc::new(PanicSearchCommand), + person_query: Arc::new(FakePersonQuery), + search_port: Arc::new(FakeSearchPort), + search_command: Arc::new(FakeSearchCommand), wrapup_stats: InMemoryWrapUpStatsQuery::new(), wrapup_repo: InMemoryWrapUpRepository::new(), + goal_repo: InMemoryGoalRepository::new(), + user_settings_repo: InMemoryUserSettingsRepository::new(), + review_logger: Arc::new(NoopReviewLogger), + social_query: Arc::new(NoopSocialQueryPort), config: AppConfig { allow_registration: true, base_url: "http://localhost:3000".into(), @@ -142,6 +165,96 @@ impl TestContextBuilder { self } + pub fn with_goal(mut self, r: Arc) -> Self { + self.goal_repo = r; + self + } + + pub fn with_webhook_tokens(mut self, r: Arc) -> Self { + self.webhook_token_repo = r; + self + } + + pub fn with_watch_events(mut self, r: Arc) -> Self { + self.watch_event_repo = r; + self + } + + pub fn with_import_sessions(mut self, r: Arc) -> Self { + self.import_session_repo = r; + self + } + + pub fn with_import_profiles(mut self, r: Arc) -> Self { + self.import_profile_repo = r; + self + } + + pub fn with_movie_profiles(mut self, r: Arc) -> Self { + self.movie_profile_repo = r; + self + } + + pub fn with_user_settings(mut self, r: Arc) -> Self { + self.user_settings_repo = r; + self + } + + pub fn with_profile_fields(mut self, r: Arc) -> Self { + self.profile_fields_repo = r; + self + } + + pub fn with_review_logger(mut self, r: Arc) -> Self { + self.review_logger = r; + self + } + + pub fn with_stats(mut self, r: Arc) -> Self { + self.stats_repo = r; + self + } + + pub fn with_person_query(mut self, r: Arc) -> Self { + self.person_query = r; + self + } + + pub fn with_search_port(mut self, r: Arc) -> Self { + self.search_port = r; + self + } + + pub fn with_search_command(mut self, r: Arc) -> Self { + self.search_command = r; + self + } + + pub fn with_document_parser(mut self, r: Arc) -> Self { + self.document_parser = r; + self + } + + pub fn with_poster_fetcher(mut self, r: Arc) -> Self { + self.poster_fetcher = r; + self + } + + pub fn with_metadata_client(mut self, r: Arc) -> Self { + self.metadata_client = r; + self + } + + pub fn with_social_query(mut self, r: Arc) -> Self { + self.social_query = r; + self + } + + pub fn with_wrapup_repo(mut self, r: Arc) -> Self { + self.wrapup_repo = r; + self + } + pub fn with_config(mut self, config: AppConfig) -> Self { self.config = config; self @@ -167,11 +280,11 @@ impl TestContextBuilder { search_port: self.search_port, search_command: self.search_command, remote_watchlist: Arc::new(NoopRemoteWatchlistRepository), - social_query: Arc::new(NoopSocialQueryPort), + social_query: self.social_query, wrapup_stats: self.wrapup_stats, wrapup_repo: self.wrapup_repo, - goal: Arc::new(domain::testing::NoopGoalRepository), - user_settings: Arc::new(domain::testing::NoopUserSettingsRepository), + goal: self.goal_repo, + user_settings: self.user_settings_repo, remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository), }, services: Services { @@ -183,6 +296,7 @@ impl TestContextBuilder { event_publisher: self.event_publisher, diary_exporter: self.diary_exporter, document_parser: self.document_parser, + review_logger: self.review_logger, }, config: self.config, } diff --git a/crates/application/src/users/get_current_profile.rs b/crates/application/src/users/get_current_profile.rs index edaa32f..dc48e02 100644 --- a/crates/application/src/users/get_current_profile.rs +++ b/crates/application/src/users/get_current_profile.rs @@ -57,3 +57,7 @@ pub async fn execute( role: user.role().as_str().into(), }) } + +#[cfg(test)] +#[path = "tests/get_current_profile.rs"] +mod tests; diff --git a/crates/application/src/users/get_profile.rs b/crates/application/src/users/get_profile.rs index 7975b26..08f0bfb 100644 --- a/crates/application/src/users/get_profile.rs +++ b/crates/application/src/users/get_profile.rs @@ -183,3 +183,139 @@ fn format_year_month_long(ym: &str) -> String { }; format!("{} {}", month, parts[0]) } + +#[cfg(test)] +#[path = "tests/get_profile.rs"] +mod tests; + +#[cfg(test)] +mod helper_tests { + use super::*; + + #[test] + fn format_year_month_long_all_months() { + assert_eq!(format_year_month_long("2024-01"), "January 2024"); + assert_eq!(format_year_month_long("2024-02"), "February 2024"); + assert_eq!(format_year_month_long("2024-03"), "March 2024"); + assert_eq!(format_year_month_long("2024-04"), "April 2024"); + assert_eq!(format_year_month_long("2024-05"), "May 2024"); + assert_eq!(format_year_month_long("2024-06"), "June 2024"); + assert_eq!(format_year_month_long("2024-07"), "July 2024"); + assert_eq!(format_year_month_long("2024-08"), "August 2024"); + assert_eq!(format_year_month_long("2024-09"), "September 2024"); + assert_eq!(format_year_month_long("2024-10"), "October 2024"); + assert_eq!(format_year_month_long("2024-11"), "November 2024"); + assert_eq!(format_year_month_long("2024-12"), "December 2024"); + } + + #[test] + fn format_year_month_long_invalid() { + assert_eq!(format_year_month_long("invalid"), "invalid"); + assert_eq!(format_year_month_long("2024-99"), "99 2024"); + } + + #[test] + fn feed_sort_to_direction_all_variants() { + use domain::ports::FeedSortBy; + assert!(matches!( + feed_sort_to_direction(FeedSortBy::Date), + SortDirection::Descending + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::DateAsc), + SortDirection::Ascending + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::Rating), + SortDirection::ByRatingDesc + )); + assert!(matches!( + feed_sort_to_direction(FeedSortBy::RatingAsc), + SortDirection::ByRatingAsc + )); + } + + #[test] + fn group_by_month_empty() { + assert!(group_by_month(vec![]).is_empty()); + } + + #[test] + fn group_by_month_groups_entries() { + use chrono::NaiveDateTime; + use domain::models::{Movie, Review}; + use domain::value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId}; + + let movie = Movie::from_persistence( + MovieId::generate(), + None, + MovieTitle::new("Test".into()).unwrap(), + ReleaseYear::new(2024).unwrap(), + None, + None, + ); + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + + let jan = + NaiveDateTime::parse_from_str("2024-01-15 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); + let jan2 = + NaiveDateTime::parse_from_str("2024-01-20 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); + let mar = + NaiveDateTime::parse_from_str("2024-03-05 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap(); + + let r1 = Review::new( + movie.id().clone(), + uid.clone(), + Rating::new(4).unwrap(), + None, + jan, + ) + .unwrap(); + let r2 = Review::new( + movie.id().clone(), + uid.clone(), + Rating::new(3).unwrap(), + None, + jan2, + ) + .unwrap(); + let r3 = Review::new( + movie.id().clone(), + uid.clone(), + Rating::new(5).unwrap(), + None, + mar, + ) + .unwrap(); + + let entries = vec![ + DiaryEntry::new(movie.clone(), r1), + DiaryEntry::new(movie.clone(), r2), + DiaryEntry::new(movie.clone(), r3), + ]; + + let result = group_by_month(entries); + // Reversed: March first, then January + assert_eq!(result.len(), 2); + assert_eq!(result[0].month_label, "March 2024"); + assert_eq!(result[0].count, 1); + assert_eq!(result[1].month_label, "January 2024"); + assert_eq!(result[1].count, 2); + } + + #[test] + fn paged_user_filter_builds_correctly() { + let uid = UserId::from_uuid(uuid::Uuid::new_v4()); + let filter = paged_user_filter( + uid.clone(), + SortDirection::Descending, + Some(20), + Some(5), + Some("blade".into()), + ) + .unwrap(); + + assert_eq!(filter.user_id.unwrap().value(), uid.value()); + assert_eq!(filter.search.as_deref(), Some("blade")); + } +} diff --git a/crates/application/src/users/get_settings.rs b/crates/application/src/users/get_settings.rs index 039d087..a763447 100644 --- a/crates/application/src/users/get_settings.rs +++ b/crates/application/src/users/get_settings.rs @@ -6,3 +6,7 @@ pub async fn execute(ctx: &AppContext, user_id: uuid::Uuid) -> Result, +) -> Uuid { + register::execute( + ctx, + RegisterCommand { + email: "alice@example.com".into(), + username: "alice".into(), + password: "password123".into(), + role: UserRole::Standard, + }, + ) + .await + .unwrap(); + + let user = users + .find_by_email(&domain::value_objects::Email::new("alice@example.com".into()).unwrap()) + .await + .unwrap() + .unwrap(); + user.id().value() +} + +#[tokio::test] +async fn updates_display_name() { + let users = InMemoryUserRepository::new(); + let events = NoopEventPublisher::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let uid = register_user(&ctx, &users).await; + + update_profile::execute( + &ctx, + UpdateProfileCommand { + user_id: uid, + display_name: Some("Alice W.".into()), + bio: None, + avatar_bytes: None, + avatar_content_type: None, + banner_bytes: None, + banner_content_type: None, + also_known_as: None, + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!( + published + .iter() + .any(|e| matches!(e, DomainEvent::UserUpdated { .. })) + ); +} + +#[tokio::test] +async fn rejects_invalid_avatar_content_type() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + let uid = register_user(&ctx, &users).await; + + let result = update_profile::execute( + &ctx, + UpdateProfileCommand { + user_id: uid, + display_name: None, + bio: None, + avatar_bytes: Some(vec![0u8; 10]), + avatar_content_type: Some("image/gif".into()), + banner_bytes: None, + banner_content_type: None, + also_known_as: None, + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn uploads_avatar() { + let users = InMemoryUserRepository::new(); + let events = NoopEventPublisher::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let uid = register_user(&ctx, &users).await; + + update_profile::execute( + &ctx, + UpdateProfileCommand { + user_id: uid, + display_name: None, + bio: None, + avatar_bytes: Some(vec![0xFFu8, 0xD8, 0xFF]), + avatar_content_type: Some("image/jpeg".into()), + banner_bytes: None, + banner_content_type: None, + also_known_as: None, + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!( + published + .iter() + .any(|e| matches!(e, DomainEvent::UserUpdated { .. })) + ); + assert!( + published + .iter() + .any(|e| matches!(e, DomainEvent::ImageStored { .. })) + ); +} + +#[tokio::test] +async fn uploads_banner() { + let users = InMemoryUserRepository::new(); + let events = NoopEventPublisher::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let uid = register_user(&ctx, &users).await; + + update_profile::execute( + &ctx, + UpdateProfileCommand { + user_id: uid, + display_name: None, + bio: None, + avatar_bytes: None, + avatar_content_type: None, + banner_bytes: Some(vec![0x89, 0x50, 0x4E]), + banner_content_type: Some("image/png".into()), + also_known_as: None, + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!( + published + .iter() + .any(|e| matches!(e, DomainEvent::UserUpdated { .. })) + ); + assert!( + published + .iter() + .any(|e| matches!(e, DomainEvent::ImageStored { .. })) + ); +} + +#[tokio::test] +async fn fails_for_nonexistent_user() { + let ctx = TestContextBuilder::new().build(); + + let result = update_profile::execute( + &ctx, + UpdateProfileCommand { + user_id: Uuid::new_v4(), + display_name: Some("Ghost".into()), + bio: None, + avatar_bytes: None, + avatar_content_type: None, + banner_bytes: None, + banner_content_type: None, + also_known_as: None, + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn rejects_invalid_banner_content_type() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + let uid = register_user(&ctx, &users).await; + + let result = update_profile::execute( + &ctx, + UpdateProfileCommand { + user_id: uid, + display_name: None, + bio: None, + avatar_bytes: None, + avatar_content_type: None, + banner_bytes: Some(vec![0u8; 10]), + banner_content_type: Some("text/plain".into()), + also_known_as: None, + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn text_only_update_emits_user_updated_no_image_stored() { + let users = InMemoryUserRepository::new(); + let events = NoopEventPublisher::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let uid = register_user(&ctx, &users).await; + + update_profile::execute( + &ctx, + UpdateProfileCommand { + user_id: uid, + display_name: Some("Alice Updated".into()), + bio: Some("Hello world".into()), + avatar_bytes: None, + avatar_content_type: None, + banner_bytes: None, + banner_content_type: None, + also_known_as: None, + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!( + published + .iter() + .any(|e| matches!(e, DomainEvent::UserUpdated { .. })) + ); + assert!( + !published + .iter() + .any(|e| matches!(e, DomainEvent::ImageStored { .. })), + "text-only update should not emit ImageStored" + ); +} diff --git a/crates/application/src/users/tests/update_profile_fields.rs b/crates/application/src/users/tests/update_profile_fields.rs new file mode 100644 index 0000000..59e9988 --- /dev/null +++ b/crates/application/src/users/tests/update_profile_fields.rs @@ -0,0 +1,70 @@ +use std::sync::Arc; + +use domain::events::DomainEvent; +use domain::models::ProfileField; +use domain::testing::{InMemoryProfileFieldsRepo, NoopEventPublisher}; +use uuid::Uuid; + +use crate::{ + test_helpers::TestContextBuilder, + users::{commands::UpdateProfileFieldsCommand, update_profile_fields}, +}; + +#[tokio::test] +async fn saves_profile_fields() { + let fields_repo = InMemoryProfileFieldsRepo::new(); + let events = NoopEventPublisher::new(); + let ctx = TestContextBuilder::new() + .with_profile_fields(Arc::clone(&fields_repo) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + update_profile_fields::execute( + &ctx, + UpdateProfileFieldsCommand { + user_id: Uuid::nil(), + fields: vec![ + ProfileField { + name: "Website".into(), + value: "https://example.com".into(), + }, + ProfileField { + name: "Location".into(), + value: "Berlin".into(), + }, + ], + }, + ) + .await + .unwrap(); + + let published = events.published(); + assert!( + published + .iter() + .any(|e| matches!(e, DomainEvent::UserUpdated { .. })) + ); +} + +#[tokio::test] +async fn rejects_more_than_four_fields() { + let ctx = TestContextBuilder::new().build(); + + let fields: Vec = (0..5) + .map(|i| ProfileField { + name: format!("field{i}"), + value: format!("val{i}"), + }) + .collect(); + + let result = update_profile_fields::execute( + &ctx, + UpdateProfileFieldsCommand { + user_id: Uuid::nil(), + fields, + }, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/users/tests/update_settings.rs b/crates/application/src/users/tests/update_settings.rs new file mode 100644 index 0000000..fa1d820 --- /dev/null +++ b/crates/application/src/users/tests/update_settings.rs @@ -0,0 +1,32 @@ +use std::sync::Arc; + +use domain::testing::InMemoryUserSettingsRepository; +use uuid::Uuid; + +use crate::{ + test_helpers::TestContextBuilder, + users::{get_settings, update_settings::UpdateUserSettingsCommand}, +}; + +#[tokio::test] +async fn updates_federate_goals() { + let settings_repo = InMemoryUserSettingsRepository::new(); + let ctx = TestContextBuilder::new() + .with_user_settings(Arc::clone(&settings_repo) as _) + .build(); + + let uid = Uuid::nil(); + + crate::users::update_settings::execute( + &ctx, + UpdateUserSettingsCommand { + user_id: uid, + federate_goals: true, + }, + ) + .await + .unwrap(); + + let settings = get_settings::execute(&ctx, uid).await.unwrap(); + assert!(settings.federate_goals()); +} diff --git a/crates/application/src/users/update_profile.rs b/crates/application/src/users/update_profile.rs index 90baf89..11e31d4 100644 --- a/crates/application/src/users/update_profile.rs +++ b/crates/application/src/users/update_profile.rs @@ -90,3 +90,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), Ok(()) } + +#[cfg(test)] +#[path = "tests/update_profile.rs"] +mod tests; diff --git a/crates/application/src/users/update_profile_fields.rs b/crates/application/src/users/update_profile_fields.rs index 5719d4a..6f7fb87 100644 --- a/crates/application/src/users/update_profile_fields.rs +++ b/crates/application/src/users/update_profile_fields.rs @@ -19,3 +19,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Resul .await?; Ok(()) } + +#[cfg(test)] +#[path = "tests/update_profile_fields.rs"] +mod tests; diff --git a/crates/application/src/users/update_settings.rs b/crates/application/src/users/update_settings.rs index 7f98c0f..57f4042 100644 --- a/crates/application/src/users/update_settings.rs +++ b/crates/application/src/users/update_settings.rs @@ -13,3 +13,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateUserSettingsCommand) -> Result settings.set_federate_goals(cmd.federate_goals); ctx.repos.user_settings.save(&settings).await } + +#[cfg(test)] +#[path = "tests/update_settings.rs"] +mod tests; diff --git a/crates/application/src/watchlist/get.rs b/crates/application/src/watchlist/get.rs index 6bcae68..c91801d 100644 --- a/crates/application/src/watchlist/get.rs +++ b/crates/application/src/watchlist/get.rs @@ -17,3 +17,7 @@ pub async fn execute( let page = PageParams::new(query.limit, query.offset)?; ctx.repos.watchlist.get_for_user(&user_id, &page).await } + +#[cfg(test)] +#[path = "tests/get.rs"] +mod tests; diff --git a/crates/application/src/watchlist/get_page.rs b/crates/application/src/watchlist/get_page.rs index 9b103de..2e79330 100644 --- a/crates/application/src/watchlist/get_page.rs +++ b/crates/application/src/watchlist/get_page.rs @@ -84,3 +84,7 @@ async fn load_remote_watchlist( limit: len, }) } + +#[cfg(test)] +#[path = "tests/get_page.rs"] +mod tests; diff --git a/crates/application/src/watchlist/is_on.rs b/crates/application/src/watchlist/is_on.rs index 3d44c45..c69cfe8 100644 --- a/crates/application/src/watchlist/is_on.rs +++ b/crates/application/src/watchlist/is_on.rs @@ -10,3 +10,7 @@ pub async fn execute(ctx: &AppContext, query: IsOnWatchlistQuery) -> Result Resul Ok(()) } + +#[cfg(test)] +#[path = "tests/remove.rs"] +mod tests; diff --git a/crates/application/src/watchlist/tests/add.rs b/crates/application/src/watchlist/tests/add.rs index 58bea04..56dce1e 100644 --- a/crates/application/src/watchlist/tests/add.rs +++ b/crates/application/src/watchlist/tests/add.rs @@ -85,3 +85,54 @@ async fn test_add_to_watchlist_already_present_is_idempotent() { assert_eq!(watchlist.count(), 1, "idempotent add should not duplicate"); } + +#[tokio::test] +async fn test_add_to_watchlist_with_manual_movie() { + let movies = InMemoryMovieRepository::new(); + let watchlist = InMemoryWatchlistRepository::new(); + + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .with_watchlist(Arc::clone(&watchlist) as _) + .build(); + + let cmd = AddToWatchlistCommand { + user_id: uuid::Uuid::new_v4(), + input: MovieInput { + movie_id: None, + external_metadata_id: None, + manual_title: Some("New Manual Movie".into()), + manual_release_year: Some(2024), + manual_director: None, + }, + }; + + add::execute(&ctx, cmd).await.unwrap(); + + assert_eq!(watchlist.count(), 1); + assert_eq!(movies.count(), 1); +} + +#[tokio::test] +async fn test_add_to_watchlist_movie_not_found_by_id() { + let movies = InMemoryMovieRepository::new(); + let watchlist = InMemoryWatchlistRepository::new(); + + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .with_watchlist(Arc::clone(&watchlist) as _) + .build(); + + let cmd = AddToWatchlistCommand { + user_id: uuid::Uuid::new_v4(), + input: MovieInput { + movie_id: Some(uuid::Uuid::new_v4()), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + }, + }; + + assert!(add::execute(&ctx, cmd).await.is_err()); +} diff --git a/crates/application/src/watchlist/tests/get.rs b/crates/application/src/watchlist/tests/get.rs new file mode 100644 index 0000000..f193f86 --- /dev/null +++ b/crates/application/src/watchlist/tests/get.rs @@ -0,0 +1,22 @@ +use uuid::Uuid; + +use crate::test_helpers::TestContextBuilder; +use crate::watchlist::{get, queries::GetWatchlistQuery}; + +#[tokio::test] +async fn returns_empty_page_for_new_user() { + let ctx = TestContextBuilder::new().build(); + let result = get::execute( + &ctx, + GetWatchlistQuery { + user_id: Uuid::new_v4(), + limit: None, + offset: None, + }, + ) + .await + .unwrap(); + + assert!(result.items.is_empty()); + assert_eq!(result.total_count, 0); +} diff --git a/crates/application/src/watchlist/tests/get_page.rs b/crates/application/src/watchlist/tests/get_page.rs new file mode 100644 index 0000000..ef965aa --- /dev/null +++ b/crates/application/src/watchlist/tests/get_page.rs @@ -0,0 +1,311 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use domain::errors::DomainError; +use domain::models::collections::{PageParams, Paginated}; +use domain::models::watchlist::{WatchlistEntry, WatchlistWithMovie}; +use domain::models::{Movie, UserRole}; +use domain::ports::WatchlistRepository; +use domain::value_objects::{Email, MovieId, MovieTitle, PosterPath, ReleaseYear, UserId}; + +use crate::auth::commands::RegisterCommand; +use crate::auth::register; +use crate::test_helpers::TestContextBuilder; +use crate::watchlist::get_page; +use crate::watchlist::queries::GetWatchlistQuery; + +struct FakeWatchlistWithItems { + user_id: UserId, + items: Vec, +} + +#[async_trait] +impl WatchlistRepository for FakeWatchlistWithItems { + async fn add(&self, _entry: &WatchlistEntry) -> Result<(), DomainError> { + Ok(()) + } + async fn remove(&self, _user_id: &UserId, _movie_id: &MovieId) -> Result<(), DomainError> { + Ok(()) + } + async fn remove_if_present( + &self, + _user_id: &UserId, + _movie_id: &MovieId, + ) -> Result { + Ok(false) + } + async fn get_for_user( + &self, + user_id: &UserId, + _page: &PageParams, + ) -> Result, DomainError> { + if user_id == &self.user_id { + Ok(Paginated { + total_count: self.items.len() as u64, + limit: 20, + offset: 0, + items: self.items.clone(), + }) + } else { + Ok(Paginated { + items: vec![], + total_count: 0, + limit: 20, + offset: 0, + }) + } + } + async fn contains(&self, _user_id: &UserId, _movie_id: &MovieId) -> Result { + Ok(false) + } +} + +#[tokio::test] +async fn returns_empty_for_local_user() { + let ctx = TestContextBuilder::new().build(); + + register::execute( + &ctx, + RegisterCommand { + email: "wl@test.com".into(), + username: "wluser".into(), + password: "password123".into(), + role: UserRole::Standard, + }, + ) + .await + .unwrap(); + + let email = Email::new("wl@test.com".into()).unwrap(); + let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap(); + let uid = user.id().value(); + + let result = get_page::execute( + &ctx, + GetWatchlistQuery { + user_id: uid, + limit: None, + offset: None, + }, + true, + ) + .await + .unwrap(); + + assert!(result.display_entries.is_empty()); +} + +#[tokio::test] +async fn returns_display_entries_for_local_user_with_items() { + let ctx = TestContextBuilder::new().build(); + + register::execute( + &ctx, + RegisterCommand { + email: "wl2@test.com".into(), + username: "wluser2".into(), + password: "password123".into(), + role: UserRole::Standard, + }, + ) + .await + .unwrap(); + + let email = Email::new("wl2@test.com".into()).unwrap(); + let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap(); + let uid = user.id().value(); + + let result = get_page::execute( + &ctx, + GetWatchlistQuery { + user_id: uid, + limit: Some(20), + offset: Some(0), + }, + true, + ) + .await + .unwrap(); + + // InMemory get_for_user returns empty, but the local-user branch is exercised + assert!(!result.has_more); + assert_eq!(result.current_offset, 0); +} + +#[tokio::test] +async fn returns_remote_watchlist_for_unknown_user() { + let ctx = TestContextBuilder::new().build(); + + let unknown_uid = uuid::Uuid::new_v4(); + + let result = get_page::execute( + &ctx, + GetWatchlistQuery { + user_id: unknown_uid, + limit: None, + offset: None, + }, + false, + ) + .await + .unwrap(); + + // NoopRemoteWatchlistRepository returns empty + assert!(result.display_entries.is_empty()); + assert!(!result.has_more); + assert_eq!(result.current_offset, 0); +} + +#[tokio::test] +async fn maps_display_entries_for_owner() { + let uid = uuid::Uuid::new_v4(); + let user_id = UserId::from_uuid(uid); + let movie_id = MovieId::generate(); + + let movie = Movie::from_persistence( + movie_id.clone(), + None, + MovieTitle::new("Blade Runner".into()).unwrap(), + ReleaseYear::new(1982).unwrap(), + None, + Some(PosterPath::new("poster123.jpg".into()).unwrap()), + ); + let entry = WatchlistEntry::new(user_id.clone(), movie_id.clone()); + + let fake_wl = Arc::new(FakeWatchlistWithItems { + user_id: user_id.clone(), + items: vec![WatchlistWithMovie { + entry, + movie: movie.clone(), + }], + }); + + let ctx = TestContextBuilder::new() + .with_watchlist(fake_wl as _) + .build(); + + // register user so find_by_id returns Some + register::execute( + &ctx, + RegisterCommand { + email: "wlmap@test.com".into(), + username: "wlmapuser".into(), + password: "password123".into(), + role: UserRole::Standard, + }, + ) + .await + .unwrap(); + + let email = Email::new("wlmap@test.com".into()).unwrap(); + let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap(); + let real_uid = user.id().value(); + + // Rebuild with the real user_id in the fake + let movie_id2 = MovieId::generate(); + let movie2 = Movie::from_persistence( + movie_id2.clone(), + None, + MovieTitle::new("Blade Runner".into()).unwrap(), + ReleaseYear::new(1982).unwrap(), + None, + Some(PosterPath::new("poster123.jpg".into()).unwrap()), + ); + let entry2 = WatchlistEntry::new(UserId::from_uuid(real_uid), movie_id2.clone()); + + let fake_wl2 = Arc::new(FakeWatchlistWithItems { + user_id: UserId::from_uuid(real_uid), + items: vec![WatchlistWithMovie { + entry: entry2, + movie: movie2.clone(), + }], + }); + + let ctx2 = TestContextBuilder::new() + .with_watchlist(fake_wl2 as _) + .with_users(ctx.repos.user.clone()) + .build(); + + let result = get_page::execute( + &ctx2, + GetWatchlistQuery { + user_id: real_uid, + limit: Some(20), + offset: Some(0), + }, + true, + ) + .await + .unwrap(); + + assert_eq!(result.display_entries.len(), 1); + let de = &result.display_entries[0]; + assert_eq!(de.movie_title, "Blade Runner"); + assert_eq!(de.release_year, 1982); + assert_eq!(de.poster_url.as_deref(), Some("/images/poster123.jpg")); + assert!(de.movie_url.is_some()); + assert!(de.remove_url.is_some()); // owner can remove +} + +#[tokio::test] +async fn maps_display_entries_for_non_owner() { + let ctx = TestContextBuilder::new().build(); + + register::execute( + &ctx, + RegisterCommand { + email: "wlno@test.com".into(), + username: "wlnoowner".into(), + password: "password123".into(), + role: UserRole::Standard, + }, + ) + .await + .unwrap(); + + let email = Email::new("wlno@test.com".into()).unwrap(); + let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap(); + let real_uid = user.id().value(); + + let movie_id = MovieId::generate(); + let movie = Movie::from_persistence( + movie_id.clone(), + None, + MovieTitle::new("Alien".into()).unwrap(), + ReleaseYear::new(1979).unwrap(), + None, + None, + ); + let entry = WatchlistEntry::new(UserId::from_uuid(real_uid), movie_id.clone()); + + let fake_wl = Arc::new(FakeWatchlistWithItems { + user_id: UserId::from_uuid(real_uid), + items: vec![WatchlistWithMovie { + entry, + movie: movie.clone(), + }], + }); + + let ctx2 = TestContextBuilder::new() + .with_watchlist(fake_wl as _) + .with_users(ctx.repos.user.clone()) + .build(); + + let result = get_page::execute( + &ctx2, + GetWatchlistQuery { + user_id: real_uid, + limit: Some(20), + offset: Some(0), + }, + false, // not owner + ) + .await + .unwrap(); + + assert_eq!(result.display_entries.len(), 1); + let de = &result.display_entries[0]; + assert_eq!(de.movie_title, "Alien"); + assert!(de.poster_url.is_none()); // no poster + assert!(de.remove_url.is_none()); // not owner +} diff --git a/crates/application/src/watchlist/tests/is_on.rs b/crates/application/src/watchlist/tests/is_on.rs new file mode 100644 index 0000000..10ac656 --- /dev/null +++ b/crates/application/src/watchlist/tests/is_on.rs @@ -0,0 +1,56 @@ +use std::sync::Arc; + +use domain::models::WatchlistEntry; +use domain::ports::WatchlistRepository; +use domain::testing::InMemoryWatchlistRepository; +use domain::value_objects::{MovieId, UserId}; +use uuid::Uuid; + +use crate::test_helpers::TestContextBuilder; +use crate::watchlist::{is_on, queries::IsOnWatchlistQuery}; + +#[tokio::test] +async fn returns_true_when_present() { + let watchlist = InMemoryWatchlistRepository::new(); + let uid = Uuid::new_v4(); + let mid = Uuid::new_v4(); + watchlist + .add(&WatchlistEntry::new( + UserId::from_uuid(uid), + MovieId::from_uuid(mid), + )) + .await + .unwrap(); + + let ctx = TestContextBuilder::new() + .with_watchlist(Arc::clone(&watchlist) as _) + .build(); + + let result = is_on::execute( + &ctx, + IsOnWatchlistQuery { + user_id: uid, + movie_id: mid, + }, + ) + .await + .unwrap(); + + assert!(result); +} + +#[tokio::test] +async fn returns_false_when_absent() { + let ctx = TestContextBuilder::new().build(); + let result = is_on::execute( + &ctx, + IsOnWatchlistQuery { + user_id: Uuid::new_v4(), + movie_id: Uuid::new_v4(), + }, + ) + .await + .unwrap(); + + assert!(!result); +} diff --git a/crates/application/src/watchlist/tests/remove.rs b/crates/application/src/watchlist/tests/remove.rs new file mode 100644 index 0000000..0a6b8f9 --- /dev/null +++ b/crates/application/src/watchlist/tests/remove.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use domain::events::DomainEvent; +use domain::models::WatchlistEntry; +use domain::ports::WatchlistRepository; +use domain::testing::{InMemoryWatchlistRepository, NoopEventPublisher}; +use domain::value_objects::{MovieId, UserId}; +use uuid::Uuid; + +use crate::test_helpers::TestContextBuilder; +use crate::watchlist::{commands::RemoveFromWatchlistCommand, remove}; + +#[tokio::test] +async fn removes_entry_and_emits_event() { + let watchlist = InMemoryWatchlistRepository::new(); + let events = NoopEventPublisher::new(); + let uid = Uuid::new_v4(); + let mid = Uuid::new_v4(); + watchlist + .add(&WatchlistEntry::new( + UserId::from_uuid(uid), + MovieId::from_uuid(mid), + )) + .await + .unwrap(); + + let ctx = TestContextBuilder::new() + .with_watchlist(Arc::clone(&watchlist) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + remove::execute( + &ctx, + RemoveFromWatchlistCommand { + user_id: uid, + movie_id: mid, + }, + ) + .await + .unwrap(); + + assert_eq!(watchlist.count(), 0); + let published = events.published(); + assert!( + published + .iter() + .any(|e| matches!(e, DomainEvent::WatchlistEntryRemoved { .. })) + ); +} + +#[tokio::test] +async fn fails_when_not_on_watchlist() { + let ctx = TestContextBuilder::new().build(); + let result = remove::execute( + &ctx, + RemoveFromWatchlistCommand { + user_id: Uuid::new_v4(), + movie_id: Uuid::new_v4(), + }, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/wrapup/delete.rs b/crates/application/src/wrapup/delete.rs index 26ced67..7631f8c 100644 --- a/crates/application/src/wrapup/delete.rs +++ b/crates/application/src/wrapup/delete.rs @@ -12,3 +12,7 @@ pub async fn execute(ctx: &AppContext, id: WrapUpId) -> Result<(), DomainError> ctx.repos.wrapup_repo.delete(&id).await } + +#[cfg(test)] +#[path = "tests/delete.rs"] +mod tests; diff --git a/crates/application/src/wrapup/generate.rs b/crates/application/src/wrapup/generate.rs index e0a2b19..329e834 100644 --- a/crates/application/src/wrapup/generate.rs +++ b/crates/application/src/wrapup/generate.rs @@ -58,3 +58,7 @@ pub async fn execute(ctx: &AppContext, cmd: RequestWrapUpCommand) -> Result Result, DomainError> { ctx.repos.wrapup_repo.get_by_id(&id).await } + +#[cfg(test)] +#[path = "tests/get_wrapup.rs"] +mod tests; diff --git a/crates/application/src/wrapup/handle_requested.rs b/crates/application/src/wrapup/handle_requested.rs index f3b1ec2..fa6a0be 100644 --- a/crates/application/src/wrapup/handle_requested.rs +++ b/crates/application/src/wrapup/handle_requested.rs @@ -59,3 +59,7 @@ pub async fn execute( } } } + +#[cfg(test)] +#[path = "tests/handle_requested.rs"] +mod tests; diff --git a/crates/application/src/wrapup/list_wrapups.rs b/crates/application/src/wrapup/list_wrapups.rs index 92deab7..1f42d94 100644 --- a/crates/application/src/wrapup/list_wrapups.rs +++ b/crates/application/src/wrapup/list_wrapups.rs @@ -18,3 +18,7 @@ pub async fn execute( None => ctx.repos.wrapup_repo.list_global().await, } } + +#[cfg(test)] +#[path = "tests/list_wrapups.rs"] +mod tests; diff --git a/crates/application/src/wrapup/tests/delete.rs b/crates/application/src/wrapup/tests/delete.rs new file mode 100644 index 0000000..359f503 --- /dev/null +++ b/crates/application/src/wrapup/tests/delete.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use chrono::NaiveDate; +use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; +use domain::testing::InMemoryWrapUpRepository; +use domain::value_objects::WrapUpId; + +use crate::test_helpers::TestContextBuilder; +use crate::wrapup::delete; + +#[tokio::test] +async fn deletes_existing_wrapup() { + let repo = InMemoryWrapUpRepository::new(); + let id = WrapUpId::generate(); + repo.store.lock().unwrap().push(WrapUpRecord { + id: id.clone(), + user_id: None, + start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + status: WrapUpStatus::Ready, + report: None, + error_message: None, + created_at: chrono::Utc::now().naive_utc(), + completed_at: None, + }); + + let ctx = TestContextBuilder::new().build(); + let ctx = crate::context::AppContext { + repos: crate::context::Repositories { + wrapup_repo: Arc::clone(&repo) as _, + ..ctx.repos + }, + ..ctx + }; + + delete::execute(&ctx, id).await.unwrap(); + assert_eq!(repo.store.lock().unwrap().len(), 0); +} + +#[tokio::test] +async fn fails_when_not_found() { + let ctx = TestContextBuilder::new().build(); + let result = delete::execute(&ctx, WrapUpId::generate()).await; + assert!(result.is_err()); +} diff --git a/crates/application/src/wrapup/tests/generate.rs b/crates/application/src/wrapup/tests/generate.rs new file mode 100644 index 0000000..008b3b4 --- /dev/null +++ b/crates/application/src/wrapup/tests/generate.rs @@ -0,0 +1,137 @@ +use std::sync::Arc; + +use chrono::NaiveDate; +use domain::events::DomainEvent; +use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; +use domain::testing::{InMemoryWrapUpRepository, NoopEventPublisher}; +use domain::value_objects::WrapUpId; +use uuid::Uuid; + +use crate::test_helpers::TestContextBuilder; +use crate::wrapup::{commands::RequestWrapUpCommand, generate}; + +fn past_cmd() -> RequestWrapUpCommand { + RequestWrapUpCommand { + user_id: Some(Uuid::nil()), + start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + } +} + +#[tokio::test] +async fn creates_pending_record_and_emits_event() { + let repo = InMemoryWrapUpRepository::new(); + let events = NoopEventPublisher::new(); + let ctx = TestContextBuilder::new() + .wrapup_stats(domain::testing::InMemoryWrapUpStatsQuery::new()) + .build(); + let ctx = crate::context::AppContext { + repos: crate::context::Repositories { + wrapup_repo: Arc::clone(&repo) as _, + ..ctx.repos + }, + services: crate::context::Services { + event_publisher: Arc::clone(&events) as _, + ..ctx.services + }, + config: ctx.config, + }; + + let id = generate::execute(&ctx, past_cmd()).await.unwrap(); + + let stored = repo.store.lock().unwrap(); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].id, id); + assert_eq!(stored[0].status, WrapUpStatus::Pending); + + let published = events.published(); + assert!( + published + .iter() + .any(|e| matches!(e, DomainEvent::WrapUpRequested { .. })) + ); +} + +#[tokio::test] +async fn reuses_existing_ready_wrapup() { + let repo = InMemoryWrapUpRepository::new(); + let existing_id = WrapUpId::generate(); + repo.store.lock().unwrap().push(WrapUpRecord { + id: existing_id.clone(), + user_id: Some(Uuid::nil()), + start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + status: WrapUpStatus::Ready, + report: None, + error_message: None, + created_at: chrono::Utc::now().naive_utc(), + completed_at: None, + }); + + let ctx = TestContextBuilder::new().build(); + let ctx = crate::context::AppContext { + repos: crate::context::Repositories { + wrapup_repo: Arc::clone(&repo) as _, + ..ctx.repos + }, + ..ctx + }; + + let id = generate::execute(&ctx, past_cmd()).await.unwrap(); + assert_eq!(id, existing_id); + assert_eq!(repo.store.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn replaces_failed_wrapup() { + let repo = InMemoryWrapUpRepository::new(); + repo.store.lock().unwrap().push(WrapUpRecord { + id: WrapUpId::generate(), + user_id: Some(Uuid::nil()), + start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + status: WrapUpStatus::Failed, + report: None, + error_message: Some("boom".into()), + created_at: chrono::Utc::now().naive_utc(), + completed_at: None, + }); + + let events = NoopEventPublisher::new(); + let ctx = TestContextBuilder::new().build(); + let ctx = crate::context::AppContext { + repos: crate::context::Repositories { + wrapup_repo: Arc::clone(&repo) as _, + ..ctx.repos + }, + services: crate::context::Services { + event_publisher: Arc::clone(&events) as _, + ..ctx.services + }, + config: ctx.config, + }; + + let id = generate::execute(&ctx, past_cmd()).await.unwrap(); + + let stored = repo.store.lock().unwrap(); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].id, id); + assert_eq!(stored[0].status, WrapUpStatus::Pending); +} + +#[tokio::test] +async fn rejects_future_end_date() { + let ctx = TestContextBuilder::new().build(); + let err = generate::execute( + &ctx, + RequestWrapUpCommand { + user_id: None, + start_date: NaiveDate::from_ymd_opt(2030, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2031, 1, 1).unwrap(), + }, + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("future")); +} diff --git a/crates/application/src/wrapup/tests/get_wrapup.rs b/crates/application/src/wrapup/tests/get_wrapup.rs new file mode 100644 index 0000000..719be72 --- /dev/null +++ b/crates/application/src/wrapup/tests/get_wrapup.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; + +use chrono::NaiveDate; +use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; +use domain::testing::InMemoryWrapUpRepository; +use domain::value_objects::WrapUpId; + +use crate::test_helpers::TestContextBuilder; +use crate::wrapup::get_wrapup; + +#[tokio::test] +async fn returns_record_when_exists() { + let repo = InMemoryWrapUpRepository::new(); + let id = WrapUpId::generate(); + repo.store.lock().unwrap().push(WrapUpRecord { + id: id.clone(), + user_id: None, + start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + status: WrapUpStatus::Pending, + report: None, + error_message: None, + created_at: chrono::Utc::now().naive_utc(), + completed_at: None, + }); + + let ctx = TestContextBuilder::new().build(); + let ctx = crate::context::AppContext { + repos: crate::context::Repositories { + wrapup_repo: Arc::clone(&repo) as _, + ..ctx.repos + }, + ..ctx + }; + + let result = get_wrapup::execute(&ctx, id).await.unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().status, WrapUpStatus::Pending); +} + +#[tokio::test] +async fn returns_none_when_missing() { + let ctx = TestContextBuilder::new().build(); + let result = get_wrapup::execute(&ctx, WrapUpId::generate()) + .await + .unwrap(); + assert!(result.is_none()); +} diff --git a/crates/application/src/wrapup/tests/handle_requested.rs b/crates/application/src/wrapup/tests/handle_requested.rs new file mode 100644 index 0000000..b0bd6aa --- /dev/null +++ b/crates/application/src/wrapup/tests/handle_requested.rs @@ -0,0 +1,134 @@ +use std::sync::Arc; + +use chrono::{NaiveDate, Utc}; +use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; +use domain::ports::WrapUpRepository; +use domain::testing::InMemoryWrapUpRepository; +use domain::value_objects::WrapUpId; + +use crate::test_helpers::TestContextBuilder; +use crate::wrapup::handle_requested; + +#[tokio::test] +async fn skips_if_already_ready() { + let repo = InMemoryWrapUpRepository::new(); + let wrapup_id = WrapUpId::generate(); + + let record = WrapUpRecord { + id: wrapup_id.clone(), + user_id: None, + start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + status: WrapUpStatus::Ready, + report: None, + error_message: None, + created_at: Utc::now().naive_utc(), + completed_at: None, + }; + repo.create(&record).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_wrapup_repo(Arc::clone(&repo) as _) + .build(); + + let result = handle_requested::execute( + &ctx, + wrapup_id, + None, + NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + ) + .await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn generates_wrapup_and_marks_complete() { + let repo = InMemoryWrapUpRepository::new(); + let stats = domain::testing::InMemoryWrapUpStatsQuery::new(); + let events = domain::testing::NoopEventPublisher::new(); + let wrapup_id = WrapUpId::generate(); + let uid = uuid::Uuid::new_v4(); + + let record = WrapUpRecord { + id: wrapup_id.clone(), + user_id: Some(uid), + start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + status: WrapUpStatus::Pending, + report: None, + error_message: None, + created_at: Utc::now().naive_utc(), + completed_at: None, + }; + repo.create(&record).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_wrapup_repo(Arc::clone(&repo) as _) + .wrapup_stats(Arc::clone(&stats) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + let result = handle_requested::execute( + &ctx, + wrapup_id.clone(), + Some(uid), + NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + ) + .await; + + assert!(result.is_ok()); + + // Verify it was marked as Ready + let final_rec = repo.get_by_id(&wrapup_id).await.unwrap().unwrap(); + assert_eq!(final_rec.status, WrapUpStatus::Ready); + assert!(final_rec.report.is_some()); + + // Verify event was published + let published = events.published(); + assert!( + published + .iter() + .any(|e| matches!(e, domain::events::DomainEvent::WrapUpCompleted { .. })) + ); +} + +#[tokio::test] +async fn skips_if_already_generating() { + let repo = InMemoryWrapUpRepository::new(); + let wrapup_id = WrapUpId::generate(); + + let record = WrapUpRecord { + id: wrapup_id.clone(), + user_id: None, + start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + status: WrapUpStatus::Generating, + report: None, + error_message: None, + created_at: Utc::now().naive_utc(), + completed_at: None, + }; + repo.create(&record).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_wrapup_repo(Arc::clone(&repo) as _) + .build(); + + let result = handle_requested::execute( + &ctx, + wrapup_id.clone(), + None, + NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(), + ) + .await; + + assert!(result.is_ok()); + + // Status should still be Generating (not changed to Ready) + let final_rec = repo.get_by_id(&wrapup_id).await.unwrap().unwrap(); + assert_eq!(final_rec.status, WrapUpStatus::Generating); +} diff --git a/crates/application/src/wrapup/tests/list_wrapups.rs b/crates/application/src/wrapup/tests/list_wrapups.rs new file mode 100644 index 0000000..76f6e47 --- /dev/null +++ b/crates/application/src/wrapup/tests/list_wrapups.rs @@ -0,0 +1,76 @@ +use std::sync::Arc; + +use chrono::NaiveDate; +use domain::models::wrapup::{WrapUpRecord, WrapUpStatus}; +use domain::testing::InMemoryWrapUpRepository; +use domain::value_objects::WrapUpId; +use uuid::Uuid; + +use crate::test_helpers::TestContextBuilder; +use crate::wrapup::list_wrapups::{self, ListWrapUpsQuery}; + +fn make_record(user_id: Option) -> WrapUpRecord { + WrapUpRecord { + id: WrapUpId::generate(), + user_id, + start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + end_date: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + status: WrapUpStatus::Ready, + report: None, + error_message: None, + created_at: chrono::Utc::now().naive_utc(), + completed_at: None, + } +} + +#[tokio::test] +async fn filters_by_user() { + let repo = InMemoryWrapUpRepository::new(); + let uid = Uuid::new_v4(); + { + let mut store = repo.store.lock().unwrap(); + store.push(make_record(Some(uid))); + store.push(make_record(Some(Uuid::new_v4()))); + store.push(make_record(None)); + } + + let ctx = TestContextBuilder::new().build(); + let ctx = crate::context::AppContext { + repos: crate::context::Repositories { + wrapup_repo: Arc::clone(&repo) as _, + ..ctx.repos + }, + ..ctx + }; + + let result = list_wrapups::execute(&ctx, ListWrapUpsQuery { user_id: Some(uid) }) + .await + .unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].user_id, Some(uid)); +} + +#[tokio::test] +async fn returns_global_when_no_user() { + let repo = InMemoryWrapUpRepository::new(); + { + let mut store = repo.store.lock().unwrap(); + store.push(make_record(None)); + store.push(make_record(None)); + store.push(make_record(Some(Uuid::new_v4()))); + } + + let ctx = TestContextBuilder::new().build(); + let ctx = crate::context::AppContext { + repos: crate::context::Repositories { + wrapup_repo: Arc::clone(&repo) as _, + ..ctx.repos + }, + ..ctx + }; + + let result = list_wrapups::execute(&ctx, ListWrapUpsQuery { user_id: None }) + .await + .unwrap(); + assert_eq!(result.len(), 2); +} diff --git a/crates/domain/src/models/goal.rs b/crates/domain/src/models/goal.rs index 87c87fb..7b295ce 100644 --- a/crates/domain/src/models/goal.rs +++ b/crates/domain/src/models/goal.rs @@ -109,3 +109,98 @@ impl GoalWithProgress { self.current_count >= self.goal.target_count } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::value_objects::UserId; + + fn make_goal(year: u16, target: u32) -> Result { + Goal::new(UserId::generate(), year, target, GoalType::Movies) + } + + #[test] + fn new_goal_valid() { + let g = make_goal(2024, 52); + assert!(g.is_ok()); + let g = g.unwrap(); + assert_eq!(g.year(), 2024); + assert_eq!(g.target_count(), 52); + } + + #[test] + fn new_goal_rejects_year_before_2020() { + assert!(make_goal(2019, 10).is_err()); + } + + #[test] + fn new_goal_rejects_zero_target() { + assert!(make_goal(2024, 0).is_err()); + } + + #[test] + fn update_target_valid() { + let mut g = make_goal(2024, 10).unwrap(); + assert!(g.update_target(50).is_ok()); + assert_eq!(g.target_count(), 50); + } + + #[test] + fn update_target_rejects_zero() { + let mut g = make_goal(2024, 10).unwrap(); + assert!(g.update_target(0).is_err()); + } + + #[test] + fn from_persistence_preserves_fields() { + let id = GoalId::generate(); + let uid = UserId::generate(); + let ts = chrono::Utc::now().naive_utc(); + let g = Goal::from_persistence(id.clone(), uid.clone(), 2025, 42, GoalType::Movies, ts); + assert_eq!(*g.id(), id); + assert_eq!(*g.user_id(), uid); + assert_eq!(g.year(), 2025); + assert_eq!(g.target_count(), 42); + assert_eq!(g.created_at(), &ts); + } + + #[test] + fn percentage_calculation() { + let g = make_goal(2024, 100).unwrap(); + let wp = GoalWithProgress { + goal: g, + current_count: 50, + }; + assert!((wp.percentage() - 50.0).abs() < f64::EPSILON); + } + + #[test] + fn percentage_caps_at_100() { + let g = make_goal(2024, 10).unwrap(); + let wp = GoalWithProgress { + goal: g, + current_count: 20, + }; + assert!((wp.percentage() - 100.0).abs() < f64::EPSILON); + } + + #[test] + fn is_complete() { + let g = make_goal(2024, 10).unwrap(); + let wp = GoalWithProgress { + goal: g, + current_count: 10, + }; + assert!(wp.is_complete()); + } + + #[test] + fn is_not_complete() { + let g = make_goal(2024, 10).unwrap(); + let wp = GoalWithProgress { + goal: g, + current_count: 9, + }; + assert!(!wp.is_complete()); + } +} diff --git a/crates/domain/src/models/person.rs b/crates/domain/src/models/person.rs index 0e59283..b4c4180 100644 --- a/crates/domain/src/models/person.rs +++ b/crates/domain/src/models/person.rs @@ -111,3 +111,62 @@ pub struct CrewCredit { pub department: String, pub poster_path: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn person_new() { + let ext = ExternalPersonId::new("tmdb:12345"); + let pid = PersonId::from_external(&ext); + let p = Person::new( + pid, + ext, + "Keanu Reeves".into(), + Some("Acting".into()), + Some("/profiles/keanu.jpg".into()), + ); + assert_eq!(p.name(), "Keanu Reeves"); + assert_eq!(p.known_for_department(), Some("Acting")); + assert_eq!(p.profile_path(), Some("/profiles/keanu.jpg")); + assert_eq!(p.external_id().value(), "tmdb:12345"); + assert_eq!(p.external_id().tmdb_id(), Some(12345)); + } + + #[test] + fn person_id_from_external() { + let ext = ExternalPersonId::new("tmdb:99999"); + let pid = PersonId::from_external(&ext); + // UUIDv5 is deterministic — just ensure it's a valid uuid + let _ = pid.value(); + } + + #[test] + fn person_id_deterministic() { + let ext = ExternalPersonId::new("tmdb:42"); + let a = PersonId::from_external(&ext); + let b = PersonId::from_external(&ext); + assert_eq!(a, b); + } + + #[test] + fn person_credits_default_empty() { + let ext = ExternalPersonId::new("tmdb:1"); + let pid = PersonId::from_external(&ext); + let p = Person::new(pid, ext, "Test".into(), None, None); + let credits = PersonCredits { + person: p, + cast: vec![], + crew: vec![], + }; + assert!(credits.cast.is_empty()); + assert!(credits.crew.is_empty()); + } + + #[test] + fn external_person_id_tmdb_id_none_for_other() { + let ext = ExternalPersonId::new("imdb:nm0000206"); + assert_eq!(ext.tmdb_id(), None); + } +} diff --git a/crates/domain/src/models/tests.rs b/crates/domain/src/models/tests.rs index d9025af..4d9d0b0 100644 --- a/crates/domain/src/models/tests.rs +++ b/crates/domain/src/models/tests.rs @@ -36,3 +36,197 @@ fn update_profile_clears_with_none() { assert_eq!(user.bio(), None); assert_eq!(user.avatar_path(), None); } + +// ── Movie ──────────────────────────────────────────────────────────────────── + +fn make_movie() -> Movie { + Movie::new( + Some(crate::value_objects::ExternalMetadataId::new("tt1234567".into()).unwrap()), + crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(), + crate::value_objects::ReleaseYear::new(1982).unwrap(), + Some("Ridley Scott".into()), + Some(crate::value_objects::PosterPath::new("/poster.jpg".into()).unwrap()), + ) +} + +#[test] +fn movie_new_sets_fields() { + let m = make_movie(); + assert_eq!(m.title().value(), "Blade Runner"); + assert_eq!(m.release_year().value(), 1982); + assert_eq!(m.director(), Some("Ridley Scott")); + assert!(m.poster_path().is_some()); + assert!(m.external_metadata_id().is_some()); +} + +#[test] +fn movie_update_poster() { + let mut m = make_movie(); + let new_poster = crate::value_objects::PosterPath::new("/new.jpg".into()).unwrap(); + m.update_poster(new_poster); + assert_eq!(m.poster_path().unwrap().value(), "/new.jpg"); +} + +// ── Review ─────────────────────────────────────────────────────────────────── + +use crate::value_objects::{Comment, MovieId, Rating, ReviewId}; + +fn make_review() -> Review { + Review::new( + MovieId::generate(), + UserId::generate(), + Rating::new(4).unwrap(), + Some(Comment::new("great".into()).unwrap()), + chrono::Utc::now().naive_utc(), + ) + .unwrap() +} + +#[test] +fn review_new_sets_local_source() { + let r = make_review(); + assert_eq!(*r.source(), ReviewSource::Local); + assert!(!r.is_remote()); +} + +#[test] +fn review_stars() { + let r = make_review(); // rating=4 + assert_eq!(r.stars(), [true, true, true, true, false]); +} + +#[test] +fn review_from_persistence() { + let id = ReviewId::generate(); + let mid = MovieId::generate(); + let uid = UserId::generate(); + let ts = chrono::Utc::now().naive_utc(); + let r = Review::from_persistence(PersistedReview { + id: id.clone(), + movie_id: mid.clone(), + user_id: uid.clone(), + rating: Rating::new(2).unwrap(), + comment: None, + watched_at: ts, + created_at: ts, + source: ReviewSource::Remote { + actor_url: "https://example.com/actor".into(), + }, + }); + assert_eq!(*r.id(), id); + assert!(r.is_remote()); + assert_eq!(r.comment(), None); +} + +// ── User ───────────────────────────────────────────────────────────────────── + +#[test] +fn user_new() { + let u = User::new( + Email::new("x@y.com".into()).unwrap(), + Username::new("bob".into()).unwrap(), + PasswordHash::new("hashed".into()).unwrap(), + UserRole::Admin, + ); + assert_eq!(u.email().value(), "x@y.com"); + assert_eq!(u.username().value(), "bob"); + assert_eq!(u.role().as_str(), "admin"); + assert_eq!(u.bio(), None); +} + +#[test] +fn user_update_password() { + let mut u = make_user(); + u.update_password(PasswordHash::new("new_hash".into()).unwrap()); + assert_eq!(u.password_hash().value(), "new_hash"); +} + +// ── GoalType ───────────────────────────────────────────────────────────────── + +#[test] +fn goal_type_as_str() { + assert_eq!(GoalType::Movies.as_str(), "movies"); +} + +#[test] +fn goal_type_from_str() { + assert_eq!("movies".parse::().unwrap(), GoalType::Movies); + assert!("invalid".parse::().is_err()); +} + +// ── UserRole ───────────────────────────────────────────────────────────────── + +#[test] +fn user_role_as_str() { + assert_eq!(UserRole::Standard.as_str(), "standard"); + assert_eq!(UserRole::Admin.as_str(), "admin"); +} + +// ── ProfileField ───────────────────────────────────────────────────────────── + +#[test] +fn profile_field_construction() { + let f = ProfileField { + name: "Website".into(), + value: "https://example.com".into(), + }; + assert_eq!(f.name, "Website"); + assert_eq!(f.value, "https://example.com"); +} + +// ── MovieStats ─────────────────────────────────────────────────────────────── + +#[test] +fn movie_stats_construction() { + let s = MovieStats { + total_count: 100, + avg_rating: Some(3.5), + federated_count: 10, + rating_histogram: [5, 10, 30, 40, 15], + }; + assert_eq!(s.total_count, 100); + assert_eq!(s.rating_histogram[4], 15); +} + +// ── FeedEntry ──────────────────────────────────────────────────────────────── + +#[test] +fn feed_entry_display_name_from_email() { + let entry = DiaryEntry::new(make_movie(), make_review()); + let fe = FeedEntry::new(entry, "alice@example.com".into()); + assert_eq!(fe.user_display_name(), "alice"); + assert_eq!(fe.user_email(), "alice@example.com"); +} + +// ── MonthActivity ──────────────────────────────────────────────────────────── + +#[test] +fn month_activity_construction() { + let ma = MonthActivity { + year_month: "2024-06".into(), + month_label: "June".into(), + count: 5, + entries: vec![], + }; + assert_eq!(ma.year_month, "2024-06"); + assert_eq!(ma.count, 5); + assert!(ma.entries.is_empty()); +} + +// ── Movie::is_manual_match ─────────────────────────────────────────────────── + +#[test] +fn movie_is_manual_match_same_title_year() { + let m = make_movie(); + let title = crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(); + let year = crate::value_objects::ReleaseYear::new(1982).unwrap(); + assert!(m.is_manual_match(&title, &year, Some("ridley scott"))); +} + +#[test] +fn movie_is_manual_match_different_director_fails() { + let m = make_movie(); + let title = crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(); + let year = crate::value_objects::ReleaseYear::new(1982).unwrap(); + assert!(!m.is_manual_match(&title, &year, Some("Denis Villeneuve"))); +} diff --git a/crates/domain/src/models/watch_event.rs b/crates/domain/src/models/watch_event.rs index de225eb..8120ea6 100644 --- a/crates/domain/src/models/watch_event.rs +++ b/crates/domain/src/models/watch_event.rs @@ -234,3 +234,143 @@ pub struct ParsedPlaybackEvent { pub tmdb_id: Option, pub imdb_id: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + fn ts() -> NaiveDateTime { + chrono::NaiveDate::from_ymd_opt(2024, 6, 1) + .unwrap() + .and_hms_opt(12, 0, 0) + .unwrap() + } + + #[test] + fn watch_event_new_has_pending_status() { + let e = WatchEvent::new( + UserId::generate(), + "Dune".into(), + Some(2021), + None, + WatchEventSource::Jellyfin, + ts(), + None, + ); + assert_eq!(*e.status(), WatchEventStatus::Pending); + } + + #[test] + fn watch_event_getters() { + let uid = UserId::generate(); + let mid = MovieId::generate(); + let e = WatchEvent::new( + uid.clone(), + "Arrival".into(), + Some(2016), + Some("ext123".into()), + WatchEventSource::Plex, + ts(), + Some(mid.clone()), + ); + assert_eq!(*e.user_id(), uid); + assert_eq!(e.title(), "Arrival"); + assert_eq!(e.year(), Some(2016)); + assert_eq!(e.external_metadata_id(), Some("ext123")); + assert_eq!(*e.source(), WatchEventSource::Plex); + assert_eq!(e.watched_at(), &ts()); + assert_eq!(*e.movie_id().unwrap(), mid); + } + + #[test] + fn webhook_token_new() { + let uid = UserId::generate(); + let t = WebhookToken::new( + uid.clone(), + "hash123".into(), + WatchEventSource::Jellyfin, + Some("my server".into()), + ); + assert_eq!(*t.user_id(), uid); + assert_eq!(t.token_hash(), "hash123"); + assert_eq!(*t.provider(), WatchEventSource::Jellyfin); + assert_eq!(t.label(), Some("my server")); + assert!(t.last_used_at().is_none()); + } + + #[test] + fn webhook_token_from_persistence() { + let id = WebhookTokenId::generate(); + let uid = UserId::generate(); + let created = ts(); + let used = chrono::NaiveDate::from_ymd_opt(2024, 7, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(); + let t = WebhookToken::from_persistence( + id.clone(), + uid.clone(), + "h".into(), + WatchEventSource::Plex, + None, + created, + Some(used), + ); + assert_eq!(*t.id(), id); + assert_eq!(*t.user_id(), uid); + assert_eq!(t.token_hash(), "h"); + assert_eq!(*t.provider(), WatchEventSource::Plex); + assert_eq!(t.label(), None); + assert_eq!(t.created_at(), &created); + assert_eq!(t.last_used_at(), Some(&used)); + } + + #[test] + fn watch_event_source_display() { + assert_eq!(WatchEventSource::Jellyfin.to_string(), "jellyfin"); + assert_eq!(WatchEventSource::Plex.to_string(), "plex"); + } + + #[test] + fn watch_event_source_from_str() { + assert_eq!( + "jellyfin".parse::().unwrap(), + WatchEventSource::Jellyfin + ); + assert_eq!( + "plex".parse::().unwrap(), + WatchEventSource::Plex + ); + assert!("unknown".parse::().is_err()); + } + + #[test] + fn watch_event_status_display() { + assert_eq!(WatchEventStatus::Pending.to_string(), "pending"); + assert_eq!(WatchEventStatus::Confirmed.to_string(), "confirmed"); + assert_eq!(WatchEventStatus::Dismissed.to_string(), "dismissed"); + } + + #[test] + fn watch_event_status_from_str() { + for s in ["pending", "confirmed", "dismissed"] { + let parsed: WatchEventStatus = s.parse().unwrap(); + assert_eq!(parsed.to_string(), s); + } + assert!("bogus".parse::().is_err()); + } + + #[test] + fn parsed_playback_event_fields() { + let p = ParsedPlaybackEvent { + title: "Matrix".into(), + year: Some(1999), + tmdb_id: Some("603".into()), + imdb_id: Some("tt0133093".into()), + }; + assert_eq!(p.title, "Matrix"); + assert_eq!(p.year, Some(1999)); + assert_eq!(p.tmdb_id.as_deref(), Some("603")); + assert_eq!(p.imdb_id.as_deref(), Some("tt0133093")); + } +} diff --git a/crates/domain/src/services/review_history.rs b/crates/domain/src/services/review_history.rs index e4efc31..4a60b53 100644 --- a/crates/domain/src/services/review_history.rs +++ b/crates/domain/src/services/review_history.rs @@ -51,3 +51,83 @@ impl ReviewHistoryAnalyzer { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Movie, Review, ReviewHistory}; + use crate::value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId}; + use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; + + fn make_movie() -> Movie { + Movie::new( + None, + MovieTitle::new("Test".into()).unwrap(), + ReleaseYear::new(2024).unwrap(), + None, + None, + ) + } + + fn dt(year: i32, month: u32, day: u32) -> NaiveDateTime { + NaiveDateTime::new( + NaiveDate::from_ymd_opt(year, month, day).unwrap(), + NaiveTime::from_hms_opt(12, 0, 0).unwrap(), + ) + } + + fn review_with_rating(movie_id: &MovieId, rating: u8, watched_at: NaiveDateTime) -> Review { + let user_id = UserId::generate(); + Review::new( + movie_id.clone(), + user_id, + Rating::new(rating).unwrap(), + None, + watched_at, + ) + .unwrap() + } + + #[test] + fn neutral_when_empty() { + let movie = make_movie(); + let history = ReviewHistory::new(movie, vec![]); + let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap(); + assert_eq!(trend, Trend::Neutral); + } + + #[test] + fn neutral_when_single_review() { + let movie = make_movie(); + let r = review_with_rating(movie.id(), 4, dt(2024, 1, 1)); + let history = ReviewHistory::new(movie, vec![r]); + let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap(); + assert_eq!(trend, Trend::Neutral); + } + + #[test] + fn improved_when_latest_above_average() { + let movie = make_movie(); + let viewings = vec![ + review_with_rating(movie.id(), 2, dt(2024, 1, 1)), + review_with_rating(movie.id(), 3, dt(2024, 2, 1)), + review_with_rating(movie.id(), 5, dt(2024, 3, 1)), + ]; + let history = ReviewHistory::new(movie, viewings); + let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap(); + assert_eq!(trend, Trend::Improved); + } + + #[test] + fn declined_when_latest_below_average() { + let movie = make_movie(); + let viewings = vec![ + review_with_rating(movie.id(), 5, dt(2024, 1, 1)), + review_with_rating(movie.id(), 4, dt(2024, 2, 1)), + review_with_rating(movie.id(), 2, dt(2024, 3, 1)), + ]; + let history = ReviewHistory::new(movie, viewings); + let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap(); + assert_eq!(trend, Trend::Declined); + } +} diff --git a/crates/domain/src/testing/fakes.rs b/crates/domain/src/testing/fakes.rs index 7e701c0..a1cb081 100644 --- a/crates/domain/src/testing/fakes.rs +++ b/crates/domain/src/testing/fakes.rs @@ -8,12 +8,16 @@ use uuid::Uuid; use crate::{ errors::DomainError, models::{ - DiaryEntry, DiaryFilter, FeedEntry, Movie, MovieStats, Review, ReviewHistory, + AnnotatedRow, DiaryEntry, DiaryFilter, ExternalPersonId, FeedEntry, FieldMapping, + FileFormat, ImportError, ImportRow, Movie, MovieProfile, MovieStats, ParsedFile, Person, + PersonCredits, PersonId, Review, ReviewHistory, RowResult, SearchQuery, SearchResults, + UserStats, UserTrends, collections::{PageParams, Paginated}, }, ports::{ - AuthService, DiaryRepository, FeedSortBy, FollowingFilter, GeneratedToken, MetadataClient, - MetadataSearchCriteria, PasswordHasher, + AuthService, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter, GeneratedToken, + MetadataClient, MetadataSearchCriteria, MovieEnrichmentClient, PasswordHasher, PersonQuery, + PosterFetcherClient, SearchCommand, SearchPort, StatsRepository, }, value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterUrl, UserId}, }; @@ -103,14 +107,24 @@ impl DiaryRepository for FakeDiaryRepository { &self, _filter: &DiaryFilter, ) -> Result, DomainError> { - unimplemented!("FakeDiaryRepository::query_diary") + Ok(Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }) } async fn query_activity_feed( &self, _page: &PageParams, ) -> Result, DomainError> { - unimplemented!("FakeDiaryRepository::query_activity_feed") + Ok(Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }) } async fn query_activity_feed_filtered( @@ -120,7 +134,12 @@ impl DiaryRepository for FakeDiaryRepository { _search: Option<&str>, _following: Option<&FollowingFilter>, ) -> Result, DomainError> { - unimplemented!("FakeDiaryRepository::query_activity_feed_filtered") + Ok(Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }) } async fn get_review_history(&self, movie_id: &MovieId) -> Result { @@ -132,11 +151,16 @@ impl DiaryRepository for FakeDiaryRepository { } async fn get_user_history(&self, _user_id: &UserId) -> Result, DomainError> { - unimplemented!("FakeDiaryRepository::get_user_history") + Ok(vec![]) } async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result { - unimplemented!("FakeDiaryRepository::get_movie_stats") + Ok(MovieStats { + total_count: 0, + avg_rating: None, + federated_count: 0, + rating_histogram: [0; 5], + }) } async fn get_movie_social_feed( @@ -144,10 +168,186 @@ impl DiaryRepository for FakeDiaryRepository { _movie_id: &MovieId, _page: &PageParams, ) -> Result, DomainError> { - unimplemented!("FakeDiaryRepository::get_movie_social_feed") + Ok(Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }) } async fn count_local_posts(&self) -> Result { - unimplemented!("FakeDiaryRepository::count_local_posts") + Ok(0) + } +} + +// ── FakeStatsRepository ───────────────────────────────────────────────────── + +pub struct FakeStatsRepository; + +#[async_trait] +impl StatsRepository for FakeStatsRepository { + async fn get_user_stats(&self, _: &UserId) -> Result { + Ok(UserStats { + total_movies: 0, + avg_rating: None, + favorite_director: None, + most_active_month: None, + }) + } + + async fn get_user_trends(&self, _: &UserId) -> Result { + Ok(UserTrends { + monthly_ratings: vec![], + top_directors: vec![], + max_director_count: 0, + }) + } +} + +// ── FakePersonQuery ───────────────────────────────────────────────────────── + +pub struct FakePersonQuery; + +#[async_trait] +impl PersonQuery for FakePersonQuery { + async fn get_by_id(&self, _: &PersonId) -> Result, DomainError> { + Ok(None) + } + + async fn get_by_external_id( + &self, + _: &ExternalPersonId, + ) -> Result, DomainError> { + Ok(None) + } + + async fn get_credits(&self, id: &PersonId) -> Result { + let dummy = Person::new( + id.clone(), + ExternalPersonId::new("tmdb:0"), + "Unknown".into(), + None, + None, + ); + Ok(PersonCredits { + person: dummy, + cast: vec![], + crew: vec![], + }) + } + + async fn list_orphaned_persons(&self) -> Result, DomainError> { + Ok(vec![]) + } + + async fn list_page(&self, _: u32, _: u32) -> Result, DomainError> { + Ok(vec![]) + } +} + +// ── FakeSearchPort ────────────────────────────────────────────────────────── + +pub struct FakeSearchPort; + +#[async_trait] +impl SearchPort for FakeSearchPort { + async fn search(&self, _: &SearchQuery) -> Result { + Ok(SearchResults { + movies: Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }, + people: Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }, + }) + } +} + +// ── FakeSearchCommand ─────────────────────────────────────────────────────── + +pub struct FakeSearchCommand; + +#[async_trait] +impl SearchCommand for FakeSearchCommand { + async fn index(&self, _: crate::models::IndexableDocument) -> Result<(), DomainError> { + Ok(()) + } + + async fn remove(&self, _: crate::models::EntityType, _: &str) -> Result<(), DomainError> { + Ok(()) + } +} + +// ── FakeDocumentParser ────────────────────────────────────────────────────── + +pub struct FakeDocumentParser; + +impl DocumentParser for FakeDocumentParser { + fn parse(&self, _: &[u8], _: FileFormat) -> Result { + Ok(ParsedFile { + columns: vec!["title".into()], + rows: vec![vec!["Test Movie".into()]], + }) + } + + fn apply_mapping(&self, _: &ParsedFile, _: &[FieldMapping]) -> Vec { + vec![AnnotatedRow { + result: RowResult::Valid(ImportRow { + title: Some("Test Movie".into()), + ..ImportRow::default() + }), + is_duplicate: false, + }] + } +} + +// ── FakePosterFetcher ─────────────────────────────────────────────────────── + +pub struct FakePosterFetcher; + +#[async_trait] +impl PosterFetcherClient for FakePosterFetcher { + async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result, DomainError> { + Ok(vec![1, 2, 3]) + } +} + +// ── FakeMovieEnrichmentClient ─────────────────────────────────────────────── + +pub struct FakeMovieEnrichmentClient; + +#[async_trait] +impl MovieEnrichmentClient for FakeMovieEnrichmentClient { + async fn fetch_profile( + &self, + movie_id: MovieId, + _external_metadata_id: &str, + ) -> Result { + Ok(MovieProfile { + movie_id, + tmdb_id: 0, + imdb_id: None, + overview: None, + tagline: None, + runtime_minutes: None, + budget_usd: None, + revenue_usd: None, + vote_average: None, + vote_count: None, + original_language: None, + collection_name: None, + genres: vec![], + keywords: vec![], + cast: vec![], + crew: vec![], + enriched_at: Utc::now(), + }) } } diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index 7028578..e8dd463 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -4,17 +4,25 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use uuid::Uuid; +use chrono::NaiveDateTime; + use crate::{ errors::DomainError, events::DomainEvent, models::{ - Movie, MovieFilter, MovieSummary, Review, User, UserSummary, WatchlistEntry, - WatchlistWithMovie, + Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary, + ProfileField, Review, User, UserSettings, UserSummary, WatchEvent, WatchEventStatus, + WatchlistEntry, WatchlistWithMovie, WebhookToken, collections::{PageParams, Paginated}, }, - ports::{MovieRepository, ReviewRepository, UserRepository, WatchlistRepository}, + ports::{ + GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, + MovieRepository, ReviewRepository, UserProfileFieldsRepository, UserRepository, + UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository, + }, value_objects::{ - Email, ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId, UserId, Username, + Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, + ReleaseYear, ReviewId, UserId, Username, WatchEventId, WebhookTokenId, }, }; @@ -310,3 +318,462 @@ impl WatchlistRepository for InMemoryWatchlistRepository { Ok(self.store.lock().unwrap().contains_key(&key)) } } + +// ── InMemoryGoalRepository ────────────────────────────────────────────────── + +pub struct InMemoryGoalRepository { + store: Mutex>, + review_counts: Mutex>, +} + +impl InMemoryGoalRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(HashMap::new()), + review_counts: Mutex::new(HashMap::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } + + pub fn set_review_count(&self, user_id: Uuid, year: u16, count: u32) { + self.review_counts + .lock() + .unwrap() + .insert((user_id, year), count); + } +} + +#[async_trait] +impl GoalRepository for InMemoryGoalRepository { + async fn save(&self, goal: &Goal) -> Result<(), DomainError> { + self.store + .lock() + .unwrap() + .insert(goal.id().value(), goal.clone()); + Ok(()) + } + + async fn update(&self, goal: &Goal) -> Result<(), DomainError> { + let mut store = self.store.lock().unwrap(); + match store.entry(goal.id().value()) { + std::collections::hash_map::Entry::Occupied(mut e) => { + e.insert(goal.clone()); + Ok(()) + } + std::collections::hash_map::Entry::Vacant(_) => { + Err(DomainError::NotFound("goal".into())) + } + } + } + + async fn delete(&self, id: &GoalId, _user_id: &UserId) -> Result<(), DomainError> { + self.store.lock().unwrap().remove(&id.value()); + Ok(()) + } + + async fn find_by_user_and_year( + &self, + user_id: &UserId, + year: u16, + ) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .values() + .find(|g| g.user_id().value() == user_id.value() && g.year() == year) + .cloned()) + } + + async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .values() + .filter(|g| g.user_id().value() == user_id.value()) + .cloned() + .collect()) + } + + async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result { + let counts = self.review_counts.lock().unwrap(); + Ok(counts.get(&(user_id.value(), year)).copied().unwrap_or(0)) + } +} + +// ── InMemoryUserSettingsRepository ────────────────────────────────────────── + +pub struct InMemoryUserSettingsRepository { + store: Mutex>, +} + +impl InMemoryUserSettingsRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(HashMap::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } +} + +#[async_trait] +impl UserSettingsRepository for InMemoryUserSettingsRepository { + async fn get(&self, user_id: &UserId) -> Result { + let store = self.store.lock().unwrap(); + Ok(store + .get(&user_id.value()) + .cloned() + .unwrap_or_else(|| UserSettings::new(user_id.clone()))) + } + + async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> { + self.store + .lock() + .unwrap() + .insert(settings.user_id().value(), settings.clone()); + Ok(()) + } +} + +// ── InMemoryWebhookTokenRepository ────────────────────────────────────────── + +pub struct InMemoryWebhookTokenRepository { + store: Mutex>, +} + +impl InMemoryWebhookTokenRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(Vec::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } +} + +#[async_trait] +impl WebhookTokenRepository for InMemoryWebhookTokenRepository { + async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> { + self.store.lock().unwrap().push(token.clone()); + Ok(()) + } + + async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store.iter().find(|t| t.token_hash() == hash).cloned()) + } + + async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .iter() + .filter(|t| t.user_id().value() == user_id.value()) + .cloned() + .collect()) + } + + async fn delete(&self, id: &WebhookTokenId, _user_id: &UserId) -> Result<(), DomainError> { + self.store + .lock() + .unwrap() + .retain(|t| t.id().value() != id.value()); + Ok(()) + } + + async fn touch_last_used(&self, _id: &WebhookTokenId) -> Result<(), DomainError> { + Ok(()) + } +} + +// ── InMemoryWatchEventRepository ──────────────────────────────────────────── + +pub struct InMemoryWatchEventRepository { + store: Mutex>, +} + +impl InMemoryWatchEventRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(Vec::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } +} + +#[async_trait] +impl WatchEventRepository for InMemoryWatchEventRepository { + async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { + self.store.lock().unwrap().push(event.clone()); + Ok(()) + } + + async fn update_status( + &self, + _id: &WatchEventId, + _status: WatchEventStatus, + ) -> Result<(), DomainError> { + Ok(()) + } + + async fn list_pending(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .iter() + .filter(|e| { + e.user_id().value() == user_id.value() && *e.status() == WatchEventStatus::Pending + }) + .cloned() + .collect()) + } + + async fn get_by_id(&self, id: &WatchEventId) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store.iter().find(|e| e.id().value() == id.value()).cloned()) + } + + async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError> { + let id_vals: Vec = ids.iter().map(|id| id.value()).collect(); + let store = self.store.lock().unwrap(); + Ok(store + .iter() + .filter(|e| id_vals.contains(&e.id().value())) + .cloned() + .collect()) + } + + async fn update_status_batch( + &self, + ids: &[WatchEventId], + _status: WatchEventStatus, + ) -> Result { + Ok(ids.len() as u64) + } + + async fn find_duplicate( + &self, + user_id: &UserId, + external_id: &str, + after: NaiveDateTime, + ) -> Result { + let store = self.store.lock().unwrap(); + Ok(store.iter().any(|e| { + e.user_id().value() == user_id.value() + && e.external_metadata_id() == Some(external_id) + && *e.watched_at() > after + })) + } + + async fn delete_non_pending_older_than( + &self, + before: NaiveDateTime, + ) -> Result { + let mut store = self.store.lock().unwrap(); + let before_len = store.len(); + store.retain(|e| *e.status() == WatchEventStatus::Pending || *e.created_at() >= before); + Ok((before_len - store.len()) as u64) + } +} + +// ── InMemoryImportSessionRepository ───────────────────────────────────────── + +pub struct InMemoryImportSessionRepository { + store: Mutex>, +} + +impl InMemoryImportSessionRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(HashMap::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } +} + +#[async_trait] +impl ImportSessionRepository for InMemoryImportSessionRepository { + async fn create(&self, session: &ImportSession) -> Result<(), DomainError> { + self.store + .lock() + .unwrap() + .insert(session.id.value(), session.clone()); + Ok(()) + } + + async fn get( + &self, + id: &ImportSessionId, + user_id: &UserId, + ) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .get(&id.value()) + .filter(|s| s.user_id.value() == user_id.value()) + .cloned()) + } + + async fn update(&self, session: &ImportSession) -> Result<(), DomainError> { + self.store + .lock() + .unwrap() + .insert(session.id.value(), session.clone()); + Ok(()) + } + + async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { + self.store.lock().unwrap().remove(&id.value()); + Ok(()) + } + + async fn delete_expired(&self) -> Result { + let mut store = self.store.lock().unwrap(); + let now = chrono::Utc::now().naive_utc(); + let before_len = store.len(); + store.retain(|_, s| s.expires_at > now); + Ok((before_len - store.len()) as u64) + } + + async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { + let mut store = self.store.lock().unwrap(); + let now = chrono::Utc::now().naive_utc(); + store.retain(|_, s| !(s.user_id.value() == user_id.value() && s.expires_at <= now)); + Ok(()) + } +} + +// ── InMemoryImportProfileRepository ───────────────────────────────────────── + +pub struct InMemoryImportProfileRepository { + store: Mutex>, +} + +impl InMemoryImportProfileRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(HashMap::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } +} + +#[async_trait] +impl ImportProfileRepository for InMemoryImportProfileRepository { + async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError> { + self.store + .lock() + .unwrap() + .insert(profile.id.value(), profile.clone()); + Ok(()) + } + + async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .values() + .filter(|p| p.user_id.value() == user_id.value()) + .cloned() + .collect()) + } + + async fn get( + &self, + id: &ImportProfileId, + user_id: &UserId, + ) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store + .get(&id.value()) + .filter(|p| p.user_id.value() == user_id.value()) + .cloned()) + } + + async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { + self.store.lock().unwrap().remove(&id.value()); + Ok(()) + } +} + +// ── InMemoryMovieProfileRepository ────────────────────────────────────────── + +pub struct InMemoryMovieProfileRepository { + store: Mutex>, +} + +impl InMemoryMovieProfileRepository { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(HashMap::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } +} + +#[async_trait] +impl MovieProfileRepository for InMemoryMovieProfileRepository { + async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError> { + self.store + .lock() + .unwrap() + .insert(profile.movie_id.value(), profile.clone()); + Ok(()) + } + + async fn get_by_movie_id(&self, id: &MovieId) -> Result, DomainError> { + Ok(self.store.lock().unwrap().get(&id.value()).cloned()) + } + + async fn list_stale(&self) -> Result, DomainError> { + Ok(vec![]) + } +} + +// ── InMemoryProfileFieldsRepo ─────────────────────────────────────────────── + +pub struct InMemoryProfileFieldsRepo { + store: Mutex>>, +} + +impl InMemoryProfileFieldsRepo { + pub fn new() -> Arc { + Arc::new(Self { + store: Mutex::new(HashMap::new()), + }) + } + + pub fn count(&self) -> usize { + self.store.lock().unwrap().len() + } +} + +#[async_trait] +impl UserProfileFieldsRepository for InMemoryProfileFieldsRepo { + async fn get_fields(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store.get(&user_id.value()).cloned().unwrap_or_default()) + } + + async fn set_fields( + &self, + user_id: &UserId, + fields: Vec, + ) -> Result<(), DomainError> { + self.store.lock().unwrap().insert(user_id.value(), fields); + Ok(()) + } +} diff --git a/crates/domain/src/value_objects.rs b/crates/domain/src/value_objects.rs index 21e6e82..0d78342 100644 --- a/crates/domain/src/value_objects.rs +++ b/crates/domain/src/value_objects.rs @@ -251,3 +251,148 @@ impl PosterUrl { &self.0 } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn movie_id_generate_unique() { + let a = MovieId::generate(); + let b = MovieId::generate(); + assert_ne!(a, b); + } + + #[test] + fn rating_valid_range() { + assert!(Rating::new(0).is_ok()); + assert!(Rating::new(5).is_ok()); + assert_eq!(Rating::new(3).unwrap().value(), 3); + } + + #[test] + fn rating_invalid() { + assert!(Rating::new(6).is_err()); + assert!(Rating::new(255).is_err()); + } + + #[test] + fn movie_title_valid() { + let t = MovieTitle::new("Test".into()); + assert!(t.is_ok()); + assert_eq!(t.unwrap().value(), "Test"); + } + + #[test] + fn movie_title_empty_rejected() { + assert!(MovieTitle::new("".into()).is_err()); + assert!(MovieTitle::new(" ".into()).is_err()); + } + + #[test] + fn release_year_valid() { + assert!(ReleaseYear::new(2024).is_ok()); + assert_eq!(ReleaseYear::new(1888).unwrap().value(), 1888); + } + + #[test] + fn release_year_too_early() { + assert!(ReleaseYear::new(1887).is_err()); + } + + #[test] + fn email_valid() { + let e = Email::new("a@b.com".into()); + assert!(e.is_ok()); + assert_eq!(e.unwrap().value(), "a@b.com"); + } + + #[test] + fn email_invalid() { + assert!(Email::new("invalid".into()).is_err()); + assert!(Email::new("".into()).is_err()); + } + + #[test] + fn username_valid() { + let u = Username::new("test".into()); + assert!(u.is_ok()); + assert_eq!(u.unwrap().value(), "test"); + } + + #[test] + fn username_lowercases() { + assert_eq!(Username::new("Alice".into()).unwrap().value(), "alice"); + } + + #[test] + fn username_rejects_too_short() { + assert!(Username::new("a".into()).is_err()); + } + + #[test] + fn username_rejects_special_chars() { + assert!(Username::new("no spaces".into()).is_err()); + assert!(Username::new("no@at".into()).is_err()); + } + + #[test] + fn poster_path_valid() { + let p = PosterPath::new("path/to/poster".into()); + assert!(p.is_ok()); + assert_eq!(p.unwrap().value(), "path/to/poster"); + } + + #[test] + fn poster_path_empty_rejected() { + assert!(PosterPath::new("".into()).is_err()); + } + + #[test] + fn comment_valid() { + let c = Comment::new("nice movie".into()); + assert!(c.is_ok()); + assert_eq!(c.unwrap().value(), "nice movie"); + } + + #[test] + fn comment_empty_is_ok() { + // empty comment allowed — only max-length checked + assert!(Comment::new("".into()).is_ok()); + } + + #[test] + fn external_metadata_id_valid() { + let e = ExternalMetadataId::new("tt1234567".into()); + assert!(e.is_ok()); + assert_eq!(e.unwrap().value(), "tt1234567"); + } + + #[test] + fn external_metadata_id_empty_rejected() { + assert!(ExternalMetadataId::new("".into()).is_err()); + assert!(ExternalMetadataId::new(" ".into()).is_err()); + } + + #[test] + fn password_hash_valid() { + assert!(PasswordHash::new("hash".into()).is_ok()); + } + + #[test] + fn password_hash_empty_rejected() { + assert!(PasswordHash::new("".into()).is_err()); + } + + #[test] + fn poster_url_valid() { + let u = PosterUrl::new("https://img.com/poster.jpg".into()); + assert!(u.is_ok()); + assert_eq!(u.unwrap().value(), "https://img.com/poster.jpg"); + } + + #[test] + fn poster_url_empty_rejected() { + assert!(PosterUrl::new("".into()).is_err()); + } +} diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index f206352..cd8844d 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -168,6 +168,14 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { #[cfg(not(feature = "federation"))] let ap_router = axum::Router::new(); + let review_logger = Arc::new(application::diary::review_logger::DefaultReviewLogger::new( + Arc::clone(&db.movie), + Arc::clone(&db.review), + Arc::clone(&db.watchlist), + Arc::clone(&metadata_client), + Arc::clone(&event_publisher_arc), + )); + let app_ctx = AppContext { repos: Repositories { movie: db.movie, @@ -209,6 +217,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { event_publisher: event_publisher_arc, diary_exporter: Arc::new(ExportAdapter) as Arc, document_parser: Arc::new(ImporterDocumentParser) as Arc, + review_logger, }, config: app_config, }; diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index b2c137a..5b66dd3 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -719,6 +719,16 @@ impl domain::ports::RemoteGoalRepository for Panic { } } +#[async_trait::async_trait] +impl application::ports::ReviewLogger for Panic { + async fn log_review( + &self, + _: application::diary::commands::LogReviewCommand, + ) -> Result<(), DomainError> { + panic!() + } +} + // --- Single state factory — only auth_service varies --- pub fn make_test_state(auth_service: Arc) -> crate::state::AppState { @@ -759,6 +769,7 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS event_publisher: Arc::clone(&repo) as _, diary_exporter: Arc::clone(&repo) as _, document_parser: Arc::clone(&repo) as _, + review_logger: Arc::clone(&repo) as _, }, config: AppConfig { allow_registration: false, diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index d3ee061..1de48f5 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -39,6 +39,17 @@ impl EventPublisher for NoopEventPublisher { } } +struct PanicReviewLogger; +#[async_trait] +impl application::ports::ReviewLogger for PanicReviewLogger { + async fn log_review( + &self, + _: application::diary::commands::LogReviewCommand, + ) -> Result<(), DomainError> { + panic!("review_logger not wired in tests") + } +} + struct PanicMeta; #[async_trait] impl MetadataClient for PanicMeta { @@ -450,6 +461,7 @@ async fn test_app() -> Router { event_publisher: Arc::new(NoopEventPublisher), diary_exporter: Arc::new(PanicExporter), document_parser: Arc::new(PanicDocumentParser), + review_logger: Arc::new(PanicReviewLogger), }, config: AppConfig { allow_registration: false, diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 48c10b7..0efdd2e 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -66,6 +66,14 @@ async fn main() -> anyhow::Result<()> { db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), }; + let review_logger = Arc::new(application::diary::review_logger::DefaultReviewLogger::new( + Arc::clone(&db.movie), + Arc::clone(&db.review), + Arc::clone(&db.watchlist), + Arc::clone(&metadata_client), + Arc::clone(&event_publisher_arc), + )); + let ctx = AppContext { repos: Repositories { movie: db.movie, @@ -107,6 +115,7 @@ async fn main() -> anyhow::Result<()> { event_publisher: event_publisher_arc, diary_exporter: Arc::new(ExportAdapter) as Arc, document_parser: Arc::new(ImporterDocumentParser) as Arc, + review_logger, }, config: app_config, };