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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user