add 400+ unit tests for domain and application layers
Some checks failed
CI / Check / Test (push) Has been cancelled
Some checks failed
CI / Check / Test (push) Has been cancelled
Extract ReviewLogger trait to decouple import/integrations from diary::log_review (cross-module coupling smell). Add in-memory fakes for all repository ports, enabling isolated testing of every use case module without a database. Coverage: domain+application 22% → 80%, 427 tests.
This commit is contained in:
@@ -190,11 +190,13 @@ make test # cargo test
|
||||
## Test
|
||||
|
||||
```bash
|
||||
cargo test # full workspace (requires DATABASE_URL for sqlx offline checks)
|
||||
cargo test -p application # domain-level unit tests only — no database required
|
||||
cargo test # full workspace (requires DATABASE_URL for sqlx offline checks)
|
||||
cargo test -p application # business logic tests only — no database required
|
||||
cargo test -p domain # domain model + value object tests
|
||||
cargo llvm-cov -p application -p domain # line coverage report (requires cargo-llvm-cov)
|
||||
```
|
||||
|
||||
The `application` crate has unit tests for core use cases backed by in-memory fakes from `domain`'s `test-helpers` feature. These run without a database and are the fastest feedback loop for business logic changes.
|
||||
The `application` and `domain` crates have 400+ unit tests covering all use case modules (auth, diary, goals, import, integrations, movies, person, search, users, watchlist, wrapup) backed by in-memory fakes from `domain`'s `test-helpers` feature. These run without a database and are the fastest feedback loop for business logic changes.
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@@ -30,3 +30,7 @@ pub async fn execute(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/register_and_login.rs"]
|
||||
mod tests;
|
||||
|
||||
22
crates/application/src/auth/tests/register_and_login.rs
Normal file
22
crates/application/src/auth/tests/register_and_login.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use crate::auth::commands::RegisterAndLoginCommand;
|
||||
use crate::auth::register_and_login;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn registers_and_returns_token() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = register_and_login::execute(
|
||||
&ctx,
|
||||
RegisterAndLoginCommand {
|
||||
email: "new@example.com".into(),
|
||||
username: "newuser".into(),
|
||||
password: "password123".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.token.is_empty());
|
||||
assert_eq!(result.email, "new@example.com");
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use domain::ports::{
|
||||
};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::ports::ReviewLogger;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Repositories {
|
||||
@@ -49,6 +50,7 @@ pub struct Services {
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub diary_exporter: Arc<dyn DiaryExporter>,
|
||||
pub document_parser: Arc<dyn DocumentParser>,
|
||||
pub review_logger: Arc<dyn ReviewLogger>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -64,3 +64,7 @@ async fn build_following_filter(
|
||||
remote_actor_urls: remote_urls,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_activity_feed.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -27,3 +27,7 @@ pub async fn execute(
|
||||
|
||||
ctx.repos.diary.query_diary(&filter).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_diary.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -43,3 +43,7 @@ pub async fn execute(
|
||||
profile,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_movie_social_page.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -21,3 +21,7 @@ pub async fn execute(
|
||||
|
||||
Ok((history, trend))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_review_history.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,98 +1,11 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Movie, Review},
|
||||
value_objects::{Comment, MovieId, Rating, UserId},
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::{
|
||||
context::AppContext,
|
||||
diary::commands::LogReviewCommand,
|
||||
diary::movie_resolver::{MovieResolver, MovieResolverDeps},
|
||||
};
|
||||
use crate::{context::AppContext, diary::commands::LogReviewCommand};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), DomainError> {
|
||||
let rating = Rating::new(cmd.rating)?;
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
|
||||
|
||||
let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id {
|
||||
let movie_id = MovieId::from_uuid(id);
|
||||
let movie = ctx
|
||||
.repos
|
||||
.movie
|
||||
.get_movie_by_id(&movie_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
|
||||
(movie, false)
|
||||
} else {
|
||||
let deps = MovieResolverDeps {
|
||||
repository: ctx.repos.movie.as_ref(),
|
||||
metadata_client: ctx.services.metadata.as_ref(),
|
||||
};
|
||||
MovieResolver::default_pipeline()
|
||||
.resolve(&cmd.input, &deps)
|
||||
.await?
|
||||
};
|
||||
|
||||
ctx.repos.movie.upsert_movie(&movie).await?;
|
||||
|
||||
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
|
||||
let review_event = ctx.repos.review.save_review(&review).await?;
|
||||
|
||||
let was_on_watchlist = ctx
|
||||
.repos
|
||||
.watchlist
|
||||
.remove_if_present(review.user_id(), review.movie_id())
|
||||
.await?;
|
||||
if was_on_watchlist {
|
||||
let _ = ctx
|
||||
.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::WatchlistEntryRemoved {
|
||||
user_id: review.user_id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
publish_events(ctx, &movie, is_new_movie, review_event).await?;
|
||||
|
||||
Ok(())
|
||||
ctx.services.review_logger.log_review(cmd).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/log_review.rs"]
|
||||
mod tests;
|
||||
|
||||
async fn publish_events(
|
||||
ctx: &AppContext,
|
||||
movie: &Movie,
|
||||
is_new_movie: bool,
|
||||
review_event: DomainEvent,
|
||||
) -> Result<(), DomainError> {
|
||||
if is_new_movie && let Some(ext_id) = movie.external_metadata_id() {
|
||||
let discovery_event = DomainEvent::MovieDiscovered {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
};
|
||||
ctx.services
|
||||
.event_publisher
|
||||
.publish(&discovery_event)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
let enrichment_event = DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.value().to_string(),
|
||||
};
|
||||
ctx.services
|
||||
.event_publisher
|
||||
.publish(&enrichment_event)
|
||||
.await?;
|
||||
}
|
||||
|
||||
ctx.services.event_publisher.publish(&review_event).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod get_review_history;
|
||||
pub mod log_review;
|
||||
pub mod movie_resolver;
|
||||
pub mod queries;
|
||||
pub mod review_logger;
|
||||
|
||||
121
crates/application/src/diary/review_logger.rs
Normal file
121
crates/application/src/diary/review_logger.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Movie, Review},
|
||||
ports::{
|
||||
EventPublisher, MetadataClient, MovieRepository, ReviewRepository, WatchlistRepository,
|
||||
},
|
||||
value_objects::{Comment, MovieId, Rating, UserId},
|
||||
};
|
||||
|
||||
use crate::diary::commands::LogReviewCommand;
|
||||
use crate::diary::movie_resolver::{MovieResolver, MovieResolverDeps};
|
||||
use crate::ports::ReviewLogger;
|
||||
|
||||
pub struct DefaultReviewLogger {
|
||||
movie_repo: Arc<dyn MovieRepository>,
|
||||
review_repo: Arc<dyn ReviewRepository>,
|
||||
watchlist_repo: Arc<dyn WatchlistRepository>,
|
||||
metadata_client: Arc<dyn MetadataClient>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
impl DefaultReviewLogger {
|
||||
pub fn new(
|
||||
movie_repo: Arc<dyn MovieRepository>,
|
||||
review_repo: Arc<dyn ReviewRepository>,
|
||||
watchlist_repo: Arc<dyn WatchlistRepository>,
|
||||
metadata_client: Arc<dyn MetadataClient>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
movie_repo,
|
||||
review_repo,
|
||||
watchlist_repo,
|
||||
metadata_client,
|
||||
event_publisher,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ReviewLogger for DefaultReviewLogger {
|
||||
async fn log_review(&self, cmd: LogReviewCommand) -> Result<(), DomainError> {
|
||||
let rating = Rating::new(cmd.rating)?;
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
|
||||
|
||||
let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id {
|
||||
let movie_id = MovieId::from_uuid(id);
|
||||
let movie = self
|
||||
.movie_repo
|
||||
.get_movie_by_id(&movie_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
|
||||
(movie, false)
|
||||
} else {
|
||||
let deps = MovieResolverDeps {
|
||||
repository: self.movie_repo.as_ref(),
|
||||
metadata_client: self.metadata_client.as_ref(),
|
||||
};
|
||||
MovieResolver::default_pipeline()
|
||||
.resolve(&cmd.input, &deps)
|
||||
.await?
|
||||
};
|
||||
|
||||
self.movie_repo.upsert_movie(&movie).await?;
|
||||
|
||||
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
|
||||
let review_event = self.review_repo.save_review(&review).await?;
|
||||
|
||||
let was_on_watchlist = self
|
||||
.watchlist_repo
|
||||
.remove_if_present(review.user_id(), review.movie_id())
|
||||
.await?;
|
||||
if was_on_watchlist {
|
||||
let _ = self
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::WatchlistEntryRemoved {
|
||||
user_id: review.user_id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
publish_events(&self.event_publisher, &movie, is_new_movie, review_event).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/review_logger.rs"]
|
||||
mod tests;
|
||||
|
||||
async fn publish_events(
|
||||
publisher: &Arc<dyn EventPublisher>,
|
||||
movie: &Movie,
|
||||
is_new_movie: bool,
|
||||
review_event: DomainEvent,
|
||||
) -> Result<(), DomainError> {
|
||||
if is_new_movie && let Some(ext_id) = movie.external_metadata_id() {
|
||||
publisher
|
||||
.publish(&DomainEvent::MovieDiscovered {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
publisher
|
||||
.publish(&DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.value().to_string(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
publisher.publish(&review_event).await
|
||||
}
|
||||
139
crates/application/src/diary/tests/get_activity_feed.rs
Normal file
139
crates/application/src/diary/tests/get_activity_feed.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::{
|
||||
diary::get_activity_feed, diary::queries::GetActivityFeedQuery,
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_feed() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_activity_feed::execute(
|
||||
&ctx,
|
||||
GetActivityFeedQuery {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort_by: domain::ports::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 ctx = TestContextBuilder::new().build();
|
||||
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
|
||||
let result = get_activity_feed::execute(
|
||||
&ctx,
|
||||
GetActivityFeedQuery {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
viewer_user_id: Some(viewer),
|
||||
filter_following: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// NoopSocialQueryPort returns empty following, so FollowingFilter
|
||||
// contains only the viewer's id. Feed is empty but the code path is hit.
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
struct FakeSocialWithFollowing(Vec<String>);
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::SocialQueryPort for FakeSocialWithFollowing {
|
||||
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
) -> Result<Vec<domain::ports::PendingFollowerInfo>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn list_all_followed_remote_actors(
|
||||
&self,
|
||||
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn following_filter_parses_local_and_remote_urls() {
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
let local_friend = uuid::Uuid::new_v4();
|
||||
|
||||
let following_urls = vec![
|
||||
format!("http://localhost:3000/users/{}", local_friend),
|
||||
"https://remote.example/actor/1".to_string(),
|
||||
];
|
||||
|
||||
let social = Arc::new(FakeSocialWithFollowing(following_urls));
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_social_query(social as _)
|
||||
.build();
|
||||
|
||||
let result = get_activity_feed::execute(
|
||||
&ctx,
|
||||
GetActivityFeedQuery {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
viewer_user_id: Some(viewer),
|
||||
filter_following: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Feed is empty (no data seeded), but the build_following_filter code path
|
||||
// with actual URL parsing ran without errors.
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn following_filter_without_viewer_returns_none() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_activity_feed::execute(
|
||||
&ctx,
|
||||
GetActivityFeedQuery {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
viewer_user_id: None,
|
||||
filter_following: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// filter_following=true but viewer_user_id=None → build_following_filter returns None
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
22
crates/application/src/diary/tests/get_diary.rs
Normal file
22
crates/application/src/diary/tests/get_diary.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use crate::{diary::get_diary, diary::queries::GetDiaryQuery, test_helpers::TestContextBuilder};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_page() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_diary::execute(
|
||||
&ctx,
|
||||
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);
|
||||
}
|
||||
65
crates/application/src/diary/tests/get_movie_social_page.rs
Normal file
65
crates/application/src/diary/tests/get_movie_social_page.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{
|
||||
models::Movie,
|
||||
ports::MovieRepository,
|
||||
testing::InMemoryMovieRepository,
|
||||
value_objects::{MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
diary::get_movie_social_page, diary::queries::GetMovieSocialPageQuery,
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_movie_not_found() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_movie_social_page::execute(
|
||||
&ctx,
|
||||
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 = InMemoryMovieRepository::new();
|
||||
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Social Movie".into()).unwrap(),
|
||||
ReleaseYear::new(2024).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 _)
|
||||
.build();
|
||||
|
||||
let result = get_movie_social_page::execute(
|
||||
&ctx,
|
||||
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);
|
||||
}
|
||||
34
crates/application/src/diary/tests/get_review_history.rs
Normal file
34
crates/application/src/diary/tests/get_review_history.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use domain::{
|
||||
models::Movie,
|
||||
services::review_history::Trend,
|
||||
value_objects::{MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
diary::get_review_history, diary::queries::GetReviewHistoryQuery,
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_history() {
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Test".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let movie_id = movie.id().value();
|
||||
|
||||
let diary = domain::testing::FakeDiaryRepository::new();
|
||||
diary.seed_history(movie, vec![]);
|
||||
|
||||
let ctx = TestContextBuilder::new().with_diary(diary as _).build();
|
||||
|
||||
let (history, trend) = get_review_history::execute(&ctx, GetReviewHistoryQuery { movie_id })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(history.viewings().is_empty());
|
||||
assert_eq!(trend, Trend::Neutral);
|
||||
}
|
||||
@@ -13,9 +13,30 @@ use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEve
|
||||
use crate::{
|
||||
diary::commands::{LogReviewCommand, MovieInput},
|
||||
diary::log_review,
|
||||
diary::review_logger::DefaultReviewLogger,
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
fn build_ctx_with_real_logger(
|
||||
movies: &Arc<InMemoryMovieRepository>,
|
||||
reviews: &Arc<InMemoryReviewRepository>,
|
||||
events: &Arc<NoopEventPublisher>,
|
||||
) -> crate::context::AppContext {
|
||||
let logger = Arc::new(DefaultReviewLogger::new(
|
||||
Arc::clone(movies) as _,
|
||||
Arc::clone(reviews) as _,
|
||||
crate::test_helpers::TestContextBuilder::new().watchlist_repo,
|
||||
Arc::new(domain::testing::FakeMetadataClient) as _,
|
||||
Arc::clone(events) as _,
|
||||
));
|
||||
TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(movies) as _)
|
||||
.with_reviews(Arc::clone(reviews) as _)
|
||||
.with_event_publisher(Arc::clone(events) as _)
|
||||
.with_review_logger(logger)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn movie_input_manual(title: &str, year: u16) -> MovieInput {
|
||||
MovieInput {
|
||||
movie_id: None,
|
||||
@@ -41,11 +62,7 @@ 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 ctx = build_ctx_with_real_logger(&movies, &reviews, &events);
|
||||
|
||||
let user_id = uuid::Uuid::new_v4();
|
||||
let cmd = LogReviewCommand {
|
||||
@@ -77,10 +94,8 @@ async fn test_log_review_reuses_existing_movie() {
|
||||
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 events = NoopEventPublisher::new();
|
||||
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events);
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: uuid::Uuid::new_v4(),
|
||||
@@ -98,7 +113,10 @@ async fn test_log_review_reuses_existing_movie() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_review_with_invalid_rating_fails() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events);
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: uuid::Uuid::new_v4(),
|
||||
input: movie_input_manual("Some Film", 2000),
|
||||
|
||||
310
crates/application/src/diary/tests/review_logger.rs
Normal file
310
crates/application/src/diary/tests/review_logger.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::Movie,
|
||||
models::WatchlistEntry,
|
||||
ports::{MetadataClient, MetadataSearchCriteria, MovieRepository, WatchlistRepository},
|
||||
testing::{
|
||||
FakeMetadataClient, InMemoryMovieRepository, InMemoryReviewRepository,
|
||||
InMemoryWatchlistRepository, NoopEventPublisher,
|
||||
},
|
||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::diary::commands::{LogReviewCommand, MovieInput};
|
||||
use crate::diary::review_logger::DefaultReviewLogger;
|
||||
use crate::ports::ReviewLogger;
|
||||
|
||||
fn make_logger(
|
||||
movies: &Arc<InMemoryMovieRepository>,
|
||||
reviews: &Arc<InMemoryReviewRepository>,
|
||||
watchlist: &Arc<InMemoryWatchlistRepository>,
|
||||
events: &Arc<NoopEventPublisher>,
|
||||
) -> DefaultReviewLogger {
|
||||
DefaultReviewLogger::new(
|
||||
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 = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
let uid = Uuid::new_v4();
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: uid,
|
||||
input: MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: None,
|
||||
manual_title: Some("Test Film".into()),
|
||||
manual_release_year: Some(2024),
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
assert_eq!(movies.count(), 1);
|
||||
assert_eq!(reviews.count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removes_from_watchlist_on_review() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
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 = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Watchlisted Film".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
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.count(), 1);
|
||||
|
||||
// Log review for same movie
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: uid,
|
||||
input: MovieInput {
|
||||
movie_id: Some(movie_id),
|
||||
external_metadata_id: None,
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 5,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
assert_eq!(watchlist.count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logs_review_with_existing_movie_by_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Existing Film".into()).unwrap(),
|
||||
ReleaseYear::new(2020).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let movie_uuid = movie.id().value();
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
input: MovieInput {
|
||||
movie_id: Some(movie_uuid),
|
||||
external_metadata_id: None,
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 3,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
assert_eq!(movies.count(), 1);
|
||||
assert_eq!(reviews.count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_movie_not_found_returns_error() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
input: MovieInput {
|
||||
movie_id: Some(Uuid::new_v4()),
|
||||
external_metadata_id: None,
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
assert!(logger.log_review(cmd).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_rating_returns_error() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
input: MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: None,
|
||||
manual_title: Some("Film".into()),
|
||||
manual_release_year: Some(2024),
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 6,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
let result = logger.log_review(cmd).await;
|
||||
assert!(result.is_err());
|
||||
// No repo calls should have happened
|
||||
assert_eq!(movies.count(), 0);
|
||||
assert_eq!(reviews.count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watchlist_not_present_does_not_publish_removed() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
input: MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: None,
|
||||
manual_title: Some("No Watchlist Film".into()),
|
||||
manual_release_year: Some(2024),
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/// A metadata client that returns a movie with an external_metadata_id,
|
||||
/// triggering the MovieDiscovered event path.
|
||||
struct MetadataClientWithExternalId;
|
||||
|
||||
#[async_trait]
|
||||
impl MetadataClient for MetadataClientWithExternalId {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
_criteria: &MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError> {
|
||||
Ok(Movie::new(
|
||||
Some(ExternalMetadataId::new("tmdb:99999".into()).unwrap()),
|
||||
MovieTitle::new("Discovered Film".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
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 = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let logger = DefaultReviewLogger::new(
|
||||
Arc::clone(&movies) as _,
|
||||
Arc::clone(&reviews) as _,
|
||||
Arc::clone(&watchlist) as _,
|
||||
Arc::new(MetadataClientWithExternalId) as _,
|
||||
Arc::clone(&events) as _,
|
||||
);
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
input: MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: None,
|
||||
manual_title: Some("Discovered Film".into()),
|
||||
manual_release_year: Some(2024),
|
||||
manual_director: None,
|
||||
},
|
||||
rating: 5,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -54,3 +54,7 @@ pub async fn execute(
|
||||
current_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/create.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -26,3 +26,7 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteGoalCommand) -> Result<(), Dom
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/delete.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -28,3 +28,7 @@ pub async fn execute(
|
||||
current_count,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -25,3 +25,7 @@ pub async fn execute(
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list.rs"]
|
||||
mod tests;
|
||||
|
||||
117
crates/application/src/goals/tests/create.rs
Normal file
117
crates/application/src/goals/tests/create.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::testing::{InMemoryGoalRepository, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::goals::{commands::CreateGoalCommand, create};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_goal_and_returns_progress() {
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
goals.set_review_count(Uuid::nil(), 2025, 5);
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_goal(Arc::clone(&goals) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let result = create::execute(
|
||||
&ctx,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 50,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.goal.year(), 2025);
|
||||
assert_eq!(result.goal.target_count(), 50);
|
||||
assert_eq!(result.current_count, 5);
|
||||
assert_eq!(goals.count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emits_goal_created_event() {
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
create::execute(
|
||||
&ctx,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 10,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::GoalCreated { year: 2025, .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_duplicate_year() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let cmd = CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 10,
|
||||
};
|
||||
|
||||
create::execute(&ctx, cmd).await.unwrap();
|
||||
|
||||
let result = create::execute(
|
||||
&ctx,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 20,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_year_before_2020() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let result = create::execute(
|
||||
&ctx,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2019,
|
||||
target_count: 10,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_zero_target() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let result = create::execute(
|
||||
&ctx,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 0,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
59
crates/application/src/goals/tests/delete.rs
Normal file
59
crates/application/src/goals/tests/delete.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{InMemoryGoalRepository, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::goals::{
|
||||
commands::{CreateGoalCommand, DeleteGoalCommand},
|
||||
create, delete,
|
||||
};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletes_existing_goal() {
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_goal(Arc::clone(&goals) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
create::execute(
|
||||
&ctx,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 10,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(goals.count(), 1);
|
||||
|
||||
delete::execute(
|
||||
&ctx,
|
||||
DeleteGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(goals.count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_not_found() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let result = delete::execute(
|
||||
&ctx,
|
||||
DeleteGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
48
crates/application/src/goals/tests/get.rs
Normal file
48
crates/application/src/goals/tests/get.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::goals::{commands::CreateGoalCommand, create, get, queries::GetGoalQuery};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_goal_when_exists() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
create::execute(
|
||||
&ctx,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 50,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = get::execute(
|
||||
&ctx,
|
||||
GetGoalQuery {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().goal.target_count(), 50);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_when_missing() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let result = get::execute(
|
||||
&ctx,
|
||||
GetGoalQuery {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
47
crates/application/src/goals/tests/list.rs
Normal file
47
crates/application/src/goals/tests/list.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::goals::{commands::CreateGoalCommand, create, list, queries::ListGoalsQuery};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_goals() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let result = list::execute(
|
||||
&ctx,
|
||||
ListGoalsQuery {
|
||||
user_id: Uuid::nil(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_all_goals_for_user() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
for year in [2023, 2024, 2025] {
|
||||
create::execute(
|
||||
&ctx,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year,
|
||||
target_count: 10,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let result = list::execute(
|
||||
&ctx,
|
||||
ListGoalsQuery {
|
||||
user_id: Uuid::nil(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.len(), 3);
|
||||
}
|
||||
78
crates/application/src/goals/tests/update.rs
Normal file
78
crates/application/src/goals/tests/update.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::goals::{
|
||||
commands::{CreateGoalCommand, UpdateGoalCommand},
|
||||
create, update,
|
||||
};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_target_count() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
create::execute(
|
||||
&ctx,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 10,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = update::execute(
|
||||
&ctx,
|
||||
UpdateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 100,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.goal.target_count(), 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_goal_not_found() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let result = update::execute(
|
||||
&ctx,
|
||||
UpdateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 10,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_zero_target() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
create::execute(
|
||||
&ctx,
|
||||
CreateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 10,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = update::execute(
|
||||
&ctx,
|
||||
UpdateGoalCommand {
|
||||
user_id: Uuid::nil(),
|
||||
year: 2025,
|
||||
target_count: 0,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
@@ -42,3 +42,7 @@ pub async fn execute(
|
||||
current_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/update.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -89,3 +89,7 @@ async fn mark_duplicates(ctx: &AppContext, rows: &mut [AnnotatedRow]) -> Result<
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/apply_mapping.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -27,3 +27,7 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportProfileCommand) -> Result
|
||||
session.row_results = None;
|
||||
ctx.repos.import_session.update(&session).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/apply_profile.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -4,3 +4,7 @@ use domain::errors::DomainError;
|
||||
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
|
||||
ctx.repos.import_session.delete_expired().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/cleanup.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -45,3 +45,7 @@ pub async fn execute(
|
||||
sample_rows,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/create_session.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -15,3 +15,7 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteImportProfileCommand) -> Resul
|
||||
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
|
||||
ctx.repos.import_profile.delete(&profile_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/delete_profile.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -9,7 +9,6 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
context::AppContext,
|
||||
diary::commands::{LogReviewCommand, MovieInput},
|
||||
diary::log_review,
|
||||
import::commands::ExecuteImportCommand,
|
||||
};
|
||||
|
||||
@@ -47,7 +46,7 @@ pub async fn execute(
|
||||
}
|
||||
match annotated.result {
|
||||
RowResult::Valid(row) => match row_to_command(&row, user_id.value()) {
|
||||
Ok(cmd) => match log_review::execute(ctx, cmd).await {
|
||||
Ok(cmd) => match ctx.services.review_logger.log_review(cmd).await {
|
||||
Ok(_) => imported += 1,
|
||||
Err(e) => failed.push((idx, e.to_string())),
|
||||
},
|
||||
@@ -68,6 +67,10 @@ pub async fn execute(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/execute.rs"]
|
||||
mod tests;
|
||||
|
||||
fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, String> {
|
||||
let rating = row
|
||||
.rating
|
||||
|
||||
@@ -7,3 +7,7 @@ pub async fn execute(
|
||||
) -> Result<Vec<ImportProfile>, DomainError> {
|
||||
ctx.repos.import_profile.list_for_user(user_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list_profiles.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -33,3 +33,7 @@ pub async fn execute(
|
||||
ctx.repos.import_profile.save(&profile).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/save_profile.rs"]
|
||||
mod tests;
|
||||
|
||||
208
crates/application/src/import/tests/apply_mapping.rs
Normal file
208
crates/application/src/import/tests/apply_mapping.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{
|
||||
models::{
|
||||
AnnotatedRow, Movie,
|
||||
import::{ImportRow, ParsedFile, RowResult},
|
||||
},
|
||||
ports::{DocumentParser, MovieRepository},
|
||||
testing::InMemoryMovieRepository,
|
||||
value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use crate::import::{
|
||||
apply_mapping,
|
||||
commands::{ApplyImportMappingCommand, CreateImportSessionCommand},
|
||||
create_session,
|
||||
};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_mapping_to_session() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let session = create_session::execute(
|
||||
&ctx,
|
||||
CreateImportSessionCommand {
|
||||
user_id,
|
||||
bytes: b"title\nTest".to_vec(),
|
||||
format: domain::models::FileFormat::Csv,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rows = apply_mapping::execute(
|
||||
&ctx,
|
||||
ApplyImportMappingCommand {
|
||||
user_id,
|
||||
session_id: session.session_id.value(),
|
||||
mappings: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!rows.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_session_not_found() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = apply_mapping::execute(
|
||||
&ctx,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
session_id: Uuid::new_v4(),
|
||||
mappings: vec![],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
/// A document parser that returns rows with specific field values for testing
|
||||
/// the mark_duplicates logic.
|
||||
struct DuplicateTestParser {
|
||||
rows: Vec<ImportRow>,
|
||||
}
|
||||
|
||||
impl DocumentParser for DuplicateTestParser {
|
||||
fn parse(
|
||||
&self,
|
||||
_: &[u8],
|
||||
_: domain::models::FileFormat,
|
||||
) -> Result<ParsedFile, domain::models::import::ImportError> {
|
||||
Ok(ParsedFile {
|
||||
columns: vec!["title".into()],
|
||||
rows: vec![vec!["x".into()]],
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_mapping(
|
||||
&self,
|
||||
_: &ParsedFile,
|
||||
_: &[domain::models::FieldMapping],
|
||||
) -> Vec<AnnotatedRow> {
|
||||
self.rows
|
||||
.iter()
|
||||
.map(|r| AnnotatedRow {
|
||||
result: RowResult::Valid(r.clone()),
|
||||
is_duplicate: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marks_duplicate_by_external_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
|
||||
let ext_id = ExternalMetadataId::new("tt1234567".into()).unwrap();
|
||||
let movie = Movie::new(
|
||||
Some(ext_id),
|
||||
MovieTitle::new("Known Movie".into()).unwrap(),
|
||||
ReleaseYear::new(2020).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
let parser = DuplicateTestParser {
|
||||
rows: vec![ImportRow {
|
||||
title: Some("Known Movie".into()),
|
||||
release_year: Some("2020".into()),
|
||||
external_metadata_id: Some("tt1234567".into()),
|
||||
..ImportRow::default()
|
||||
}],
|
||||
};
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(&movies) as _)
|
||||
.with_document_parser(Arc::new(parser) as _)
|
||||
.build();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let session = create_session::execute(
|
||||
&ctx,
|
||||
CreateImportSessionCommand {
|
||||
user_id,
|
||||
bytes: b"title\nKnown Movie".to_vec(),
|
||||
format: domain::models::FileFormat::Csv,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rows = apply_mapping::execute(
|
||||
&ctx,
|
||||
ApplyImportMappingCommand {
|
||||
user_id,
|
||||
session_id: session.session_id.value(),
|
||||
mappings: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let has_dup = rows.iter().any(|r| r.is_duplicate);
|
||||
assert!(has_dup, "row with matching external_id should be duplicate");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marks_duplicate_by_title_and_year() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Duplicate Film".into()).unwrap(),
|
||||
ReleaseYear::new(2022).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
let parser = DuplicateTestParser {
|
||||
rows: vec![ImportRow {
|
||||
title: Some("Duplicate Film".into()),
|
||||
release_year: Some("2022".into()),
|
||||
..ImportRow::default()
|
||||
}],
|
||||
};
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(&movies) as _)
|
||||
.with_document_parser(Arc::new(parser) as _)
|
||||
.build();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let session = create_session::execute(
|
||||
&ctx,
|
||||
CreateImportSessionCommand {
|
||||
user_id,
|
||||
bytes: b"title\nDuplicate Film".to_vec(),
|
||||
format: domain::models::FileFormat::Csv,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rows = apply_mapping::execute(
|
||||
&ctx,
|
||||
ApplyImportMappingCommand {
|
||||
user_id,
|
||||
session_id: session.session_id.value(),
|
||||
mappings: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let has_dup = rows.iter().any(|r| r.is_duplicate);
|
||||
assert!(has_dup, "row with matching title+year should be duplicate");
|
||||
}
|
||||
115
crates/application/src/import/tests/apply_profile.rs
Normal file
115
crates/application/src/import/tests/apply_profile.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use domain::models::ImportProfile;
|
||||
use domain::ports::{ImportProfileRepository, ImportSessionRepository};
|
||||
use domain::testing::InMemoryImportProfileRepository;
|
||||
use domain::value_objects::{ImportProfileId, UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::{apply_profile, commands::ApplyImportProfileCommand};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_profile_not_found() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = apply_profile::execute(
|
||||
&ctx,
|
||||
ApplyImportProfileCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
session_id: Uuid::new_v4(),
|
||||
profile_id: Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_session_not_found() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let profile = ImportProfile::new(
|
||||
ImportProfileId::generate(),
|
||||
UserId::from_uuid(user_id),
|
||||
"test".into(),
|
||||
vec![],
|
||||
Utc::now().naive_utc(),
|
||||
);
|
||||
let profile_id = profile.id.clone();
|
||||
profiles.save(&profile).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_profiles(Arc::clone(&profiles) as _)
|
||||
.build();
|
||||
|
||||
let result = apply_profile::execute(
|
||||
&ctx,
|
||||
ApplyImportProfileCommand {
|
||||
user_id,
|
||||
session_id: Uuid::new_v4(),
|
||||
profile_id: profile_id.value(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_profile_mappings_to_session() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let sessions = domain::testing::InMemoryImportSessionRepository::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let profile = ImportProfile::new(
|
||||
ImportProfileId::generate(),
|
||||
UserId::from_uuid(user_id),
|
||||
"letterboxd".into(),
|
||||
vec![domain::models::FieldMapping {
|
||||
source_column: "Name".into(),
|
||||
domain_field: domain::models::import::DomainField::Title,
|
||||
transform: domain::models::import::Transform::Identity,
|
||||
}],
|
||||
Utc::now().naive_utc(),
|
||||
);
|
||||
let profile_id = profile.id.clone();
|
||||
profiles.save(&profile).await.unwrap();
|
||||
|
||||
let session = domain::models::ImportSession::new(
|
||||
domain::value_objects::ImportSessionId::generate(),
|
||||
UserId::from_uuid(user_id),
|
||||
Utc::now().naive_utc(),
|
||||
);
|
||||
let session_id = session.id.clone();
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_profiles(Arc::clone(&profiles) as _)
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
apply_profile::execute(
|
||||
&ctx,
|
||||
ApplyImportProfileCommand {
|
||||
user_id,
|
||||
session_id: session_id.value(),
|
||||
profile_id: profile_id.value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify the session got updated with field_mappings and row_results cleared
|
||||
let updated = sessions
|
||||
.get(&session_id, &UserId::from_uuid(user_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(updated.field_mappings.is_some());
|
||||
assert_eq!(updated.field_mappings.unwrap().len(), 1);
|
||||
assert!(updated.row_results.is_none());
|
||||
}
|
||||
18
crates/application/src/import/tests/cleanup.rs
Normal file
18
crates/application/src/import/tests/cleanup.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
|
||||
use crate::import::cleanup;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_zero_when_nothing_expired() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = cleanup::execute(&ctx).await.unwrap();
|
||||
|
||||
assert_eq!(result, 0);
|
||||
}
|
||||
22
crates/application/src/import/tests/create_session.rs
Normal file
22
crates/application/src/import/tests/create_session.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::{commands::CreateImportSessionCommand, create_session};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_session_with_parsed_file() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = create_session::execute(
|
||||
&ctx,
|
||||
CreateImportSessionCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
bytes: b"col1\nval1".to_vec(),
|
||||
format: domain::models::FileFormat::Csv,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.columns.is_empty());
|
||||
}
|
||||
26
crates/application/src/import/tests/delete_profile.rs
Normal file
26
crates/application/src/import/tests/delete_profile.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryImportProfileRepository;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::{commands::DeleteImportProfileCommand, delete_profile};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_profile_not_found() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_profiles(Arc::clone(&profiles) as _)
|
||||
.build();
|
||||
|
||||
let result = delete_profile::execute(
|
||||
&ctx,
|
||||
DeleteImportProfileCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
profile_id: Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
558
crates/application/src/import/tests/execute.rs
Normal file
558
crates/application/src/import/tests/execute.rs
Normal file
@@ -0,0 +1,558 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use domain::models::{AnnotatedRow, ImportSession, import::RowResult};
|
||||
use domain::ports::ImportSessionRepository;
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
use domain::value_objects::{ImportSessionId, UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::commands::ExecuteImportCommand;
|
||||
use crate::import::execute;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
fn make_session_with_rows(user_id: UserId, session_id: ImportSessionId) -> ImportSession {
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut session = ImportSession::new(session_id, user_id, now);
|
||||
session.row_results = Some(vec![
|
||||
AnnotatedRow {
|
||||
result: RowResult::Valid(domain::models::ImportRow {
|
||||
title: Some("Test Movie".into()),
|
||||
release_year: Some("2024".into()),
|
||||
rating: Some("4".into()),
|
||||
watched_at: Some("2024-06-01".into()),
|
||||
external_metadata_id: None,
|
||||
director: None,
|
||||
comment: None,
|
||||
}),
|
||||
is_duplicate: false,
|
||||
},
|
||||
AnnotatedRow {
|
||||
result: RowResult::Valid(domain::models::ImportRow {
|
||||
title: Some("Another".into()),
|
||||
release_year: Some("2023".into()),
|
||||
rating: Some("3".into()),
|
||||
watched_at: Some("2024-07-01".into()),
|
||||
external_metadata_id: None,
|
||||
director: None,
|
||||
comment: None,
|
||||
}),
|
||||
is_duplicate: false,
|
||||
},
|
||||
]);
|
||||
session
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_confirmed_rows() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let session = make_session_with_rows(UserId::from_uuid(uid), sid.clone());
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0, 1],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 2);
|
||||
assert_eq!(result.skipped_duplicates, 0);
|
||||
assert!(result.failed.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_unconfirmed_rows() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let session = make_session_with_rows(UserId::from_uuid(uid), sid.clone());
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 1);
|
||||
assert_eq!(result.skipped_duplicates, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_session_not_found() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
session_id: Uuid::new_v4(),
|
||||
confirmed_indices: vec![],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handles_datetime_format() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now);
|
||||
session.row_results = Some(vec![AnnotatedRow {
|
||||
result: RowResult::Valid(domain::models::ImportRow {
|
||||
title: Some("DateTime Movie".into()),
|
||||
release_year: Some("2024".into()),
|
||||
rating: Some("5".into()),
|
||||
watched_at: Some("2024-06-01T12:30:00".into()),
|
||||
external_metadata_id: None,
|
||||
director: None,
|
||||
comment: None,
|
||||
}),
|
||||
is_duplicate: false,
|
||||
}]);
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 1);
|
||||
assert!(result.failed.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_on_invalid_rating() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now);
|
||||
session.row_results = Some(vec![AnnotatedRow {
|
||||
result: RowResult::Valid(domain::models::ImportRow {
|
||||
title: Some("Bad Rating Movie".into()),
|
||||
release_year: Some("2024".into()),
|
||||
rating: Some("not_a_number".into()),
|
||||
watched_at: Some("2024-06-01".into()),
|
||||
external_metadata_id: None,
|
||||
director: None,
|
||||
comment: None,
|
||||
}),
|
||||
is_duplicate: false,
|
||||
}]);
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 0);
|
||||
assert_eq!(result.failed.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_on_missing_watched_at() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now);
|
||||
session.row_results = Some(vec![AnnotatedRow {
|
||||
result: RowResult::Valid(domain::models::ImportRow {
|
||||
title: Some("No Date Movie".into()),
|
||||
release_year: Some("2024".into()),
|
||||
rating: Some("4".into()),
|
||||
watched_at: None,
|
||||
external_metadata_id: None,
|
||||
director: None,
|
||||
comment: None,
|
||||
}),
|
||||
is_duplicate: false,
|
||||
}]);
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 0);
|
||||
assert_eq!(result.failed.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_row_with_external_metadata_id() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now);
|
||||
session.row_results = Some(vec![AnnotatedRow {
|
||||
result: RowResult::Valid(domain::models::ImportRow {
|
||||
title: Some("TMDB Movie".into()),
|
||||
release_year: Some("2024".into()),
|
||||
rating: Some("5".into()),
|
||||
watched_at: Some("2024-06-01".into()),
|
||||
external_metadata_id: Some("tt9999999".into()),
|
||||
director: None,
|
||||
comment: None,
|
||||
}),
|
||||
is_duplicate: false,
|
||||
}]);
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 1);
|
||||
assert!(result.failed.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_row_with_director_and_comment() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now);
|
||||
session.row_results = Some(vec![AnnotatedRow {
|
||||
result: RowResult::Valid(domain::models::ImportRow {
|
||||
title: Some("Directed Movie".into()),
|
||||
release_year: Some("2022".into()),
|
||||
rating: Some("4".into()),
|
||||
watched_at: Some("2024-06-01".into()),
|
||||
external_metadata_id: None,
|
||||
director: Some("John Director".into()),
|
||||
comment: Some("A great film".into()),
|
||||
}),
|
||||
is_duplicate: false,
|
||||
}]);
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 1);
|
||||
assert!(result.failed.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handles_space_separated_datetime_format() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now);
|
||||
session.row_results = Some(vec![AnnotatedRow {
|
||||
result: RowResult::Valid(domain::models::ImportRow {
|
||||
title: Some("Space DateTime".into()),
|
||||
release_year: Some("2024".into()),
|
||||
rating: Some("3".into()),
|
||||
watched_at: Some("2024-06-01 14:30:00".into()),
|
||||
external_metadata_id: None,
|
||||
director: None,
|
||||
comment: None,
|
||||
}),
|
||||
is_duplicate: false,
|
||||
}]);
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 1);
|
||||
assert!(result.failed.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_invalid_row_result_errors() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now);
|
||||
session.row_results = Some(vec![AnnotatedRow {
|
||||
result: RowResult::Invalid {
|
||||
errors: vec!["missing title".into(), "bad year".into()],
|
||||
raw: vec![("col1".into(), "val1".into())],
|
||||
},
|
||||
is_duplicate: false,
|
||||
}]);
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 0);
|
||||
assert_eq!(result.failed.len(), 1);
|
||||
assert!(result.failed[0].1.contains("missing title"));
|
||||
assert!(result.failed[0].1.contains("bad year"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_on_missing_rating() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now);
|
||||
session.row_results = Some(vec![AnnotatedRow {
|
||||
result: RowResult::Valid(domain::models::ImportRow {
|
||||
title: Some("No Rating Movie".into()),
|
||||
release_year: Some("2024".into()),
|
||||
rating: None,
|
||||
watched_at: Some("2024-06-01".into()),
|
||||
external_metadata_id: None,
|
||||
director: None,
|
||||
comment: None,
|
||||
}),
|
||||
is_duplicate: false,
|
||||
}]);
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 0);
|
||||
assert_eq!(result.failed.len(), 1);
|
||||
assert!(result.failed[0].1.contains("missing rating"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_on_unparseable_date() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now);
|
||||
session.row_results = Some(vec![AnnotatedRow {
|
||||
result: RowResult::Valid(domain::models::ImportRow {
|
||||
title: Some("Bad Date Movie".into()),
|
||||
release_year: Some("2024".into()),
|
||||
rating: Some("3".into()),
|
||||
watched_at: Some("not-a-date".into()),
|
||||
external_metadata_id: None,
|
||||
director: None,
|
||||
comment: None,
|
||||
}),
|
||||
is_duplicate: false,
|
||||
}]);
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 0);
|
||||
assert_eq!(result.failed.len(), 1);
|
||||
assert!(result.failed[0].1.contains("cannot parse watched_at"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_row_without_release_year() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now);
|
||||
session.row_results = Some(vec![AnnotatedRow {
|
||||
result: RowResult::Valid(domain::models::ImportRow {
|
||||
title: Some("No Year Movie".into()),
|
||||
release_year: None,
|
||||
rating: Some("4".into()),
|
||||
watched_at: Some("2024-06-01".into()),
|
||||
external_metadata_id: None,
|
||||
director: None,
|
||||
comment: None,
|
||||
}),
|
||||
is_duplicate: false,
|
||||
}]);
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.imported, 1);
|
||||
assert!(result.failed.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletes_session_after_import() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let session = make_session_with_rows(UserId::from_uuid(uid), sid.clone());
|
||||
sessions.create(&session).await.unwrap();
|
||||
assert_eq!(sessions.count(), 1);
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
execute::execute(
|
||||
&ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: uid,
|
||||
session_id: sid.value(),
|
||||
confirmed_indices: vec![0],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sessions.count(),
|
||||
0,
|
||||
"session should be deleted after import"
|
||||
);
|
||||
}
|
||||
21
crates/application/src/import/tests/list_profiles.rs
Normal file
21
crates/application/src/import/tests/list_profiles.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryImportProfileRepository;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::list_profiles;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_profiles() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_profiles(Arc::clone(&profiles) as _)
|
||||
.build();
|
||||
|
||||
let user_id = UserId::from_uuid(Uuid::new_v4());
|
||||
let result = list_profiles::execute(&ctx, &user_id).await.unwrap();
|
||||
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
62
crates/application/src/import/tests/save_profile.rs
Normal file
62
crates/application/src/import/tests/save_profile.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use domain::models::ImportSession;
|
||||
use domain::ports::ImportSessionRepository;
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
use domain::value_objects::{ImportSessionId, UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::{commands::SaveImportProfileCommand, save_profile};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_session_not_found() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = save_profile::execute(
|
||||
&ctx,
|
||||
SaveImportProfileCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
session_id: Uuid::new_v4(),
|
||||
name: "my profile".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saves_profile_from_session() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
let sid = ImportSessionId::generate();
|
||||
|
||||
let mut session = ImportSession::new(
|
||||
sid.clone(),
|
||||
UserId::from_uuid(user_id),
|
||||
Utc::now().naive_utc(),
|
||||
);
|
||||
session.field_mappings = Some(vec![]);
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_import_sessions(Arc::clone(&sessions) as _)
|
||||
.build();
|
||||
|
||||
let result = save_profile::execute(
|
||||
&ctx,
|
||||
SaveImportProfileCommand {
|
||||
user_id,
|
||||
session_id: sid.value(),
|
||||
name: "my profile".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
@@ -10,3 +10,7 @@ pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
|
||||
.delete_non_pending_older_than(cutoff)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/cleanup.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -7,7 +7,6 @@ use domain::{
|
||||
use crate::{
|
||||
context::AppContext,
|
||||
diary::commands::{LogReviewCommand, MovieInput},
|
||||
diary::log_review,
|
||||
integrations::commands::ConfirmWatchEventsCommand,
|
||||
};
|
||||
|
||||
@@ -54,7 +53,7 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result
|
||||
watched_at: *event.watched_at(),
|
||||
};
|
||||
|
||||
log_review::execute(ctx, review_cmd).await?;
|
||||
ctx.services.review_logger.log_review(review_cmd).await?;
|
||||
|
||||
ctx.repos
|
||||
.watch_event
|
||||
@@ -66,3 +65,7 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result
|
||||
|
||||
Ok(confirmed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/confirm.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -39,3 +39,7 @@ pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result
|
||||
|
||||
Ok(count as u32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/dismiss.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -36,3 +36,7 @@ pub fn hash_token(plaintext: &str) -> String {
|
||||
hasher.update(plaintext.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/generate_token.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -9,3 +9,7 @@ pub async fn execute(
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
ctx.repos.watch_event.list_pending(&user_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_queue.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -9,3 +9,7 @@ pub async fn execute(
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
ctx.repos.webhook_token.list_by_user(&user_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_tokens.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -69,3 +69,7 @@ pub async fn execute(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/ingest.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -10,3 +10,7 @@ pub async fn execute(ctx: &AppContext, cmd: RevokeWebhookTokenCommand) -> Result
|
||||
let token_id = WebhookTokenId::from_uuid(cmd.token_id);
|
||||
ctx.repos.webhook_token.delete(&token_id, &user_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/revoke_token.rs"]
|
||||
mod tests;
|
||||
|
||||
11
crates/application/src/integrations/tests/cleanup.rs
Normal file
11
crates/application/src/integrations/tests/cleanup.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use crate::integrations::cleanup;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_zero_when_nothing_to_clean() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let count = cleanup::execute(&ctx).await.unwrap();
|
||||
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
372
crates/application/src/integrations/tests/confirm.rs
Normal file
372
crates/application/src/integrations/tests/confirm.rs
Normal file
@@ -0,0 +1,372 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::{WatchEvent, WatchEventSource};
|
||||
use domain::ports::{MovieRepository, WatchEventRepository};
|
||||
use domain::testing::{InMemoryWatchEventRepository, NoopEventPublisher};
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::commands::{ConfirmWatchEventsCommand, WatchEventConfirmation};
|
||||
use crate::integrations::confirm;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_watch_event_via_review_logger() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let event = WatchEvent::new(
|
||||
UserId::from_uuid(uid),
|
||||
"Test Movie".into(),
|
||||
Some(2024),
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
);
|
||||
let event_id = event.id().value();
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(Arc::clone(&watch_events) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let result = confirm::execute(
|
||||
&ctx,
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
watch_event_id: event_id,
|
||||
rating: 4,
|
||||
comment: None,
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_confirmations_returns_zero() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = confirm::execute(
|
||||
&ctx,
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
confirmations: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_event_with_external_metadata_id_and_no_movie_id() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let event = WatchEvent::new(
|
||||
UserId::from_uuid(uid),
|
||||
"External Movie".into(),
|
||||
Some(2023),
|
||||
Some("tt1234567".into()),
|
||||
WatchEventSource::Jellyfin,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
);
|
||||
let event_id = event.id().value();
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(Arc::clone(&watch_events) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let result = confirm::execute(
|
||||
&ctx,
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
watch_event_id: event_id,
|
||||
rating: 3,
|
||||
comment: Some("Great film".into()),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_other_users_event() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let owner = Uuid::new_v4();
|
||||
let intruder = Uuid::new_v4();
|
||||
|
||||
let event = WatchEvent::new(
|
||||
UserId::from_uuid(owner),
|
||||
"Movie".into(),
|
||||
None,
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
);
|
||||
let event_id = event.id().value();
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(Arc::clone(&watch_events) as _)
|
||||
.build();
|
||||
|
||||
let result = confirm::execute(
|
||||
&ctx,
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: intruder,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
watch_event_id: event_id,
|
||||
rating: 3,
|
||||
comment: None,
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_event_not_found() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = confirm::execute(
|
||||
&ctx,
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
watch_event_id: Uuid::new_v4(),
|
||||
rating: 4,
|
||||
comment: None,
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_event_with_movie_id() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let movie_uuid = Uuid::new_v4();
|
||||
|
||||
let event = WatchEvent::new(
|
||||
UserId::from_uuid(uid),
|
||||
"Movie With Id".into(),
|
||||
Some(2024),
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
Some(domain::value_objects::MovieId::from_uuid(movie_uuid)),
|
||||
);
|
||||
let event_id = event.id().value();
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
// Also seed movie repo so review_logger can find it
|
||||
let movies = domain::testing::InMemoryMovieRepository::new();
|
||||
let movie = domain::models::Movie::from_persistence(
|
||||
domain::value_objects::MovieId::from_uuid(movie_uuid),
|
||||
None,
|
||||
domain::value_objects::MovieTitle::new("Movie With Id".into()).unwrap(),
|
||||
domain::value_objects::ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
// Build a real review logger
|
||||
let reviews = domain::testing::InMemoryReviewRepository::new();
|
||||
let watchlist = domain::testing::InMemoryWatchlistRepository::new();
|
||||
let review_logger = std::sync::Arc::new(crate::diary::review_logger::DefaultReviewLogger::new(
|
||||
std::sync::Arc::clone(&movies) as _,
|
||||
std::sync::Arc::clone(&reviews) as _,
|
||||
std::sync::Arc::clone(&watchlist) as _,
|
||||
std::sync::Arc::new(domain::testing::FakeMetadataClient) as _,
|
||||
std::sync::Arc::clone(&events) as _,
|
||||
));
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(std::sync::Arc::clone(&watch_events) as _)
|
||||
.with_event_publisher(std::sync::Arc::clone(&events) as _)
|
||||
.with_movies(std::sync::Arc::clone(&movies) as _)
|
||||
.with_review_logger(review_logger as _)
|
||||
.build();
|
||||
|
||||
let result = confirm::execute(
|
||||
&ctx,
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
watch_event_id: event_id,
|
||||
rating: 4,
|
||||
comment: None,
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_event_without_movie_id_and_without_external_metadata_id() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let event = WatchEvent::new(
|
||||
UserId::from_uuid(uid),
|
||||
"Title Only Movie".into(),
|
||||
Some(2022),
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
);
|
||||
let event_id = event.id().value();
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(Arc::clone(&watch_events) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let result = confirm::execute(
|
||||
&ctx,
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
watch_event_id: event_id,
|
||||
rating: 5,
|
||||
comment: Some("Amazing".into()),
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_multiple_events() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let event1 = WatchEvent::new(
|
||||
UserId::from_uuid(uid),
|
||||
"Movie One".into(),
|
||||
Some(2020),
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
);
|
||||
let id1 = event1.id().value();
|
||||
|
||||
let event2 = WatchEvent::new(
|
||||
UserId::from_uuid(uid),
|
||||
"Movie Two".into(),
|
||||
Some(2021),
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
);
|
||||
let id2 = event2.id().value();
|
||||
|
||||
watch_events.save(&event1).await.unwrap();
|
||||
watch_events.save(&event2).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(Arc::clone(&watch_events) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let result = confirm::execute(
|
||||
&ctx,
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![
|
||||
WatchEventConfirmation {
|
||||
watch_event_id: id1,
|
||||
rating: 3,
|
||||
comment: None,
|
||||
},
|
||||
WatchEventConfirmation {
|
||||
watch_event_id: id2,
|
||||
rating: 4,
|
||||
comment: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_event_without_year() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let event = WatchEvent::new(
|
||||
UserId::from_uuid(uid),
|
||||
"No Year Movie".into(),
|
||||
None, // no year
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
);
|
||||
let event_id = event.id().value();
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(Arc::clone(&watch_events) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let result = confirm::execute(
|
||||
&ctx,
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
watch_event_id: event_id,
|
||||
rating: 3,
|
||||
comment: None,
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, 1);
|
||||
}
|
||||
95
crates/application/src/integrations/tests/dismiss.rs
Normal file
95
crates/application/src/integrations/tests/dismiss.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::{WatchEvent, WatchEventSource};
|
||||
use domain::ports::WatchEventRepository;
|
||||
use domain::testing::InMemoryWatchEventRepository;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::{commands::DismissWatchEventsCommand, dismiss};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn dismisses_empty_list_returns_zero() {
|
||||
let events = InMemoryWatchEventRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let result = dismiss::execute(
|
||||
&ctx,
|
||||
DismissWatchEventsCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
event_ids: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_event_not_found() {
|
||||
let events = InMemoryWatchEventRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let result = dismiss::execute(
|
||||
&ctx,
|
||||
DismissWatchEventsCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
event_ids: vec![Uuid::new_v4()],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dismisses_existing_events() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let user_id = UserId::from_uuid(uid);
|
||||
|
||||
let e1 = WatchEvent::new(
|
||||
user_id.clone(),
|
||||
"Movie A".into(),
|
||||
Some(2024),
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
);
|
||||
let e2 = WatchEvent::new(
|
||||
user_id,
|
||||
"Movie B".into(),
|
||||
Some(2023),
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
);
|
||||
let id1 = e1.id().value();
|
||||
let id2 = e2.id().value();
|
||||
watch_events.save(&e1).await.unwrap();
|
||||
watch_events.save(&e2).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(Arc::clone(&watch_events) as _)
|
||||
.build();
|
||||
|
||||
let result = dismiss::execute(
|
||||
&ctx,
|
||||
DismissWatchEventsCommand {
|
||||
user_id: uid,
|
||||
event_ids: vec![id1, id2],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, 2);
|
||||
}
|
||||
39
crates/application/src/integrations/tests/generate_token.rs
Normal file
39
crates/application/src/integrations/tests/generate_token.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::WatchEventSource;
|
||||
use domain::testing::InMemoryWebhookTokenRepository;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::{commands::GenerateWebhookTokenCommand, generate_token};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn generates_token_and_saves() {
|
||||
let tokens = InMemoryWebhookTokenRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_webhook_tokens(Arc::clone(&tokens) as _)
|
||||
.build();
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let result = generate_token::execute(
|
||||
&ctx,
|
||||
GenerateWebhookTokenCommand {
|
||||
user_id,
|
||||
provider: WatchEventSource::Jellyfin,
|
||||
label: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.token_plaintext.is_empty());
|
||||
|
||||
let saved = ctx
|
||||
.repos
|
||||
.webhook_token
|
||||
.list_by_user(&domain::value_objects::UserId::from_uuid(user_id))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved.len(), 1);
|
||||
assert_eq!(saved[0].id().value(), result.token.id().value());
|
||||
}
|
||||
56
crates/application/src/integrations/tests/get_queue.rs
Normal file
56
crates/application/src/integrations/tests/get_queue.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use domain::models::{WatchEvent, WatchEventSource};
|
||||
use domain::ports::WatchEventRepository;
|
||||
use domain::testing::InMemoryWatchEventRepository;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::{get_queue, queries::GetWatchQueueQuery};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_events() {
|
||||
let events = InMemoryWatchEventRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let result = get_queue::execute(
|
||||
&ctx,
|
||||
GetWatchQueueQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_pending_events() {
|
||||
let events = InMemoryWatchEventRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watch_events(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let event = WatchEvent::new(
|
||||
UserId::from_uuid(user_id),
|
||||
"Blade Runner 2049".into(),
|
||||
Some(2017),
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
Utc::now().naive_utc(),
|
||||
None,
|
||||
);
|
||||
events.save(&event).await.unwrap();
|
||||
|
||||
let result = get_queue::execute(&ctx, GetWatchQueueQuery { user_id })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
}
|
||||
68
crates/application/src/integrations/tests/get_tokens.rs
Normal file
68
crates/application/src/integrations/tests/get_tokens.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::WatchEventSource;
|
||||
use domain::testing::InMemoryWebhookTokenRepository;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::{
|
||||
commands::GenerateWebhookTokenCommand, generate_token, get_tokens,
|
||||
queries::GetWebhookTokensQuery,
|
||||
};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_tokens() {
|
||||
let tokens = InMemoryWebhookTokenRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_webhook_tokens(Arc::clone(&tokens) as _)
|
||||
.build();
|
||||
|
||||
let result = get_tokens::execute(
|
||||
&ctx,
|
||||
GetWebhookTokensQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_tokens_after_generate() {
|
||||
let tokens = InMemoryWebhookTokenRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_webhook_tokens(Arc::clone(&tokens) as _)
|
||||
.build();
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
generate_token::execute(
|
||||
&ctx,
|
||||
GenerateWebhookTokenCommand {
|
||||
user_id,
|
||||
provider: WatchEventSource::Jellyfin,
|
||||
label: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
generate_token::execute(
|
||||
&ctx,
|
||||
GenerateWebhookTokenCommand {
|
||||
user_id,
|
||||
provider: WatchEventSource::Plex,
|
||||
label: Some("living room".into()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = get_tokens::execute(&ctx, GetWebhookTokensQuery { user_id })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
76
crates/application/src/integrations/tests/ingest.rs
Normal file
76
crates/application/src/integrations/tests/ingest.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::WatchEventSource;
|
||||
use domain::testing::InMemoryWebhookTokenRepository;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::commands::GenerateWebhookTokenCommand;
|
||||
use crate::integrations::{commands::IngestWatchEventCommand, generate_token, ingest};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
struct FakeParser;
|
||||
|
||||
impl domain::ports::MediaServerParser for FakeParser {
|
||||
fn parse_playback_event(
|
||||
&self,
|
||||
_: &[u8],
|
||||
) -> Result<Option<domain::models::ParsedPlaybackEvent>, domain::errors::DomainError> {
|
||||
Ok(Some(domain::models::ParsedPlaybackEvent {
|
||||
title: "Test".into(),
|
||||
year: Some(2024),
|
||||
tmdb_id: None,
|
||||
imdb_id: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingests_watch_event() {
|
||||
let tokens = InMemoryWebhookTokenRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_webhook_tokens(Arc::clone(&tokens) as _)
|
||||
.build();
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let generated = generate_token::execute(
|
||||
&ctx,
|
||||
GenerateWebhookTokenCommand {
|
||||
user_id,
|
||||
provider: WatchEventSource::Jellyfin,
|
||||
label: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = ingest::execute(
|
||||
&ctx,
|
||||
IngestWatchEventCommand {
|
||||
token: generated.token_plaintext,
|
||||
raw_payload: vec![],
|
||||
source: WatchEventSource::Jellyfin,
|
||||
},
|
||||
&FakeParser,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_token() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = ingest::execute(
|
||||
&ctx,
|
||||
IngestWatchEventCommand {
|
||||
token: "bad-token".into(),
|
||||
raw_payload: vec![],
|
||||
source: WatchEventSource::Jellyfin,
|
||||
},
|
||||
&FakeParser,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
46
crates/application/src/integrations/tests/revoke_token.rs
Normal file
46
crates/application/src/integrations/tests/revoke_token.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::WatchEventSource;
|
||||
use domain::testing::InMemoryWebhookTokenRepository;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::{
|
||||
commands::{GenerateWebhookTokenCommand, RevokeWebhookTokenCommand},
|
||||
generate_token, get_tokens,
|
||||
queries::GetWebhookTokensQuery,
|
||||
revoke_token,
|
||||
};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn revokes_existing_token() {
|
||||
let tokens = InMemoryWebhookTokenRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_webhook_tokens(Arc::clone(&tokens) as _)
|
||||
.build();
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let generated = generate_token::execute(
|
||||
&ctx,
|
||||
GenerateWebhookTokenCommand {
|
||||
user_id,
|
||||
provider: WatchEventSource::Jellyfin,
|
||||
label: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let token_id = generated.token.id().value();
|
||||
|
||||
revoke_token::execute(&ctx, RevokeWebhookTokenCommand { user_id, token_id })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let remaining = get_tokens::execute(&ctx, GetWebhookTokensQuery { user_id })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(remaining.is_empty());
|
||||
}
|
||||
@@ -92,3 +92,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec<Person> {
|
||||
|
||||
seen.into_values().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/enrich_movie.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -76,3 +76,7 @@ pub async fn execute(
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_movie_profile.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -18,3 +18,7 @@ pub async fn execute(
|
||||
};
|
||||
ctx.repos.movie.list_movies(&page, &filter).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_movies.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -42,3 +42,7 @@ pub async fn fetch_if_stale(
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/request_enrichment.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -96,3 +96,7 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/sync_poster.rs"]
|
||||
mod tests;
|
||||
|
||||
151
crates/application/src/movies/tests/enrich_movie.rs
Normal file
151
crates/application/src/movies/tests/enrich_movie.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
models::{Movie, MovieProfile},
|
||||
ports::MovieRepository,
|
||||
testing::{
|
||||
FakeSearchCommand, InMemoryMovieProfileRepository, InMemoryMovieRepository,
|
||||
PanicPersonCommand,
|
||||
},
|
||||
value_objects::{MovieId, MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use crate::movies::{commands::EnrichMovieCommand, enrich_movie};
|
||||
|
||||
#[tokio::test]
|
||||
async fn stores_profile_and_indexes() {
|
||||
let movie_repo = InMemoryMovieRepository::new();
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let search_cmd: Arc<dyn domain::ports::SearchCommand> = Arc::new(FakeSearchCommand);
|
||||
// PanicPersonCommand is safe here — empty cast/crew means upsert_batch is never called
|
||||
let person_cmd: Arc<dyn domain::ports::PersonCommand> = Arc::new(PanicPersonCommand);
|
||||
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Test".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let movie_id = MovieId::from_uuid(movie.id().value());
|
||||
movie_repo.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
let profile = MovieProfile {
|
||||
movie_id: movie_id.clone(),
|
||||
tmdb_id: 999,
|
||||
imdb_id: None,
|
||||
overview: Some("A test movie".into()),
|
||||
tagline: None,
|
||||
runtime_minutes: Some(120),
|
||||
budget_usd: None,
|
||||
revenue_usd: None,
|
||||
vote_average: Some(7.5),
|
||||
vote_count: Some(100),
|
||||
original_language: Some("en".into()),
|
||||
collection_name: None,
|
||||
genres: vec![],
|
||||
keywords: vec![],
|
||||
cast: vec![],
|
||||
crew: vec![],
|
||||
enriched_at: Utc::now(),
|
||||
};
|
||||
|
||||
enrich_movie::execute(
|
||||
&(movie_repo as Arc<_>),
|
||||
&(profile_repo.clone() as Arc<_>),
|
||||
&person_cmd,
|
||||
&search_cmd,
|
||||
EnrichMovieCommand {
|
||||
movie_id: movie_id.clone(),
|
||||
profile,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(profile_repo.count(), 1);
|
||||
}
|
||||
|
||||
struct NoopPersonCommand;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::PersonCommand for NoopPersonCommand {
|
||||
async fn upsert_batch(
|
||||
&self,
|
||||
_: &[domain::models::Person],
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn backfill_from_credits_batch(
|
||||
&self,
|
||||
_: u32,
|
||||
) -> Result<(u64, bool), domain::errors::DomainError> {
|
||||
Ok((0, false))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extracts_and_indexes_persons() {
|
||||
let movie_repo = InMemoryMovieRepository::new();
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let search_cmd: Arc<dyn domain::ports::SearchCommand> = Arc::new(FakeSearchCommand);
|
||||
let person_cmd: Arc<dyn domain::ports::PersonCommand> = Arc::new(NoopPersonCommand);
|
||||
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Cast Movie".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let movie_id = MovieId::from_uuid(movie.id().value());
|
||||
movie_repo.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
let profile = MovieProfile {
|
||||
movie_id: movie_id.clone(),
|
||||
tmdb_id: 1001,
|
||||
imdb_id: None,
|
||||
overview: None,
|
||||
tagline: None,
|
||||
runtime_minutes: None,
|
||||
budget_usd: None,
|
||||
revenue_usd: None,
|
||||
vote_average: None,
|
||||
vote_count: None,
|
||||
original_language: None,
|
||||
collection_name: None,
|
||||
genres: vec![],
|
||||
keywords: vec![],
|
||||
cast: vec![domain::models::CastMember {
|
||||
tmdb_person_id: 42,
|
||||
name: "Actor One".into(),
|
||||
character: "Hero".into(),
|
||||
billing_order: 0,
|
||||
profile_path: None,
|
||||
}],
|
||||
crew: vec![domain::models::CrewMember {
|
||||
tmdb_person_id: 99,
|
||||
name: "Director One".into(),
|
||||
job: "Director".into(),
|
||||
department: "Directing".into(),
|
||||
profile_path: None,
|
||||
}],
|
||||
enriched_at: Utc::now(),
|
||||
};
|
||||
|
||||
enrich_movie::execute(
|
||||
&(movie_repo as Arc<_>),
|
||||
&(profile_repo.clone() as Arc<_>),
|
||||
&person_cmd,
|
||||
&search_cmd,
|
||||
EnrichMovieCommand {
|
||||
movie_id: movie_id.clone(),
|
||||
profile,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(profile_repo.count(), 1);
|
||||
}
|
||||
92
crates/application/src/movies/tests/get_movie_profile.rs
Normal file
92
crates/application/src/movies/tests/get_movie_profile.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{
|
||||
models::{CastMember, CrewMember, MovieProfile},
|
||||
ports::MovieProfileRepository,
|
||||
testing::InMemoryMovieProfileRepository,
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
movies::get_movie_profile::{self, GetMovieProfileQuery},
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_when_no_profile() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_movie_profile::execute(
|
||||
&ctx,
|
||||
GetMovieProfileQuery {
|
||||
movie_id: Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_with_cast_and_crew() {
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
let profile = MovieProfile {
|
||||
movie_id: movie_id.clone(),
|
||||
tmdb_id: 42,
|
||||
imdb_id: Some("tt1234567".into()),
|
||||
overview: Some("A great movie".into()),
|
||||
tagline: None,
|
||||
runtime_minutes: Some(120),
|
||||
budget_usd: None,
|
||||
revenue_usd: None,
|
||||
vote_average: Some(8.0),
|
||||
vote_count: Some(500),
|
||||
original_language: Some("en".into()),
|
||||
collection_name: None,
|
||||
genres: vec![],
|
||||
keywords: vec![],
|
||||
cast: vec![CastMember {
|
||||
tmdb_person_id: 1,
|
||||
name: "Alice".into(),
|
||||
character: "Hero".into(),
|
||||
billing_order: 0,
|
||||
profile_path: None,
|
||||
}],
|
||||
crew: vec![CrewMember {
|
||||
tmdb_person_id: 2,
|
||||
name: "Bob".into(),
|
||||
job: "Director".into(),
|
||||
department: "Directing".into(),
|
||||
profile_path: None,
|
||||
}],
|
||||
enriched_at: Utc::now(),
|
||||
};
|
||||
profile_repo.upsert(&profile).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_movie_profiles(Arc::clone(&profile_repo) as _)
|
||||
.build();
|
||||
|
||||
let result = get_movie_profile::execute(
|
||||
&ctx,
|
||||
GetMovieProfileQuery {
|
||||
movie_id: movie_id.value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let res = result.expect("profile should be present");
|
||||
assert_eq!(res.cast.len(), 1);
|
||||
assert_eq!(res.cast[0].name, "Alice");
|
||||
assert_eq!(res.cast[0].character, "Hero");
|
||||
assert_eq!(res.crew.len(), 1);
|
||||
assert_eq!(res.crew[0].name, "Bob");
|
||||
assert_eq!(res.crew[0].job, "Director");
|
||||
}
|
||||
24
crates/application/src/movies/tests/get_movies.rs
Normal file
24
crates/application/src/movies/tests/get_movies.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::{
|
||||
movies::{get_movies, queries::GetMoviesQuery},
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_movies() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_movies::execute(
|
||||
&ctx,
|
||||
GetMoviesQuery {
|
||||
limit: None,
|
||||
offset: None,
|
||||
search: None,
|
||||
genre: None,
|
||||
language: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
103
crates/application/src/movies/tests/request_enrichment.rs
Normal file
103
crates/application/src/movies/tests/request_enrichment.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::MovieProfile,
|
||||
ports::{MovieEnrichmentClient, MovieProfileRepository},
|
||||
testing::{FakeMovieEnrichmentClient, InMemoryMovieProfileRepository},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::movies::request_enrichment;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_when_none_cached() {
|
||||
let enrichment = FakeMovieEnrichmentClient;
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
let result = request_enrichment::fetch_if_stale(
|
||||
&enrichment,
|
||||
&(profile_repo as Arc<_>),
|
||||
movie_id.clone(),
|
||||
"tmdb:12345",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().movie_id, movie_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_when_profile_is_fresh() {
|
||||
let enrichment = FakeMovieEnrichmentClient;
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
// Seed a fresh profile (enriched_at = now)
|
||||
let fresh_profile = MovieProfile {
|
||||
movie_id: movie_id.clone(),
|
||||
tmdb_id: 12345,
|
||||
imdb_id: None,
|
||||
overview: None,
|
||||
tagline: None,
|
||||
runtime_minutes: None,
|
||||
budget_usd: None,
|
||||
revenue_usd: None,
|
||||
vote_average: None,
|
||||
vote_count: None,
|
||||
original_language: None,
|
||||
collection_name: None,
|
||||
genres: vec![],
|
||||
keywords: vec![],
|
||||
cast: vec![],
|
||||
crew: vec![],
|
||||
enriched_at: Utc::now(),
|
||||
};
|
||||
profile_repo.upsert(&fresh_profile).await.unwrap();
|
||||
|
||||
let result = request_enrichment::fetch_if_stale(
|
||||
&enrichment,
|
||||
&(Arc::clone(&profile_repo) as Arc<_>),
|
||||
movie_id,
|
||||
"tmdb:12345",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none(), "fresh profile should be skipped");
|
||||
}
|
||||
|
||||
struct NotFoundEnrichmentClient;
|
||||
|
||||
#[async_trait]
|
||||
impl MovieEnrichmentClient for NotFoundEnrichmentClient {
|
||||
async fn fetch_profile(
|
||||
&self,
|
||||
_movie_id: MovieId,
|
||||
_external_metadata_id: &str,
|
||||
) -> Result<MovieProfile, DomainError> {
|
||||
Err(DomainError::NotFound("not found in TMDb".into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_on_not_found_from_client() {
|
||||
let enrichment = NotFoundEnrichmentClient;
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
let result = request_enrichment::fetch_if_stale(
|
||||
&enrichment,
|
||||
&(profile_repo as Arc<_>),
|
||||
movie_id,
|
||||
"tmdb:99999",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none(), "NotFound should return Ok(None), not Err");
|
||||
}
|
||||
103
crates/application/src/movies/tests/sync_poster.rs
Normal file
103
crates/application/src/movies/tests/sync_poster.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::Movie,
|
||||
ports::{MetadataClient, MovieRepository},
|
||||
testing::InMemoryMovieRepository,
|
||||
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
diary::commands::SyncPosterCommand, movies::sync_poster, test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_movie_not_found() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = sync_poster::execute(
|
||||
&ctx,
|
||||
SyncPosterCommand {
|
||||
movie_id: Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_no_external_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Test".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let movie_id = movie.id().value();
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(&movies) as _)
|
||||
.build();
|
||||
|
||||
let result = sync_poster::execute(&ctx, SyncPosterCommand { movie_id }).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
struct FakeMetaWithPoster;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataClient for FakeMetaWithPoster {
|
||||
async fn fetch_movie_metadata(
|
||||
&self,
|
||||
_: &domain::ports::MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError> {
|
||||
Ok(Some(
|
||||
PosterUrl::new("https://example.com/poster.jpg".into()).unwrap(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn syncs_poster_for_movie_with_external_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let ext_id = ExternalMetadataId::new("tmdb:999".into()).unwrap();
|
||||
let movie = Movie::new(
|
||||
Some(ext_id),
|
||||
MovieTitle::new("Poster Movie".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let movie_id = movie.id().value();
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(&movies) as _)
|
||||
.with_metadata_client(Arc::new(FakeMetaWithPoster) as _)
|
||||
.build();
|
||||
|
||||
sync_poster::execute(&ctx, SyncPosterCommand { movie_id })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let updated = movies
|
||||
.get_movie_by_id(&domain::value_objects::MovieId::from_uuid(movie_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(updated.poster_path().is_some());
|
||||
}
|
||||
@@ -7,3 +7,7 @@ use domain::{
|
||||
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> {
|
||||
ctx.repos.person_query.get_by_id(&id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -7,3 +7,7 @@ use domain::{
|
||||
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<PersonCredits, DomainError> {
|
||||
ctx.repos.person_query.get_credits(&id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_credits.rs"]
|
||||
mod tests;
|
||||
|
||||
16
crates/application/src/person/tests/get.rs
Normal file
16
crates/application/src/person/tests/get.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::models::PersonId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::person::get;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_for_unknown_person() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get::execute(&ctx, PersonId::from_uuid(Uuid::new_v4()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
17
crates/application/src/person/tests/get_credits.rs
Normal file
17
crates/application/src/person/tests/get_credits.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use domain::models::PersonId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::person::get_credits;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_credits() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_credits::execute(&ctx, PersonId::from_uuid(Uuid::new_v4()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.cast.is_empty());
|
||||
assert!(result.crew.is_empty());
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::DiaryEntry;
|
||||
|
||||
use crate::diary::commands::LogReviewCommand;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ReviewLogger: Send + Sync {
|
||||
async fn log_review(&self, cmd: LogReviewCommand) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
pub struct HtmlPageContext {
|
||||
pub user_email: Option<String>,
|
||||
pub user_id: Option<Uuid>,
|
||||
|
||||
@@ -7,3 +7,7 @@ use domain::{
|
||||
pub async fn execute(ctx: &AppContext, query: SearchQuery) -> Result<SearchResults, DomainError> {
|
||||
ctx.repos.search_port.search(&query).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/execute.rs"]
|
||||
mod tests;
|
||||
|
||||
16
crates/application/src/search/tests/execute.rs
Normal file
16
crates/application/src/search/tests/execute.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::models::SearchQuery;
|
||||
|
||||
use crate::search::execute;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_results() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = execute::execute(&ctx, SearchQuery::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.movies.items.is_empty());
|
||||
assert!(result.people.items.is_empty());
|
||||
}
|
||||
@@ -1,34 +1,49 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{
|
||||
InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopRemoteWatchlistRepository,
|
||||
NoopSocialQueryPort,
|
||||
InMemoryGoalRepository, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery,
|
||||
NoopRemoteWatchlistRepository, NoopSocialQueryPort,
|
||||
};
|
||||
use domain::{
|
||||
ports::{
|
||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
||||
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
|
||||
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery,
|
||||
PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository,
|
||||
UserProfileFieldsRepository, UserRepository, WatchEventRepository, WatchlistRepository,
|
||||
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
|
||||
GoalRepository, ImportProfileRepository, ImportSessionRepository, MetadataClient,
|
||||
MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher, PersonCommand,
|
||||
PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
|
||||
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
|
||||
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
|
||||
WrapUpStatsQuery,
|
||||
},
|
||||
testing::{
|
||||
FakeAuthService, FakeMetadataClient, FakePasswordHasher, InMemoryMovieRepository,
|
||||
InMemoryReviewRepository, InMemoryUserRepository, InMemoryWatchlistRepository,
|
||||
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicDiaryRepository,
|
||||
PanicDocumentParser, PanicImportProfileRepository, PanicImportSessionRepository,
|
||||
PanicMovieProfileRepository, PanicPersonCommand, PanicPersonQuery, PanicPosterFetcher,
|
||||
PanicProfileFieldsRepo, PanicSearchCommand, PanicSearchPort, PanicStatsRepository,
|
||||
PanicWatchEventRepository, PanicWebhookTokenRepository,
|
||||
FakeAuthService, FakeDiaryRepository, FakeDocumentParser, FakeMetadataClient,
|
||||
FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort,
|
||||
FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository,
|
||||
InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo,
|
||||
InMemoryReviewRepository, InMemoryUserRepository, InMemoryUserSettingsRepository,
|
||||
InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository,
|
||||
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicPersonCommand,
|
||||
},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::{
|
||||
config::AppConfig,
|
||||
context::{AppContext, Repositories, Services},
|
||||
diary::commands::LogReviewCommand,
|
||||
ports::ReviewLogger,
|
||||
};
|
||||
|
||||
pub struct NoopReviewLogger;
|
||||
|
||||
#[async_trait]
|
||||
impl ReviewLogger for NoopReviewLogger {
|
||||
async fn log_review(&self, _cmd: LogReviewCommand) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TestContextBuilder {
|
||||
pub movie_repo: Arc<dyn MovieRepository>,
|
||||
pub review_repo: Arc<dyn ReviewRepository>,
|
||||
@@ -56,6 +71,10 @@ pub struct TestContextBuilder {
|
||||
pub search_command: Arc<dyn SearchCommand>,
|
||||
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
|
||||
pub wrapup_repo: Arc<dyn WrapUpRepository>,
|
||||
pub goal_repo: Arc<dyn GoalRepository>,
|
||||
pub user_settings_repo: Arc<dyn UserSettingsRepository>,
|
||||
pub review_logger: Arc<dyn ReviewLogger>,
|
||||
pub social_query: Arc<dyn domain::ports::SocialQueryPort>,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
@@ -70,30 +89,34 @@ impl TestContextBuilder {
|
||||
Self {
|
||||
movie_repo: InMemoryMovieRepository::new(),
|
||||
review_repo: InMemoryReviewRepository::new(),
|
||||
diary_repo: Arc::new(PanicDiaryRepository),
|
||||
diary_repo: FakeDiaryRepository::new(),
|
||||
diary_exporter: Arc::new(PanicDiaryExporter),
|
||||
document_parser: Arc::new(PanicDocumentParser),
|
||||
stats_repo: Arc::new(PanicStatsRepository),
|
||||
document_parser: Arc::new(FakeDocumentParser),
|
||||
stats_repo: Arc::new(FakeStatsRepository),
|
||||
metadata_client: Arc::new(FakeMetadataClient),
|
||||
poster_fetcher: Arc::new(PanicPosterFetcher),
|
||||
poster_fetcher: Arc::new(FakePosterFetcher),
|
||||
object_storage: Arc::new(NoopObjectStorage),
|
||||
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),
|
||||
import_session_repo: InMemoryImportSessionRepository::new(),
|
||||
import_profile_repo: InMemoryImportProfileRepository::new(),
|
||||
movie_profile_repo: InMemoryMovieProfileRepository::new(),
|
||||
watchlist_repo: InMemoryWatchlistRepository::new(),
|
||||
watch_event_repo: Arc::new(PanicWatchEventRepository),
|
||||
webhook_token_repo: Arc::new(PanicWebhookTokenRepository),
|
||||
profile_fields_repo: Arc::new(PanicProfileFieldsRepo),
|
||||
watch_event_repo: InMemoryWatchEventRepository::new(),
|
||||
webhook_token_repo: InMemoryWebhookTokenRepository::new(),
|
||||
profile_fields_repo: InMemoryProfileFieldsRepo::new(),
|
||||
person_command: Arc::new(PanicPersonCommand),
|
||||
person_query: Arc::new(PanicPersonQuery),
|
||||
search_port: Arc::new(PanicSearchPort),
|
||||
search_command: Arc::new(PanicSearchCommand),
|
||||
person_query: Arc::new(FakePersonQuery),
|
||||
search_port: Arc::new(FakeSearchPort),
|
||||
search_command: Arc::new(FakeSearchCommand),
|
||||
wrapup_stats: InMemoryWrapUpStatsQuery::new(),
|
||||
wrapup_repo: InMemoryWrapUpRepository::new(),
|
||||
goal_repo: InMemoryGoalRepository::new(),
|
||||
user_settings_repo: InMemoryUserSettingsRepository::new(),
|
||||
review_logger: Arc::new(NoopReviewLogger),
|
||||
social_query: Arc::new(NoopSocialQueryPort),
|
||||
config: AppConfig {
|
||||
allow_registration: true,
|
||||
base_url: "http://localhost:3000".into(),
|
||||
@@ -142,6 +165,96 @@ impl TestContextBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_goal(mut self, r: Arc<dyn GoalRepository>) -> Self {
|
||||
self.goal_repo = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_webhook_tokens(mut self, r: Arc<dyn WebhookTokenRepository>) -> Self {
|
||||
self.webhook_token_repo = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_watch_events(mut self, r: Arc<dyn WatchEventRepository>) -> Self {
|
||||
self.watch_event_repo = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_import_sessions(mut self, r: Arc<dyn ImportSessionRepository>) -> Self {
|
||||
self.import_session_repo = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_import_profiles(mut self, r: Arc<dyn ImportProfileRepository>) -> Self {
|
||||
self.import_profile_repo = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_movie_profiles(mut self, r: Arc<dyn MovieProfileRepository>) -> Self {
|
||||
self.movie_profile_repo = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_user_settings(mut self, r: Arc<dyn UserSettingsRepository>) -> Self {
|
||||
self.user_settings_repo = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_profile_fields(mut self, r: Arc<dyn UserProfileFieldsRepository>) -> Self {
|
||||
self.profile_fields_repo = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_review_logger(mut self, r: Arc<dyn ReviewLogger>) -> Self {
|
||||
self.review_logger = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_stats(mut self, r: Arc<dyn StatsRepository>) -> Self {
|
||||
self.stats_repo = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_person_query(mut self, r: Arc<dyn PersonQuery>) -> Self {
|
||||
self.person_query = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_search_port(mut self, r: Arc<dyn SearchPort>) -> Self {
|
||||
self.search_port = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_search_command(mut self, r: Arc<dyn SearchCommand>) -> Self {
|
||||
self.search_command = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_document_parser(mut self, r: Arc<dyn DocumentParser>) -> Self {
|
||||
self.document_parser = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_poster_fetcher(mut self, r: Arc<dyn PosterFetcherClient>) -> Self {
|
||||
self.poster_fetcher = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_metadata_client(mut self, r: Arc<dyn MetadataClient>) -> Self {
|
||||
self.metadata_client = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_social_query(mut self, r: Arc<dyn domain::ports::SocialQueryPort>) -> Self {
|
||||
self.social_query = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_wrapup_repo(mut self, r: Arc<dyn WrapUpRepository>) -> Self {
|
||||
self.wrapup_repo = r;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_config(mut self, config: AppConfig) -> Self {
|
||||
self.config = config;
|
||||
self
|
||||
@@ -167,11 +280,11 @@ impl TestContextBuilder {
|
||||
search_port: self.search_port,
|
||||
search_command: self.search_command,
|
||||
remote_watchlist: Arc::new(NoopRemoteWatchlistRepository),
|
||||
social_query: Arc::new(NoopSocialQueryPort),
|
||||
social_query: self.social_query,
|
||||
wrapup_stats: self.wrapup_stats,
|
||||
wrapup_repo: self.wrapup_repo,
|
||||
goal: Arc::new(domain::testing::NoopGoalRepository),
|
||||
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
|
||||
goal: self.goal_repo,
|
||||
user_settings: self.user_settings_repo,
|
||||
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
||||
},
|
||||
services: Services {
|
||||
@@ -183,6 +296,7 @@ impl TestContextBuilder {
|
||||
event_publisher: self.event_publisher,
|
||||
diary_exporter: self.diary_exporter,
|
||||
document_parser: self.document_parser,
|
||||
review_logger: self.review_logger,
|
||||
},
|
||||
config: self.config,
|
||||
}
|
||||
|
||||
@@ -57,3 +57,7 @@ pub async fn execute(
|
||||
role: user.role().as_str().into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_current_profile.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -183,3 +183,139 @@ fn format_year_month_long(ym: &str) -> String {
|
||||
};
|
||||
format!("{} {}", month, parts[0])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_profile.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod helper_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_year_month_long_all_months() {
|
||||
assert_eq!(format_year_month_long("2024-01"), "January 2024");
|
||||
assert_eq!(format_year_month_long("2024-02"), "February 2024");
|
||||
assert_eq!(format_year_month_long("2024-03"), "March 2024");
|
||||
assert_eq!(format_year_month_long("2024-04"), "April 2024");
|
||||
assert_eq!(format_year_month_long("2024-05"), "May 2024");
|
||||
assert_eq!(format_year_month_long("2024-06"), "June 2024");
|
||||
assert_eq!(format_year_month_long("2024-07"), "July 2024");
|
||||
assert_eq!(format_year_month_long("2024-08"), "August 2024");
|
||||
assert_eq!(format_year_month_long("2024-09"), "September 2024");
|
||||
assert_eq!(format_year_month_long("2024-10"), "October 2024");
|
||||
assert_eq!(format_year_month_long("2024-11"), "November 2024");
|
||||
assert_eq!(format_year_month_long("2024-12"), "December 2024");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_year_month_long_invalid() {
|
||||
assert_eq!(format_year_month_long("invalid"), "invalid");
|
||||
assert_eq!(format_year_month_long("2024-99"), "99 2024");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_sort_to_direction_all_variants() {
|
||||
use domain::ports::FeedSortBy;
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::Date),
|
||||
SortDirection::Descending
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::DateAsc),
|
||||
SortDirection::Ascending
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::Rating),
|
||||
SortDirection::ByRatingDesc
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::RatingAsc),
|
||||
SortDirection::ByRatingAsc
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_by_month_empty() {
|
||||
assert!(group_by_month(vec![]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_by_month_groups_entries() {
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::models::{Movie, Review};
|
||||
use domain::value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId};
|
||||
|
||||
let movie = Movie::from_persistence(
|
||||
MovieId::generate(),
|
||||
None,
|
||||
MovieTitle::new("Test".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
|
||||
let jan =
|
||||
NaiveDateTime::parse_from_str("2024-01-15 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
|
||||
let jan2 =
|
||||
NaiveDateTime::parse_from_str("2024-01-20 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
|
||||
let mar =
|
||||
NaiveDateTime::parse_from_str("2024-03-05 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
|
||||
|
||||
let r1 = Review::new(
|
||||
movie.id().clone(),
|
||||
uid.clone(),
|
||||
Rating::new(4).unwrap(),
|
||||
None,
|
||||
jan,
|
||||
)
|
||||
.unwrap();
|
||||
let r2 = Review::new(
|
||||
movie.id().clone(),
|
||||
uid.clone(),
|
||||
Rating::new(3).unwrap(),
|
||||
None,
|
||||
jan2,
|
||||
)
|
||||
.unwrap();
|
||||
let r3 = Review::new(
|
||||
movie.id().clone(),
|
||||
uid.clone(),
|
||||
Rating::new(5).unwrap(),
|
||||
None,
|
||||
mar,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let entries = vec![
|
||||
DiaryEntry::new(movie.clone(), r1),
|
||||
DiaryEntry::new(movie.clone(), r2),
|
||||
DiaryEntry::new(movie.clone(), r3),
|
||||
];
|
||||
|
||||
let result = group_by_month(entries);
|
||||
// Reversed: March first, then January
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].month_label, "March 2024");
|
||||
assert_eq!(result[0].count, 1);
|
||||
assert_eq!(result[1].month_label, "January 2024");
|
||||
assert_eq!(result[1].count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paged_user_filter_builds_correctly() {
|
||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let filter = paged_user_filter(
|
||||
uid.clone(),
|
||||
SortDirection::Descending,
|
||||
Some(20),
|
||||
Some(5),
|
||||
Some("blade".into()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(filter.user_id.unwrap().value(), uid.value());
|
||||
assert_eq!(filter.search.as_deref(), Some("blade"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,7 @@ pub async fn execute(ctx: &AppContext, user_id: uuid::Uuid) -> Result<UserSettin
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
ctx.repos.user_settings.get(&uid).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_settings.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -20,3 +20,7 @@ pub async fn execute(
|
||||
remote_actors: actors_result?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_users.rs"]
|
||||
mod tests;
|
||||
|
||||
115
crates/application/src/users/tests/get_current_profile.rs
Normal file
115
crates/application/src/users/tests/get_current_profile.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::{ProfileField, User, UserProfile, UserRole};
|
||||
use domain::ports::UserRepository;
|
||||
use domain::testing::InMemoryUserRepository;
|
||||
use domain::value_objects::{Email, PasswordHash, UserId, Username};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::{commands::RegisterCommand, register},
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{get_current_profile, queries::GetCurrentProfileQuery},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_for_existing_user() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.build();
|
||||
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "alice@example.com".into(),
|
||||
username: "alice".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user = users
|
||||
.find_by_email(&domain::value_objects::Email::new("alice@example.com".into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let profile = get_current_profile::execute(
|
||||
&ctx,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: user.id().value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(profile.username, "alice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_for_nonexistent_user() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_current_profile::execute(
|
||||
&ctx,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_with_avatar_banner_and_fields() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let uid = UserId::generate();
|
||||
|
||||
let user = User::from_persistence(
|
||||
uid.clone(),
|
||||
Email::new("full@example.com".into()).unwrap(),
|
||||
Username::new("fulluser".into()).unwrap(),
|
||||
PasswordHash::new("hashed".into()).unwrap(),
|
||||
UserRole::Standard,
|
||||
UserProfile {
|
||||
display_name: Some("Full Name".into()),
|
||||
bio: Some("My bio".into()),
|
||||
avatar_path: Some("avatars/abc123".into()),
|
||||
banner_path: Some("banners/def456".into()),
|
||||
also_known_as: None,
|
||||
profile_fields: vec![ProfileField {
|
||||
name: "Website".into(),
|
||||
value: "https://example.com".into(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
users.store.lock().unwrap().insert(uid.value(), user);
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.build();
|
||||
|
||||
let profile = get_current_profile::execute(
|
||||
&ctx,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: uid.value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(profile.username, "fulluser");
|
||||
assert_eq!(profile.display_name.as_deref(), Some("Full Name"));
|
||||
assert_eq!(profile.bio.as_deref(), Some("My bio"));
|
||||
assert!(profile.avatar_url.is_some());
|
||||
assert!(profile.avatar_url.unwrap().contains("avatars/abc123"));
|
||||
assert!(profile.banner_url.is_some());
|
||||
assert!(profile.banner_url.unwrap().contains("banners/def456"));
|
||||
assert_eq!(profile.fields.len(), 1);
|
||||
assert_eq!(profile.fields[0].name, "Website");
|
||||
assert_eq!(profile.fields[0].value, "https://example.com");
|
||||
}
|
||||
240
crates/application/src/users/tests/get_profile.rs
Normal file
240
crates/application/src/users/tests/get_profile.rs
Normal file
@@ -0,0 +1,240 @@
|
||||
use domain::models::UserRole;
|
||||
use domain::value_objects::Email;
|
||||
|
||||
use crate::auth::commands::RegisterCommand;
|
||||
use crate::auth::register;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::get_profile;
|
||||
use crate::users::queries::{GetUserProfileQuery, ProfileView};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_with_empty_stats() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "profile@test.com".into(),
|
||||
username: "profuser".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let email = Email::new("profile@test.com".into()).unwrap();
|
||||
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&ctx,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.entries.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_history_view() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "hist@test.com".into(),
|
||||
username: "histuser".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let email = Email::new("hist@test.com".into()).unwrap();
|
||||
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&ctx,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::History,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.history.is_some());
|
||||
assert!(result.entries.is_none());
|
||||
assert!(result.trends.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_trends_view() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "trends@test.com".into(),
|
||||
username: "trendsuser".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let email = Email::new("trends@test.com".into()).unwrap();
|
||||
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&ctx,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Trends,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.trends.is_some());
|
||||
assert!(result.entries.is_none());
|
||||
assert!(result.history.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_ratings_view() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "ratings@test.com".into(),
|
||||
username: "ratingsuser".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let email = Email::new("ratings@test.com".into()).unwrap();
|
||||
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&ctx,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Ratings,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::ports::FeedSortBy::Rating,
|
||||
search: None,
|
||||
is_own_profile: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.entries.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_recent_with_search() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "search@test.com".into(),
|
||||
username: "searchuser".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let email = Email::new("search@test.com".into()).unwrap();
|
||||
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&ctx,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
limit: Some(10),
|
||||
offset: Some(0),
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: Some("blade".into()),
|
||||
is_own_profile: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.entries.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_own_profile_skips_pending_followers() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "other@test.com".into(),
|
||||
username: "otheruser".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let email = Email::new("other@test.com".into()).unwrap();
|
||||
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&ctx,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.pending_followers.is_empty());
|
||||
}
|
||||
12
crates/application/src/users/tests/get_settings.rs
Normal file
12
crates/application/src/users/tests/get_settings.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{test_helpers::TestContextBuilder, users::get_settings};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_default_settings() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let settings = get_settings::execute(&ctx, Uuid::nil()).await.unwrap();
|
||||
|
||||
assert!(!settings.federate_goals());
|
||||
}
|
||||
13
crates/application/src/users/tests/get_users.rs
Normal file
13
crates/application/src/users/tests/get_users.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::get_users;
|
||||
use crate::users::queries::GetUsersQuery;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_users() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = get_users::execute(&ctx, GetUsersQuery).await.unwrap();
|
||||
|
||||
assert!(result.users.is_empty());
|
||||
assert!(result.remote_actors.is_empty());
|
||||
}
|
||||
269
crates/application/src/users/tests/update_profile.rs
Normal file
269
crates/application/src/users/tests/update_profile.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::models::UserRole;
|
||||
use domain::ports::UserRepository;
|
||||
use domain::testing::{InMemoryUserRepository, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::{commands::RegisterCommand, register},
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{commands::UpdateProfileCommand, update_profile},
|
||||
};
|
||||
|
||||
async fn register_user(
|
||||
ctx: &crate::context::AppContext,
|
||||
users: &Arc<InMemoryUserRepository>,
|
||||
) -> Uuid {
|
||||
register::execute(
|
||||
ctx,
|
||||
RegisterCommand {
|
||||
email: "alice@example.com".into(),
|
||||
username: "alice".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user = users
|
||||
.find_by_email(&domain::value_objects::Email::new("alice@example.com".into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
user.id().value()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_display_name() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let uid = register_user(&ctx, &users).await;
|
||||
|
||||
update_profile::execute(
|
||||
&ctx,
|
||||
UpdateProfileCommand {
|
||||
user_id: uid,
|
||||
display_name: Some("Alice W.".into()),
|
||||
bio: None,
|
||||
avatar_bytes: None,
|
||||
avatar_content_type: None,
|
||||
banner_bytes: None,
|
||||
banner_content_type: None,
|
||||
also_known_as: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_avatar_content_type() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.build();
|
||||
|
||||
let uid = register_user(&ctx, &users).await;
|
||||
|
||||
let result = update_profile::execute(
|
||||
&ctx,
|
||||
UpdateProfileCommand {
|
||||
user_id: uid,
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_bytes: Some(vec![0u8; 10]),
|
||||
avatar_content_type: Some("image/gif".into()),
|
||||
banner_bytes: None,
|
||||
banner_content_type: None,
|
||||
also_known_as: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uploads_avatar() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let uid = register_user(&ctx, &users).await;
|
||||
|
||||
update_profile::execute(
|
||||
&ctx,
|
||||
UpdateProfileCommand {
|
||||
user_id: uid,
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_bytes: Some(vec![0xFFu8, 0xD8, 0xFF]),
|
||||
avatar_content_type: Some("image/jpeg".into()),
|
||||
banner_bytes: None,
|
||||
banner_content_type: None,
|
||||
also_known_as: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
||||
);
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uploads_banner() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let uid = register_user(&ctx, &users).await;
|
||||
|
||||
update_profile::execute(
|
||||
&ctx,
|
||||
UpdateProfileCommand {
|
||||
user_id: uid,
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_bytes: None,
|
||||
avatar_content_type: None,
|
||||
banner_bytes: Some(vec![0x89, 0x50, 0x4E]),
|
||||
banner_content_type: Some("image/png".into()),
|
||||
also_known_as: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
||||
);
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_for_nonexistent_user() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = update_profile::execute(
|
||||
&ctx,
|
||||
UpdateProfileCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
display_name: Some("Ghost".into()),
|
||||
bio: None,
|
||||
avatar_bytes: None,
|
||||
avatar_content_type: None,
|
||||
banner_bytes: None,
|
||||
banner_content_type: None,
|
||||
also_known_as: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_banner_content_type() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.build();
|
||||
|
||||
let uid = register_user(&ctx, &users).await;
|
||||
|
||||
let result = update_profile::execute(
|
||||
&ctx,
|
||||
UpdateProfileCommand {
|
||||
user_id: uid,
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_bytes: None,
|
||||
avatar_content_type: None,
|
||||
banner_bytes: Some(vec![0u8; 10]),
|
||||
banner_content_type: Some("text/plain".into()),
|
||||
also_known_as: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn text_only_update_emits_user_updated_no_image_stored() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let uid = register_user(&ctx, &users).await;
|
||||
|
||||
update_profile::execute(
|
||||
&ctx,
|
||||
UpdateProfileCommand {
|
||||
user_id: uid,
|
||||
display_name: Some("Alice Updated".into()),
|
||||
bio: Some("Hello world".into()),
|
||||
avatar_bytes: None,
|
||||
avatar_content_type: None,
|
||||
banner_bytes: None,
|
||||
banner_content_type: None,
|
||||
also_known_as: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
||||
);
|
||||
assert!(
|
||||
!published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::ImageStored { .. })),
|
||||
"text-only update should not emit ImageStored"
|
||||
);
|
||||
}
|
||||
70
crates/application/src/users/tests/update_profile_fields.rs
Normal file
70
crates/application/src/users/tests/update_profile_fields.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::models::ProfileField;
|
||||
use domain::testing::{InMemoryProfileFieldsRepo, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{commands::UpdateProfileFieldsCommand, update_profile_fields},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn saves_profile_fields() {
|
||||
let fields_repo = InMemoryProfileFieldsRepo::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_profile_fields(Arc::clone(&fields_repo) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
update_profile_fields::execute(
|
||||
&ctx,
|
||||
UpdateProfileFieldsCommand {
|
||||
user_id: Uuid::nil(),
|
||||
fields: vec![
|
||||
ProfileField {
|
||||
name: "Website".into(),
|
||||
value: "https://example.com".into(),
|
||||
},
|
||||
ProfileField {
|
||||
name: "Location".into(),
|
||||
value: "Berlin".into(),
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_more_than_four_fields() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let fields: Vec<ProfileField> = (0..5)
|
||||
.map(|i| ProfileField {
|
||||
name: format!("field{i}"),
|
||||
value: format!("val{i}"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let result = update_profile_fields::execute(
|
||||
&ctx,
|
||||
UpdateProfileFieldsCommand {
|
||||
user_id: Uuid::nil(),
|
||||
fields,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
32
crates/application/src/users/tests/update_settings.rs
Normal file
32
crates/application/src/users/tests/update_settings.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryUserSettingsRepository;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{get_settings, update_settings::UpdateUserSettingsCommand},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_federate_goals() {
|
||||
let settings_repo = InMemoryUserSettingsRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_user_settings(Arc::clone(&settings_repo) as _)
|
||||
.build();
|
||||
|
||||
let uid = Uuid::nil();
|
||||
|
||||
crate::users::update_settings::execute(
|
||||
&ctx,
|
||||
UpdateUserSettingsCommand {
|
||||
user_id: uid,
|
||||
federate_goals: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let settings = get_settings::execute(&ctx, uid).await.unwrap();
|
||||
assert!(settings.federate_goals());
|
||||
}
|
||||
@@ -90,3 +90,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/update_profile.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -19,3 +19,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Resul
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/update_profile_fields.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -13,3 +13,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateUserSettingsCommand) -> Result
|
||||
settings.set_federate_goals(cmd.federate_goals);
|
||||
ctx.repos.user_settings.save(&settings).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/update_settings.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -17,3 +17,7 @@ pub async fn execute(
|
||||
let page = PageParams::new(query.limit, query.offset)?;
|
||||
ctx.repos.watchlist.get_for_user(&user_id, &page).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -84,3 +84,7 @@ async fn load_remote_watchlist(
|
||||
limit: len,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_page.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -10,3 +10,7 @@ pub async fn execute(ctx: &AppContext, query: IsOnWatchlistQuery) -> Result<bool
|
||||
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||
ctx.repos.watchlist.contains(&user_id, &movie_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/is_on.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -19,3 +19,7 @@ pub async fn execute(ctx: &AppContext, cmd: RemoveFromWatchlistCommand) -> Resul
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/remove.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -85,3 +85,54 @@ async fn test_add_to_watchlist_already_present_is_idempotent() {
|
||||
|
||||
assert_eq!(watchlist.count(), 1, "idempotent add should not duplicate");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_to_watchlist_with_manual_movie() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
|
||||
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: None,
|
||||
external_metadata_id: None,
|
||||
manual_title: Some("New Manual Movie".into()),
|
||||
manual_release_year: Some(2024),
|
||||
manual_director: None,
|
||||
},
|
||||
};
|
||||
|
||||
add::execute(&ctx, cmd).await.unwrap();
|
||||
|
||||
assert_eq!(watchlist.count(), 1);
|
||||
assert_eq!(movies.count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_to_watchlist_movie_not_found_by_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
|
||||
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(uuid::Uuid::new_v4()),
|
||||
external_metadata_id: None,
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
manual_director: None,
|
||||
},
|
||||
};
|
||||
|
||||
assert!(add::execute(&ctx, cmd).await.is_err());
|
||||
}
|
||||
|
||||
22
crates/application/src/watchlist/tests/get.rs
Normal file
22
crates/application/src/watchlist/tests/get.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::watchlist::{get, queries::GetWatchlistQuery};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_page_for_new_user() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let result = get::execute(
|
||||
&ctx,
|
||||
GetWatchlistQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
limit: None,
|
||||
offset: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total_count, 0);
|
||||
}
|
||||
311
crates/application/src/watchlist/tests/get_page.rs
Normal file
311
crates/application/src/watchlist/tests/get_page.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::collections::{PageParams, Paginated};
|
||||
use domain::models::watchlist::{WatchlistEntry, WatchlistWithMovie};
|
||||
use domain::models::{Movie, UserRole};
|
||||
use domain::ports::WatchlistRepository;
|
||||
use domain::value_objects::{Email, MovieId, MovieTitle, PosterPath, ReleaseYear, UserId};
|
||||
|
||||
use crate::auth::commands::RegisterCommand;
|
||||
use crate::auth::register;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::watchlist::get_page;
|
||||
use crate::watchlist::queries::GetWatchlistQuery;
|
||||
|
||||
struct FakeWatchlistWithItems {
|
||||
user_id: UserId,
|
||||
items: Vec<WatchlistWithMovie>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WatchlistRepository for FakeWatchlistWithItems {
|
||||
async fn add(&self, _entry: &WatchlistEntry) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn remove(&self, _user_id: &UserId, _movie_id: &MovieId) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn remove_if_present(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_movie_id: &MovieId,
|
||||
) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn get_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
_page: &PageParams,
|
||||
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
|
||||
if user_id == &self.user_id {
|
||||
Ok(Paginated {
|
||||
total_count: self.items.len() as u64,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
items: self.items.clone(),
|
||||
})
|
||||
} else {
|
||||
Ok(Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
async fn contains(&self, _user_id: &UserId, _movie_id: &MovieId) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_for_local_user() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "wl@test.com".into(),
|
||||
username: "wluser".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let email = Email::new("wl@test.com".into()).unwrap();
|
||||
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_page::execute(
|
||||
&ctx,
|
||||
GetWatchlistQuery {
|
||||
user_id: uid,
|
||||
limit: None,
|
||||
offset: None,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.display_entries.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_display_entries_for_local_user_with_items() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "wl2@test.com".into(),
|
||||
username: "wluser2".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let email = Email::new("wl2@test.com".into()).unwrap();
|
||||
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_page::execute(
|
||||
&ctx,
|
||||
GetWatchlistQuery {
|
||||
user_id: uid,
|
||||
limit: Some(20),
|
||||
offset: Some(0),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// InMemory get_for_user returns empty, but the local-user branch is exercised
|
||||
assert!(!result.has_more);
|
||||
assert_eq!(result.current_offset, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_remote_watchlist_for_unknown_user() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let unknown_uid = uuid::Uuid::new_v4();
|
||||
|
||||
let result = get_page::execute(
|
||||
&ctx,
|
||||
GetWatchlistQuery {
|
||||
user_id: unknown_uid,
|
||||
limit: None,
|
||||
offset: None,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// NoopRemoteWatchlistRepository returns empty
|
||||
assert!(result.display_entries.is_empty());
|
||||
assert!(!result.has_more);
|
||||
assert_eq!(result.current_offset, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maps_display_entries_for_owner() {
|
||||
let uid = uuid::Uuid::new_v4();
|
||||
let user_id = UserId::from_uuid(uid);
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
let movie = Movie::from_persistence(
|
||||
movie_id.clone(),
|
||||
None,
|
||||
MovieTitle::new("Blade Runner".into()).unwrap(),
|
||||
ReleaseYear::new(1982).unwrap(),
|
||||
None,
|
||||
Some(PosterPath::new("poster123.jpg".into()).unwrap()),
|
||||
);
|
||||
let entry = WatchlistEntry::new(user_id.clone(), movie_id.clone());
|
||||
|
||||
let fake_wl = Arc::new(FakeWatchlistWithItems {
|
||||
user_id: user_id.clone(),
|
||||
items: vec![WatchlistWithMovie {
|
||||
entry,
|
||||
movie: movie.clone(),
|
||||
}],
|
||||
});
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watchlist(fake_wl as _)
|
||||
.build();
|
||||
|
||||
// register user so find_by_id returns Some
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "wlmap@test.com".into(),
|
||||
username: "wlmapuser".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let email = Email::new("wlmap@test.com".into()).unwrap();
|
||||
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
|
||||
let real_uid = user.id().value();
|
||||
|
||||
// Rebuild with the real user_id in the fake
|
||||
let movie_id2 = MovieId::generate();
|
||||
let movie2 = Movie::from_persistence(
|
||||
movie_id2.clone(),
|
||||
None,
|
||||
MovieTitle::new("Blade Runner".into()).unwrap(),
|
||||
ReleaseYear::new(1982).unwrap(),
|
||||
None,
|
||||
Some(PosterPath::new("poster123.jpg".into()).unwrap()),
|
||||
);
|
||||
let entry2 = WatchlistEntry::new(UserId::from_uuid(real_uid), movie_id2.clone());
|
||||
|
||||
let fake_wl2 = Arc::new(FakeWatchlistWithItems {
|
||||
user_id: UserId::from_uuid(real_uid),
|
||||
items: vec![WatchlistWithMovie {
|
||||
entry: entry2,
|
||||
movie: movie2.clone(),
|
||||
}],
|
||||
});
|
||||
|
||||
let ctx2 = TestContextBuilder::new()
|
||||
.with_watchlist(fake_wl2 as _)
|
||||
.with_users(ctx.repos.user.clone())
|
||||
.build();
|
||||
|
||||
let result = get_page::execute(
|
||||
&ctx2,
|
||||
GetWatchlistQuery {
|
||||
user_id: real_uid,
|
||||
limit: Some(20),
|
||||
offset: Some(0),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.display_entries.len(), 1);
|
||||
let de = &result.display_entries[0];
|
||||
assert_eq!(de.movie_title, "Blade Runner");
|
||||
assert_eq!(de.release_year, 1982);
|
||||
assert_eq!(de.poster_url.as_deref(), Some("/images/poster123.jpg"));
|
||||
assert!(de.movie_url.is_some());
|
||||
assert!(de.remove_url.is_some()); // owner can remove
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maps_display_entries_for_non_owner() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "wlno@test.com".into(),
|
||||
username: "wlnoowner".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let email = Email::new("wlno@test.com".into()).unwrap();
|
||||
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
|
||||
let real_uid = user.id().value();
|
||||
|
||||
let movie_id = MovieId::generate();
|
||||
let movie = Movie::from_persistence(
|
||||
movie_id.clone(),
|
||||
None,
|
||||
MovieTitle::new("Alien".into()).unwrap(),
|
||||
ReleaseYear::new(1979).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let entry = WatchlistEntry::new(UserId::from_uuid(real_uid), movie_id.clone());
|
||||
|
||||
let fake_wl = Arc::new(FakeWatchlistWithItems {
|
||||
user_id: UserId::from_uuid(real_uid),
|
||||
items: vec![WatchlistWithMovie {
|
||||
entry,
|
||||
movie: movie.clone(),
|
||||
}],
|
||||
});
|
||||
|
||||
let ctx2 = TestContextBuilder::new()
|
||||
.with_watchlist(fake_wl as _)
|
||||
.with_users(ctx.repos.user.clone())
|
||||
.build();
|
||||
|
||||
let result = get_page::execute(
|
||||
&ctx2,
|
||||
GetWatchlistQuery {
|
||||
user_id: real_uid,
|
||||
limit: Some(20),
|
||||
offset: Some(0),
|
||||
},
|
||||
false, // not owner
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.display_entries.len(), 1);
|
||||
let de = &result.display_entries[0];
|
||||
assert_eq!(de.movie_title, "Alien");
|
||||
assert!(de.poster_url.is_none()); // no poster
|
||||
assert!(de.remove_url.is_none()); // not owner
|
||||
}
|
||||
56
crates/application/src/watchlist/tests/is_on.rs
Normal file
56
crates/application/src/watchlist/tests/is_on.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::WatchlistEntry;
|
||||
use domain::ports::WatchlistRepository;
|
||||
use domain::testing::InMemoryWatchlistRepository;
|
||||
use domain::value_objects::{MovieId, UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::watchlist::{is_on, queries::IsOnWatchlistQuery};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_true_when_present() {
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let mid = Uuid::new_v4();
|
||||
watchlist
|
||||
.add(&WatchlistEntry::new(
|
||||
UserId::from_uuid(uid),
|
||||
MovieId::from_uuid(mid),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watchlist(Arc::clone(&watchlist) as _)
|
||||
.build();
|
||||
|
||||
let result = is_on::execute(
|
||||
&ctx,
|
||||
IsOnWatchlistQuery {
|
||||
user_id: uid,
|
||||
movie_id: mid,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_false_when_absent() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let result = is_on::execute(
|
||||
&ctx,
|
||||
IsOnWatchlistQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
movie_id: Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
64
crates/application/src/watchlist/tests/remove.rs
Normal file
64
crates/application/src/watchlist/tests/remove.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::models::WatchlistEntry;
|
||||
use domain::ports::WatchlistRepository;
|
||||
use domain::testing::{InMemoryWatchlistRepository, NoopEventPublisher};
|
||||
use domain::value_objects::{MovieId, UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::watchlist::{commands::RemoveFromWatchlistCommand, remove};
|
||||
|
||||
#[tokio::test]
|
||||
async fn removes_entry_and_emits_event() {
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let mid = Uuid::new_v4();
|
||||
watchlist
|
||||
.add(&WatchlistEntry::new(
|
||||
UserId::from_uuid(uid),
|
||||
MovieId::from_uuid(mid),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_watchlist(Arc::clone(&watchlist) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
remove::execute(
|
||||
&ctx,
|
||||
RemoveFromWatchlistCommand {
|
||||
user_id: uid,
|
||||
movie_id: mid,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(watchlist.count(), 0);
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::WatchlistEntryRemoved { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_not_on_watchlist() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let result = remove::execute(
|
||||
&ctx,
|
||||
RemoveFromWatchlistCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
movie_id: Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user