refactor(watchlist): WatchlistAddDeps, scoped Arc deps

This commit is contained in:
2026-06-11 21:40:48 +02:00
parent f006ba00a8
commit b552c1d156
12 changed files with 106 additions and 76 deletions

View File

@@ -6,34 +6,31 @@ use domain::{
}; };
use crate::{ use crate::{
context::AppContext,
diary::movie_resolver::{MovieResolver, MovieResolverDeps}, 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 user_id = UserId::from_uuid(cmd.user_id);
let movie = if let Some(id) = cmd.input.movie_id { let movie = if let Some(id) = cmd.input.movie_id {
let movie_id = MovieId::from_uuid(id); let movie_id = MovieId::from_uuid(id);
ctx.repos deps.movie
.movie
.get_movie_by_id(&movie_id) .get_movie_by_id(&movie_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))? .ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?
} else { } else {
let deps = MovieResolverDeps { let resolver_deps = MovieResolverDeps {
repository: ctx.repos.movie.as_ref(), repository: deps.movie.as_ref(),
metadata_client: ctx.services.metadata.as_ref(), metadata_client: deps.metadata.as_ref(),
}; };
let (movie, is_new) = MovieResolver::default_pipeline() let (movie, is_new) = MovieResolver::default_pipeline()
.resolve(&cmd.input, &deps) .resolve(&cmd.input, &resolver_deps)
.await?; .await?;
if is_new { 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() { if let Some(ext_id) = movie.external_metadata_id() {
let _ = ctx let _ = deps
.services
.event_publisher .event_publisher
.publish(&DomainEvent::MovieDiscovered { .publish(&DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(), 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()); let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone());
ctx.repos.watchlist.add(&entry).await?; deps.watchlist.add(&entry).await?;
let _ = ctx let _ = deps
.services
.event_publisher .event_publisher
.publish(&DomainEvent::WatchlistEntryAdded { .publish(&DomainEvent::WatchlistEntryAdded {
user_id, user_id,

View File

@@ -0,0 +1,10 @@
use std::sync::Arc;
use domain::ports::{EventPublisher, MetadataClient, MovieRepository, WatchlistRepository};
pub struct WatchlistAddDeps {
pub movie: Arc<dyn MovieRepository>,
pub metadata: Arc<dyn MetadataClient>,
pub watchlist: Arc<dyn WatchlistRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
}

View File

@@ -1,21 +1,24 @@
use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
WatchlistWithMovie, WatchlistWithMovie,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::WatchlistRepository,
value_objects::UserId, value_objects::UserId,
}; };
use crate::{context::AppContext, watchlist::queries::GetWatchlistQuery}; use crate::watchlist::queries::GetWatchlistQuery;
pub async fn execute( pub async fn execute(
ctx: &AppContext, watchlist: Arc<dyn WatchlistRepository>,
query: GetWatchlistQuery, query: GetWatchlistQuery,
) -> Result<Paginated<WatchlistWithMovie>, DomainError> { ) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
let page = PageParams::new(query.limit, query.offset)?; 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)] #[cfg(test)]

View File

@@ -1,14 +1,20 @@
use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
ports::WatchlistRepository,
value_objects::{MovieId, UserId}, 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<bool, DomainError> { pub async fn execute(
watchlist: Arc<dyn WatchlistRepository>,
query: IsOnWatchlistQuery,
) -> Result<bool, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
let movie_id = MovieId::from_uuid(query.movie_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)] #[cfg(test)]

View File

@@ -1,5 +1,6 @@
pub mod add; pub mod add;
pub mod commands; pub mod commands;
pub mod deps;
pub mod get; pub mod get;
pub mod is_on; pub mod is_on;
pub mod queries; pub mod queries;

View File

@@ -1,19 +1,24 @@
use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::{EventPublisher, WatchlistRepository},
value_objects::{MovieId, UserId}, 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<dyn WatchlistRepository>,
event_publisher: Arc<dyn EventPublisher>,
cmd: RemoveFromWatchlistCommand,
) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let movie_id = MovieId::from_uuid(cmd.movie_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 let _ = event_publisher
.services
.event_publisher
.publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id }) .publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id })
.await; .await;

View File

@@ -3,15 +3,27 @@ use std::sync::Arc;
use domain::{ use domain::{
models::Movie, models::Movie,
ports::MovieRepository, ports::MovieRepository,
testing::{InMemoryMovieRepository, InMemoryWatchlistRepository}, testing::{InMemoryMovieRepository, InMemoryWatchlistRepository, NoopEventPublisher},
value_objects::{MovieTitle, ReleaseYear}, value_objects::{MovieTitle, ReleaseYear},
}; };
use crate::{ use crate::{
diary::commands::MovieInput, test_helpers::TestContextBuilder, watchlist::add, diary::commands::MovieInput,
watchlist::commands::AddToWatchlistCommand, watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps},
}; };
fn make_deps(
movies: Arc<InMemoryMovieRepository>,
watchlist: Arc<InMemoryWatchlistRepository>,
) -> WatchlistAddDeps {
WatchlistAddDeps {
movie: movies,
metadata: Arc::new(domain::testing::FakeMetadataClient),
watchlist,
event_publisher: NoopEventPublisher::new(),
}
}
#[tokio::test] #[tokio::test]
async fn test_add_to_watchlist_resolves_and_saves() { async fn test_add_to_watchlist_resolves_and_saves() {
let movies = InMemoryMovieRepository::new(); let movies = InMemoryMovieRepository::new();
@@ -27,10 +39,7 @@ async fn test_add_to_watchlist_resolves_and_saves() {
let movie_uuid = movie.id().value(); let movie_uuid = movie.id().value();
movies.upsert_movie(&movie).await.unwrap(); movies.upsert_movie(&movie).await.unwrap();
let ctx = TestContextBuilder::new() let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist));
.with_movies(Arc::clone(&movies) as _)
.with_watchlist(Arc::clone(&watchlist) as _)
.build();
let cmd = AddToWatchlistCommand { let cmd = AddToWatchlistCommand {
user_id: uuid::Uuid::new_v4(), 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); 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(); let user_id = uuid::Uuid::new_v4();
movies.upsert_movie(&movie).await.unwrap(); movies.upsert_movie(&movie).await.unwrap();
let ctx = TestContextBuilder::new() let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist));
.with_movies(Arc::clone(&movies) as _)
.with_watchlist(Arc::clone(&watchlist) as _)
.build();
let make_cmd = || AddToWatchlistCommand { let make_cmd = || AddToWatchlistCommand {
user_id, 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(&deps, make_cmd()).await.unwrap();
add::execute(&ctx, make_cmd()).await.unwrap(); add::execute(&deps, make_cmd()).await.unwrap();
assert_eq!(watchlist.count(), 1, "idempotent add should not duplicate"); 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 movies = InMemoryMovieRepository::new();
let watchlist = InMemoryWatchlistRepository::new(); let watchlist = InMemoryWatchlistRepository::new();
let ctx = TestContextBuilder::new() let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist));
.with_movies(Arc::clone(&movies) as _)
.with_watchlist(Arc::clone(&watchlist) as _)
.build();
let cmd = AddToWatchlistCommand { let cmd = AddToWatchlistCommand {
user_id: uuid::Uuid::new_v4(), 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!(watchlist.count(), 1);
assert_eq!(movies.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 movies = InMemoryMovieRepository::new();
let watchlist = InMemoryWatchlistRepository::new(); let watchlist = InMemoryWatchlistRepository::new();
let ctx = TestContextBuilder::new() let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist));
.with_movies(Arc::clone(&movies) as _)
.with_watchlist(Arc::clone(&watchlist) as _)
.build();
let cmd = AddToWatchlistCommand { let cmd = AddToWatchlistCommand {
user_id: uuid::Uuid::new_v4(), 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());
} }

View File

@@ -5,9 +5,9 @@ use crate::watchlist::{get, queries::GetWatchlistQuery};
#[tokio::test] #[tokio::test]
async fn returns_empty_page_for_new_user() { async fn returns_empty_page_for_new_user() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let result = get::execute( let result = get::execute(
&ctx, b.watchlist_repo.clone(),
GetWatchlistQuery { GetWatchlistQuery {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
limit: None, limit: None,

View File

@@ -22,12 +22,8 @@ async fn returns_true_when_present() {
.await .await
.unwrap(); .unwrap();
let ctx = TestContextBuilder::new()
.with_watchlist(Arc::clone(&watchlist) as _)
.build();
let result = is_on::execute( let result = is_on::execute(
&ctx, Arc::clone(&watchlist) as _,
IsOnWatchlistQuery { IsOnWatchlistQuery {
user_id: uid, user_id: uid,
movie_id: mid, movie_id: mid,
@@ -41,9 +37,9 @@ async fn returns_true_when_present() {
#[tokio::test] #[tokio::test]
async fn returns_false_when_absent() { async fn returns_false_when_absent() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let result = is_on::execute( let result = is_on::execute(
&ctx, b.watchlist_repo.clone(),
IsOnWatchlistQuery { IsOnWatchlistQuery {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
movie_id: Uuid::new_v4(), movie_id: Uuid::new_v4(),

View File

@@ -24,13 +24,9 @@ async fn removes_entry_and_emits_event() {
.await .await
.unwrap(); .unwrap();
let ctx = TestContextBuilder::new()
.with_watchlist(Arc::clone(&watchlist) as _)
.with_event_publisher(Arc::clone(&events) as _)
.build();
remove::execute( remove::execute(
&ctx, Arc::clone(&watchlist) as _,
Arc::clone(&events) as _,
RemoveFromWatchlistCommand { RemoveFromWatchlistCommand {
user_id: uid, user_id: uid,
movie_id: mid, movie_id: mid,
@@ -50,9 +46,10 @@ async fn removes_entry_and_emits_event() {
#[tokio::test] #[tokio::test]
async fn fails_when_not_on_watchlist() { async fn fails_when_not_on_watchlist() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let result = remove::execute( let result = remove::execute(
&ctx, b.watchlist_repo.clone(),
b.event_publisher.clone(),
RemoveFromWatchlistCommand { RemoveFromWatchlistCommand {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
movie_id: Uuid::new_v4(), movie_id: Uuid::new_v4(),

View File

@@ -288,7 +288,7 @@ pub async fn get_movie_detail_html(
result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32; result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32;
let on_watchlist = match &user_id { let on_watchlist = match &user_id {
Some(uid) => is_on_watchlist::execute( Some(uid) => is_on_watchlist::execute(
&state.app_ctx, state.app_ctx.repos.watchlist.clone(),
IsOnWatchlistQuery { IsOnWatchlistQuery {
user_id: uid.value(), user_id: uid.value(),
movie_id, movie_id,

View File

@@ -11,6 +11,7 @@ use application::{
watchlist::{ watchlist::{
add as add_to_watchlist, add as add_to_watchlist,
commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand}, commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand},
deps::WatchlistAddDeps,
get as get_watchlist, is_on as is_on_watchlist, get as get_watchlist, is_on as is_on_watchlist,
queries::{GetWatchlistQuery, IsOnWatchlistQuery}, queries::{GetWatchlistQuery, IsOnWatchlistQuery},
remove as remove_from_watchlist, remove as remove_from_watchlist,
@@ -58,7 +59,7 @@ pub async fn get_watchlist_handler(
Query(params): Query<PaginationQueryParams>, Query(params): Query<PaginationQueryParams>,
) -> Result<Json<WatchlistResponse>, ApiError> { ) -> Result<Json<WatchlistResponse>, ApiError> {
let page = get_watchlist::execute( let page = get_watchlist::execute(
&state.app_ctx, state.app_ctx.repos.watchlist.clone(),
GetWatchlistQuery { GetWatchlistQuery {
user_id: user.0.value(), user_id: user.0.value(),
limit: params.limit, limit: params.limit,
@@ -98,8 +99,14 @@ pub async fn post_watchlist_add(
user: AuthenticatedUser, user: AuthenticatedUser,
Json(req): Json<AddToWatchlistRequest>, Json(req): Json<AddToWatchlistRequest>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
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( add_to_watchlist::execute(
&state.app_ctx, &deps,
AddToWatchlistCommand { AddToWatchlistCommand {
user_id: user.0.value(), user_id: user.0.value(),
input: MovieInput { input: MovieInput {
@@ -131,7 +138,8 @@ pub async fn delete_watchlist_entry(
Path(movie_id): Path<Uuid>, Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
remove_from_watchlist::execute( remove_from_watchlist::execute(
&state.app_ctx, state.app_ctx.repos.watchlist.clone(),
state.app_ctx.services.event_publisher.clone(),
RemoveFromWatchlistCommand { RemoveFromWatchlistCommand {
user_id: user.0.value(), user_id: user.0.value(),
movie_id, movie_id,
@@ -156,7 +164,7 @@ pub async fn get_watchlist_status(
Path(movie_id): Path<Uuid>, Path(movie_id): Path<Uuid>,
) -> Result<Json<WatchlistStatusResponse>, ApiError> { ) -> Result<Json<WatchlistStatusResponse>, ApiError> {
let on_watchlist = is_on_watchlist::execute( let on_watchlist = is_on_watchlist::execute(
&state.app_ctx, state.app_ctx.repos.watchlist.clone(),
IsOnWatchlistQuery { IsOnWatchlistQuery {
user_id: user.0.value(), user_id: user.0.value(),
movie_id, movie_id,
@@ -190,7 +198,7 @@ pub async fn get_watchlist_page(
let result = if is_local { let result = if is_local {
match get_watchlist::execute( match get_watchlist::execute(
&state.app_ctx, state.app_ctx.repos.watchlist.clone(),
application::watchlist::queries::GetWatchlistQuery { application::watchlist::queries::GetWatchlistQuery {
user_id: owner_id, user_id: owner_id,
limit: params.limit.or(Some(20)), 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( match add_to_watchlist::execute(
&state.app_ctx, &deps,
AddToWatchlistCommand { AddToWatchlistCommand {
user_id: user_id.value(), user_id: user_id.value(),
input, input,
@@ -311,7 +326,8 @@ pub async fn post_watchlist_remove_html(
return StatusCode::FORBIDDEN.into_response(); return StatusCode::FORBIDDEN.into_response();
} }
match remove_from_watchlist::execute( match remove_from_watchlist::execute(
&state.app_ctx, state.app_ctx.repos.watchlist.clone(),
state.app_ctx.services.event_publisher.clone(),
RemoveFromWatchlistCommand { RemoveFromWatchlistCommand {
user_id: user_id.value(), user_id: user_id.value(),
movie_id, movie_id,