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:
2026-05-14 00:41:25 +02:00
parent e41d85bd7e
commit edc1f6c850
14 changed files with 1458 additions and 96 deletions

View File

@@ -18,3 +18,4 @@ federation = []
[dev-dependencies]
tokio = { workspace = true }
domain = { workspace = true, features = ["test-helpers"] }

View File

@@ -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;

View 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,
}
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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,

View File

@@ -37,3 +37,92 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult,
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());
}
}

View File

@@ -44,3 +44,55 @@ pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> 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");
}
}