From edc1f6c850ddc73488cd705f0f3c370108aabf1f Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 00:41:25 +0200 Subject: [PATCH] feat: domain mocks, TestContextBuilder, use case tests, factory pattern - Add test-helpers feature to domain crate with in-memory mocks and panic stubs for all ports - Add TestContextBuilder to application crate for zero-database test setup - Add unit tests for log_review, register, login, add_to_watchlist, delete_review use cases - Extract DatabaseAdapters factory and build_* helpers into presentation/src/factory.rs - Refactor wire_dependencies() in main.rs to use factory module --- crates/application/Cargo.toml | 1 + crates/application/src/lib.rs | 3 + crates/application/src/test_helpers.rs | 148 ++++ .../src/use_cases/add_to_watchlist.rs | 92 +++ .../src/use_cases/delete_review.rs | 105 +++ .../application/src/use_cases/log_review.rs | 115 +++ crates/application/src/use_cases/login.rs | 89 +++ crates/application/src/use_cases/register.rs | 52 ++ crates/domain/Cargo.toml | 3 + crates/domain/src/lib.rs | 3 + crates/domain/src/testing.rs | 689 ++++++++++++++++++ crates/presentation/src/factory.rs | 127 ++++ crates/presentation/src/lib.rs | 1 + crates/presentation/src/main.rs | 126 +--- 14 files changed, 1458 insertions(+), 96 deletions(-) create mode 100644 crates/application/src/test_helpers.rs create mode 100644 crates/domain/src/testing.rs create mode 100644 crates/presentation/src/factory.rs diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 8c8b792..a4b8334 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -18,3 +18,4 @@ federation = [] [dev-dependencies] tokio = { workspace = true } +domain = { workspace = true, features = ["test-helpers"] } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 653105a..190b061 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -10,5 +10,8 @@ pub mod search_cleanup; pub mod use_cases; pub mod worker; +#[cfg(test)] +pub mod test_helpers; + pub use movie_discovery_indexer::MovieDiscoveryIndexer; pub use search_cleanup::SearchCleanupHandler; diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs new file mode 100644 index 0000000..dfee9a6 --- /dev/null +++ b/crates/application/src/test_helpers.rs @@ -0,0 +1,148 @@ +use std::sync::Arc; + +use domain::{ + ports::{ + AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage, + ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository, + MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, + ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, + UserRepository, WatchlistRepository, + }, + testing::{ + FakeAuthService, FakeDiaryRepository, FakeMetadataClient, FakePasswordHasher, + InMemoryMovieRepository, InMemoryReviewRepository, InMemoryUserRepository, + InMemoryWatchlistRepository, NoopEventPublisher, NoopImageStorage, PanicDiaryExporter, + PanicDiaryRepository, PanicDocumentParser, PanicImportProfileRepository, + PanicImportSessionRepository, PanicMovieProfileRepository, PanicPersonCommand, + PanicPersonQuery, PanicPosterFetcher, PanicProfileFieldsRepo, PanicSearchCommand, + PanicSearchPort, PanicStatsRepository, + }, +}; + +use crate::{ + config::AppConfig, + context::AppContext, +}; + +pub struct TestContextBuilder { + pub movie_repo: Arc, + pub review_repo: Arc, + pub diary_repo: Arc, + pub diary_exporter: Arc, + pub document_parser: Arc, + pub stats_repo: Arc, + pub metadata_client: Arc, + pub poster_fetcher: Arc, + pub image_storage: Arc, + pub event_publisher: Arc, + pub auth_service: Arc, + pub password_hasher: Arc, + pub user_repo: Arc, + pub import_session_repo: Arc, + pub import_profile_repo: Arc, + pub movie_profile_repo: Arc, + pub watchlist_repo: Arc, + pub profile_fields_repo: Arc, + pub person_command: Arc, + pub person_query: Arc, + pub search_port: Arc, + pub search_command: Arc, + pub config: AppConfig, +} + +impl TestContextBuilder { + pub fn new() -> Self { + Self { + movie_repo: InMemoryMovieRepository::new(), + review_repo: InMemoryReviewRepository::new(), + diary_repo: Arc::new(PanicDiaryRepository), + diary_exporter: Arc::new(PanicDiaryExporter), + document_parser: Arc::new(PanicDocumentParser), + stats_repo: Arc::new(PanicStatsRepository), + metadata_client: Arc::new(FakeMetadataClient), + poster_fetcher: Arc::new(PanicPosterFetcher), + image_storage: Arc::new(NoopImageStorage), + 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), + watchlist_repo: InMemoryWatchlistRepository::new(), + profile_fields_repo: Arc::new(PanicProfileFieldsRepo), + person_command: Arc::new(PanicPersonCommand), + person_query: Arc::new(PanicPersonQuery), + search_port: Arc::new(PanicSearchPort), + search_command: Arc::new(PanicSearchCommand), + config: AppConfig { + allow_registration: true, + base_url: "http://localhost:3000".into(), + rate_limit: 20, + }, + } + } + + pub fn with_movies(mut self, r: Arc) -> Self { + self.movie_repo = r; + self + } + + pub fn with_reviews(mut self, r: Arc) -> Self { + self.review_repo = r; + self + } + + pub fn with_users(mut self, r: Arc) -> Self { + self.user_repo = r; + self + } + + pub fn with_watchlist(mut self, r: Arc) -> Self { + self.watchlist_repo = r; + self + } + + pub fn with_diary(mut self, r: Arc) -> Self { + self.diary_repo = r; + self + } + + pub fn with_event_publisher(mut self, p: Arc) -> Self { + self.event_publisher = p; + self + } + + pub fn with_config(mut self, config: AppConfig) -> Self { + self.config = config; + self + } + + pub fn build(self) -> AppContext { + AppContext { + movie_repository: self.movie_repo, + review_repository: self.review_repo, + diary_repository: self.diary_repo, + diary_exporter: self.diary_exporter, + document_parser: self.document_parser, + stats_repository: self.stats_repo, + metadata_client: self.metadata_client, + poster_fetcher: self.poster_fetcher, + image_storage: self.image_storage, + event_publisher: self.event_publisher, + auth_service: self.auth_service, + password_hasher: self.password_hasher, + user_repository: self.user_repo, + import_session_repository: self.import_session_repo, + import_profile_repository: self.import_profile_repo, + movie_profile_repository: self.movie_profile_repo, + watchlist_repository: self.watchlist_repo, + profile_fields_repository: self.profile_fields_repo, + person_command: self.person_command, + person_query: self.person_query, + search_port: self.search_port, + search_command: self.search_command, + config: self.config, + } + } +} diff --git a/crates/application/src/use_cases/add_to_watchlist.rs b/crates/application/src/use_cases/add_to_watchlist.rs index 7b28905..1b12360 100644 --- a/crates/application/src/use_cases/add_to_watchlist.rs +++ b/crates/application/src/use_cases/add_to_watchlist.rs @@ -60,3 +60,95 @@ pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(), Ok(()) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use domain::{ + models::Movie, + ports::MovieRepository, + value_objects::{MovieTitle, ReleaseYear}, + testing::{InMemoryMovieRepository, InMemoryWatchlistRepository}, + }; + + use crate::{ + commands::{AddToWatchlistCommand, MovieInput}, + test_helpers::TestContextBuilder, + use_cases::add_to_watchlist, + }; + + #[tokio::test] + async fn test_add_to_watchlist_resolves_and_saves() { + let movies = InMemoryMovieRepository::new(); + let watchlist = InMemoryWatchlistRepository::new(); + + let movie = Movie::new( + None, + MovieTitle::new("The Thing".into()).unwrap(), + ReleaseYear::new(1982).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 _) + .with_watchlist(Arc::clone(&watchlist) as _) + .build(); + + let cmd = AddToWatchlistCommand { + user_id: uuid::Uuid::new_v4(), + input: MovieInput { + movie_id: Some(movie_uuid), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + }, + }; + + add_to_watchlist::execute(&ctx, cmd).await.unwrap(); + + assert_eq!(watchlist.count(), 1); + } + + #[tokio::test] + async fn test_add_to_watchlist_already_present_is_idempotent() { + let movies = InMemoryMovieRepository::new(); + let watchlist = InMemoryWatchlistRepository::new(); + + let movie = Movie::new( + None, + MovieTitle::new("RoboCop".into()).unwrap(), + ReleaseYear::new(1987).unwrap(), + None, + None, + ); + let movie_uuid = movie.id().value(); + 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 make_cmd = || AddToWatchlistCommand { + user_id, + input: MovieInput { + movie_id: Some(movie_uuid), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + }, + }; + + add_to_watchlist::execute(&ctx, make_cmd()).await.unwrap(); + add_to_watchlist::execute(&ctx, make_cmd()).await.unwrap(); + + assert_eq!(watchlist.count(), 1, "idempotent add should not duplicate"); + } +} diff --git a/crates/application/src/use_cases/delete_review.rs b/crates/application/src/use_cases/delete_review.rs index 0916800..afffd69 100644 --- a/crates/application/src/use_cases/delete_review.rs +++ b/crates/application/src/use_cases/delete_review.rs @@ -52,3 +52,108 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), D Ok(()) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use chrono::Utc; + + use domain::{ + models::{Movie, Review}, + ports::{MovieRepository, ReviewRepository}, + value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId}, + testing::{ + FakeDiaryRepository, InMemoryMovieRepository, InMemoryReviewRepository, + NoopEventPublisher, + }, + }; + + use crate::{ + commands::DeleteReviewCommand, + test_helpers::TestContextBuilder, + use_cases::delete_review, + }; + + fn make_movie() -> Movie { + Movie::new( + None, + MovieTitle::new("Terminator".into()).unwrap(), + ReleaseYear::new(1984).unwrap(), + None, + None, + ) + } + + fn make_review(movie_id: MovieId, user_id: UserId) -> Review { + Review::new(movie_id, user_id, Rating::new(4).unwrap(), None, Utc::now().naive_utc()) + .unwrap() + } + + #[tokio::test] + async fn test_delete_review_removes_it() { + let movies = InMemoryMovieRepository::new(); + let reviews = InMemoryReviewRepository::new(); + let diary = FakeDiaryRepository::new(); + let events = NoopEventPublisher::new(); + + let movie = make_movie(); + let user_id = UserId::from_uuid(uuid::Uuid::new_v4()); + let review = make_review(movie.id().clone(), user_id.clone()); + + movies.upsert_movie(&movie).await.unwrap(); + reviews.save_review(&review).await.unwrap(); + diary.seed_history(movie.clone(), vec![]); + + let ctx = TestContextBuilder::new() + .with_movies(Arc::clone(&movies) as _) + .with_reviews(Arc::clone(&reviews) as _) + .with_diary(Arc::clone(&diary) as _) + .with_event_publisher(Arc::clone(&events) as _) + .build(); + + delete_review::execute( + &ctx, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: user_id.value(), + }, + ) + .await + .unwrap(); + + assert_eq!(reviews.count(), 0, "review should be deleted"); + assert!( + movies.get_movie_by_id(movie.id()).await.unwrap().is_none(), + "movie should be deleted when no reviews remain" + ); + } + + #[tokio::test] + async fn test_delete_review_wrong_user_is_unauthorized() { + let reviews = InMemoryReviewRepository::new(); + + let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4()); + let owner_id = UserId::from_uuid(uuid::Uuid::new_v4()); + let other_id = uuid::Uuid::new_v4(); + let review = make_review(movie_id, owner_id); + + reviews.save_review(&review).await.unwrap(); + + let ctx = TestContextBuilder::new() + .with_reviews(Arc::clone(&reviews) as _) + .build(); + + let result = delete_review::execute( + &ctx, + DeleteReviewCommand { + review_id: review.id().value(), + requesting_user_id: other_id, + }, + ) + .await; + + assert!(result.is_err(), "wrong user should not be able to delete"); + assert_eq!(reviews.count(), 1, "review should still exist"); + } +} diff --git a/crates/application/src/use_cases/log_review.rs b/crates/application/src/use_cases/log_review.rs index 71a7b33..9a6ef84 100644 --- a/crates/application/src/use_cases/log_review.rs +++ b/crates/application/src/use_cases/log_review.rs @@ -58,6 +58,121 @@ pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), Doma Ok(()) } +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use chrono::Utc; + + use domain::{ + models::Movie, + value_objects::{MovieId, MovieTitle, ReleaseYear}, + }; + + use domain::ports::MovieRepository; + use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher}; + + use crate::{ + commands::{LogReviewCommand, MovieInput}, + test_helpers::TestContextBuilder, + use_cases::log_review, + }; + + fn movie_input_manual(title: &str, year: u16) -> MovieInput { + MovieInput { + movie_id: None, + external_metadata_id: None, + manual_title: Some(title.to_string()), + manual_release_year: Some(year), + manual_director: None, + } + } + + fn movie_input_by_id(id: uuid::Uuid) -> MovieInput { + MovieInput { + movie_id: Some(id), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + } + } + + #[tokio::test] + 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 user_id = uuid::Uuid::new_v4(); + let cmd = LogReviewCommand { + user_id, + input: movie_input_manual("Blade Runner", 1982), + rating: 4, + comment: None, + watched_at: Utc::now().naive_utc(), + }; + + log_review::execute(&ctx, cmd).await.unwrap(); + + assert_eq!(reviews.count(), 1, "review should be saved"); + assert!(!events.published().is_empty(), "events should be published"); + } + + #[tokio::test] + async fn test_log_review_reuses_existing_movie() { + let movies = InMemoryMovieRepository::new(); + let reviews = InMemoryReviewRepository::new(); + + let existing_movie = Movie::new( + None, + MovieTitle::new("Alien".into()).unwrap(), + ReleaseYear::new(1979).unwrap(), + None, + None, + ); + 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 cmd = LogReviewCommand { + user_id: uuid::Uuid::new_v4(), + input: movie_input_by_id(movie_uuid), + rating: 5, + comment: None, + watched_at: Utc::now().naive_utc(), + }; + + log_review::execute(&ctx, cmd).await.unwrap(); + + assert_eq!(movies.count(), 1, "no duplicate movie"); + assert_eq!(reviews.count(), 1); + } + + #[tokio::test] + async fn test_log_review_with_invalid_rating_fails() { + let ctx = TestContextBuilder::new().build(); + let cmd = LogReviewCommand { + user_id: uuid::Uuid::new_v4(), + input: movie_input_manual("Some Film", 2000), + rating: 6, + comment: None, + watched_at: Utc::now().naive_utc(), + }; + let result = log_review::execute(&ctx, cmd).await; + assert!(result.is_err(), "rating > 5 should fail"); + } +} + async fn publish_events( ctx: &AppContext, movie: &Movie, diff --git a/crates/application/src/use_cases/login.rs b/crates/application/src/use_cases/login.rs index 8198cee..2265bfc 100644 --- a/crates/application/src/use_cases/login.rs +++ b/crates/application/src/use_cases/login.rs @@ -37,3 +37,92 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result Result<(), Domai .save(&User::new(email, username, hash, cmd.role)) .await } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use domain::models::UserRole; + use domain::ports::UserRepository; + use domain::testing::InMemoryUserRepository; + use domain::value_objects::Email; + + use crate::{ + commands::RegisterCommand, + test_helpers::TestContextBuilder, + use_cases::register, + }; + + fn cmd(email: &str) -> RegisterCommand { + RegisterCommand { + email: email.to_string(), + username: "alice".to_string(), + password: "password123".to_string(), + role: UserRole::Standard, + } + } + + #[tokio::test] + async fn test_register_creates_user() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + register::execute(&ctx, cmd("alice@example.com")).await.unwrap(); + + let email = Email::new("alice@example.com".into()).unwrap(); + let user = users.find_by_email(&email).await.unwrap().unwrap(); + assert_eq!(user.email().value(), "alice@example.com"); + assert!(user.password_hash().value().starts_with("hashed:")); + } + + #[tokio::test] + async fn test_register_duplicate_email_fails() { + let users = InMemoryUserRepository::new(); + let ctx = TestContextBuilder::new() + .with_users(Arc::clone(&users) as _) + .build(); + + register::execute(&ctx, cmd("bob@example.com")).await.unwrap(); + let result = register::execute(&ctx, cmd("bob@example.com")).await; + assert!(result.is_err(), "duplicate email should fail"); + } +} diff --git a/crates/domain/Cargo.toml b/crates/domain/Cargo.toml index 2936c93..20db610 100644 --- a/crates/domain/Cargo.toml +++ b/crates/domain/Cargo.toml @@ -11,3 +11,6 @@ thiserror = { workspace = true } futures = { workspace = true } email_address = "0.2.9" + +[features] +test-helpers = [] diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 9a99ca5..42b7ebc 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -4,3 +4,6 @@ pub mod models; pub mod ports; pub mod services; pub mod value_objects; + +#[cfg(any(test, feature = "test-helpers"))] +pub mod testing; diff --git a/crates/domain/src/testing.rs b/crates/domain/src/testing.rs new file mode 100644 index 0000000..8343654 --- /dev/null +++ b/crates/domain/src/testing.rs @@ -0,0 +1,689 @@ +#![cfg(any(test, feature = "test-helpers"))] + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::Utc; +use uuid::Uuid; + +use crate::{ + errors::DomainError, + events::DomainEvent, + models::{ + DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping, FileFormat, ImportError, + ImportProfile, ImportSession, IndexableDocument, Movie, MovieFilter, MovieProfile, + MovieStats, MovieSummary, ParsedFile, Person, PersonCredits, PersonId, ExternalPersonId, + Review, ReviewHistory, SearchQuery, SearchResults, User, UserStats, UserSummary, + UserTrends, WatchlistEntry, WatchlistWithMovie, AnnotatedRow, EntityType, + collections::{PageParams, Paginated}, + }, + ports::{ + AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, + FeedSortBy, FollowingFilter, GeneratedToken, ImageStorage, ImportProfileRepository, + ImportSessionRepository, MetadataClient, MetadataSearchCriteria, MovieProfileRepository, + MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, + ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, + UserRepository, WatchlistRepository, + }, + value_objects::{ + Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, + PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username, + }, +}; + +// ── InMemoryMovieRepository ─────────────────────────────────────────────────── + +pub struct InMemoryMovieRepository { + pub store: Mutex>, +} + +impl InMemoryMovieRepository { + 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 MovieRepository for InMemoryMovieRepository { + async fn get_movie_by_external_id( + &self, + external_metadata_id: &ExternalMetadataId, + ) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store.values().find(|m| { + m.external_metadata_id() + .map(|e| e.value() == external_metadata_id.value()) + .unwrap_or(false) + }).cloned()) + } + + async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { + Ok(self.store.lock().unwrap().get(&movie_id.value()).cloned()) + } + + async fn get_movies_by_title_and_year( + &self, + title: &MovieTitle, + year: &ReleaseYear, + ) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store.values().filter(|m| m.title() == title && m.release_year() == year).cloned().collect()) + } + + async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { + self.store.lock().unwrap().insert(movie.id().value(), movie.clone()); + Ok(()) + } + + async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { + self.store.lock().unwrap().remove(&movie_id.value()); + Ok(()) + } + + async fn list_movies( + &self, + _page: &crate::models::collections::PageParams, + _filter: &MovieFilter, + ) -> Result, DomainError> { + Ok(Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 }) + } +} + +// ── InMemoryReviewRepository ────────────────────────────────────────────────── + +pub struct InMemoryReviewRepository { + store: Mutex>, +} + +impl InMemoryReviewRepository { + 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 ReviewRepository for InMemoryReviewRepository { + async fn save_review(&self, review: &Review) -> Result { + self.store.lock().unwrap().insert(review.id().value(), review.clone()); + Ok(DomainEvent::ReviewLogged { + review_id: review.id().clone(), + movie_id: review.movie_id().clone(), + user_id: review.user_id().clone(), + rating: review.rating().clone(), + watched_at: *review.watched_at(), + }) + } + + async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { + Ok(self.store.lock().unwrap().get(&review_id.value()).cloned()) + } + + async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { + self.store.lock().unwrap().remove(&review_id.value()); + Ok(()) + } + + async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store.values().filter(|r| r.user_id() == user_id).cloned().collect()) + } +} + +// ── InMemoryUserRepository ──────────────────────────────────────────────────── + +pub struct InMemoryUserRepository { + pub store: Mutex>, +} + +impl InMemoryUserRepository { + 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 UserRepository for InMemoryUserRepository { + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store.values().find(|u| u.email().value() == email.value()).cloned()) + } + + async fn find_by_username(&self, username: &Username) -> Result, DomainError> { + let store = self.store.lock().unwrap(); + Ok(store.values().find(|u| u.username().value() == username.value()).cloned()) + } + + async fn save(&self, user: &User) -> Result<(), DomainError> { + self.store.lock().unwrap().insert(user.id().value(), user.clone()); + Ok(()) + } + + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + Ok(self.store.lock().unwrap().get(&id.value()).cloned()) + } + + async fn list_with_stats(&self) -> Result, DomainError> { + Ok(vec![]) + } + + async fn update_profile( + &self, + _user_id: &UserId, + _bio: Option, + _avatar_path: Option, + _banner_path: Option, + _also_known_as: Option, + ) -> Result<(), DomainError> { + Ok(()) + } +} + +// ── InMemoryWatchlistRepository ─────────────────────────────────────────────── + +pub struct InMemoryWatchlistRepository { + store: Mutex>, +} + +impl InMemoryWatchlistRepository { + 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 WatchlistRepository for InMemoryWatchlistRepository { + async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { + let key = (entry.user_id.value(), entry.movie_id.value()); + self.store.lock().unwrap().entry(key).or_insert_with(|| entry.clone()); + Ok(()) + } + + async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { + let key = (user_id.value(), movie_id.value()); + self.store.lock().unwrap().remove(&key) + .ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?; + Ok(()) + } + + async fn remove_if_present( + &self, + user_id: &UserId, + movie_id: &MovieId, + ) -> Result { + let key = (user_id.value(), movie_id.value()); + Ok(self.store.lock().unwrap().remove(&key).is_some()) + } + + async fn get_for_user( + &self, + _user_id: &UserId, + _page: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 }) + } + + async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result { + let key = (user_id.value(), movie_id.value()); + Ok(self.store.lock().unwrap().contains_key(&key)) + } +} + +// ── NoopEventPublisher ──────────────────────────────────────────────────────── + +pub struct NoopEventPublisher { + pub events: Mutex>, +} + +impl NoopEventPublisher { + pub fn new() -> Arc { + Arc::new(Self { events: Mutex::new(vec![]) }) + } + + pub fn published(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +#[async_trait] +impl EventPublisher for NoopEventPublisher { + async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { + self.events.lock().unwrap().push(event.clone()); + Ok(()) + } +} + +// ── NoopImageStorage ────────────────────────────────────────────────────────── + +pub struct NoopImageStorage; + +#[async_trait] +impl ImageStorage for NoopImageStorage { + async fn store(&self, key: &str, _image_bytes: &[u8]) -> Result { + Ok(format!("noop://{key}")) + } + + async fn get(&self, _key: &str) -> Result, DomainError> { + Ok(vec![]) + } + + async fn delete(&self, _key: &str) -> Result<(), DomainError> { + Ok(()) + } +} + +// ── FakeAuthService ─────────────────────────────────────────────────────────── + +pub struct FakeAuthService; + +#[async_trait] +impl AuthService for FakeAuthService { + async fn generate_token(&self, user_id: &UserId) -> Result { + Ok(GeneratedToken { + token: user_id.value().to_string(), + expires_at: Utc::now() + chrono::Duration::hours(24), + }) + } + + async fn validate_token(&self, token: &str) -> Result { + Uuid::parse_str(token) + .map(UserId::from_uuid) + .map_err(|_| DomainError::Unauthorized("invalid token".into())) + } +} + +// ── FakePasswordHasher ──────────────────────────────────────────────────────── + +pub struct FakePasswordHasher; + +#[async_trait] +impl PasswordHasher for FakePasswordHasher { + async fn hash(&self, plain_password: &str) -> Result { + PasswordHash::new(format!("hashed:{plain_password}")) + } + + async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result { + Ok(hash.value() == format!("hashed:{plain_password}")) + } +} + +// ── FakeMetadataClient ──────────────────────────────────────────────────────── + +pub struct FakeMetadataClient; + +#[async_trait] +impl MetadataClient for FakeMetadataClient { + async fn fetch_movie_metadata( + &self, + _criteria: &MetadataSearchCriteria, + ) -> Result { + Err(DomainError::InfrastructureError("fake metadata client".into())) + } + + async fn get_poster_url( + &self, + _external_metadata_id: &ExternalMetadataId, + ) -> Result, DomainError> { + Ok(None) + } +} + +// ── FakeDiaryRepository ─────────────────────────────────────────────────────── + +pub struct FakeDiaryRepository { + histories: Mutex)>>, +} + +impl FakeDiaryRepository { + pub fn new() -> Arc { + Arc::new(Self { histories: Mutex::new(HashMap::new()) }) + } + + pub fn seed_history(&self, movie: Movie, reviews: Vec) { + self.histories.lock().unwrap().insert(movie.id().value(), (movie, reviews)); + } +} + +#[async_trait] +impl DiaryRepository for FakeDiaryRepository { + async fn query_diary(&self, _filter: &DiaryFilter) -> Result, DomainError> { + unimplemented!("FakeDiaryRepository::query_diary") + } + + async fn query_activity_feed( + &self, + _page: &PageParams, + ) -> Result, DomainError> { + unimplemented!("FakeDiaryRepository::query_activity_feed") + } + + async fn query_activity_feed_filtered( + &self, + _page: &PageParams, + _sort_by: &FeedSortBy, + _search: Option<&str>, + _following: Option<&FollowingFilter>, + ) -> Result, DomainError> { + unimplemented!("FakeDiaryRepository::query_activity_feed_filtered") + } + + async fn get_review_history(&self, movie_id: &MovieId) -> Result { + let histories = self.histories.lock().unwrap(); + let (movie, reviews) = histories + .get(&movie_id.value()) + .ok_or_else(|| DomainError::NotFound(format!("movie {}", movie_id.value())))?; + Ok(ReviewHistory::new(movie.clone(), reviews.clone())) + } + + async fn get_user_history(&self, _user_id: &UserId) -> Result, DomainError> { + unimplemented!("FakeDiaryRepository::get_user_history") + } + + async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result { + unimplemented!("FakeDiaryRepository::get_movie_stats") + } + + async fn get_movie_social_feed( + &self, + _movie_id: &MovieId, + _page: &PageParams, + ) -> Result, DomainError> { + unimplemented!("FakeDiaryRepository::get_movie_social_feed") + } + + async fn count_local_posts(&self) -> Result { + unimplemented!("FakeDiaryRepository::count_local_posts") + } +} + +// ── PanicDiaryRepository ────────────────────────────────────────────────────── + +pub struct PanicDiaryRepository; + +#[async_trait] +impl DiaryRepository for PanicDiaryRepository { + async fn query_diary(&self, _filter: &DiaryFilter) -> Result, DomainError> { + panic!("PanicDiaryRepository called") + } + + async fn query_activity_feed( + &self, + _page: &PageParams, + ) -> Result, DomainError> { + panic!("PanicDiaryRepository called") + } + + async fn query_activity_feed_filtered( + &self, + _page: &PageParams, + _sort_by: &FeedSortBy, + _search: Option<&str>, + _following: Option<&FollowingFilter>, + ) -> Result, DomainError> { + panic!("PanicDiaryRepository called") + } + + async fn get_review_history(&self, _movie_id: &MovieId) -> Result { + panic!("PanicDiaryRepository called") + } + + async fn get_user_history(&self, _user_id: &UserId) -> Result, DomainError> { + panic!("PanicDiaryRepository called") + } + + async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result { + panic!("PanicDiaryRepository called") + } + + async fn get_movie_social_feed( + &self, + _movie_id: &MovieId, + _page: &PageParams, + ) -> Result, DomainError> { + panic!("PanicDiaryRepository called") + } + + async fn count_local_posts(&self) -> Result { + panic!("PanicDiaryRepository called") + } +} + +// ── PanicStatsRepository ────────────────────────────────────────────────────── + +pub struct PanicStatsRepository; + +#[async_trait] +impl StatsRepository for PanicStatsRepository { + async fn get_user_stats(&self, _user_id: &UserId) -> Result { + panic!("PanicStatsRepository called") + } + + async fn get_user_trends(&self, _user_id: &UserId) -> Result { + panic!("PanicStatsRepository called") + } +} + +// ── PanicImportSessionRepository ────────────────────────────────────────────── + +pub struct PanicImportSessionRepository; + +#[async_trait] +impl ImportSessionRepository for PanicImportSessionRepository { + async fn create(&self, _session: &ImportSession) -> Result<(), DomainError> { + panic!("PanicImportSessionRepository called") + } + + async fn get( + &self, + _id: &ImportSessionId, + _user_id: &UserId, + ) -> Result, DomainError> { + panic!("PanicImportSessionRepository called") + } + + async fn update(&self, _session: &ImportSession) -> Result<(), DomainError> { + panic!("PanicImportSessionRepository called") + } + + async fn delete(&self, _id: &ImportSessionId) -> Result<(), DomainError> { + panic!("PanicImportSessionRepository called") + } + + async fn delete_expired(&self) -> Result { + panic!("PanicImportSessionRepository called") + } + + async fn delete_expired_for_user(&self, _user_id: &UserId) -> Result<(), DomainError> { + panic!("PanicImportSessionRepository called") + } +} + +// ── PanicImportProfileRepository ────────────────────────────────────────────── + +pub struct PanicImportProfileRepository; + +#[async_trait] +impl ImportProfileRepository for PanicImportProfileRepository { + async fn save(&self, _profile: &ImportProfile) -> Result<(), DomainError> { + panic!("PanicImportProfileRepository called") + } + + async fn list_for_user(&self, _user_id: &UserId) -> Result, DomainError> { + panic!("PanicImportProfileRepository called") + } + + async fn get( + &self, + _id: &ImportProfileId, + _user_id: &UserId, + ) -> Result, DomainError> { + panic!("PanicImportProfileRepository called") + } + + async fn delete(&self, _id: &ImportProfileId) -> Result<(), DomainError> { + panic!("PanicImportProfileRepository called") + } +} + +// ── PanicMovieProfileRepository ─────────────────────────────────────────────── + +pub struct PanicMovieProfileRepository; + +#[async_trait] +impl MovieProfileRepository for PanicMovieProfileRepository { + async fn upsert(&self, _profile: &MovieProfile) -> Result<(), DomainError> { + panic!("PanicMovieProfileRepository called") + } + + async fn get_by_movie_id(&self, _id: &MovieId) -> Result, DomainError> { + panic!("PanicMovieProfileRepository called") + } + + async fn list_stale(&self) -> Result, DomainError> { + panic!("PanicMovieProfileRepository called") + } +} + +// ── PanicPersonCommand ──────────────────────────────────────────────────────── + +pub struct PanicPersonCommand; + +#[async_trait] +impl PersonCommand for PanicPersonCommand { + async fn upsert_batch(&self, _persons: &[Person]) -> Result<(), DomainError> { + panic!("PanicPersonCommand called") + } +} + +// ── PanicPersonQuery ────────────────────────────────────────────────────────── + +pub struct PanicPersonQuery; + +#[async_trait] +impl PersonQuery for PanicPersonQuery { + async fn get_by_id(&self, _id: &PersonId) -> Result, DomainError> { + panic!("PanicPersonQuery called") + } + + async fn get_by_external_id( + &self, + _id: &ExternalPersonId, + ) -> Result, DomainError> { + panic!("PanicPersonQuery called") + } + + async fn get_credits(&self, _id: &PersonId) -> Result { + panic!("PanicPersonQuery called") + } + + async fn list_orphaned_persons(&self) -> Result, DomainError> { + panic!("PanicPersonQuery called") + } +} + +// ── PanicSearchPort ─────────────────────────────────────────────────────────── + +pub struct PanicSearchPort; + +#[async_trait] +impl SearchPort for PanicSearchPort { + async fn search(&self, _query: &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 }, + }) + } +} + +// ── PanicSearchCommand ──────────────────────────────────────────────────────── + +pub struct PanicSearchCommand; + +#[async_trait] +impl SearchCommand for PanicSearchCommand { + async fn index(&self, _doc: IndexableDocument) -> Result<(), DomainError> { + panic!("PanicSearchCommand called") + } + + async fn remove(&self, _entity_type: EntityType, _id: &str) -> Result<(), DomainError> { + panic!("PanicSearchCommand called") + } +} + +// ── PanicPosterFetcher ──────────────────────────────────────────────────────── + +pub struct PanicPosterFetcher; + +#[async_trait] +impl PosterFetcherClient for PanicPosterFetcher { + async fn fetch_poster_bytes(&self, _poster_url: &PosterUrl) -> Result, DomainError> { + panic!("PanicPosterFetcher called") + } +} + +// ── PanicDiaryExporter ──────────────────────────────────────────────────────── + +pub struct PanicDiaryExporter; + +#[async_trait] +impl DiaryExporter for PanicDiaryExporter { + async fn serialize_entries( + &self, + _entries: &[crate::models::DiaryEntry], + _format: ExportFormat, + ) -> Result, DomainError> { + panic!("PanicDiaryExporter called") + } +} + +// ── PanicDocumentParser ─────────────────────────────────────────────────────── + +pub struct PanicDocumentParser; + +impl DocumentParser for PanicDocumentParser { + fn parse(&self, _bytes: &[u8], _format: FileFormat) -> Result { + panic!("PanicDocumentParser called") + } + + fn apply_mapping(&self, _file: &ParsedFile, _mappings: &[FieldMapping]) -> Vec { + panic!("PanicDocumentParser called") + } +} + +// ── PanicProfileFieldsRepo ──────────────────────────────────────────────────── + +pub struct PanicProfileFieldsRepo; + +#[async_trait] +impl UserProfileFieldsRepository for PanicProfileFieldsRepo { + async fn get_fields( + &self, + _user_id: &UserId, + ) -> Result, DomainError> { + panic!("PanicProfileFieldsRepo called") + } + + async fn set_fields( + &self, + _user_id: &UserId, + _fields: Vec, + ) -> Result<(), DomainError> { + panic!("PanicProfileFieldsRepo called") + } +} diff --git a/crates/presentation/src/factory.rs b/crates/presentation/src/factory.rs new file mode 100644 index 0000000..e11c3c6 --- /dev/null +++ b/crates/presentation/src/factory.rs @@ -0,0 +1,127 @@ +use std::sync::Arc; + +use anyhow::Context; + +use domain::ports::{ + AuthService, DiaryRepository, ImageStorage, ImportProfileRepository, + ImportSessionRepository, MetadataClient, MovieProfileRepository, MovieRepository, + PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, ReviewRepository, + SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, UserRepository, + WatchlistRepository, +}; + +pub struct DatabaseAdapters { + pub movie_repo: Arc, + pub review_repo: Arc, + pub diary_repo: Arc, + pub stats_repo: Arc, + pub user_repo: Arc, + pub import_session_repo: Arc, + pub import_profile_repo: Arc, + pub movie_profile_repo: Arc, + pub watchlist_repo: Arc, + pub person_command: Arc, + pub person_query: Arc, + pub search_port: Arc, + pub search_command: Arc, + pub profile_fields_repo: Arc, + pub db_pool: DbPool, +} + +pub enum DbPool { + #[cfg(feature = "sqlite")] + Sqlite(sqlx::SqlitePool), + #[cfg(feature = "postgres")] + Postgres(sqlx::PgPool), +} + +pub async fn build_database_adapters( + backend: &str, + url: &str, +) -> anyhow::Result { + match backend { + #[cfg(feature = "postgres")] + "postgres" => { + let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(url) + .await + .context("PostgreSQL connection failed")?; + let (pc, pq) = postgres::create_person_adapter(pool.clone()); + let (sc, sp) = postgres_search::create_search_adapter(pool.clone()); + let pf = postgres::create_profile_fields_repo(pool.clone()); + Ok(DatabaseAdapters { + movie_repo: m, + review_repo: r, + diary_repo: d, + stats_repo: s, + user_repo: u, + import_session_repo: is, + import_profile_repo: ip, + movie_profile_repo: mp, + watchlist_repo: wl, + person_command: pc, + person_query: pq, + search_port: sp, + search_command: sc, + profile_fields_repo: pf, + db_pool: DbPool::Postgres(pool), + }) + } + #[cfg(feature = "sqlite")] + _ => { + let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(url) + .await + .context("SQLite connection failed")?; + let (pc, pq) = sqlite::create_person_adapter(pool.clone()); + let (sc, sp) = sqlite_search::create_search_adapter(pool.clone()); + let pf = sqlite::create_profile_fields_repo(pool.clone()); + Ok(DatabaseAdapters { + movie_repo: m, + review_repo: r, + diary_repo: d, + stats_repo: s, + user_repo: u, + import_session_repo: is, + import_profile_repo: ip, + movie_profile_repo: mp, + watchlist_repo: wl, + person_command: pc, + person_query: pq, + search_port: sp, + search_command: sc, + profile_fields_repo: pf, + db_pool: DbPool::Sqlite(pool), + }) + } + #[cfg(not(feature = "sqlite"))] + _ => anyhow::bail!( + "DATABASE_BACKEND={backend} is not supported by this build (enable sqlite or postgres feature)" + ), + } +} + +pub fn build_auth_adapters() -> anyhow::Result<(Arc, Arc)> { + auth::create() +} + +pub fn build_metadata_client() -> anyhow::Result> { + metadata::create() +} + +pub fn build_poster_fetcher() -> anyhow::Result> { + poster_fetcher::create() +} + +pub fn build_image_storage() -> anyhow::Result> { + image_storage::create() +} + +pub fn build_profile_fields_repo(pool: &DbPool) -> anyhow::Result> { + match pool { + #[cfg(feature = "postgres")] + DbPool::Postgres(pool) => Ok(postgres::create_profile_fields_repo(pool.clone())), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(pool) => Ok(sqlite::create_profile_fields_repo(pool.clone())), + #[cfg(not(feature = "sqlite"))] + _ => anyhow::bail!("no profile fields repo for this backend"), + } +} diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 66d3bc4..3cef903 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -1,6 +1,7 @@ pub mod csrf; pub mod errors; pub mod extractors; +pub mod factory; pub mod forms; pub mod handlers; pub mod openapi; diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index af78811..1c1998c 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -11,11 +11,9 @@ use importer::ImporterDocumentParser; use rss::RssAdapter; use template_askama::AskamaHtmlRenderer; -use presentation::{openapi, routes, state::AppState}; +use presentation::{factory, openapi, routes, state::AppState}; -use domain::ports::{ - DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository, -}; +use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; #[cfg(feature = "postgres")] use postgres_search; @@ -55,85 +53,28 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string()); - let (auth_service, password_hasher) = auth::create()?; - let metadata_client = metadata::create()?; - let poster_fetcher = poster_fetcher::create()?; - let image_storage = image_storage::create()?; + let (auth_service, password_hasher) = factory::build_auth_adapters()?; + let metadata_client = factory::build_metadata_client()?; + let poster_fetcher = factory::build_poster_fetcher()?; + let image_storage = factory::build_image_storage()?; - let ( - movie_repository, - review_repository, - diary_repository, - stats_repository, - user_repository, - import_session_repository, - import_profile_repository, - movie_profile_repository, - watchlist_repository, - person_command, - person_query, - search_command, - search_port, - db_pool, - ) = match backend.as_str() { - #[cfg(feature = "postgres")] - "postgres" => { - let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(&database_url).await?; - let (pc, pq) = postgres::create_person_adapter(pool.clone()); - let (sc, sp) = postgres_search::create_search_adapter(pool.clone()); - ( - m, - r, - d, - s, - u, - is, - ip, - mp, - wl, - pc, - pq, - sc, - sp, - DbPool::Postgres(pool), - ) - } - #[cfg(feature = "sqlite")] - _ => { - let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(&database_url).await?; - let (pc, pq) = sqlite::create_person_adapter(pool.clone()); - let (sc, sp) = sqlite_search::create_search_adapter(pool.clone()); - ( - m, - r, - d, - s, - u, - is, - ip, - mp, - wl, - pc, - pq, - sc, - sp, - DbPool::Sqlite(pool), - ) - } - #[cfg(not(feature = "sqlite"))] - _ => anyhow::bail!( - "DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)" - ), - }; + let db = factory::build_database_adapters(&backend, &database_url).await?; - let profile_fields_repo = match &db_pool { - #[cfg(feature = "postgres")] - DbPool::Postgres(pool) => postgres::create_profile_fields_repo(pool.clone()), - #[cfg(feature = "sqlite")] - DbPool::Sqlite(pool) => sqlite::create_profile_fields_repo(pool.clone()), - #[cfg(not(feature = "sqlite"))] - _ => anyhow::bail!("no profile fields repo for this backend"), - }; + let movie_repository = db.movie_repo; + let review_repository = db.review_repo; + let diary_repository = db.diary_repo; + let stats_repository = db.stats_repo; + let user_repository = db.user_repo; + let import_session_repository = db.import_session_repo; + let import_profile_repository = db.import_profile_repo; + let movie_profile_repository = db.movie_profile_repo; + let watchlist_repository = db.watchlist_repo; + let person_command = db.person_command; + let person_query = db.person_query; + let search_port = db.search_port; + let search_command = db.search_command; + let profile_fields_repo = db.profile_fields_repo; + let db_pool = db.db_pool; // Wire up event channel, federation service, and ap_router let event_bus = EventBusBackend::from_env()?; @@ -143,9 +84,9 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) = match &db_pool { #[cfg(feature = "postgres-federation")] - DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), + factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), #[cfg(feature = "sqlite-federation")] - DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), + factory::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), #[cfg(not(feature = "sqlite-federation"))] _ => anyhow::bail!( "DATABASE_BACKEND={backend} federation is not supported by this build" @@ -157,12 +98,12 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { tracing::info!("event bus: DB queue"); match &db_pool { #[cfg(feature = "postgres")] - DbPool::Postgres(pool) => { + factory::DbPool::Postgres(pool) => { postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()) .await? } #[cfg(feature = "sqlite")] - DbPool::Sqlite(pool) => { + factory::DbPool::Sqlite(pool) => { sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await? } } @@ -207,11 +148,11 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { tracing::info!("event bus: DB queue"); match &db_pool { #[cfg(feature = "postgres")] - DbPool::Postgres(pool) => { + factory::DbPool::Postgres(pool) => { postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await? } #[cfg(feature = "sqlite")] - DbPool::Sqlite(pool) => { + factory::DbPool::Sqlite(pool) => { sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await? } #[cfg(not(feature = "sqlite"))] @@ -245,8 +186,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { auth_service, password_hasher, user_repository, - import_session_repository: import_session_repository as Arc, - import_profile_repository: import_profile_repository as Arc, + import_session_repository, + import_profile_repository, movie_profile_repository, watchlist_repository, profile_fields_repository: profile_fields_repo, @@ -273,13 +214,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { Ok((state, ap_router)) } -enum DbPool { - #[cfg(feature = "sqlite")] - Sqlite(sqlx::SqlitePool), - #[cfg(feature = "postgres")] - Postgres(sqlx::PgPool), -} - #[derive(Clone, Copy)] enum EventBusBackend { Db,