From b552c1d156e2fa8ccf056d003ab09482fea129ac Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 11 Jun 2026 21:40:48 +0200 Subject: [PATCH] refactor(watchlist): WatchlistAddDeps, scoped Arc deps --- crates/application/src/watchlist/add.rs | 26 +++++----- crates/application/src/watchlist/deps.rs | 10 ++++ crates/application/src/watchlist/get.rs | 9 ++-- crates/application/src/watchlist/is_on.rs | 12 +++-- crates/application/src/watchlist/mod.rs | 1 + crates/application/src/watchlist/remove.rs | 17 ++++--- crates/application/src/watchlist/tests/add.rs | 48 +++++++++---------- crates/application/src/watchlist/tests/get.rs | 4 +- .../application/src/watchlist/tests/is_on.rs | 10 ++-- .../application/src/watchlist/tests/remove.rs | 13 ++--- crates/presentation/src/handlers/movies.rs | 2 +- crates/presentation/src/handlers/watchlist.rs | 30 +++++++++--- 12 files changed, 106 insertions(+), 76 deletions(-) create mode 100644 crates/application/src/watchlist/deps.rs diff --git a/crates/application/src/watchlist/add.rs b/crates/application/src/watchlist/add.rs index f3caf5d..a1ba851 100644 --- a/crates/application/src/watchlist/add.rs +++ b/crates/application/src/watchlist/add.rs @@ -6,34 +6,31 @@ use domain::{ }; use crate::{ - context::AppContext, diary::movie_resolver::{MovieResolver, MovieResolverDeps}, - watchlist::commands::AddToWatchlistCommand, + watchlist::{commands::AddToWatchlistCommand, deps::WatchlistAddDeps}, }; -pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(), DomainError> { +pub async fn execute(deps: &WatchlistAddDeps, cmd: AddToWatchlistCommand) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let movie = if let Some(id) = cmd.input.movie_id { let movie_id = MovieId::from_uuid(id); - ctx.repos - .movie + deps.movie .get_movie_by_id(&movie_id) .await? .ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))? } else { - let deps = MovieResolverDeps { - repository: ctx.repos.movie.as_ref(), - metadata_client: ctx.services.metadata.as_ref(), + let resolver_deps = MovieResolverDeps { + repository: deps.movie.as_ref(), + metadata_client: deps.metadata.as_ref(), }; let (movie, is_new) = MovieResolver::default_pipeline() - .resolve(&cmd.input, &deps) + .resolve(&cmd.input, &resolver_deps) .await?; if is_new { - ctx.repos.movie.upsert_movie(&movie).await?; + deps.movie.upsert_movie(&movie).await?; if let Some(ext_id) = movie.external_metadata_id() { - let _ = ctx - .services + let _ = deps .event_publisher .publish(&DomainEvent::MovieDiscovered { movie_id: movie.id().clone(), @@ -46,10 +43,9 @@ pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(), }; let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone()); - ctx.repos.watchlist.add(&entry).await?; + deps.watchlist.add(&entry).await?; - let _ = ctx - .services + let _ = deps .event_publisher .publish(&DomainEvent::WatchlistEntryAdded { user_id, diff --git a/crates/application/src/watchlist/deps.rs b/crates/application/src/watchlist/deps.rs new file mode 100644 index 0000000..b34e609 --- /dev/null +++ b/crates/application/src/watchlist/deps.rs @@ -0,0 +1,10 @@ +use std::sync::Arc; + +use domain::ports::{EventPublisher, MetadataClient, MovieRepository, WatchlistRepository}; + +pub struct WatchlistAddDeps { + pub movie: Arc, + pub metadata: Arc, + pub watchlist: Arc, + pub event_publisher: Arc, +} diff --git a/crates/application/src/watchlist/get.rs b/crates/application/src/watchlist/get.rs index c91801d..cfb3691 100644 --- a/crates/application/src/watchlist/get.rs +++ b/crates/application/src/watchlist/get.rs @@ -1,21 +1,24 @@ +use std::sync::Arc; + use domain::{ errors::DomainError, models::{ WatchlistWithMovie, collections::{PageParams, Paginated}, }, + ports::WatchlistRepository, value_objects::UserId, }; -use crate::{context::AppContext, watchlist::queries::GetWatchlistQuery}; +use crate::watchlist::queries::GetWatchlistQuery; pub async fn execute( - ctx: &AppContext, + watchlist: Arc, query: GetWatchlistQuery, ) -> Result, DomainError> { let user_id = UserId::from_uuid(query.user_id); let page = PageParams::new(query.limit, query.offset)?; - ctx.repos.watchlist.get_for_user(&user_id, &page).await + watchlist.get_for_user(&user_id, &page).await } #[cfg(test)] diff --git a/crates/application/src/watchlist/is_on.rs b/crates/application/src/watchlist/is_on.rs index c69cfe8..a197446 100644 --- a/crates/application/src/watchlist/is_on.rs +++ b/crates/application/src/watchlist/is_on.rs @@ -1,14 +1,20 @@ +use std::sync::Arc; + use domain::{ errors::DomainError, + ports::WatchlistRepository, value_objects::{MovieId, UserId}, }; -use crate::{context::AppContext, watchlist::queries::IsOnWatchlistQuery}; +use crate::watchlist::queries::IsOnWatchlistQuery; -pub async fn execute(ctx: &AppContext, query: IsOnWatchlistQuery) -> Result { +pub async fn execute( + watchlist: Arc, + query: IsOnWatchlistQuery, +) -> Result { let user_id = UserId::from_uuid(query.user_id); let movie_id = MovieId::from_uuid(query.movie_id); - ctx.repos.watchlist.contains(&user_id, &movie_id).await + watchlist.contains(&user_id, &movie_id).await } #[cfg(test)] diff --git a/crates/application/src/watchlist/mod.rs b/crates/application/src/watchlist/mod.rs index 7ad9d2c..d0bc91e 100644 --- a/crates/application/src/watchlist/mod.rs +++ b/crates/application/src/watchlist/mod.rs @@ -1,5 +1,6 @@ pub mod add; pub mod commands; +pub mod deps; pub mod get; pub mod is_on; pub mod queries; diff --git a/crates/application/src/watchlist/remove.rs b/crates/application/src/watchlist/remove.rs index 4d04099..d4297e0 100644 --- a/crates/application/src/watchlist/remove.rs +++ b/crates/application/src/watchlist/remove.rs @@ -1,19 +1,24 @@ +use std::sync::Arc; + use domain::{ errors::DomainError, events::DomainEvent, + ports::{EventPublisher, WatchlistRepository}, value_objects::{MovieId, UserId}, }; -use crate::{context::AppContext, watchlist::commands::RemoveFromWatchlistCommand}; +use crate::watchlist::commands::RemoveFromWatchlistCommand; -pub async fn execute(ctx: &AppContext, cmd: RemoveFromWatchlistCommand) -> Result<(), DomainError> { +pub async fn execute( + watchlist: Arc, + event_publisher: Arc, + cmd: RemoveFromWatchlistCommand, +) -> Result<(), DomainError> { let user_id = UserId::from_uuid(cmd.user_id); let movie_id = MovieId::from_uuid(cmd.movie_id); - ctx.repos.watchlist.remove(&user_id, &movie_id).await?; + watchlist.remove(&user_id, &movie_id).await?; - let _ = ctx - .services - .event_publisher + let _ = event_publisher .publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id }) .await; diff --git a/crates/application/src/watchlist/tests/add.rs b/crates/application/src/watchlist/tests/add.rs index 56dce1e..85b2d44 100644 --- a/crates/application/src/watchlist/tests/add.rs +++ b/crates/application/src/watchlist/tests/add.rs @@ -3,15 +3,27 @@ use std::sync::Arc; use domain::{ models::Movie, ports::MovieRepository, - testing::{InMemoryMovieRepository, InMemoryWatchlistRepository}, + testing::{InMemoryMovieRepository, InMemoryWatchlistRepository, NoopEventPublisher}, value_objects::{MovieTitle, ReleaseYear}, }; use crate::{ - diary::commands::MovieInput, test_helpers::TestContextBuilder, watchlist::add, - watchlist::commands::AddToWatchlistCommand, + diary::commands::MovieInput, + watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps}, }; +fn make_deps( + movies: Arc, + watchlist: Arc, +) -> WatchlistAddDeps { + WatchlistAddDeps { + movie: movies, + metadata: Arc::new(domain::testing::FakeMetadataClient), + watchlist, + event_publisher: NoopEventPublisher::new(), + } +} + #[tokio::test] async fn test_add_to_watchlist_resolves_and_saves() { let movies = InMemoryMovieRepository::new(); @@ -27,10 +39,7 @@ async fn test_add_to_watchlist_resolves_and_saves() { let movie_uuid = movie.id().value(); movies.upsert_movie(&movie).await.unwrap(); - let ctx = TestContextBuilder::new() - .with_movies(Arc::clone(&movies) as _) - .with_watchlist(Arc::clone(&watchlist) as _) - .build(); + let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); let cmd = AddToWatchlistCommand { user_id: uuid::Uuid::new_v4(), @@ -43,7 +52,7 @@ async fn test_add_to_watchlist_resolves_and_saves() { }, }; - add::execute(&ctx, cmd).await.unwrap(); + add::execute(&deps, cmd).await.unwrap(); assert_eq!(watchlist.count(), 1); } @@ -64,10 +73,7 @@ async fn test_add_to_watchlist_already_present_is_idempotent() { let user_id = uuid::Uuid::new_v4(); movies.upsert_movie(&movie).await.unwrap(); - let ctx = TestContextBuilder::new() - .with_movies(Arc::clone(&movies) as _) - .with_watchlist(Arc::clone(&watchlist) as _) - .build(); + let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); let make_cmd = || AddToWatchlistCommand { user_id, @@ -80,8 +86,8 @@ async fn test_add_to_watchlist_already_present_is_idempotent() { }, }; - add::execute(&ctx, make_cmd()).await.unwrap(); - add::execute(&ctx, make_cmd()).await.unwrap(); + add::execute(&deps, make_cmd()).await.unwrap(); + add::execute(&deps, make_cmd()).await.unwrap(); assert_eq!(watchlist.count(), 1, "idempotent add should not duplicate"); } @@ -91,10 +97,7 @@ 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 deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); let cmd = AddToWatchlistCommand { user_id: uuid::Uuid::new_v4(), @@ -107,7 +110,7 @@ async fn test_add_to_watchlist_with_manual_movie() { }, }; - add::execute(&ctx, cmd).await.unwrap(); + add::execute(&deps, cmd).await.unwrap(); assert_eq!(watchlist.count(), 1); assert_eq!(movies.count(), 1); @@ -118,10 +121,7 @@ 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 deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist)); let cmd = AddToWatchlistCommand { user_id: uuid::Uuid::new_v4(), @@ -134,5 +134,5 @@ async fn test_add_to_watchlist_movie_not_found_by_id() { }, }; - assert!(add::execute(&ctx, cmd).await.is_err()); + assert!(add::execute(&deps, cmd).await.is_err()); } diff --git a/crates/application/src/watchlist/tests/get.rs b/crates/application/src/watchlist/tests/get.rs index f193f86..e4b78c5 100644 --- a/crates/application/src/watchlist/tests/get.rs +++ b/crates/application/src/watchlist/tests/get.rs @@ -5,9 +5,9 @@ use crate::watchlist::{get, queries::GetWatchlistQuery}; #[tokio::test] async fn returns_empty_page_for_new_user() { - let ctx = TestContextBuilder::new().build(); + let b = TestContextBuilder::new(); let result = get::execute( - &ctx, + b.watchlist_repo.clone(), GetWatchlistQuery { user_id: Uuid::new_v4(), limit: None, diff --git a/crates/application/src/watchlist/tests/is_on.rs b/crates/application/src/watchlist/tests/is_on.rs index 10ac656..c05d31b 100644 --- a/crates/application/src/watchlist/tests/is_on.rs +++ b/crates/application/src/watchlist/tests/is_on.rs @@ -22,12 +22,8 @@ async fn returns_true_when_present() { .await .unwrap(); - let ctx = TestContextBuilder::new() - .with_watchlist(Arc::clone(&watchlist) as _) - .build(); - let result = is_on::execute( - &ctx, + Arc::clone(&watchlist) as _, IsOnWatchlistQuery { user_id: uid, movie_id: mid, @@ -41,9 +37,9 @@ async fn returns_true_when_present() { #[tokio::test] async fn returns_false_when_absent() { - let ctx = TestContextBuilder::new().build(); + let b = TestContextBuilder::new(); let result = is_on::execute( - &ctx, + b.watchlist_repo.clone(), IsOnWatchlistQuery { user_id: Uuid::new_v4(), movie_id: Uuid::new_v4(), diff --git a/crates/application/src/watchlist/tests/remove.rs b/crates/application/src/watchlist/tests/remove.rs index 0a6b8f9..e5fd4e4 100644 --- a/crates/application/src/watchlist/tests/remove.rs +++ b/crates/application/src/watchlist/tests/remove.rs @@ -24,13 +24,9 @@ async fn removes_entry_and_emits_event() { .await .unwrap(); - let ctx = TestContextBuilder::new() - .with_watchlist(Arc::clone(&watchlist) as _) - .with_event_publisher(Arc::clone(&events) as _) - .build(); - remove::execute( - &ctx, + Arc::clone(&watchlist) as _, + Arc::clone(&events) as _, RemoveFromWatchlistCommand { user_id: uid, movie_id: mid, @@ -50,9 +46,10 @@ async fn removes_entry_and_emits_event() { #[tokio::test] async fn fails_when_not_on_watchlist() { - let ctx = TestContextBuilder::new().build(); + let b = TestContextBuilder::new(); let result = remove::execute( - &ctx, + b.watchlist_repo.clone(), + b.event_publisher.clone(), RemoveFromWatchlistCommand { user_id: Uuid::new_v4(), movie_id: Uuid::new_v4(), diff --git a/crates/presentation/src/handlers/movies.rs b/crates/presentation/src/handlers/movies.rs index 452ef35..158ba5c 100644 --- a/crates/presentation/src/handlers/movies.rs +++ b/crates/presentation/src/handlers/movies.rs @@ -288,7 +288,7 @@ pub async fn get_movie_detail_html( result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32; let on_watchlist = match &user_id { Some(uid) => is_on_watchlist::execute( - &state.app_ctx, + state.app_ctx.repos.watchlist.clone(), IsOnWatchlistQuery { user_id: uid.value(), movie_id, diff --git a/crates/presentation/src/handlers/watchlist.rs b/crates/presentation/src/handlers/watchlist.rs index 25d92e5..bc8d921 100644 --- a/crates/presentation/src/handlers/watchlist.rs +++ b/crates/presentation/src/handlers/watchlist.rs @@ -11,6 +11,7 @@ use application::{ watchlist::{ add as add_to_watchlist, commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, + deps::WatchlistAddDeps, get as get_watchlist, is_on as is_on_watchlist, queries::{GetWatchlistQuery, IsOnWatchlistQuery}, remove as remove_from_watchlist, @@ -58,7 +59,7 @@ pub async fn get_watchlist_handler( Query(params): Query, ) -> Result, ApiError> { let page = get_watchlist::execute( - &state.app_ctx, + state.app_ctx.repos.watchlist.clone(), GetWatchlistQuery { user_id: user.0.value(), limit: params.limit, @@ -98,8 +99,14 @@ pub async fn post_watchlist_add( user: AuthenticatedUser, Json(req): Json, ) -> Result { + let deps = WatchlistAddDeps { + movie: state.app_ctx.repos.movie.clone(), + metadata: state.app_ctx.services.metadata.clone(), + watchlist: state.app_ctx.repos.watchlist.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; add_to_watchlist::execute( - &state.app_ctx, + &deps, AddToWatchlistCommand { user_id: user.0.value(), input: MovieInput { @@ -131,7 +138,8 @@ pub async fn delete_watchlist_entry( Path(movie_id): Path, ) -> Result { remove_from_watchlist::execute( - &state.app_ctx, + state.app_ctx.repos.watchlist.clone(), + state.app_ctx.services.event_publisher.clone(), RemoveFromWatchlistCommand { user_id: user.0.value(), movie_id, @@ -156,7 +164,7 @@ pub async fn get_watchlist_status( Path(movie_id): Path, ) -> Result, ApiError> { let on_watchlist = is_on_watchlist::execute( - &state.app_ctx, + state.app_ctx.repos.watchlist.clone(), IsOnWatchlistQuery { user_id: user.0.value(), movie_id, @@ -190,7 +198,7 @@ pub async fn get_watchlist_page( let result = if is_local { match get_watchlist::execute( - &state.app_ctx, + state.app_ctx.repos.watchlist.clone(), application::watchlist::queries::GetWatchlistQuery { user_id: owner_id, limit: params.limit.or(Some(20)), @@ -276,8 +284,15 @@ pub async fn post_watchlist_add_html( } }; + let deps = WatchlistAddDeps { + movie: state.app_ctx.repos.movie.clone(), + metadata: state.app_ctx.services.metadata.clone(), + watchlist: state.app_ctx.repos.watchlist.clone(), + event_publisher: state.app_ctx.services.event_publisher.clone(), + }; + match add_to_watchlist::execute( - &state.app_ctx, + &deps, AddToWatchlistCommand { user_id: user_id.value(), input, @@ -311,7 +326,8 @@ pub async fn post_watchlist_remove_html( return StatusCode::FORBIDDEN.into_response(); } match remove_from_watchlist::execute( - &state.app_ctx, + state.app_ctx.repos.watchlist.clone(), + state.app_ctx.services.event_publisher.clone(), RemoveFromWatchlistCommand { user_id: user_id.value(), movie_id,