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
This commit is contained in:
@@ -18,3 +18,4 @@ federation = []
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
|
|||||||
@@ -10,5 +10,8 @@ pub mod search_cleanup;
|
|||||||
pub mod use_cases;
|
pub mod use_cases;
|
||||||
pub mod worker;
|
pub mod worker;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod test_helpers;
|
||||||
|
|
||||||
pub use movie_discovery_indexer::MovieDiscoveryIndexer;
|
pub use movie_discovery_indexer::MovieDiscoveryIndexer;
|
||||||
pub use search_cleanup::SearchCleanupHandler;
|
pub use search_cleanup::SearchCleanupHandler;
|
||||||
|
|||||||
148
crates/application/src/test_helpers.rs
Normal file
148
crates/application/src/test_helpers.rs
Normal file
@@ -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<dyn MovieRepository>,
|
||||||
|
pub review_repo: Arc<dyn ReviewRepository>,
|
||||||
|
pub diary_repo: Arc<dyn DiaryRepository>,
|
||||||
|
pub diary_exporter: Arc<dyn DiaryExporter>,
|
||||||
|
pub document_parser: Arc<dyn DocumentParser>,
|
||||||
|
pub stats_repo: Arc<dyn StatsRepository>,
|
||||||
|
pub metadata_client: Arc<dyn MetadataClient>,
|
||||||
|
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||||
|
pub image_storage: Arc<dyn ImageStorage>,
|
||||||
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
pub auth_service: Arc<dyn AuthService>,
|
||||||
|
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub user_repo: Arc<dyn UserRepository>,
|
||||||
|
pub import_session_repo: Arc<dyn ImportSessionRepository>,
|
||||||
|
pub import_profile_repo: Arc<dyn ImportProfileRepository>,
|
||||||
|
pub movie_profile_repo: Arc<dyn MovieProfileRepository>,
|
||||||
|
pub watchlist_repo: Arc<dyn WatchlistRepository>,
|
||||||
|
pub profile_fields_repo: Arc<dyn UserProfileFieldsRepository>,
|
||||||
|
pub person_command: Arc<dyn PersonCommand>,
|
||||||
|
pub person_query: Arc<dyn PersonQuery>,
|
||||||
|
pub search_port: Arc<dyn SearchPort>,
|
||||||
|
pub search_command: Arc<dyn SearchCommand>,
|
||||||
|
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<dyn MovieRepository>) -> Self {
|
||||||
|
self.movie_repo = r;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_reviews(mut self, r: Arc<dyn ReviewRepository>) -> Self {
|
||||||
|
self.review_repo = r;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_users(mut self, r: Arc<dyn UserRepository>) -> Self {
|
||||||
|
self.user_repo = r;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_watchlist(mut self, r: Arc<dyn WatchlistRepository>) -> Self {
|
||||||
|
self.watchlist_repo = r;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_diary(mut self, r: Arc<dyn DiaryRepository>) -> Self {
|
||||||
|
self.diary_repo = r;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_event_publisher(mut self, p: Arc<dyn EventPublisher>) -> 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,3 +60,95 @@ pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(),
|
|||||||
|
|
||||||
Ok(())
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,3 +52,108 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), D
|
|||||||
|
|
||||||
Ok(())
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,121 @@ pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), Doma
|
|||||||
Ok(())
|
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(
|
async fn publish_events(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
movie: &Movie,
|
movie: &Movie,
|
||||||
|
|||||||
@@ -37,3 +37,92 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult,
|
|||||||
expires_at: generated.expires_at,
|
expires_at: generated.expires_at,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::models::UserRole;
|
||||||
|
use domain::testing::InMemoryUserRepository;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
commands::RegisterCommand,
|
||||||
|
queries::LoginQuery,
|
||||||
|
test_helpers::TestContextBuilder,
|
||||||
|
use_cases::{login, register},
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn setup_user(ctx: &crate::context::AppContext, email: &str, password: &str) {
|
||||||
|
register::execute(
|
||||||
|
ctx,
|
||||||
|
RegisterCommand {
|
||||||
|
email: email.to_string(),
|
||||||
|
username: "testuser".to_string(),
|
||||||
|
password: password.to_string(),
|
||||||
|
role: UserRole::Standard,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_login_valid_credentials_returns_token() {
|
||||||
|
let users = InMemoryUserRepository::new();
|
||||||
|
let ctx = TestContextBuilder::new()
|
||||||
|
.with_users(Arc::clone(&users) as _)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
setup_user(&ctx, "carol@example.com", "secret123").await;
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&ctx,
|
||||||
|
LoginQuery {
|
||||||
|
email: "carol@example.com".into(),
|
||||||
|
password: "secret123".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!result.token.is_empty());
|
||||||
|
assert_eq!(result.email, "carol@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_login_wrong_password_fails() {
|
||||||
|
let users = InMemoryUserRepository::new();
|
||||||
|
let ctx = TestContextBuilder::new()
|
||||||
|
.with_users(Arc::clone(&users) as _)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
setup_user(&ctx, "dave@example.com", "correct_password").await;
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&ctx,
|
||||||
|
LoginQuery {
|
||||||
|
email: "dave@example.com".into(),
|
||||||
|
password: "wrong_password".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_login_unknown_email_fails() {
|
||||||
|
let ctx = TestContextBuilder::new().build();
|
||||||
|
|
||||||
|
let result = login::execute(
|
||||||
|
&ctx,
|
||||||
|
LoginQuery {
|
||||||
|
email: "nobody@example.com".into(),
|
||||||
|
password: "anything".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,3 +44,55 @@ pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), Domai
|
|||||||
.save(&User::new(email, username, hash, cmd.role))
|
.save(&User::new(email, username, hash, cmd.role))
|
||||||
.await
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,3 +11,6 @@ thiserror = { workspace = true }
|
|||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
|
||||||
email_address = "0.2.9"
|
email_address = "0.2.9"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
test-helpers = []
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ pub mod models;
|
|||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod value_objects;
|
pub mod value_objects;
|
||||||
|
|
||||||
|
#[cfg(any(test, feature = "test-helpers"))]
|
||||||
|
pub mod testing;
|
||||||
|
|||||||
689
crates/domain/src/testing.rs
Normal file
689
crates/domain/src/testing.rs
Normal file
@@ -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<HashMap<Uuid, Movie>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryMovieRepository {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
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<Option<Movie>, 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<Option<Movie>, 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<Vec<Movie>, 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<Paginated<MovieSummary>, DomainError> {
|
||||||
|
Ok(Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── InMemoryReviewRepository ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct InMemoryReviewRepository {
|
||||||
|
store: Mutex<HashMap<Uuid, Review>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryReviewRepository {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
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<DomainEvent, DomainError> {
|
||||||
|
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<Option<Review>, 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<Vec<Review>, 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<HashMap<Uuid, User>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryUserRepository {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
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<Option<User>, 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<Option<User>, 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<Option<User>, DomainError> {
|
||||||
|
Ok(self.store.lock().unwrap().get(&id.value()).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_profile(
|
||||||
|
&self,
|
||||||
|
_user_id: &UserId,
|
||||||
|
_bio: Option<String>,
|
||||||
|
_avatar_path: Option<String>,
|
||||||
|
_banner_path: Option<String>,
|
||||||
|
_also_known_as: Option<String>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── InMemoryWatchlistRepository ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct InMemoryWatchlistRepository {
|
||||||
|
store: Mutex<HashMap<(Uuid, Uuid), WatchlistEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryWatchlistRepository {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
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<bool, DomainError> {
|
||||||
|
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<Paginated<WatchlistWithMovie>, DomainError> {
|
||||||
|
Ok(Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
|
||||||
|
let key = (user_id.value(), movie_id.value());
|
||||||
|
Ok(self.store.lock().unwrap().contains_key(&key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NoopEventPublisher ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct NoopEventPublisher {
|
||||||
|
pub events: Mutex<Vec<DomainEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NoopEventPublisher {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self { events: Mutex::new(vec![]) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn published(&self) -> Vec<DomainEvent> {
|
||||||
|
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<String, DomainError> {
|
||||||
|
Ok(format!("noop://{key}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, _key: &str) -> Result<Vec<u8>, 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<GeneratedToken, DomainError> {
|
||||||
|
Ok(GeneratedToken {
|
||||||
|
token: user_id.value().to_string(),
|
||||||
|
expires_at: Utc::now() + chrono::Duration::hours(24),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||||
|
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, DomainError> {
|
||||||
|
PasswordHash::new(format!("hashed:{plain_password}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||||
|
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<Movie, DomainError> {
|
||||||
|
Err(DomainError::InfrastructureError("fake metadata client".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_poster_url(
|
||||||
|
&self,
|
||||||
|
_external_metadata_id: &ExternalMetadataId,
|
||||||
|
) -> Result<Option<PosterUrl>, DomainError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FakeDiaryRepository ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct FakeDiaryRepository {
|
||||||
|
histories: Mutex<HashMap<Uuid, (Movie, Vec<Review>)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeDiaryRepository {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self { histories: Mutex::new(HashMap::new()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seed_history(&self, movie: Movie, reviews: Vec<Review>) {
|
||||||
|
self.histories.lock().unwrap().insert(movie.id().value(), (movie, reviews));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DiaryRepository for FakeDiaryRepository {
|
||||||
|
async fn query_diary(&self, _filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||||
|
unimplemented!("FakeDiaryRepository::query_diary")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_activity_feed(
|
||||||
|
&self,
|
||||||
|
_page: &PageParams,
|
||||||
|
) -> Result<Paginated<FeedEntry>, 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<Paginated<FeedEntry>, DomainError> {
|
||||||
|
unimplemented!("FakeDiaryRepository::query_activity_feed_filtered")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
|
||||||
|
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<Vec<DiaryEntry>, DomainError> {
|
||||||
|
unimplemented!("FakeDiaryRepository::get_user_history")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result<MovieStats, DomainError> {
|
||||||
|
unimplemented!("FakeDiaryRepository::get_movie_stats")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_movie_social_feed(
|
||||||
|
&self,
|
||||||
|
_movie_id: &MovieId,
|
||||||
|
_page: &PageParams,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
unimplemented!("FakeDiaryRepository::get_movie_social_feed")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||||
|
unimplemented!("FakeDiaryRepository::count_local_posts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PanicDiaryRepository ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct PanicDiaryRepository;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DiaryRepository for PanicDiaryRepository {
|
||||||
|
async fn query_diary(&self, _filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||||
|
panic!("PanicDiaryRepository called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_activity_feed(
|
||||||
|
&self,
|
||||||
|
_page: &PageParams,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
panic!("PanicDiaryRepository called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_activity_feed_filtered(
|
||||||
|
&self,
|
||||||
|
_page: &PageParams,
|
||||||
|
_sort_by: &FeedSortBy,
|
||||||
|
_search: Option<&str>,
|
||||||
|
_following: Option<&FollowingFilter>,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
panic!("PanicDiaryRepository called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_review_history(&self, _movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
|
||||||
|
panic!("PanicDiaryRepository called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_history(&self, _user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||||
|
panic!("PanicDiaryRepository called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result<MovieStats, DomainError> {
|
||||||
|
panic!("PanicDiaryRepository called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_movie_social_feed(
|
||||||
|
&self,
|
||||||
|
_movie_id: &MovieId,
|
||||||
|
_page: &PageParams,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
panic!("PanicDiaryRepository called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||||
|
panic!("PanicDiaryRepository called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PanicStatsRepository ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct PanicStatsRepository;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl StatsRepository for PanicStatsRepository {
|
||||||
|
async fn get_user_stats(&self, _user_id: &UserId) -> Result<UserStats, DomainError> {
|
||||||
|
panic!("PanicStatsRepository called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user_trends(&self, _user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||||
|
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<Option<ImportSession>, 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<u64, DomainError> {
|
||||||
|
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<Vec<ImportProfile>, DomainError> {
|
||||||
|
panic!("PanicImportProfileRepository called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
_id: &ImportProfileId,
|
||||||
|
_user_id: &UserId,
|
||||||
|
) -> Result<Option<ImportProfile>, 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<Option<MovieProfile>, DomainError> {
|
||||||
|
panic!("PanicMovieProfileRepository called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, 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<Option<Person>, DomainError> {
|
||||||
|
panic!("PanicPersonQuery called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_external_id(
|
||||||
|
&self,
|
||||||
|
_id: &ExternalPersonId,
|
||||||
|
) -> Result<Option<Person>, DomainError> {
|
||||||
|
panic!("PanicPersonQuery called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_credits(&self, _id: &PersonId) -> Result<PersonCredits, DomainError> {
|
||||||
|
panic!("PanicPersonQuery called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
|
||||||
|
panic!("PanicPersonQuery called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PanicSearchPort ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct PanicSearchPort;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SearchPort for PanicSearchPort {
|
||||||
|
async fn search(&self, _query: &SearchQuery) -> Result<SearchResults, DomainError> {
|
||||||
|
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<Vec<u8>, 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<Vec<u8>, DomainError> {
|
||||||
|
panic!("PanicDiaryExporter called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PanicDocumentParser ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct PanicDocumentParser;
|
||||||
|
|
||||||
|
impl DocumentParser for PanicDocumentParser {
|
||||||
|
fn parse(&self, _bytes: &[u8], _format: FileFormat) -> Result<ParsedFile, ImportError> {
|
||||||
|
panic!("PanicDocumentParser called")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_mapping(&self, _file: &ParsedFile, _mappings: &[FieldMapping]) -> Vec<AnnotatedRow> {
|
||||||
|
panic!("PanicDocumentParser called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PanicProfileFieldsRepo ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct PanicProfileFieldsRepo;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserProfileFieldsRepository for PanicProfileFieldsRepo {
|
||||||
|
async fn get_fields(
|
||||||
|
&self,
|
||||||
|
_user_id: &UserId,
|
||||||
|
) -> Result<Vec<crate::models::ProfileField>, DomainError> {
|
||||||
|
panic!("PanicProfileFieldsRepo called")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_fields(
|
||||||
|
&self,
|
||||||
|
_user_id: &UserId,
|
||||||
|
_fields: Vec<crate::models::ProfileField>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
panic!("PanicProfileFieldsRepo called")
|
||||||
|
}
|
||||||
|
}
|
||||||
127
crates/presentation/src/factory.rs
Normal file
127
crates/presentation/src/factory.rs
Normal file
@@ -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<dyn MovieRepository>,
|
||||||
|
pub review_repo: Arc<dyn ReviewRepository>,
|
||||||
|
pub diary_repo: Arc<dyn DiaryRepository>,
|
||||||
|
pub stats_repo: Arc<dyn StatsRepository>,
|
||||||
|
pub user_repo: Arc<dyn UserRepository>,
|
||||||
|
pub import_session_repo: Arc<dyn ImportSessionRepository>,
|
||||||
|
pub import_profile_repo: Arc<dyn ImportProfileRepository>,
|
||||||
|
pub movie_profile_repo: Arc<dyn MovieProfileRepository>,
|
||||||
|
pub watchlist_repo: Arc<dyn WatchlistRepository>,
|
||||||
|
pub person_command: Arc<dyn PersonCommand>,
|
||||||
|
pub person_query: Arc<dyn PersonQuery>,
|
||||||
|
pub search_port: Arc<dyn SearchPort>,
|
||||||
|
pub search_command: Arc<dyn SearchCommand>,
|
||||||
|
pub profile_fields_repo: Arc<dyn UserProfileFieldsRepository>,
|
||||||
|
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<DatabaseAdapters> {
|
||||||
|
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<dyn AuthService>, Arc<dyn PasswordHasher>)> {
|
||||||
|
auth::create()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_metadata_client() -> anyhow::Result<Arc<dyn MetadataClient>> {
|
||||||
|
metadata::create()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_poster_fetcher() -> anyhow::Result<Arc<dyn PosterFetcherClient>> {
|
||||||
|
poster_fetcher::create()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_image_storage() -> anyhow::Result<Arc<dyn ImageStorage>> {
|
||||||
|
image_storage::create()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_profile_fields_repo(pool: &DbPool) -> anyhow::Result<Arc<dyn UserProfileFieldsRepository>> {
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod csrf;
|
pub mod csrf;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod extractors;
|
pub mod extractors;
|
||||||
|
pub mod factory;
|
||||||
pub mod forms;
|
pub mod forms;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ use importer::ImporterDocumentParser;
|
|||||||
use rss::RssAdapter;
|
use rss::RssAdapter;
|
||||||
use template_askama::AskamaHtmlRenderer;
|
use template_askama::AskamaHtmlRenderer;
|
||||||
|
|
||||||
use presentation::{openapi, routes, state::AppState};
|
use presentation::{factory, openapi, routes, state::AppState};
|
||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher};
|
||||||
DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
use postgres_search;
|
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 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 backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string());
|
||||||
|
|
||||||
let (auth_service, password_hasher) = auth::create()?;
|
let (auth_service, password_hasher) = factory::build_auth_adapters()?;
|
||||||
let metadata_client = metadata::create()?;
|
let metadata_client = factory::build_metadata_client()?;
|
||||||
let poster_fetcher = poster_fetcher::create()?;
|
let poster_fetcher = factory::build_poster_fetcher()?;
|
||||||
let image_storage = image_storage::create()?;
|
let image_storage = factory::build_image_storage()?;
|
||||||
|
|
||||||
let (
|
let db = factory::build_database_adapters(&backend, &database_url).await?;
|
||||||
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 profile_fields_repo = match &db_pool {
|
let movie_repository = db.movie_repo;
|
||||||
#[cfg(feature = "postgres")]
|
let review_repository = db.review_repo;
|
||||||
DbPool::Postgres(pool) => postgres::create_profile_fields_repo(pool.clone()),
|
let diary_repository = db.diary_repo;
|
||||||
#[cfg(feature = "sqlite")]
|
let stats_repository = db.stats_repo;
|
||||||
DbPool::Sqlite(pool) => sqlite::create_profile_fields_repo(pool.clone()),
|
let user_repository = db.user_repo;
|
||||||
#[cfg(not(feature = "sqlite"))]
|
let import_session_repository = db.import_session_repo;
|
||||||
_ => anyhow::bail!("no profile fields repo for this backend"),
|
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
|
// Wire up event channel, federation service, and ap_router
|
||||||
let event_bus = EventBusBackend::from_env()?;
|
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) =
|
let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) =
|
||||||
match &db_pool {
|
match &db_pool {
|
||||||
#[cfg(feature = "postgres-federation")]
|
#[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")]
|
#[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"))]
|
#[cfg(not(feature = "sqlite-federation"))]
|
||||||
_ => anyhow::bail!(
|
_ => anyhow::bail!(
|
||||||
"DATABASE_BACKEND={backend} federation is not supported by this build"
|
"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");
|
tracing::info!("event bus: DB queue");
|
||||||
match &db_pool {
|
match &db_pool {
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
DbPool::Postgres(pool) => {
|
factory::DbPool::Postgres(pool) => {
|
||||||
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone())
|
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone())
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
DbPool::Sqlite(pool) => {
|
factory::DbPool::Sqlite(pool) => {
|
||||||
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
|
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");
|
tracing::info!("event bus: DB queue");
|
||||||
match &db_pool {
|
match &db_pool {
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
DbPool::Postgres(pool) => {
|
factory::DbPool::Postgres(pool) => {
|
||||||
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await?
|
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await?
|
||||||
}
|
}
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
DbPool::Sqlite(pool) => {
|
factory::DbPool::Sqlite(pool) => {
|
||||||
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
|
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "sqlite"))]
|
#[cfg(not(feature = "sqlite"))]
|
||||||
@@ -245,8 +186,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
|||||||
auth_service,
|
auth_service,
|
||||||
password_hasher,
|
password_hasher,
|
||||||
user_repository,
|
user_repository,
|
||||||
import_session_repository: import_session_repository as Arc<dyn ImportSessionRepository>,
|
import_session_repository,
|
||||||
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
|
import_profile_repository,
|
||||||
movie_profile_repository,
|
movie_profile_repository,
|
||||||
watchlist_repository,
|
watchlist_repository,
|
||||||
profile_fields_repository: profile_fields_repo,
|
profile_fields_repository: profile_fields_repo,
|
||||||
@@ -273,13 +214,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
|||||||
Ok((state, ap_router))
|
Ok((state, ap_router))
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DbPool {
|
|
||||||
#[cfg(feature = "sqlite")]
|
|
||||||
Sqlite(sqlx::SqlitePool),
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
Postgres(sqlx::PgPool),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum EventBusBackend {
|
enum EventBusBackend {
|
||||||
Db,
|
Db,
|
||||||
|
|||||||
Reference in New Issue
Block a user