From 89e78a0d1fe3c45c508a7ae4d5b36b8da634e011 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 9 May 2026 18:58:29 +0200 Subject: [PATCH] Refactor application context and repository structure - Updated `AppContext` to include separate repositories for movies, reviews, diaries, and stats. - Modified use cases to utilize the new repository structure, ensuring that the correct repositories are called for their respective operations. - Introduced `DiaryRepository` and `StatsRepository` traits to encapsulate diary and statistics-related operations. - Updated all relevant use cases, handlers, and tests to reflect the changes in repository usage. - Ensured that panic repositories are updated to implement the new traits for testing purposes. --- .../adapters/activitypub/src/event_handler.rs | 14 +- .../activitypub/src/review_handler.rs | 9 +- crates/adapters/sqlite/src/lib.rs | 157 +++++++++-------- crates/application/src/context.rs | 10 +- crates/application/src/movie_resolver.rs | 163 ++---------------- .../src/use_cases/delete_review.rs | 8 +- .../application/src/use_cases/export_diary.rs | 4 +- .../src/use_cases/get_activity_feed.rs | 2 +- crates/application/src/use_cases/get_diary.rs | 2 +- .../src/use_cases/get_review_history.rs | 2 +- .../src/use_cases/get_user_profile.rs | 12 +- .../application/src/use_cases/log_review.rs | 6 +- .../application/src/use_cases/sync_poster.rs | 4 +- crates/domain/src/ports.rs | 35 ++-- crates/presentation/src/event_handlers.rs | 39 +++-- crates/presentation/src/extractors.rs | 66 +++++-- crates/presentation/src/handlers.rs | 2 +- crates/presentation/src/main.rs | 30 +++- crates/presentation/tests/api_test.rs | 6 +- 19 files changed, 260 insertions(+), 311 deletions(-) diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 0c18225..915a56f 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use domain::{ errors::DomainError, events::DomainEvent, - ports::MovieRepository, + ports::{MovieRepository, ReviewRepository}, value_objects::{ReviewId, UserId}, }; use domain::ports::EventHandler; @@ -15,17 +15,19 @@ use crate::urls::{actor_url, review_url}; pub struct ActivityPubEventHandler { ap_service: Arc, - movie_repo: Arc, + movie_repository: Arc, + review_repository: Arc, base_url: String, } impl ActivityPubEventHandler { pub fn new( ap_service: Arc, - movie_repo: Arc, + movie_repository: Arc, + review_repository: Arc, base_url: String, ) -> Self { - Self { ap_service, movie_repo, base_url } + Self { ap_service, movie_repository, review_repository, base_url } } } @@ -48,7 +50,7 @@ impl ActivityPubEventHandler { user_id: &UserId, review_id: &ReviewId, ) -> anyhow::Result<()> { - let review = match self.movie_repo.get_review_by_id(review_id).await? { + let review = match self.review_repository.get_review_by_id(review_id).await? { Some(r) => r, None => return Ok(()), }; @@ -56,7 +58,7 @@ impl ActivityPubEventHandler { let ap_id = review_url(&self.base_url, review_id); let actor = actor_url(&self.base_url, user_id.value()); - let movie = self.movie_repo.get_movie_by_id(review.movie_id()).await.ok().flatten(); + let movie = self.movie_repository.get_movie_by_id(review.movie_id()).await.ok().flatten(); let movie_title = movie.as_ref() .map(|m| m.title().value().to_string()) .unwrap_or_else(|| "Unknown".to_string()); diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index ad78cd0..47f5bb1 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -4,7 +4,7 @@ use activitypub_base::ApObjectHandler; use async_trait::async_trait; use domain::{ models::{Review, ReviewSource}, - ports::MovieRepository, + ports::{DiaryRepository, MovieRepository}, value_objects::{Comment, MovieId, Rating, ReviewId, UserId}, }; use url::Url; @@ -14,7 +14,8 @@ use crate::remote_review_repository::RemoteReviewRepository; use crate::urls::{actor_url, review_url}; pub struct ReviewObjectHandler { - pub movie_repo: Arc, + pub movie_repository: Arc, + pub diary_repository: Arc, pub review_store: Arc, pub base_url: String, } @@ -26,7 +27,7 @@ impl ApObjectHandler for ReviewObjectHandler { user_id: uuid::Uuid, ) -> anyhow::Result> { let domain_user_id = UserId::from_uuid(user_id); - let history = self.movie_repo.get_user_history(&domain_user_id).await?; + let history = self.diary_repository.get_user_history(&domain_user_id).await?; let mut results = Vec::new(); for entry in history { @@ -38,7 +39,7 @@ impl ApObjectHandler for ReviewObjectHandler { let ap_id = review_url(&self.base_url, review.id()); let actor_url = actor_url(&self.base_url, user_id); - let movie = self.movie_repo.get_movie_by_id(review.movie_id()).await.ok().flatten(); + let movie = self.movie_repository.get_movie_by_id(review.movie_id()).await.ok().flatten(); let movie_title = movie.as_ref() .map(|m| m.title().value().to_string()) .unwrap_or_else(|| "Unknown".to_string()); diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index a7ecc77..22ca88d 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -7,7 +7,7 @@ use domain::{ Review, ReviewHistory, ReviewSource, SortDirection, UserStats, UserTrends, collections::{PageParams, Paginated}, }, - ports::MovieRepository, + ports::{DiaryRepository, MovieRepository, ReviewRepository, StatsRepository}, value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId, UserId}, }; use sqlx::SqlitePool; @@ -378,6 +378,18 @@ impl MovieRepository for SqliteMovieRepository { Ok(()) } + async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { + let id = movie_id.value().to_string(); + sqlx::query!("DELETE FROM movies WHERE id = ?", id) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(()) + } +} + +#[async_trait] +impl ReviewRepository for SqliteMovieRepository { async fn save_review(&self, review: &Review) -> Result { let id = review.id().value().to_string(); let movie_id = review.movie_id().value().to_string(); @@ -416,6 +428,33 @@ impl MovieRepository for SqliteMovieRepository { }) } + async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { + let id = review_id.value().to_string(); + sqlx::query_as!( + ReviewRow, + "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url + FROM reviews WHERE id = ?", + id + ) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)? + .map(ReviewRow::to_domain) + .transpose() + } + + async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { + let id = review_id.value().to_string(); + sqlx::query!("DELETE FROM reviews WHERE id = ?", id) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(()) + } +} + +#[async_trait] +impl DiaryRepository for SqliteMovieRepository { async fn query_diary(&self, filter: &DiaryFilter) -> Result, DomainError> { let limit = filter.page.limit as i64; let offset = filter.page.offset as i64; @@ -465,37 +504,29 @@ impl MovieRepository for SqliteMovieRepository { }) } - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { - let id = review_id.value().to_string(); - sqlx::query_as!( - ReviewRow, - "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url - FROM reviews WHERE id = ?", - id - ) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)? - .map(ReviewRow::to_domain) - .transpose() - } + async fn query_activity_feed( + &self, + page: &PageParams, + ) -> Result, DomainError> { + let limit = page.limit as i64; + let offset = page.offset as i64; - async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { - let id = review_id.value().to_string(); - sqlx::query!("DELETE FROM reviews WHERE id = ?", id) - .execute(&self.pool) - .await - .map_err(Self::map_err)?; - Ok(()) - } + let (total, rows) = tokio::try_join!( + self.count_feed_entries(), + self.fetch_feed_rows(limit, offset) + )?; - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { - let id = movie_id.value().to_string(); - sqlx::query!("DELETE FROM movies WHERE id = ?", id) - .execute(&self.pool) - .await - .map_err(Self::map_err)?; - Ok(()) + let items = rows + .into_iter() + .map(FeedRow::to_domain) + .collect::, _>>()?; + + Ok(Paginated { + items, + total_count: total as u64, + limit: page.limit, + offset: page.offset, + }) } async fn get_review_history(&self, movie_id: &MovieId) -> Result { @@ -529,50 +560,6 @@ impl MovieRepository for SqliteMovieRepository { Ok(ReviewHistory::new(movie, viewings)) } - async fn query_activity_feed( - &self, - page: &PageParams, - ) -> Result, DomainError> { - let limit = page.limit as i64; - let offset = page.offset as i64; - - let (total, rows) = tokio::try_join!( - self.count_feed_entries(), - self.fetch_feed_rows(limit, offset) - )?; - - let items = rows - .into_iter() - .map(FeedRow::to_domain) - .collect::, _>>()?; - - Ok(Paginated { - items, - total_count: total as u64, - limit: page.limit, - offset: page.offset, - }) - } - - async fn get_user_stats(&self, user_id: &UserId) -> Result { - let uid = user_id.value().to_string(); - - let (totals, fav_director, most_active) = tokio::try_join!( - self.fetch_user_totals(&uid), - self.fetch_user_favorite_director(&uid), - self.fetch_user_most_active_month(&uid) - )?; - - let most_active_month = most_active.map(|ym| format_year_month(&ym)); - - Ok(UserStats { - total_movies: totals.total, - avg_rating: totals.avg_rating, - favorite_director: fav_director, - most_active_month, - }) - } - async fn get_user_history(&self, user_id: &UserId) -> Result, DomainError> { let uid = user_id.value().to_string(); let rows = sqlx::query_as!( @@ -591,6 +578,28 @@ impl MovieRepository for SqliteMovieRepository { rows.into_iter().map(DiaryRow::to_domain).collect() } +} + +#[async_trait] +impl StatsRepository for SqliteMovieRepository { + async fn get_user_stats(&self, user_id: &UserId) -> Result { + let uid = user_id.value().to_string(); + + let (totals, fav_director, most_active) = tokio::try_join!( + self.fetch_user_totals(&uid), + self.fetch_user_favorite_director(&uid), + self.fetch_user_most_active_month(&uid) + )?; + + let most_active_month = most_active.map(|ym| format_year_month(&ym)); + + Ok(UserStats { + total_movies: totals.total, + avg_rating: totals.avg_rating, + favorite_director: fav_director, + most_active_month, + }) + } async fn get_user_trends(&self, user_id: &UserId) -> Result { let uid = user_id.value().to_string(); diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index 823d25b..1093217 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -1,15 +1,19 @@ use std::sync::Arc; use domain::ports::{ - AuthService, EventPublisher, MetadataClient, MovieRepository, PasswordHasher, - PosterFetcherClient, PosterStorage, UserRepository, + AuthService, DiaryRepository, EventPublisher, MetadataClient, MovieRepository, + PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository, + UserRepository, }; use crate::config::AppConfig; #[derive(Clone)] pub struct AppContext { - pub repository: Arc, + pub movie_repository: Arc, + pub review_repository: Arc, + pub diary_repository: Arc, + pub stats_repository: Arc, pub metadata_client: Arc, pub poster_fetcher: Arc, pub poster_storage: Arc, diff --git a/crates/application/src/movie_resolver.rs b/crates/application/src/movie_resolver.rs index 36c08e6..2a78ab4 100644 --- a/crates/application/src/movie_resolver.rs +++ b/crates/application/src/movie_resolver.rs @@ -170,12 +170,9 @@ mod tests { use chrono::NaiveDate; use domain::{ errors::DomainError, - events::DomainEvent, - models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated}, + models::Movie, ports::{MetadataSearchCriteria, MovieRepository}, - value_objects::{ - ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear, ReviewId, - }, + value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear}, }; fn make_cmd(ext_id: Option<&str>, title: Option<&str>, year: Option) -> LogReviewCommand { @@ -210,155 +207,29 @@ mod tests { #[async_trait] impl MovieRepository for RepoWithExternalMovie { - async fn get_movie_by_external_id( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - Ok(Some(self.0.clone())) - } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { - panic!("unexpected") - } - async fn get_movies_by_title_and_year( - &self, - _: &MovieTitle, - _: &ReleaseYear, - ) -> Result, DomainError> { - panic!("unexpected") - } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn save_review(&self, _: &Review) -> Result { - panic!("unexpected") - } - async fn query_diary( - &self, - _: &DiaryFilter, - ) -> Result, DomainError> { - panic!("unexpected") - } - async fn get_review_history(&self, _: &MovieId) -> Result { - panic!("unexpected") - } - async fn get_review_by_id( - &self, - _: &ReviewId, - ) -> Result, DomainError> { - panic!("unexpected") - } - async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result, DomainError> { panic!("unexpected") } - async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result { panic!("unexpected") } - async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result, DomainError> { panic!("unexpected") } - async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result { panic!("unexpected") } + async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result, DomainError> { Ok(Some(self.0.clone())) } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { panic!("unexpected") } + async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result, DomainError> { panic!("unexpected") } + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } } #[async_trait] impl MovieRepository for RepoEmpty { - async fn get_movie_by_external_id( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - Ok(None) - } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { - panic!("unexpected") - } - async fn get_movies_by_title_and_year( - &self, - _: &MovieTitle, - _: &ReleaseYear, - ) -> Result, DomainError> { - Ok(vec![]) - } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn save_review(&self, _: &Review) -> Result { - panic!("unexpected") - } - async fn query_diary( - &self, - _: &DiaryFilter, - ) -> Result, DomainError> { - panic!("unexpected") - } - async fn get_review_history(&self, _: &MovieId) -> Result { - panic!("unexpected") - } - async fn get_review_by_id( - &self, - _: &ReviewId, - ) -> Result, DomainError> { - panic!("unexpected") - } - async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result, DomainError> { panic!("unexpected") } - async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result { panic!("unexpected") } - async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result, DomainError> { panic!("unexpected") } - async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result { panic!("unexpected") } + async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result, DomainError> { Ok(None) } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { panic!("unexpected") } + async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result, DomainError> { Ok(vec![]) } + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } } #[async_trait] impl MovieRepository for RepoWithTitleMatch { - async fn get_movie_by_external_id( - &self, - _: &ExternalMetadataId, - ) -> Result, DomainError> { - panic!("unexpected") - } - async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { - panic!("unexpected") - } - async fn get_movies_by_title_and_year( - &self, - _: &MovieTitle, - _: &ReleaseYear, - ) -> Result, DomainError> { - Ok(vec![self.0.clone()]) - } - async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn save_review(&self, _: &Review) -> Result { - panic!("unexpected") - } - async fn query_diary( - &self, - _: &DiaryFilter, - ) -> Result, DomainError> { - panic!("unexpected") - } - async fn get_review_history(&self, _: &MovieId) -> Result { - panic!("unexpected") - } - async fn get_review_by_id( - &self, - _: &ReviewId, - ) -> Result, DomainError> { - panic!("unexpected") - } - async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { - panic!("unexpected") - } - async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result, DomainError> { panic!("unexpected") } - async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result { panic!("unexpected") } - async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result, DomainError> { panic!("unexpected") } - async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result { panic!("unexpected") } + async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result, DomainError> { panic!("unexpected") } + async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { panic!("unexpected") } + async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result, DomainError> { Ok(vec![self.0.clone()]) } + async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } } struct MetaReturnsMovie(Movie); diff --git a/crates/application/src/use_cases/delete_review.rs b/crates/application/src/use_cases/delete_review.rs index 9d19dfc..3cc887a 100644 --- a/crates/application/src/use_cases/delete_review.rs +++ b/crates/application/src/use_cases/delete_review.rs @@ -6,7 +6,7 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), D let requesting_user_id = UserId::from_uuid(cmd.requesting_user_id); let review = ctx - .repository + .review_repository .get_review_by_id(&review_id) .await? .ok_or_else(|| DomainError::NotFound(format!("review {}", cmd.review_id)))?; @@ -16,11 +16,11 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), D } let movie_id = review.movie_id().clone(); - ctx.repository.delete_review(&review_id).await?; + ctx.review_repository.delete_review(&review_id).await?; - let history = ctx.repository.get_review_history(&movie_id).await?; + let history = ctx.diary_repository.get_review_history(&movie_id).await?; if history.viewings().is_empty() { - ctx.repository.delete_movie(&movie_id).await?; + ctx.movie_repository.delete_movie(&movie_id).await?; } Ok(()) diff --git a/crates/application/src/use_cases/export_diary.rs b/crates/application/src/use_cases/export_diary.rs index 3e17e25..58a40f7 100644 --- a/crates/application/src/use_cases/export_diary.rs +++ b/crates/application/src/use_cases/export_diary.rs @@ -2,13 +2,13 @@ use std::sync::Arc; use domain::{ errors::DomainError, - ports::{DiaryExporter, MovieRepository}, + ports::{DiaryExporter, DiaryRepository}, }; use crate::commands::ExportCommand; pub struct ExportDiary { - repository: Arc, + repository: Arc, exporter: Arc, } diff --git a/crates/application/src/use_cases/get_activity_feed.rs b/crates/application/src/use_cases/get_activity_feed.rs index afee970..bf7cb1e 100644 --- a/crates/application/src/use_cases/get_activity_feed.rs +++ b/crates/application/src/use_cases/get_activity_feed.rs @@ -9,5 +9,5 @@ pub async fn execute( query: GetActivityFeedQuery, ) -> Result, DomainError> { let page = PageParams::new(query.limit, query.offset)?; - ctx.repository.query_activity_feed(&page).await + ctx.diary_repository.query_activity_feed(&page).await } diff --git a/crates/application/src/use_cases/get_diary.rs b/crates/application/src/use_cases/get_diary.rs index eaa432c..4859588 100644 --- a/crates/application/src/use_cases/get_diary.rs +++ b/crates/application/src/use_cases/get_diary.rs @@ -24,5 +24,5 @@ pub async fn execute( user_id, }; - ctx.repository.query_diary(&filter).await + ctx.diary_repository.query_diary(&filter).await } diff --git a/crates/application/src/use_cases/get_review_history.rs b/crates/application/src/use_cases/get_review_history.rs index 216d478..617e16d 100644 --- a/crates/application/src/use_cases/get_review_history.rs +++ b/crates/application/src/use_cases/get_review_history.rs @@ -13,7 +13,7 @@ pub async fn execute( ) -> Result<(ReviewHistory, Trend), DomainError> { let movie_id = MovieId::from_uuid(query.movie_id); - let mut history = ctx.repository.get_review_history(&movie_id).await?; + let mut history = ctx.diary_repository.get_review_history(&movie_id).await?; let trend = ReviewHistoryAnalyzer::rating_trend(&history)?; diff --git a/crates/application/src/use_cases/get_user_profile.rs b/crates/application/src/use_cases/get_user_profile.rs index a2c4a65..d66540b 100644 --- a/crates/application/src/use_cases/get_user_profile.rs +++ b/crates/application/src/use_cases/get_user_profile.rs @@ -21,28 +21,26 @@ pub async fn execute( query: GetUserProfileQuery, ) -> Result { let user_id = UserId::from_uuid(query.user_id); - let stats = ctx.repository.get_user_stats(&user_id).await?; + let stats = ctx.stats_repository.get_user_stats(&user_id).await?; match query.view { ProfileView::History => { - // V1: loads all entries into memory. Personal diaries are bounded in size; - // spec calls for showing every movie grouped by month, so full load is intentional. - let all_entries = ctx.repository.get_user_history(&user_id).await?; + let all_entries = ctx.diary_repository.get_user_history(&user_id).await?; let history = group_by_month(all_entries); Ok(UserProfileData { stats, entries: None, history: Some(history), trends: None }) } ProfileView::Trends => { - let trends = ctx.repository.get_user_trends(&user_id).await?; + let trends = ctx.stats_repository.get_user_trends(&user_id).await?; Ok(UserProfileData { stats, entries: None, history: None, trends: Some(trends) }) } ProfileView::Ratings => { let filter = paged_user_filter(user_id, SortDirection::ByRatingDesc, query.limit, query.offset)?; - let entries = ctx.repository.query_diary(&filter).await?; + let entries = ctx.diary_repository.query_diary(&filter).await?; Ok(UserProfileData { stats, entries: Some(entries), history: None, trends: None }) } ProfileView::Recent => { let filter = paged_user_filter(user_id, SortDirection::Descending, query.limit, query.offset)?; - let entries = ctx.repository.query_diary(&filter).await?; + let entries = ctx.diary_repository.query_diary(&filter).await?; Ok(UserProfileData { stats, entries: Some(entries), history: None, trends: None }) } } diff --git a/crates/application/src/use_cases/log_review.rs b/crates/application/src/use_cases/log_review.rs index 054d9ea..496e46c 100644 --- a/crates/application/src/use_cases/log_review.rs +++ b/crates/application/src/use_cases/log_review.rs @@ -17,15 +17,15 @@ pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), Doma let comment = cmd.comment.clone().map(Comment::new).transpose()?; let deps = MovieResolverDeps { - repository: ctx.repository.as_ref(), + repository: ctx.movie_repository.as_ref(), metadata_client: ctx.metadata_client.as_ref(), }; let (movie, is_new_movie) = MovieResolver::default_pipeline().resolve(&cmd, &deps).await?; - ctx.repository.upsert_movie(&movie).await?; + ctx.movie_repository.upsert_movie(&movie).await?; let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?; - let review_event = ctx.repository.save_review(&review).await?; + let review_event = ctx.review_repository.save_review(&review).await?; publish_events(ctx, &movie, is_new_movie, review_event).await?; diff --git a/crates/application/src/use_cases/sync_poster.rs b/crates/application/src/use_cases/sync_poster.rs index 4e80bb4..c1adfa2 100644 --- a/crates/application/src/use_cases/sync_poster.rs +++ b/crates/application/src/use_cases/sync_poster.rs @@ -9,7 +9,7 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom let movie_id = MovieId::from_uuid(cmd.movie_id); let external_metadata_id = ExternalMetadataId::new(cmd.external_metadata_id)?; - let mut movie = match ctx.repository.get_movie_by_id(&movie_id).await? { + let mut movie = match ctx.movie_repository.get_movie_by_id(&movie_id).await? { Some(m) => m, None => { tracing::warn!( @@ -41,7 +41,7 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom .await?; movie.update_poster(stored_path); - ctx.repository.upsert_movie(&movie).await?; + ctx.movie_repository.upsert_movie(&movie).await?; Ok(()) } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 566f4e2..88d020d 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -27,31 +27,28 @@ pub trait MovieRepository: Send + Sync { title: &MovieTitle, year: &ReleaseYear, ) -> Result, DomainError>; - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>; - - async fn save_review(&self, review: &Review) -> Result; - - async fn query_diary(&self, filter: &DiaryFilter) - -> Result, DomainError>; - - async fn get_review_history(&self, movie_id: &MovieId) -> Result; - - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; - - async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>; - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>; +} - async fn query_activity_feed( - &self, - page: &PageParams, - ) -> Result, DomainError>; - - async fn get_user_stats(&self, user_id: &UserId) -> Result; +#[async_trait] +pub trait ReviewRepository: Send + Sync { + async fn save_review(&self, review: &Review) -> Result; + async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError>; + async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>; +} +#[async_trait] +pub trait DiaryRepository: Send + Sync { + async fn query_diary(&self, filter: &DiaryFilter) -> Result, DomainError>; + async fn query_activity_feed(&self, page: &PageParams) -> Result, DomainError>; + async fn get_review_history(&self, movie_id: &MovieId) -> Result; async fn get_user_history(&self, user_id: &UserId) -> Result, DomainError>; +} +#[async_trait] +pub trait StatsRepository: Send + Sync { + async fn get_user_stats(&self, user_id: &UserId) -> Result; async fn get_user_trends(&self, user_id: &UserId) -> Result; } diff --git a/crates/presentation/src/event_handlers.rs b/crates/presentation/src/event_handlers.rs index a5d8249..c760f36 100644 --- a/crates/presentation/src/event_handlers.rs +++ b/crates/presentation/src/event_handlers.rs @@ -68,10 +68,11 @@ mod tests { use domain::{ errors::DomainError, events::DomainEvent, - models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, User, collections::Paginated}, + models::{DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, User, UserStats, UserTrends, collections::{PageParams, Paginated}}, ports::{ - AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria, - MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, UserRepository, + AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient, + MetadataSearchCriteria, MovieRepository, PasswordHasher, PosterFetcherClient, + PosterStorage, ReviewRepository, StatsRepository, UserRepository, }, value_objects::{ Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl, @@ -79,8 +80,6 @@ mod tests { }, }; - // Panic-stub ports: each method panics so any accidental dispatch into a service - // fails the test loudly rather than silently succeeding. struct PanicRepo; struct PanicMetadata; struct PanicFetcher; @@ -96,16 +95,28 @@ mod tests { async fn get_movie_by_id(&self, _: &MovieId) -> Result, DomainError> { panic!("unexpected") } async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result, DomainError> { panic!("unexpected") } async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") } + async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } + } + + #[async_trait] + impl ReviewRepository for PanicRepo { async fn save_review(&self, _: &Review) -> Result { panic!("unexpected") } - async fn query_diary(&self, _: &DiaryFilter) -> Result, DomainError> { panic!("unexpected") } - async fn get_review_history(&self, _: &MovieId) -> Result { panic!("unexpected") } async fn get_review_by_id(&self, _: &ReviewId) -> Result, DomainError> { panic!("unexpected") } async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!("unexpected") } - async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") } - async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result, DomainError> { panic!("unexpected") } - async fn get_user_stats(&self, _: &UserId) -> Result { panic!("unexpected") } + } + + #[async_trait] + impl DiaryRepository for PanicRepo { + async fn query_diary(&self, _: &DiaryFilter) -> Result, DomainError> { panic!("unexpected") } + async fn query_activity_feed(&self, _: &PageParams) -> Result, DomainError> { panic!("unexpected") } + async fn get_review_history(&self, _: &MovieId) -> Result { panic!("unexpected") } async fn get_user_history(&self, _: &UserId) -> Result, DomainError> { panic!("unexpected") } - async fn get_user_trends(&self, _: &UserId) -> Result { panic!("unexpected") } + } + + #[async_trait] + impl StatsRepository for PanicRepo { + async fn get_user_stats(&self, _: &UserId) -> Result { panic!("unexpected") } + async fn get_user_trends(&self, _: &UserId) -> Result { panic!("unexpected") } } #[async_trait] @@ -152,8 +163,12 @@ mod tests { } fn panic_ctx() -> AppContext { + let repo = Arc::new(PanicRepo); AppContext { - repository: Arc::new(PanicRepo), + movie_repository: Arc::clone(&repo) as _, + review_repository: Arc::clone(&repo) as _, + diary_repository: Arc::clone(&repo) as _, + stats_repository: repo as _, metadata_client: Arc::new(PanicMetadata), poster_fetcher: Arc::new(PanicFetcher), poster_storage: Arc::new(PanicStorage), diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index ecd0313..866f67c 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -128,15 +128,24 @@ mod tests { async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result, domain::errors::DomainError> { panic!() } async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result, domain::errors::DomainError> { panic!() } async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() } + async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() } + } + #[async_trait::async_trait] + impl domain::ports::ReviewRepository for PanicRepo { async fn save_review(&self, _: &domain::models::Review) -> Result { panic!() } - async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result, domain::errors::DomainError> { panic!() } - async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result { panic!() } async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result, domain::errors::DomainError> { panic!() } async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() } - async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() } + } + #[async_trait::async_trait] + impl domain::ports::DiaryRepository for PanicRepo { + async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result, domain::errors::DomainError> { panic!() } async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result, domain::errors::DomainError> { panic!() } - async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result { panic!() } + async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result { panic!() } async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result, domain::errors::DomainError> { panic!() } + } + #[async_trait::async_trait] + impl domain::ports::StatsRepository for PanicRepo { + async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result { panic!() } async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result { panic!() } } @@ -169,7 +178,10 @@ mod tests { let state = crate::state::AppState { app_ctx: AppContext { - repository: Arc::new(PanicRepo), + movie_repository: Arc::new(PanicRepo) as _, + review_repository: Arc::new(PanicRepo) as _, + diary_repository: Arc::new(PanicRepo) as _, + stats_repository: Arc::new(PanicRepo) as _, metadata_client: Arc::new(PanicMeta), poster_fetcher: Arc::new(PanicFetcher), poster_storage: Arc::new(PanicStorage), @@ -241,15 +253,24 @@ mod tests { async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result, domain::errors::DomainError> { panic!() } async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result, domain::errors::DomainError> { panic!() } async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() } + async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() } + } + #[async_trait::async_trait] + impl domain::ports::ReviewRepository for PanicRepo2 { async fn save_review(&self, _: &domain::models::Review) -> Result { panic!() } - async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result, domain::errors::DomainError> { panic!() } - async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result { panic!() } async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result, domain::errors::DomainError> { panic!() } async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() } - async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() } + } + #[async_trait::async_trait] + impl domain::ports::DiaryRepository for PanicRepo2 { + async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result, domain::errors::DomainError> { panic!() } async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result, domain::errors::DomainError> { panic!() } - async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result { panic!() } + async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result { panic!() } async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result, domain::errors::DomainError> { panic!() } + } + #[async_trait::async_trait] + impl domain::ports::StatsRepository for PanicRepo2 { + async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result { panic!() } async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result { panic!() } } struct PanicMeta2; struct PanicFetcher2; struct PanicStorage2; struct PanicEvent2; struct PanicHasher2; struct PanicUserRepo2; @@ -279,7 +300,10 @@ mod tests { struct PanicAuth2; crate::state::AppState { app_ctx: AppContext { - repository: Arc::new(PanicRepo2), + movie_repository: Arc::new(PanicRepo2) as _, + review_repository: Arc::new(PanicRepo2) as _, + diary_repository: Arc::new(PanicRepo2) as _, + stats_repository: Arc::new(PanicRepo2) as _, metadata_client: Arc::new(PanicMeta2), poster_fetcher: Arc::new(PanicFetcher2), poster_storage: Arc::new(PanicStorage2), @@ -305,15 +329,24 @@ mod tests { async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result, domain::errors::DomainError> { panic!() } async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result, domain::errors::DomainError> { panic!() } async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() } + async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() } + } + #[async_trait::async_trait] + impl domain::ports::ReviewRepository for PanicRepo3 { async fn save_review(&self, _: &domain::models::Review) -> Result { panic!() } - async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result, domain::errors::DomainError> { panic!() } - async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result { panic!() } async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result, domain::errors::DomainError> { panic!() } async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() } - async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() } + } + #[async_trait::async_trait] + impl domain::ports::DiaryRepository for PanicRepo3 { + async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result, domain::errors::DomainError> { panic!() } async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result, domain::errors::DomainError> { panic!() } - async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result { panic!() } + async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result { panic!() } async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result, domain::errors::DomainError> { panic!() } + } + #[async_trait::async_trait] + impl domain::ports::StatsRepository for PanicRepo3 { + async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result { panic!() } async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result { panic!() } } struct PanicMeta3; struct PanicFetcher3; struct PanicStorage3; struct PanicEvent3; struct PanicHasher3; struct PanicUserRepo3; @@ -341,7 +374,10 @@ mod tests { } crate::state::AppState { app_ctx: AppContext { - repository: Arc::new(PanicRepo3), + movie_repository: Arc::new(PanicRepo3) as _, + review_repository: Arc::new(PanicRepo3) as _, + diary_repository: Arc::new(PanicRepo3) as _, + stats_repository: Arc::new(PanicRepo3) as _, metadata_client: Arc::new(PanicMeta3), poster_fetcher: Arc::new(PanicFetcher3), poster_storage: Arc::new(PanicStorage3), diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index e64b2eb..b97f5b2 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -878,7 +878,7 @@ pub mod api { ) -> Result { let movie = state .app_ctx - .repository + .movie_repository .get_movie_by_id(&MovieId::from_uuid(movie_id)) .await? .ok_or_else(|| ApiError(DomainError::NotFound(format!("Movie {movie_id}"))))?; diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 94ef15c..cbfa701 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -60,18 +60,22 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { .await .context("Failed to connect to SQLite database")?; - let movie_repo = SqliteMovieRepository::new(pool.clone()); - movie_repo + let sqlite_repo = Arc::new(SqliteMovieRepository::new(pool.clone())); + sqlite_repo .migrate() .await .map_err(|e| anyhow::anyhow!("{}", e)) .context("Database migration failed")?; use domain::ports::{ - AuthService, MetadataClient, MovieRepository, PasswordHasher, - PosterFetcherClient, PosterStorage, UserRepository, + AuthService, DiaryRepository, MetadataClient, MovieRepository, PasswordHasher, + PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository, UserRepository, }; - let repository: Arc = Arc::new(movie_repo); + let movie_repository: Arc = Arc::clone(&sqlite_repo) as _; + let review_repository: Arc = Arc::clone(&sqlite_repo) as _; + let diary_repository: Arc = Arc::clone(&sqlite_repo) as _; + let stats_repository: Arc = Arc::clone(&sqlite_repo) as _; + let user_repository: Arc = Arc::new(SqliteUserRepository::new(pool.clone())); let metadata_client: Arc = Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)); let poster_fetcher: Arc = Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?); @@ -82,7 +86,10 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { // Build a context for the poster handler. sync_poster doesn't publish events, // so a noop publisher here is safe and avoids a circular dependency. let handler_ctx = AppContext { - repository: Arc::clone(&repository), + movie_repository: Arc::clone(&movie_repository), + review_repository: Arc::clone(&review_repository), + diary_repository: Arc::clone(&diary_repository), + stats_repository: Arc::clone(&stats_repository), metadata_client: Arc::clone(&metadata_client), poster_fetcher: Arc::clone(&poster_fetcher), poster_storage: Arc::clone(&poster_storage), @@ -97,7 +104,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let federation_repo = Arc::new(SqliteFederationRepository::new(pool)); let user_repo_adapter = Arc::new(DomainUserRepoAdapter(Arc::clone(&user_repository))); let review_handler = Arc::new(ReviewObjectHandler { - movie_repo: Arc::clone(&repository), + movie_repository: Arc::clone(&movie_repository), + diary_repository: Arc::clone(&diary_repository), review_store: Arc::clone(&federation_repo) as Arc, base_url: app_config.base_url.clone(), }); @@ -114,7 +122,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let ap_router = concrete_ap_service.router(); let ap_event_handler = ActivityPubEventHandler::new( Arc::clone(&concrete_ap_service), - Arc::clone(&repository), + Arc::clone(&movie_repository), + Arc::clone(&review_repository), app_config.base_url.clone(), ); let ap_service: Arc = concrete_ap_service; @@ -127,7 +136,10 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { tokio::spawn(event_worker.run()); let app_ctx = AppContext { - repository, + movie_repository, + review_repository, + diary_repository, + stats_repository, metadata_client, poster_fetcher, poster_storage, diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index b95b75a..ec66cfb 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -96,9 +96,13 @@ async fn test_app() -> Router { let repo = SqliteMovieRepository::new(pool); repo.migrate().await.expect("migration failed"); + let repo = Arc::new(repo); let state = AppState { app_ctx: AppContext { - repository: Arc::new(repo), + movie_repository: Arc::clone(&repo) as _, + review_repository: Arc::clone(&repo) as _, + diary_repository: Arc::clone(&repo) as _, + stats_repository: Arc::clone(&repo) as _, metadata_client: Arc::new(PanicMeta), poster_fetcher: Arc::new(PanicFetcher), poster_storage: Arc::new(PanicStorage),