test: move application tests to tests/

test_support/test_helpers -> tests/support; watched_at unified w/ domain test_watched_at (2024-03-15).
Kept inline: users::diary_filter tests (feed_sort_to_direction_all_variants, paged_user_filter_builds_correctly) - pub(super) helpers in private module.
Dropped stale never-compiled users/tests/get_users.rs (old execute signature).
This commit is contained in:
2026-10-25 22:36:52 +01:00
parent a2c713c1fa
commit 8526b77f18
175 changed files with 541 additions and 695 deletions

View File

@@ -52,7 +52,3 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul
Ok(())
}
#[cfg(test)]
#[path = "tests/delete_review.rs"]
mod tests;

View File

@@ -55,7 +55,3 @@ pub async fn execute(deps: &EditReviewDeps, cmd: EditReviewCommand) -> Result<()
Ok(())
}
#[cfg(test)]
#[path = "tests/edit_review.rs"]
mod tests;

View File

@@ -60,7 +60,3 @@ async fn build_following_filter(
remote_actor_urls: remote_urls,
})
}
#[cfg(test)]
#[path = "tests/get_activity_feed.rs"]
mod tests;

View File

@@ -29,7 +29,3 @@ pub async fn execute(
deps.diary.query_diary(&filter).await
}
#[cfg(test)]
#[path = "tests/get_diary.rs"]
mod tests;

View File

@@ -42,7 +42,3 @@ pub async fn execute(
profile,
})
}
#[cfg(test)]
#[path = "tests/get_movie_social_page.rs"]
mod tests;

View File

@@ -22,7 +22,3 @@ pub async fn execute(
Ok((history, trend))
}
#[cfg(test)]
#[path = "tests/get_review_history.rs"]
mod tests;

View File

@@ -52,7 +52,3 @@ pub async fn execute(
entries: page.items,
})
}
#[cfg(test)]
#[path = "tests/get_user_feed.rs"]
mod tests;

View File

@@ -10,7 +10,3 @@ pub async fn execute(
) -> Result<(), DomainError> {
review_logger.log_review(cmd).await
}
#[cfg(test)]
#[path = "tests/log_review.rs"]
mod tests;

View File

@@ -179,7 +179,3 @@ impl ResolutionStrategy for ManualMovieStrategy {
}
}
}
#[cfg(test)]
#[path = "tests/movie_resolver.rs"]
mod tests;

View File

@@ -110,7 +110,3 @@ impl ReviewLogger for DefaultReviewLogger {
self.event_publisher.publish(&review_event).await
}
}
#[cfg(test)]
#[path = "tests/review_logger.rs"]
mod tests;

View File

@@ -1,85 +0,0 @@
use domain::testing::{test_movie_with_year, test_review};
use std::sync::Arc;
use domain::{
ports::{MovieCommand, MovieQuery, ReviewRepository},
testing::{FakeDiaryQuery, InMemoryStore, NoopEventPublisher},
value_objects::{MovieId, UserId},
};
use crate::{
diary::commands::DeleteReviewCommand, diary::delete_review, diary::deps::DeleteReviewDeps,
};
#[tokio::test]
async fn test_delete_review_removes_it() {
let movies = InMemoryStore::new();
let reviews = Arc::clone(&movies);
let diary = FakeDiaryQuery::new();
let events = NoopEventPublisher::new();
let movie = test_movie_with_year("Terminator", 1984);
let user_id = UserId::from_uuid(uuid::Uuid::new_v4());
let review = test_review(movie.id(), &user_id, 4);
movies.upsert_movie(&movie).await.unwrap();
reviews.save_review(&review).await.unwrap();
diary.seed_history(movie.clone(), vec![]);
let deps = DeleteReviewDeps {
review: Arc::clone(&reviews) as _,
diary: diary.clone() as _,
movie_command: Arc::clone(&movies) as _,
event_publisher: Arc::clone(&events) as _,
};
delete_review::execute(
&deps,
DeleteReviewCommand {
review_id: review.id().value(),
requesting_user_id: user_id.value(),
},
)
.await
.unwrap();
assert_eq!(reviews.review_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 = InMemoryStore::new();
let diary = FakeDiaryQuery::new();
let movies = Arc::clone(&reviews);
let events = NoopEventPublisher::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 = test_review(&movie_id, &owner_id, 4);
reviews.save_review(&review).await.unwrap();
let deps = DeleteReviewDeps {
review: Arc::clone(&reviews) as _,
diary: diary as _,
movie_command: movies as _,
event_publisher: Arc::clone(&events) as _,
};
let result = delete_review::execute(
&deps,
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.review_count(), 1, "review should still exist");
}

View File

@@ -1,155 +0,0 @@
use crate::test_support::edit_review_command;
use std::sync::Arc;
use domain::{
models::ReviewEdit,
ports::ReviewRepository,
testing::{InMemoryStore, NoopEventPublisher, test_review},
value_objects::{Comment, MovieId, ReviewId, UserId, WatchMedium},
};
use crate::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review};
async fn setup() -> (
Arc<InMemoryStore>,
Arc<NoopEventPublisher>,
ReviewId,
UserId,
) {
let reviews = InMemoryStore::new();
let events = NoopEventPublisher::new();
let user_id = UserId::generate();
let review = test_review(&MovieId::generate(), &user_id, 3).apply_edit(ReviewEdit {
comment: Some(Some(Comment::new("original comment".into()).unwrap())),
watch_medium: Some(Some(WatchMedium::Streaming)),
..Default::default()
});
let review_id = review.id().clone();
reviews.save_review(&review).await.unwrap();
(reviews, events, review_id, user_id)
}
fn deps(reviews: &Arc<InMemoryStore>, events: &Arc<NoopEventPublisher>) -> EditReviewDeps {
EditReviewDeps {
review: Arc::clone(reviews) as _,
event_publisher: Arc::clone(events) as _,
}
}
#[tokio::test]
async fn edit_own_review_updates_rating() {
let (reviews, events, review_id, user_id) = setup().await;
edit_review::execute(
&deps(&reviews, &events),
EditReviewCommand {
review_id: review_id.value(),
requesting_user_id: user_id.value(),
..edit_review_command(5)
},
)
.await
.unwrap();
let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap();
assert_eq!(updated.rating().value(), 5);
assert_eq!(updated.comment().unwrap().value(), "original comment");
assert_eq!(updated.watch_medium(), Some(&WatchMedium::Streaming));
}
#[tokio::test]
async fn edit_nonexistent_review_returns_not_found() {
let reviews = InMemoryStore::new();
let events = NoopEventPublisher::new();
let err = edit_review::execute(&deps(&reviews, &events), edit_review_command(5))
.await
.unwrap_err();
assert!(matches!(err, domain::errors::DomainError::NotFound(_)));
}
#[tokio::test]
async fn edit_other_users_review_returns_forbidden() {
let (reviews, events, review_id, _user_id) = setup().await;
let err = edit_review::execute(
&deps(&reviews, &events),
EditReviewCommand {
review_id: review_id.value(),
..edit_review_command(5)
},
)
.await
.unwrap_err();
assert!(matches!(err, domain::errors::DomainError::Forbidden(_)));
}
#[tokio::test]
async fn edit_publishes_review_updated_event() {
let (reviews, events, review_id, user_id) = setup().await;
edit_review::execute(
&deps(&reviews, &events),
EditReviewCommand {
review_id: review_id.value(),
requesting_user_id: user_id.value(),
..edit_review_command(1)
},
)
.await
.unwrap();
let published = events.published();
assert_eq!(published.len(), 1);
assert!(matches!(
published[0],
domain::events::DomainEvent::ReviewUpdated { .. }
));
}
#[tokio::test]
async fn edit_sets_watch_medium() {
let (reviews, events, review_id, user_id) = setup().await;
edit_review::execute(
&deps(&reviews, &events),
EditReviewCommand {
rating: None,
review_id: review_id.value(),
requesting_user_id: user_id.value(),
watch_medium: Some(Some(WatchMedium::Cinema)),
..edit_review_command(1)
},
)
.await
.unwrap();
let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap();
assert_eq!(updated.watch_medium(), Some(&WatchMedium::Cinema));
}
#[tokio::test]
async fn editing_a_review_fails_when_the_store_refuses() {
let (_, events, review_id, user_id) = setup().await;
let deps = EditReviewDeps {
review: Arc::new(domain::testing::RefusingReviewRepository),
event_publisher: Arc::clone(&events) as _,
};
let result = edit_review::execute(
&deps,
EditReviewCommand {
review_id: review_id.value(),
requesting_user_id: user_id.value(),
..edit_review_command(5)
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused"
));
}

View File

@@ -1,178 +0,0 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::errors::DomainError;
use domain::testing::InMemoryStore;
use domain::value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId};
use crate::{
config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed,
diary::queries::GetActivityFeedQuery, test_helpers::TestContextBuilder,
};
fn default_deps() -> GetActivityFeedDeps {
GetActivityFeedDeps {
diary: domain::testing::FakeDiaryQuery::new() as _,
social_query: InMemoryStore::new() as _,
config: TestContextBuilder::new().config,
}
}
#[tokio::test]
async fn returns_empty_feed() {
let deps = default_deps();
let result = get_activity_feed::execute(
&deps,
GetActivityFeedQuery {
limit: 10,
offset: 0,
sort_by: domain::models::FeedSortBy::Date,
search: None,
viewer_user_id: None,
filter_following: false,
},
)
.await
.unwrap();
assert!(result.items.is_empty());
assert_eq!(result.total_count, 0);
}
#[tokio::test]
async fn returns_feed_with_following_filter() {
let deps = default_deps();
let viewer = uuid::Uuid::new_v4();
let result = get_activity_feed::execute(
&deps,
GetActivityFeedQuery {
limit: 10,
offset: 0,
sort_by: domain::models::FeedSortBy::Date,
search: None,
viewer_user_id: Some(viewer),
filter_following: true,
},
)
.await
.unwrap();
assert!(result.items.is_empty());
}
struct FakeSocialWithFollowing(Vec<SocialActor>);
#[async_trait]
impl domain::ports::FollowGraphQuery for FakeSocialWithFollowing {
async fn get_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(self.0.clone())
}
async fn get_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
}
async fn get_pending_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
}
async fn get_pending_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
}
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_followers(&self, _: &UserId) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_pending_followers(&self, _: &UserId) -> Result<usize, DomainError> {
Ok(0)
}
async fn get_relation(
&self,
_: &UserId,
_: &SocialIdentity,
) -> Result<FollowRelation, DomainError> {
Ok(FollowRelation::default())
}
}
#[tokio::test]
async fn following_filter_separates_local_and_remote() {
let viewer = uuid::Uuid::new_v4();
let local_friend = uuid::Uuid::new_v4();
let following = vec![
SocialActor {
identity: SocialIdentity::Local(UserId::from_uuid(local_friend)),
handle: "friend".into(),
display_name: None,
avatar_url: None,
},
SocialActor {
identity: SocialIdentity::Remote {
actor_url: "https://remote.example/actor/1".into(),
},
handle: "@alice@remote.example".into(),
display_name: None,
avatar_url: None,
},
];
let social = Arc::new(FakeSocialWithFollowing(following));
let deps = GetActivityFeedDeps {
diary: domain::testing::FakeDiaryQuery::new() as _,
social_query: social as _,
config: AppConfig {
allow_registration: true,
base_url: "http://localhost:3000".into(),
rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
secure_cookies: false,
cors_origins: String::new(),
wrapup: crate::config::WrapUpConfig {
font_path: None,
logo_path: None,
bg_dir: None,
},
},
};
let result = get_activity_feed::execute(
&deps,
GetActivityFeedQuery {
limit: 10,
offset: 0,
sort_by: domain::models::FeedSortBy::Date,
search: None,
viewer_user_id: Some(viewer),
filter_following: true,
},
)
.await
.unwrap();
assert!(result.items.is_empty());
}
#[tokio::test]
async fn following_filter_without_viewer_returns_none() {
let deps = default_deps();
let result = get_activity_feed::execute(
&deps,
GetActivityFeedQuery {
limit: 10,
offset: 0,
sort_by: domain::models::FeedSortBy::Date,
search: None,
viewer_user_id: None,
filter_following: true,
},
)
.await
.unwrap();
assert!(result.items.is_empty());
}

View File

@@ -1,26 +0,0 @@
use domain::testing::FakeDiaryQuery;
use std::sync::Arc;
use crate::{diary::deps::GetDiaryDeps, diary::get_diary, diary::queries::GetDiaryQuery};
#[tokio::test]
async fn returns_empty_page() {
let diary = FakeDiaryQuery::new() as Arc<dyn domain::ports::DiaryQuery>;
let deps = GetDiaryDeps { diary };
let result = get_diary::execute(
&deps,
GetDiaryQuery {
limit: None,
offset: None,
sort_by: None,
movie_id: None,
user_id: None,
},
)
.await
.unwrap();
assert!(result.items.is_empty());
assert_eq!(result.total_count, 0);
}

View File

@@ -1,64 +0,0 @@
use domain::testing::test_movie_with_year;
use std::sync::Arc;
use uuid::Uuid;
use domain::{
ports::MovieCommand,
testing::{FakeDiaryQuery, InMemoryStore},
};
use crate::{
diary::deps::GetMovieSocialPageDeps, diary::get_movie_social_page,
diary::queries::GetMovieSocialPageQuery,
};
#[tokio::test]
async fn fails_when_movie_not_found() {
let deps = GetMovieSocialPageDeps {
movie_query: InMemoryStore::new(),
diary: FakeDiaryQuery::new() as _,
movie_profile: InMemoryStore::new(),
};
let result = get_movie_social_page::execute(
&deps,
GetMovieSocialPageQuery {
movie_id: Uuid::new_v4(),
limit: 10,
offset: 0,
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn returns_movie_social_page() {
let movies = InMemoryStore::new();
let movie = test_movie_with_year("Social Movie", 2024);
let movie_uuid = movie.id().value();
movies.upsert_movie(&movie).await.unwrap();
let deps = GetMovieSocialPageDeps {
movie_query: Arc::clone(&movies) as _,
diary: FakeDiaryQuery::new() as _,
movie_profile: InMemoryStore::new(),
};
let result = get_movie_social_page::execute(
&deps,
GetMovieSocialPageQuery {
movie_id: movie_uuid,
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(result.movie.title().value(), "Social Movie");
assert_eq!(result.reviews.items.len(), 0);
}

View File

@@ -1,27 +0,0 @@
use domain::testing::test_movie_with_year;
use std::sync::Arc;
use domain::{ports::DiaryQuery, services::review_history::Trend};
use crate::{
diary::deps::GetReviewHistoryDeps, diary::get_review_history,
diary::queries::GetReviewHistoryQuery,
};
#[tokio::test]
async fn returns_empty_history() {
let movie = test_movie_with_year("Test", 2024);
let movie_id = movie.id().value();
let diary = domain::testing::FakeDiaryQuery::new();
diary.seed_history(movie, vec![]);
let diary: Arc<dyn DiaryQuery> = diary;
let deps = GetReviewHistoryDeps { diary };
let (history, trend) = get_review_history::execute(&deps, GetReviewHistoryQuery { movie_id })
.await
.unwrap();
assert!(history.viewings().is_empty());
assert_eq!(trend, Trend::Neutral);
}

View File

@@ -1,72 +0,0 @@
use crate::test_support::register_command;
use domain::testing::{test_movie_with_year, test_review};
use std::sync::Arc;
use uuid::Uuid;
use domain::errors::DomainError;
use domain::models::{DiaryEntry, collections::Paginated};
use domain::testing::FakeDiaryQuery;
use domain::value_objects::{Email, UserId};
use crate::auth::deps::RegisterDeps;
use crate::auth::register;
use crate::diary::deps::GetUserFeedDeps;
use crate::diary::get_user_feed;
use crate::test_helpers::TestContextBuilder;
async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) {
let deps = RegisterDeps {
user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
config: b.config.clone(),
};
register::execute(&deps, register_command(email, username))
.await
.unwrap();
}
#[tokio::test]
async fn user_feed_carries_author_and_entries() {
let b = TestContextBuilder::new();
setup_user(&b, "feed@test.com", "feeduser").await;
let email = Email::new("feed@test.com".into()).unwrap();
let user = b.user_repo.find_by_email(&email).await.unwrap().unwrap();
let uid = user.id().value();
let diary = FakeDiaryQuery::new();
let movie = test_movie_with_year("Feed Movie", 2020);
let review = test_review(movie.id(), &UserId::from_uuid(uid), 5);
diary.set_diary_page(Paginated {
items: vec![DiaryEntry::new(movie, review)],
total_count: 1,
limit: 50,
offset: 0,
});
let deps = GetUserFeedDeps {
user: b.user_repo.clone(),
diary: Arc::clone(&diary) as _,
};
let feed = get_user_feed::execute(&deps, uid, 50).await.unwrap();
assert_eq!(feed.author.display_name, "feed");
assert_eq!(feed.entries.len(), 1);
}
#[tokio::test]
async fn user_feed_is_not_found_for_unknown_user() {
let b = TestContextBuilder::new();
let deps = GetUserFeedDeps {
user: b.user_repo.clone(),
diary: b.diary_repo.clone(),
};
let err = match get_user_feed::execute(&deps, Uuid::new_v4(), 50).await {
Err(e) => e,
Ok(_) => panic!("expected Err(NotFound) for an unknown user id, got Ok"),
};
assert!(matches!(err, DomainError::NotFound(_)));
}

View File

@@ -1,81 +0,0 @@
use crate::test_support::{log_review_command, log_review_command_manual};
use domain::testing::test_movie_with_year;
use std::sync::Arc;
use domain::ports::MovieCommand;
use domain::testing::{InMemoryStore, NoopEventPublisher};
use crate::{
diary::commands::LogReviewCommand, diary::log_review,
diary::review_logger::DefaultReviewLogger, test_helpers::TestContextBuilder,
};
fn build_logger(
movies: &Arc<InMemoryStore>,
reviews: &Arc<InMemoryStore>,
events: &Arc<NoopEventPublisher>,
) -> Arc<dyn crate::ports::ReviewLogger> {
Arc::new(DefaultReviewLogger::new(
Arc::clone(movies) as _,
Arc::clone(movies) as _,
Arc::clone(reviews) as _,
TestContextBuilder::new().watchlist_repo,
Arc::new(domain::testing::FakeMetadataClient) as _,
Arc::clone(events) as _,
))
}
#[tokio::test]
async fn test_log_review_creates_movie_and_review() {
let movies = InMemoryStore::new();
let reviews = Arc::clone(&movies);
let events = NoopEventPublisher::new();
let logger = build_logger(&movies, &reviews, &events);
let user_id = uuid::Uuid::new_v4();
let cmd = log_review_command_manual(user_id, "Blade Runner", 1982);
log_review::execute(&logger, cmd).await.unwrap();
assert_eq!(reviews.review_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 = InMemoryStore::new();
let reviews = Arc::clone(&movies);
let existing_movie = test_movie_with_year("Alien", 1979);
let movie_uuid = existing_movie.id().value();
movies.upsert_movie(&existing_movie).await.unwrap();
let events = NoopEventPublisher::new();
let logger = build_logger(&movies, &reviews, &events);
let cmd = LogReviewCommand {
rating: 5,
..log_review_command(uuid::Uuid::new_v4(), movie_uuid)
};
log_review::execute(&logger, cmd).await.unwrap();
assert_eq!(movies.movie_count(), 1, "no duplicate movie");
assert_eq!(reviews.review_count(), 1);
}
#[tokio::test]
async fn test_log_review_with_invalid_rating_fails() {
let movies = InMemoryStore::new();
let reviews = Arc::clone(&movies);
let events = NoopEventPublisher::new();
let logger = build_logger(&movies, &reviews, &events);
let cmd = LogReviewCommand {
rating: 6,
..log_review_command_manual(uuid::Uuid::new_v4(), "Some Film", 2000)
};
let result = log_review::execute(&logger, cmd).await;
assert!(result.is_err(), "rating > 5 should fail");
}

View File

@@ -1,388 +0,0 @@
use super::*;
use crate::diary::commands::MovieInput;
use domain::testing::test_movie_with_year;
use domain::{
errors::DomainError,
models::{MetadataSearchCriteria, Movie},
ports::MovieQuery,
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear},
};
fn make_input(ext_id: Option<&str>, title: Option<&str>, year: Option<u16>) -> MovieInput {
MovieInput {
movie_id: None,
external_metadata_id: ext_id.map(String::from),
manual_title: title.map(String::from),
manual_release_year: year,
manual_director: None,
}
}
struct RepoWithExternalMovie(Movie);
struct RepoEmpty;
struct RepoWithTitleMatch(Movie);
#[async_trait::async_trait]
impl MovieQuery for RepoWithExternalMovie {
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
Ok(Some(self.0.clone()))
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
panic!("unexpected")
}
async fn existing_external_ids(
&self,
_: &[ExternalMetadataId],
) -> Result<std::collections::HashSet<String>, DomainError> {
panic!("unexpected")
}
async fn existing_title_year_pairs(
&self,
_: &[(MovieTitle, ReleaseYear)],
) -> Result<std::collections::HashSet<(String, u16)>, DomainError> {
panic!("unexpected")
}
async fn list_movies(
&self,
_: &domain::models::collections::PageParams,
_: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
panic!("unexpected")
}
async fn list_movies_with_external_id(
&self,
) -> Result<Vec<domain::models::Movie>, DomainError> {
Ok(vec![])
}
}
#[async_trait::async_trait]
impl MovieQuery for RepoEmpty {
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
Ok(None)
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![])
}
async fn existing_external_ids(
&self,
_: &[ExternalMetadataId],
) -> Result<std::collections::HashSet<String>, DomainError> {
panic!("unexpected")
}
async fn existing_title_year_pairs(
&self,
_: &[(MovieTitle, ReleaseYear)],
) -> Result<std::collections::HashSet<(String, u16)>, DomainError> {
panic!("unexpected")
}
async fn list_movies(
&self,
_: &domain::models::collections::PageParams,
_: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
panic!("unexpected")
}
async fn list_movies_with_external_id(
&self,
) -> Result<Vec<domain::models::Movie>, DomainError> {
Ok(vec![])
}
}
#[async_trait::async_trait]
impl MovieQuery for RepoWithTitleMatch {
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![self.0.clone()])
}
async fn existing_external_ids(
&self,
_: &[ExternalMetadataId],
) -> Result<std::collections::HashSet<String>, DomainError> {
panic!("unexpected")
}
async fn existing_title_year_pairs(
&self,
_: &[(MovieTitle, ReleaseYear)],
) -> Result<std::collections::HashSet<(String, u16)>, DomainError> {
panic!("unexpected")
}
async fn list_movies(
&self,
_: &domain::models::collections::PageParams,
_: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
panic!("unexpected")
}
async fn list_movies_with_external_id(
&self,
) -> Result<Vec<domain::models::Movie>, DomainError> {
Ok(vec![])
}
}
struct MetaReturnsMovie(Movie);
struct MetaErrors;
#[async_trait::async_trait]
impl MetadataClient for MetaReturnsMovie {
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
Ok(self.0.clone())
}
async fn get_poster_url(
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
panic!("unexpected")
}
}
#[async_trait::async_trait]
impl MetadataClient for MetaErrors {
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
Err(DomainError::InfrastructureError(
"metadata unavailable".into(),
))
}
async fn get_poster_url(
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
panic!("unexpected")
}
}
// --- ExternalIdStrategy ---
#[test]
fn external_id_strategy_can_handle_cmd_with_id() {
let input = make_input(Some("tt123"), None, None);
assert!(ExternalIdStrategy.can_handle(&input));
}
#[test]
fn external_id_strategy_cannot_handle_cmd_without_id() {
let input = make_input(None, Some("Inception"), Some(2010));
assert!(!ExternalIdStrategy.can_handle(&input));
}
#[tokio::test]
async fn external_id_strategy_returns_cached_movie() {
let movie = test_movie_with_year("Inception", 2010);
let repo = RepoWithExternalMovie(movie.clone());
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let input = make_input(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, false))));
}
#[tokio::test]
async fn external_id_strategy_fetches_from_metadata_when_not_cached() {
let movie = test_movie_with_year("Inception", 2010);
let repo = RepoEmpty;
let meta = MetaReturnsMovie(movie);
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let input = make_input(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
#[tokio::test]
async fn external_id_strategy_falls_through_on_metadata_error() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let input = make_input(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
assert!(result.is_none());
}
// --- TitleSearchStrategy ---
#[test]
fn title_strategy_can_handle_cmd_with_title() {
let input = make_input(None, Some("Inception"), Some(2010));
assert!(TitleSearchStrategy.can_handle(&input));
}
#[test]
fn title_strategy_cannot_handle_cmd_without_title() {
let input = make_input(Some("tt123"), None, None);
assert!(!TitleSearchStrategy.can_handle(&input));
}
#[tokio::test]
async fn title_strategy_fetches_from_metadata() {
let movie = test_movie_with_year("Inception", 2010);
let repo = RepoEmpty;
let meta = MetaReturnsMovie(movie);
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let input = make_input(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
#[tokio::test]
async fn title_strategy_falls_through_on_metadata_error() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let input = make_input(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&input, &deps).await.unwrap();
assert!(result.is_none());
}
// --- ManualMovieStrategy ---
#[test]
fn manual_strategy_can_handle_cmd_with_title() {
let input = make_input(None, Some("Inception"), Some(2010));
assert!(ManualMovieStrategy.can_handle(&input));
}
#[test]
fn manual_strategy_cannot_handle_cmd_without_title() {
let input = make_input(Some("tt123"), None, None);
assert!(!ManualMovieStrategy.can_handle(&input));
}
#[tokio::test]
async fn manual_strategy_returns_existing_movie() {
let movie = test_movie_with_year("Inception", 2010);
let repo = RepoWithTitleMatch(movie.clone());
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let input = make_input(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, false))));
}
#[tokio::test]
async fn manual_strategy_creates_new_movie_when_no_match() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let input = make_input(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
#[tokio::test]
async fn manual_strategy_errors_without_year() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let input = make_input(None, Some("Inception"), None);
assert!(ManualMovieStrategy.resolve(&input, &deps).await.is_err());
}
// --- MovieResolver pipeline ---
#[tokio::test]
async fn resolver_returns_error_when_no_strategy_matches() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let input = make_input(None, None, None);
let result = MovieResolver::default_pipeline()
.resolve(&input, &deps)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn resolver_uses_cached_movie_when_external_id_matches() {
let movie = test_movie_with_year("Inception", 2010);
let repo = RepoWithExternalMovie(movie.clone());
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let input = make_input(Some("tt123"), None, None);
let (_, is_new) = MovieResolver::default_pipeline()
.resolve(&input, &deps)
.await
.unwrap();
assert!(!is_new);
}
#[tokio::test]
async fn resolver_falls_through_to_manual_when_external_and_title_both_fail() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let input = make_input(Some("tt123"), Some("Inception"), Some(2010));
let (_, is_new) = MovieResolver::default_pipeline()
.resolve(&input, &deps)
.await
.unwrap();
assert!(is_new);
}

View File

@@ -1,266 +0,0 @@
use crate::test_support::{log_review_command, log_review_command_manual};
use domain::testing::{test_movie_with_external_id, test_movie_with_year};
use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::WatchlistEntry,
models::{MetadataSearchCriteria, Movie},
ports::{MetadataClient, MovieCommand, WatchlistRepository},
testing::{FakeMetadataClient, InMemoryStore, NoopEventPublisher},
value_objects::{ExternalMetadataId, MovieId, PosterUrl, UserId},
};
use uuid::Uuid;
use crate::diary::commands::LogReviewCommand;
use crate::diary::review_logger::DefaultReviewLogger;
use crate::ports::ReviewLogger;
fn make_logger(
movies: &Arc<InMemoryStore>,
reviews: &Arc<InMemoryStore>,
watchlist: &Arc<InMemoryStore>,
events: &Arc<NoopEventPublisher>,
) -> DefaultReviewLogger {
DefaultReviewLogger::new(
Arc::clone(movies) as _,
Arc::clone(movies) as _,
Arc::clone(reviews) as _,
Arc::clone(watchlist) as _,
Arc::new(FakeMetadataClient) as _,
Arc::clone(events) as _,
)
}
#[tokio::test]
async fn logs_review_with_manual_movie() {
let movies = InMemoryStore::new();
let reviews = Arc::clone(&movies);
let watchlist = Arc::clone(&movies);
let events = NoopEventPublisher::new();
let logger = make_logger(&movies, &reviews, &watchlist, &events);
let uid = Uuid::new_v4();
let cmd = log_review_command_manual(uid, "Test Film", 2024);
logger.log_review(cmd).await.unwrap();
assert_eq!(movies.movie_count(), 1);
assert_eq!(reviews.review_count(), 1);
}
#[tokio::test]
async fn removes_from_watchlist_on_review() {
let movies = InMemoryStore::new();
let reviews = Arc::clone(&movies);
let watchlist = Arc::clone(&movies);
let events = NoopEventPublisher::new();
let logger = make_logger(&movies, &reviews, &watchlist, &events);
let uid = Uuid::new_v4();
let user_id = UserId::from_uuid(uid);
// Create and store movie
let movie = test_movie_with_year("Watchlisted Film", 2024);
let movie_id = movie.id().value();
movies.upsert_movie(&movie).await.unwrap();
// Add to watchlist
let entry = WatchlistEntry::new(user_id.clone(), MovieId::from_uuid(movie_id));
watchlist.add(&entry).await.unwrap();
assert_eq!(watchlist.watchlist_count(), 1);
// Log review for same movie
let cmd = LogReviewCommand {
rating: 5,
..log_review_command(uid, movie_id)
};
logger.log_review(cmd).await.unwrap();
assert_eq!(watchlist.watchlist_count(), 0);
}
#[tokio::test]
async fn logs_review_with_existing_movie_by_id() {
let movies = InMemoryStore::new();
let reviews = Arc::clone(&movies);
let watchlist = Arc::clone(&movies);
let events = NoopEventPublisher::new();
let logger = make_logger(&movies, &reviews, &watchlist, &events);
let movie = test_movie_with_year("Existing Film", 2020);
let movie_uuid = movie.id().value();
movies.upsert_movie(&movie).await.unwrap();
let cmd = LogReviewCommand {
rating: 3,
..log_review_command(Uuid::new_v4(), movie_uuid)
};
logger.log_review(cmd).await.unwrap();
assert_eq!(movies.movie_count(), 1);
assert_eq!(reviews.review_count(), 1);
}
#[tokio::test]
async fn existing_movie_not_found_returns_error() {
let movies = InMemoryStore::new();
let reviews = Arc::clone(&movies);
let watchlist = Arc::clone(&movies);
let events = NoopEventPublisher::new();
let logger = make_logger(&movies, &reviews, &watchlist, &events);
let cmd = log_review_command(Uuid::new_v4(), Uuid::new_v4());
assert!(logger.log_review(cmd).await.is_err());
}
#[tokio::test]
async fn invalid_rating_returns_error() {
let movies = InMemoryStore::new();
let reviews = Arc::clone(&movies);
let watchlist = Arc::clone(&movies);
let events = NoopEventPublisher::new();
let logger = make_logger(&movies, &reviews, &watchlist, &events);
let cmd = LogReviewCommand {
rating: 6,
..log_review_command_manual(Uuid::new_v4(), "Film", 2024)
};
let result = logger.log_review(cmd).await;
assert!(result.is_err());
// No repo calls should have happened
assert_eq!(movies.movie_count(), 0);
assert_eq!(reviews.review_count(), 0);
}
#[tokio::test]
async fn watchlist_not_present_does_not_publish_removed() {
let movies = InMemoryStore::new();
let reviews = Arc::clone(&movies);
let watchlist = Arc::clone(&movies);
let events = NoopEventPublisher::new();
let logger = make_logger(&movies, &reviews, &watchlist, &events);
let cmd = log_review_command_manual(Uuid::new_v4(), "No Watchlist Film", 2024);
logger.log_review(cmd).await.unwrap();
let published = events.published();
assert!(
!published
.iter()
.any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })),
"should not publish WatchlistEntryRemoved when not on watchlist"
);
}
struct MetadataClientWithExternalId;
#[async_trait]
impl MetadataClient for MetadataClientWithExternalId {
async fn fetch_movie_metadata(
&self,
_criteria: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
Ok(test_movie_with_external_id(
"Discovered Film",
2024,
ExternalMetadataId::new("tmdb:99999".into()).unwrap(),
))
}
async fn get_poster_url(
&self,
_external_metadata_id: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
Ok(None)
}
}
#[tokio::test]
async fn publishes_movie_discovered_for_new_movie_with_external_id() {
let movies = InMemoryStore::new();
let reviews = Arc::clone(&movies);
let watchlist = Arc::clone(&movies);
let events = NoopEventPublisher::new();
let logger = DefaultReviewLogger::new(
Arc::clone(&movies) as _,
Arc::clone(&movies) as _,
Arc::clone(&reviews) as _,
Arc::clone(&watchlist) as _,
Arc::new(MetadataClientWithExternalId) as _,
Arc::clone(&events) as _,
);
let cmd = LogReviewCommand {
rating: 5,
..log_review_command_manual(Uuid::new_v4(), "Discovered Film", 2024)
};
logger.log_review(cmd).await.unwrap();
let published = events.published();
assert!(
published
.iter()
.any(|e| matches!(e, domain::events::DomainEvent::MovieDiscovered { .. })),
"should publish MovieDiscovered for new movie with external_metadata_id"
);
assert!(
published.iter().any(|e| matches!(
e,
domain::events::DomainEvent::MovieEnrichmentRequested { .. }
)),
"should publish MovieEnrichmentRequested"
);
}
#[tokio::test]
async fn logging_a_review_fails_when_the_review_store_refuses() {
let movies = InMemoryStore::new();
let logger = DefaultReviewLogger::new(
Arc::clone(&movies) as _,
Arc::clone(&movies) as _,
Arc::new(domain::testing::RefusingReviewRepository),
Arc::clone(&movies) as _,
Arc::new(FakeMetadataClient),
NoopEventPublisher::new(),
);
let result = logger
.log_review(log_review_command_manual(Uuid::new_v4(), "Test Film", 2024))
.await;
assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused"));
}
#[tokio::test]
async fn logging_a_review_when_event_publishing_fails_returns_the_error_but_keeps_the_saved_review()
{
let movies = InMemoryStore::new();
let logger = DefaultReviewLogger::new(
Arc::clone(&movies) as _,
Arc::clone(&movies) as _,
Arc::clone(&movies) as _,
Arc::clone(&movies) as _,
Arc::new(FakeMetadataClient),
Arc::new(domain::testing::RefusingEventPublisher),
);
let result = logger
.log_review(log_review_command_manual(Uuid::new_v4(), "Test Film", 2024))
.await;
assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused"));
assert_eq!(
movies.review_count(),
1,
"review is saved before publishing"
);
}