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
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test # full workspace (requires DATABASE_URL for sqlx offline checks)
|
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 -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
|
## Docker
|
||||||
|
|
||||||
|
|||||||
@@ -30,3 +30,7 @@ pub async fn execute(
|
|||||||
)
|
)
|
||||||
.await
|
.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::config::AppConfig;
|
||||||
|
use crate::ports::ReviewLogger;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Repositories {
|
pub struct Repositories {
|
||||||
@@ -49,6 +50,7 @@ pub struct Services {
|
|||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
pub diary_exporter: Arc<dyn DiaryExporter>,
|
pub diary_exporter: Arc<dyn DiaryExporter>,
|
||||||
pub document_parser: Arc<dyn DocumentParser>,
|
pub document_parser: Arc<dyn DocumentParser>,
|
||||||
|
pub review_logger: Arc<dyn ReviewLogger>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|||||||
@@ -64,3 +64,7 @@ async fn build_following_filter(
|
|||||||
remote_actor_urls: remote_urls,
|
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
|
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,
|
profile,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/get_movie_social_page.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -21,3 +21,7 @@ pub async fn execute(
|
|||||||
|
|
||||||
Ok((history, trend))
|
Ok((history, trend))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/get_review_history.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -1,98 +1,11 @@
|
|||||||
use domain::{
|
use domain::errors::DomainError;
|
||||||
errors::DomainError,
|
|
||||||
events::DomainEvent,
|
|
||||||
models::{Movie, Review},
|
|
||||||
value_objects::{Comment, MovieId, Rating, UserId},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{context::AppContext, diary::commands::LogReviewCommand};
|
||||||
context::AppContext,
|
|
||||||
diary::commands::LogReviewCommand,
|
|
||||||
diary::movie_resolver::{MovieResolver, MovieResolverDeps},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), DomainError> {
|
pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), DomainError> {
|
||||||
let rating = Rating::new(cmd.rating)?;
|
ctx.services.review_logger.log_review(cmd).await
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "tests/log_review.rs"]
|
#[path = "tests/log_review.rs"]
|
||||||
mod tests;
|
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 log_review;
|
||||||
pub mod movie_resolver;
|
pub mod movie_resolver;
|
||||||
pub mod queries;
|
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::{
|
use crate::{
|
||||||
diary::commands::{LogReviewCommand, MovieInput},
|
diary::commands::{LogReviewCommand, MovieInput},
|
||||||
diary::log_review,
|
diary::log_review,
|
||||||
|
diary::review_logger::DefaultReviewLogger,
|
||||||
test_helpers::TestContextBuilder,
|
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 {
|
fn movie_input_manual(title: &str, year: u16) -> MovieInput {
|
||||||
MovieInput {
|
MovieInput {
|
||||||
movie_id: None,
|
movie_id: None,
|
||||||
@@ -41,11 +62,7 @@ async fn test_log_review_creates_movie_and_review() {
|
|||||||
let movies = InMemoryMovieRepository::new();
|
let movies = InMemoryMovieRepository::new();
|
||||||
let reviews = InMemoryReviewRepository::new();
|
let reviews = InMemoryReviewRepository::new();
|
||||||
let events = NoopEventPublisher::new();
|
let events = NoopEventPublisher::new();
|
||||||
let ctx = TestContextBuilder::new()
|
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events);
|
||||||
.with_movies(Arc::clone(&movies) as _)
|
|
||||||
.with_reviews(Arc::clone(&reviews) as _)
|
|
||||||
.with_event_publisher(Arc::clone(&events) as _)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let user_id = uuid::Uuid::new_v4();
|
let user_id = uuid::Uuid::new_v4();
|
||||||
let cmd = LogReviewCommand {
|
let cmd = LogReviewCommand {
|
||||||
@@ -77,10 +94,8 @@ async fn test_log_review_reuses_existing_movie() {
|
|||||||
let movie_uuid = existing_movie.id().value();
|
let movie_uuid = existing_movie.id().value();
|
||||||
movies.upsert_movie(&existing_movie).await.unwrap();
|
movies.upsert_movie(&existing_movie).await.unwrap();
|
||||||
|
|
||||||
let ctx = TestContextBuilder::new()
|
let events = NoopEventPublisher::new();
|
||||||
.with_movies(Arc::clone(&movies) as _)
|
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events);
|
||||||
.with_reviews(Arc::clone(&reviews) as _)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let cmd = LogReviewCommand {
|
let cmd = LogReviewCommand {
|
||||||
user_id: uuid::Uuid::new_v4(),
|
user_id: uuid::Uuid::new_v4(),
|
||||||
@@ -98,7 +113,10 @@ async fn test_log_review_reuses_existing_movie() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_log_review_with_invalid_rating_fails() {
|
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 {
|
let cmd = LogReviewCommand {
|
||||||
user_id: uuid::Uuid::new_v4(),
|
user_id: uuid::Uuid::new_v4(),
|
||||||
input: movie_input_manual("Some Film", 2000),
|
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,
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/delete.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -28,3 +28,7 @@ pub async fn execute(
|
|||||||
current_count,
|
current_count,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/get.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -25,3 +25,7 @@ pub async fn execute(
|
|||||||
|
|
||||||
Ok(result)
|
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,
|
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(())
|
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;
|
session.row_results = None;
|
||||||
ctx.repos.import_session.update(&session).await
|
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> {
|
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
|
||||||
ctx.repos.import_session.delete_expired().await
|
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,
|
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()))?;
|
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
|
||||||
ctx.repos.import_profile.delete(&profile_id).await
|
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::{
|
use crate::{
|
||||||
context::AppContext,
|
context::AppContext,
|
||||||
diary::commands::{LogReviewCommand, MovieInput},
|
diary::commands::{LogReviewCommand, MovieInput},
|
||||||
diary::log_review,
|
|
||||||
import::commands::ExecuteImportCommand,
|
import::commands::ExecuteImportCommand,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ pub async fn execute(
|
|||||||
}
|
}
|
||||||
match annotated.result {
|
match annotated.result {
|
||||||
RowResult::Valid(row) => match row_to_command(&row, user_id.value()) {
|
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,
|
Ok(_) => imported += 1,
|
||||||
Err(e) => failed.push((idx, e.to_string())),
|
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> {
|
fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, String> {
|
||||||
let rating = row
|
let rating = row
|
||||||
.rating
|
.rating
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ pub async fn execute(
|
|||||||
) -> Result<Vec<ImportProfile>, DomainError> {
|
) -> Result<Vec<ImportProfile>, DomainError> {
|
||||||
ctx.repos.import_profile.list_for_user(user_id).await
|
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?;
|
ctx.repos.import_profile.save(&profile).await?;
|
||||||
Ok(id)
|
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)
|
.delete_non_pending_older_than(cutoff)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/cleanup.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use domain::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
context::AppContext,
|
context::AppContext,
|
||||||
diary::commands::{LogReviewCommand, MovieInput},
|
diary::commands::{LogReviewCommand, MovieInput},
|
||||||
diary::log_review,
|
|
||||||
integrations::commands::ConfirmWatchEventsCommand,
|
integrations::commands::ConfirmWatchEventsCommand,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result
|
|||||||
watched_at: *event.watched_at(),
|
watched_at: *event.watched_at(),
|
||||||
};
|
};
|
||||||
|
|
||||||
log_review::execute(ctx, review_cmd).await?;
|
ctx.services.review_logger.log_review(review_cmd).await?;
|
||||||
|
|
||||||
ctx.repos
|
ctx.repos
|
||||||
.watch_event
|
.watch_event
|
||||||
@@ -66,3 +65,7 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result
|
|||||||
|
|
||||||
Ok(confirmed)
|
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)
|
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());
|
hasher.update(plaintext.as_bytes());
|
||||||
hex::encode(hasher.finalize())
|
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);
|
let user_id = UserId::from_uuid(query.user_id);
|
||||||
ctx.repos.watch_event.list_pending(&user_id).await
|
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);
|
let user_id = UserId::from_uuid(query.user_id);
|
||||||
ctx.repos.webhook_token.list_by_user(&user_id).await
|
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(())
|
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);
|
let token_id = WebhookTokenId::from_uuid(cmd.token_id);
|
||||||
ctx.repos.webhook_token.delete(&token_id, &user_id).await
|
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()
|
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
|
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),
|
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(())
|
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> {
|
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> {
|
||||||
ctx.repos.person_query.get_by_id(&id).await
|
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> {
|
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<PersonCredits, DomainError> {
|
||||||
ctx.repos.person_query.get_credits(&id).await
|
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 uuid::Uuid;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
use domain::models::DiaryEntry;
|
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 struct HtmlPageContext {
|
||||||
pub user_email: Option<String>,
|
pub user_email: Option<String>,
|
||||||
pub user_id: Option<Uuid>,
|
pub user_id: Option<Uuid>,
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ use domain::{
|
|||||||
pub async fn execute(ctx: &AppContext, query: SearchQuery) -> Result<SearchResults, DomainError> {
|
pub async fn execute(ctx: &AppContext, query: SearchQuery) -> Result<SearchResults, DomainError> {
|
||||||
ctx.repos.search_port.search(&query).await
|
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 std::sync::Arc;
|
||||||
|
|
||||||
use domain::testing::{
|
use domain::testing::{
|
||||||
InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopRemoteWatchlistRepository,
|
InMemoryGoalRepository, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery,
|
||||||
NoopSocialQueryPort,
|
NoopRemoteWatchlistRepository, NoopSocialQueryPort,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
ports::{
|
ports::{
|
||||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
||||||
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
|
GoalRepository, ImportProfileRepository, ImportSessionRepository, MetadataClient,
|
||||||
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery,
|
MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher, PersonCommand,
|
||||||
PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository,
|
PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
|
||||||
UserProfileFieldsRepository, UserRepository, WatchEventRepository, WatchlistRepository,
|
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
|
||||||
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
|
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
|
||||||
|
WrapUpStatsQuery,
|
||||||
},
|
},
|
||||||
testing::{
|
testing::{
|
||||||
FakeAuthService, FakeMetadataClient, FakePasswordHasher, InMemoryMovieRepository,
|
FakeAuthService, FakeDiaryRepository, FakeDocumentParser, FakeMetadataClient,
|
||||||
InMemoryReviewRepository, InMemoryUserRepository, InMemoryWatchlistRepository,
|
FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort,
|
||||||
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicDiaryRepository,
|
FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository,
|
||||||
PanicDocumentParser, PanicImportProfileRepository, PanicImportSessionRepository,
|
InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo,
|
||||||
PanicMovieProfileRepository, PanicPersonCommand, PanicPersonQuery, PanicPosterFetcher,
|
InMemoryReviewRepository, InMemoryUserRepository, InMemoryUserSettingsRepository,
|
||||||
PanicProfileFieldsRepo, PanicSearchCommand, PanicSearchPort, PanicStatsRepository,
|
InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository,
|
||||||
PanicWatchEventRepository, PanicWebhookTokenRepository,
|
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicPersonCommand,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
context::{AppContext, Repositories, Services},
|
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 struct TestContextBuilder {
|
||||||
pub movie_repo: Arc<dyn MovieRepository>,
|
pub movie_repo: Arc<dyn MovieRepository>,
|
||||||
pub review_repo: Arc<dyn ReviewRepository>,
|
pub review_repo: Arc<dyn ReviewRepository>,
|
||||||
@@ -56,6 +71,10 @@ pub struct TestContextBuilder {
|
|||||||
pub search_command: Arc<dyn SearchCommand>,
|
pub search_command: Arc<dyn SearchCommand>,
|
||||||
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
|
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
|
||||||
pub wrapup_repo: Arc<dyn WrapUpRepository>,
|
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,
|
pub config: AppConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,30 +89,34 @@ impl TestContextBuilder {
|
|||||||
Self {
|
Self {
|
||||||
movie_repo: InMemoryMovieRepository::new(),
|
movie_repo: InMemoryMovieRepository::new(),
|
||||||
review_repo: InMemoryReviewRepository::new(),
|
review_repo: InMemoryReviewRepository::new(),
|
||||||
diary_repo: Arc::new(PanicDiaryRepository),
|
diary_repo: FakeDiaryRepository::new(),
|
||||||
diary_exporter: Arc::new(PanicDiaryExporter),
|
diary_exporter: Arc::new(PanicDiaryExporter),
|
||||||
document_parser: Arc::new(PanicDocumentParser),
|
document_parser: Arc::new(FakeDocumentParser),
|
||||||
stats_repo: Arc::new(PanicStatsRepository),
|
stats_repo: Arc::new(FakeStatsRepository),
|
||||||
metadata_client: Arc::new(FakeMetadataClient),
|
metadata_client: Arc::new(FakeMetadataClient),
|
||||||
poster_fetcher: Arc::new(PanicPosterFetcher),
|
poster_fetcher: Arc::new(FakePosterFetcher),
|
||||||
object_storage: Arc::new(NoopObjectStorage),
|
object_storage: Arc::new(NoopObjectStorage),
|
||||||
event_publisher: NoopEventPublisher::new(),
|
event_publisher: NoopEventPublisher::new(),
|
||||||
auth_service: Arc::new(FakeAuthService),
|
auth_service: Arc::new(FakeAuthService),
|
||||||
password_hasher: Arc::new(FakePasswordHasher),
|
password_hasher: Arc::new(FakePasswordHasher),
|
||||||
user_repo: InMemoryUserRepository::new(),
|
user_repo: InMemoryUserRepository::new(),
|
||||||
import_session_repo: Arc::new(PanicImportSessionRepository),
|
import_session_repo: InMemoryImportSessionRepository::new(),
|
||||||
import_profile_repo: Arc::new(PanicImportProfileRepository),
|
import_profile_repo: InMemoryImportProfileRepository::new(),
|
||||||
movie_profile_repo: Arc::new(PanicMovieProfileRepository),
|
movie_profile_repo: InMemoryMovieProfileRepository::new(),
|
||||||
watchlist_repo: InMemoryWatchlistRepository::new(),
|
watchlist_repo: InMemoryWatchlistRepository::new(),
|
||||||
watch_event_repo: Arc::new(PanicWatchEventRepository),
|
watch_event_repo: InMemoryWatchEventRepository::new(),
|
||||||
webhook_token_repo: Arc::new(PanicWebhookTokenRepository),
|
webhook_token_repo: InMemoryWebhookTokenRepository::new(),
|
||||||
profile_fields_repo: Arc::new(PanicProfileFieldsRepo),
|
profile_fields_repo: InMemoryProfileFieldsRepo::new(),
|
||||||
person_command: Arc::new(PanicPersonCommand),
|
person_command: Arc::new(PanicPersonCommand),
|
||||||
person_query: Arc::new(PanicPersonQuery),
|
person_query: Arc::new(FakePersonQuery),
|
||||||
search_port: Arc::new(PanicSearchPort),
|
search_port: Arc::new(FakeSearchPort),
|
||||||
search_command: Arc::new(PanicSearchCommand),
|
search_command: Arc::new(FakeSearchCommand),
|
||||||
wrapup_stats: InMemoryWrapUpStatsQuery::new(),
|
wrapup_stats: InMemoryWrapUpStatsQuery::new(),
|
||||||
wrapup_repo: InMemoryWrapUpRepository::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 {
|
config: AppConfig {
|
||||||
allow_registration: true,
|
allow_registration: true,
|
||||||
base_url: "http://localhost:3000".into(),
|
base_url: "http://localhost:3000".into(),
|
||||||
@@ -142,6 +165,96 @@ impl TestContextBuilder {
|
|||||||
self
|
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 {
|
pub fn with_config(mut self, config: AppConfig) -> Self {
|
||||||
self.config = config;
|
self.config = config;
|
||||||
self
|
self
|
||||||
@@ -167,11 +280,11 @@ impl TestContextBuilder {
|
|||||||
search_port: self.search_port,
|
search_port: self.search_port,
|
||||||
search_command: self.search_command,
|
search_command: self.search_command,
|
||||||
remote_watchlist: Arc::new(NoopRemoteWatchlistRepository),
|
remote_watchlist: Arc::new(NoopRemoteWatchlistRepository),
|
||||||
social_query: Arc::new(NoopSocialQueryPort),
|
social_query: self.social_query,
|
||||||
wrapup_stats: self.wrapup_stats,
|
wrapup_stats: self.wrapup_stats,
|
||||||
wrapup_repo: self.wrapup_repo,
|
wrapup_repo: self.wrapup_repo,
|
||||||
goal: Arc::new(domain::testing::NoopGoalRepository),
|
goal: self.goal_repo,
|
||||||
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
|
user_settings: self.user_settings_repo,
|
||||||
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
||||||
},
|
},
|
||||||
services: Services {
|
services: Services {
|
||||||
@@ -183,6 +296,7 @@ impl TestContextBuilder {
|
|||||||
event_publisher: self.event_publisher,
|
event_publisher: self.event_publisher,
|
||||||
diary_exporter: self.diary_exporter,
|
diary_exporter: self.diary_exporter,
|
||||||
document_parser: self.document_parser,
|
document_parser: self.document_parser,
|
||||||
|
review_logger: self.review_logger,
|
||||||
},
|
},
|
||||||
config: self.config,
|
config: self.config,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,3 +57,7 @@ pub async fn execute(
|
|||||||
role: user.role().as_str().into(),
|
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])
|
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);
|
let uid = UserId::from_uuid(user_id);
|
||||||
ctx.repos.user_settings.get(&uid).await
|
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?,
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/update_profile.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -19,3 +19,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Resul
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
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);
|
settings.set_federate_goals(cmd.federate_goals);
|
||||||
ctx.repos.user_settings.save(&settings).await
|
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)?;
|
let page = PageParams::new(query.limit, query.offset)?;
|
||||||
ctx.repos.watchlist.get_for_user(&user_id, &page).await
|
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,
|
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);
|
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||||
ctx.repos.watchlist.contains(&user_id, &movie_id).await
|
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(())
|
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");
|
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