add 400+ unit tests for domain and application layers
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:
2026-06-09 02:07:26 +02:00
parent 30a6200b5b
commit d867a14b28
122 changed files with 7033 additions and 151 deletions

View File

@@ -190,11 +190,13 @@ make test # cargo test
## Test
```bash
cargo test # full workspace (requires DATABASE_URL for sqlx offline checks)
cargo test -p application # domain-level unit tests only — no database required
cargo test # full workspace (requires DATABASE_URL for sqlx offline checks)
cargo test -p application # business logic tests only — no database required
cargo test -p domain # domain model + value object tests
cargo llvm-cov -p application -p domain # line coverage report (requires cargo-llvm-cov)
```
The `application` crate has unit tests for core use cases backed by in-memory fakes from `domain`'s `test-helpers` feature. These run without a database and are the fastest feedback loop for business logic changes.
The `application` and `domain` crates have 400+ unit tests covering all use case modules (auth, diary, goals, import, integrations, movies, person, search, users, watchlist, wrapup) backed by in-memory fakes from `domain`'s `test-helpers` feature. These run without a database and are the fastest feedback loop for business logic changes.
## Docker

View File

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

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

View File

@@ -11,6 +11,7 @@ use domain::ports::{
};
use crate::config::AppConfig;
use crate::ports::ReviewLogger;
#[derive(Clone)]
pub struct Repositories {
@@ -49,6 +50,7 @@ pub struct Services {
pub event_publisher: Arc<dyn EventPublisher>,
pub diary_exporter: Arc<dyn DiaryExporter>,
pub document_parser: Arc<dyn DocumentParser>,
pub review_logger: Arc<dyn ReviewLogger>,
}
#[derive(Clone)]

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,98 +1,11 @@
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Movie, Review},
value_objects::{Comment, MovieId, Rating, UserId},
};
use domain::errors::DomainError;
use crate::{
context::AppContext,
diary::commands::LogReviewCommand,
diary::movie_resolver::{MovieResolver, MovieResolverDeps},
};
use crate::{context::AppContext, diary::commands::LogReviewCommand};
pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), DomainError> {
let rating = Rating::new(cmd.rating)?;
let user_id = UserId::from_uuid(cmd.user_id);
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id {
let movie_id = MovieId::from_uuid(id);
let movie = ctx
.repos
.movie
.get_movie_by_id(&movie_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
(movie, false)
} else {
let deps = MovieResolverDeps {
repository: ctx.repos.movie.as_ref(),
metadata_client: ctx.services.metadata.as_ref(),
};
MovieResolver::default_pipeline()
.resolve(&cmd.input, &deps)
.await?
};
ctx.repos.movie.upsert_movie(&movie).await?;
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
let review_event = ctx.repos.review.save_review(&review).await?;
let was_on_watchlist = ctx
.repos
.watchlist
.remove_if_present(review.user_id(), review.movie_id())
.await?;
if was_on_watchlist {
let _ = ctx
.services
.event_publisher
.publish(&DomainEvent::WatchlistEntryRemoved {
user_id: review.user_id().clone(),
movie_id: review.movie_id().clone(),
})
.await;
}
publish_events(ctx, &movie, is_new_movie, review_event).await?;
Ok(())
ctx.services.review_logger.log_review(cmd).await
}
#[cfg(test)]
#[path = "tests/log_review.rs"]
mod tests;
async fn publish_events(
ctx: &AppContext,
movie: &Movie,
is_new_movie: bool,
review_event: DomainEvent,
) -> Result<(), DomainError> {
if is_new_movie && let Some(ext_id) = movie.external_metadata_id() {
let discovery_event = DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
};
ctx.services
.event_publisher
.publish(&discovery_event)
.await?;
}
if let Some(ext_id) = movie.external_metadata_id() {
let enrichment_event = DomainEvent::MovieEnrichmentRequested {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.value().to_string(),
};
ctx.services
.event_publisher
.publish(&enrichment_event)
.await?;
}
ctx.services.event_publisher.publish(&review_event).await?;
Ok(())
}

View File

@@ -8,3 +8,4 @@ pub mod get_review_history;
pub mod log_review;
pub mod movie_resolver;
pub mod queries;
pub mod review_logger;

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

View 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());
}

View 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);
}

View 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);
}

View 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);
}

View File

@@ -13,9 +13,30 @@ use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEve
use crate::{
diary::commands::{LogReviewCommand, MovieInput},
diary::log_review,
diary::review_logger::DefaultReviewLogger,
test_helpers::TestContextBuilder,
};
fn build_ctx_with_real_logger(
movies: &Arc<InMemoryMovieRepository>,
reviews: &Arc<InMemoryReviewRepository>,
events: &Arc<NoopEventPublisher>,
) -> crate::context::AppContext {
let logger = Arc::new(DefaultReviewLogger::new(
Arc::clone(movies) as _,
Arc::clone(reviews) as _,
crate::test_helpers::TestContextBuilder::new().watchlist_repo,
Arc::new(domain::testing::FakeMetadataClient) as _,
Arc::clone(events) as _,
));
TestContextBuilder::new()
.with_movies(Arc::clone(movies) as _)
.with_reviews(Arc::clone(reviews) as _)
.with_event_publisher(Arc::clone(events) as _)
.with_review_logger(logger)
.build()
}
fn movie_input_manual(title: &str, year: u16) -> MovieInput {
MovieInput {
movie_id: None,
@@ -41,11 +62,7 @@ async fn test_log_review_creates_movie_and_review() {
let movies = InMemoryMovieRepository::new();
let reviews = InMemoryReviewRepository::new();
let events = NoopEventPublisher::new();
let ctx = TestContextBuilder::new()
.with_movies(Arc::clone(&movies) as _)
.with_reviews(Arc::clone(&reviews) as _)
.with_event_publisher(Arc::clone(&events) as _)
.build();
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events);
let user_id = uuid::Uuid::new_v4();
let cmd = LogReviewCommand {
@@ -77,10 +94,8 @@ async fn test_log_review_reuses_existing_movie() {
let movie_uuid = existing_movie.id().value();
movies.upsert_movie(&existing_movie).await.unwrap();
let ctx = TestContextBuilder::new()
.with_movies(Arc::clone(&movies) as _)
.with_reviews(Arc::clone(&reviews) as _)
.build();
let events = NoopEventPublisher::new();
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events);
let cmd = LogReviewCommand {
user_id: uuid::Uuid::new_v4(),
@@ -98,7 +113,10 @@ async fn test_log_review_reuses_existing_movie() {
#[tokio::test]
async fn test_log_review_with_invalid_rating_fails() {
let ctx = TestContextBuilder::new().build();
let movies = InMemoryMovieRepository::new();
let reviews = InMemoryReviewRepository::new();
let events = NoopEventPublisher::new();
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events);
let cmd = LogReviewCommand {
user_id: uuid::Uuid::new_v4(),
input: movie_input_manual("Some Film", 2000),

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

View File

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

View File

@@ -26,3 +26,7 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteGoalCommand) -> Result<(), Dom
Ok(())
}
#[cfg(test)]
#[path = "tests/delete.rs"]
mod tests;

View File

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

View File

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

View 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());
}

View 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());
}

View 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());
}

View 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);
}

View 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());
}

View File

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

View File

@@ -89,3 +89,7 @@ async fn mark_duplicates(ctx: &AppContext, rows: &mut [AnnotatedRow]) -> Result<
Ok(())
}
#[cfg(test)]
#[path = "tests/apply_mapping.rs"]
mod tests;

View File

@@ -27,3 +27,7 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportProfileCommand) -> Result
session.row_results = None;
ctx.repos.import_session.update(&session).await
}
#[cfg(test)]
#[path = "tests/apply_profile.rs"]
mod tests;

View File

@@ -4,3 +4,7 @@ use domain::errors::DomainError;
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
ctx.repos.import_session.delete_expired().await
}
#[cfg(test)]
#[path = "tests/cleanup.rs"]
mod tests;

View File

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

View File

@@ -15,3 +15,7 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteImportProfileCommand) -> Resul
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
ctx.repos.import_profile.delete(&profile_id).await
}
#[cfg(test)]
#[path = "tests/delete_profile.rs"]
mod tests;

View File

@@ -9,7 +9,6 @@ use uuid::Uuid;
use crate::{
context::AppContext,
diary::commands::{LogReviewCommand, MovieInput},
diary::log_review,
import::commands::ExecuteImportCommand,
};
@@ -47,7 +46,7 @@ pub async fn execute(
}
match annotated.result {
RowResult::Valid(row) => match row_to_command(&row, user_id.value()) {
Ok(cmd) => match log_review::execute(ctx, cmd).await {
Ok(cmd) => match ctx.services.review_logger.log_review(cmd).await {
Ok(_) => imported += 1,
Err(e) => failed.push((idx, e.to_string())),
},
@@ -68,6 +67,10 @@ pub async fn execute(
})
}
#[cfg(test)]
#[path = "tests/execute.rs"]
mod tests;
fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, String> {
let rating = row
.rating

View File

@@ -7,3 +7,7 @@ pub async fn execute(
) -> Result<Vec<ImportProfile>, DomainError> {
ctx.repos.import_profile.list_for_user(user_id).await
}
#[cfg(test)]
#[path = "tests/list_profiles.rs"]
mod tests;

View File

@@ -33,3 +33,7 @@ pub async fn execute(
ctx.repos.import_profile.save(&profile).await?;
Ok(id)
}
#[cfg(test)]
#[path = "tests/save_profile.rs"]
mod tests;

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

View 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());
}

View 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);
}

View 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());
}

View 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());
}

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

View 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());
}

View 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());
}

View File

@@ -10,3 +10,7 @@ pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
.delete_non_pending_older_than(cutoff)
.await
}
#[cfg(test)]
#[path = "tests/cleanup.rs"]
mod tests;

View File

@@ -7,7 +7,6 @@ use domain::{
use crate::{
context::AppContext,
diary::commands::{LogReviewCommand, MovieInput},
diary::log_review,
integrations::commands::ConfirmWatchEventsCommand,
};
@@ -54,7 +53,7 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result
watched_at: *event.watched_at(),
};
log_review::execute(ctx, review_cmd).await?;
ctx.services.review_logger.log_review(review_cmd).await?;
ctx.repos
.watch_event
@@ -66,3 +65,7 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result
Ok(confirmed)
}
#[cfg(test)]
#[path = "tests/confirm.rs"]
mod tests;

View File

@@ -39,3 +39,7 @@ pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result
Ok(count as u32)
}
#[cfg(test)]
#[path = "tests/dismiss.rs"]
mod tests;

View File

@@ -36,3 +36,7 @@ pub fn hash_token(plaintext: &str) -> String {
hasher.update(plaintext.as_bytes());
hex::encode(hasher.finalize())
}
#[cfg(test)]
#[path = "tests/generate_token.rs"]
mod tests;

View File

@@ -9,3 +9,7 @@ pub async fn execute(
let user_id = UserId::from_uuid(query.user_id);
ctx.repos.watch_event.list_pending(&user_id).await
}
#[cfg(test)]
#[path = "tests/get_queue.rs"]
mod tests;

View File

@@ -9,3 +9,7 @@ pub async fn execute(
let user_id = UserId::from_uuid(query.user_id);
ctx.repos.webhook_token.list_by_user(&user_id).await
}
#[cfg(test)]
#[path = "tests/get_tokens.rs"]
mod tests;

View File

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

View File

@@ -10,3 +10,7 @@ pub async fn execute(ctx: &AppContext, cmd: RevokeWebhookTokenCommand) -> Result
let token_id = WebhookTokenId::from_uuid(cmd.token_id);
ctx.repos.webhook_token.delete(&token_id, &user_id).await
}
#[cfg(test)]
#[path = "tests/revoke_token.rs"]
mod tests;

View 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);
}

View 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);
}

View 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);
}

View 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());
}

View 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);
}

View 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);
}

View 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());
}

View 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());
}

View File

@@ -92,3 +92,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec<Person> {
seen.into_values().collect()
}
#[cfg(test)]
#[path = "tests/enrich_movie.rs"]
mod tests;

View File

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

View File

@@ -18,3 +18,7 @@ pub async fn execute(
};
ctx.repos.movie.list_movies(&page, &filter).await
}
#[cfg(test)]
#[path = "tests/get_movies.rs"]
mod tests;

View File

@@ -42,3 +42,7 @@ pub async fn fetch_if_stale(
Err(e) => Err(e),
}
}
#[cfg(test)]
#[path = "tests/request_enrichment.rs"]
mod tests;

View File

@@ -96,3 +96,7 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
Ok(())
}
#[cfg(test)]
#[path = "tests/sync_poster.rs"]
mod tests;

View 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);
}

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

View 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());
}

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

View 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());
}

View File

@@ -7,3 +7,7 @@ use domain::{
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> {
ctx.repos.person_query.get_by_id(&id).await
}
#[cfg(test)]
#[path = "tests/get.rs"]
mod tests;

View File

@@ -7,3 +7,7 @@ use domain::{
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<PersonCredits, DomainError> {
ctx.repos.person_query.get_credits(&id).await
}
#[cfg(test)]
#[path = "tests/get_credits.rs"]
mod tests;

View 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());
}

View 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());
}

View File

@@ -1,7 +1,16 @@
use async_trait::async_trait;
use uuid::Uuid;
use domain::errors::DomainError;
use domain::models::DiaryEntry;
use crate::diary::commands::LogReviewCommand;
#[async_trait]
pub trait ReviewLogger: Send + Sync {
async fn log_review(&self, cmd: LogReviewCommand) -> Result<(), DomainError>;
}
pub struct HtmlPageContext {
pub user_email: Option<String>,
pub user_id: Option<Uuid>,

View File

@@ -7,3 +7,7 @@ use domain::{
pub async fn execute(ctx: &AppContext, query: SearchQuery) -> Result<SearchResults, DomainError> {
ctx.repos.search_port.search(&query).await
}
#[cfg(test)]
#[path = "tests/execute.rs"]
mod tests;

View 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());
}

View File

@@ -1,34 +1,49 @@
use std::sync::Arc;
use domain::testing::{
InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopRemoteWatchlistRepository,
NoopSocialQueryPort,
InMemoryGoalRepository, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery,
NoopRemoteWatchlistRepository, NoopSocialQueryPort,
};
use domain::{
ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery,
PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository,
UserProfileFieldsRepository, UserRepository, WatchEventRepository, WatchlistRepository,
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
GoalRepository, ImportProfileRepository, ImportSessionRepository, MetadataClient,
MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher, PersonCommand,
PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
WrapUpStatsQuery,
},
testing::{
FakeAuthService, FakeMetadataClient, FakePasswordHasher, InMemoryMovieRepository,
InMemoryReviewRepository, InMemoryUserRepository, InMemoryWatchlistRepository,
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicDiaryRepository,
PanicDocumentParser, PanicImportProfileRepository, PanicImportSessionRepository,
PanicMovieProfileRepository, PanicPersonCommand, PanicPersonQuery, PanicPosterFetcher,
PanicProfileFieldsRepo, PanicSearchCommand, PanicSearchPort, PanicStatsRepository,
PanicWatchEventRepository, PanicWebhookTokenRepository,
FakeAuthService, FakeDiaryRepository, FakeDocumentParser, FakeMetadataClient,
FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort,
FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository,
InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo,
InMemoryReviewRepository, InMemoryUserRepository, InMemoryUserSettingsRepository,
InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository,
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicPersonCommand,
},
};
use async_trait::async_trait;
use domain::errors::DomainError;
use crate::{
config::AppConfig,
context::{AppContext, Repositories, Services},
diary::commands::LogReviewCommand,
ports::ReviewLogger,
};
pub struct NoopReviewLogger;
#[async_trait]
impl ReviewLogger for NoopReviewLogger {
async fn log_review(&self, _cmd: LogReviewCommand) -> Result<(), DomainError> {
Ok(())
}
}
pub struct TestContextBuilder {
pub movie_repo: Arc<dyn MovieRepository>,
pub review_repo: Arc<dyn ReviewRepository>,
@@ -56,6 +71,10 @@ pub struct TestContextBuilder {
pub search_command: Arc<dyn SearchCommand>,
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
pub wrapup_repo: Arc<dyn WrapUpRepository>,
pub goal_repo: Arc<dyn GoalRepository>,
pub user_settings_repo: Arc<dyn UserSettingsRepository>,
pub review_logger: Arc<dyn ReviewLogger>,
pub social_query: Arc<dyn domain::ports::SocialQueryPort>,
pub config: AppConfig,
}
@@ -70,30 +89,34 @@ impl TestContextBuilder {
Self {
movie_repo: InMemoryMovieRepository::new(),
review_repo: InMemoryReviewRepository::new(),
diary_repo: Arc::new(PanicDiaryRepository),
diary_repo: FakeDiaryRepository::new(),
diary_exporter: Arc::new(PanicDiaryExporter),
document_parser: Arc::new(PanicDocumentParser),
stats_repo: Arc::new(PanicStatsRepository),
document_parser: Arc::new(FakeDocumentParser),
stats_repo: Arc::new(FakeStatsRepository),
metadata_client: Arc::new(FakeMetadataClient),
poster_fetcher: Arc::new(PanicPosterFetcher),
poster_fetcher: Arc::new(FakePosterFetcher),
object_storage: Arc::new(NoopObjectStorage),
event_publisher: NoopEventPublisher::new(),
auth_service: Arc::new(FakeAuthService),
password_hasher: Arc::new(FakePasswordHasher),
user_repo: InMemoryUserRepository::new(),
import_session_repo: Arc::new(PanicImportSessionRepository),
import_profile_repo: Arc::new(PanicImportProfileRepository),
movie_profile_repo: Arc::new(PanicMovieProfileRepository),
import_session_repo: InMemoryImportSessionRepository::new(),
import_profile_repo: InMemoryImportProfileRepository::new(),
movie_profile_repo: InMemoryMovieProfileRepository::new(),
watchlist_repo: InMemoryWatchlistRepository::new(),
watch_event_repo: Arc::new(PanicWatchEventRepository),
webhook_token_repo: Arc::new(PanicWebhookTokenRepository),
profile_fields_repo: Arc::new(PanicProfileFieldsRepo),
watch_event_repo: InMemoryWatchEventRepository::new(),
webhook_token_repo: InMemoryWebhookTokenRepository::new(),
profile_fields_repo: InMemoryProfileFieldsRepo::new(),
person_command: Arc::new(PanicPersonCommand),
person_query: Arc::new(PanicPersonQuery),
search_port: Arc::new(PanicSearchPort),
search_command: Arc::new(PanicSearchCommand),
person_query: Arc::new(FakePersonQuery),
search_port: Arc::new(FakeSearchPort),
search_command: Arc::new(FakeSearchCommand),
wrapup_stats: InMemoryWrapUpStatsQuery::new(),
wrapup_repo: InMemoryWrapUpRepository::new(),
goal_repo: InMemoryGoalRepository::new(),
user_settings_repo: InMemoryUserSettingsRepository::new(),
review_logger: Arc::new(NoopReviewLogger),
social_query: Arc::new(NoopSocialQueryPort),
config: AppConfig {
allow_registration: true,
base_url: "http://localhost:3000".into(),
@@ -142,6 +165,96 @@ impl TestContextBuilder {
self
}
pub fn with_goal(mut self, r: Arc<dyn GoalRepository>) -> Self {
self.goal_repo = r;
self
}
pub fn with_webhook_tokens(mut self, r: Arc<dyn WebhookTokenRepository>) -> Self {
self.webhook_token_repo = r;
self
}
pub fn with_watch_events(mut self, r: Arc<dyn WatchEventRepository>) -> Self {
self.watch_event_repo = r;
self
}
pub fn with_import_sessions(mut self, r: Arc<dyn ImportSessionRepository>) -> Self {
self.import_session_repo = r;
self
}
pub fn with_import_profiles(mut self, r: Arc<dyn ImportProfileRepository>) -> Self {
self.import_profile_repo = r;
self
}
pub fn with_movie_profiles(mut self, r: Arc<dyn MovieProfileRepository>) -> Self {
self.movie_profile_repo = r;
self
}
pub fn with_user_settings(mut self, r: Arc<dyn UserSettingsRepository>) -> Self {
self.user_settings_repo = r;
self
}
pub fn with_profile_fields(mut self, r: Arc<dyn UserProfileFieldsRepository>) -> Self {
self.profile_fields_repo = r;
self
}
pub fn with_review_logger(mut self, r: Arc<dyn ReviewLogger>) -> Self {
self.review_logger = r;
self
}
pub fn with_stats(mut self, r: Arc<dyn StatsRepository>) -> Self {
self.stats_repo = r;
self
}
pub fn with_person_query(mut self, r: Arc<dyn PersonQuery>) -> Self {
self.person_query = r;
self
}
pub fn with_search_port(mut self, r: Arc<dyn SearchPort>) -> Self {
self.search_port = r;
self
}
pub fn with_search_command(mut self, r: Arc<dyn SearchCommand>) -> Self {
self.search_command = r;
self
}
pub fn with_document_parser(mut self, r: Arc<dyn DocumentParser>) -> Self {
self.document_parser = r;
self
}
pub fn with_poster_fetcher(mut self, r: Arc<dyn PosterFetcherClient>) -> Self {
self.poster_fetcher = r;
self
}
pub fn with_metadata_client(mut self, r: Arc<dyn MetadataClient>) -> Self {
self.metadata_client = r;
self
}
pub fn with_social_query(mut self, r: Arc<dyn domain::ports::SocialQueryPort>) -> Self {
self.social_query = r;
self
}
pub fn with_wrapup_repo(mut self, r: Arc<dyn WrapUpRepository>) -> Self {
self.wrapup_repo = r;
self
}
pub fn with_config(mut self, config: AppConfig) -> Self {
self.config = config;
self
@@ -167,11 +280,11 @@ impl TestContextBuilder {
search_port: self.search_port,
search_command: self.search_command,
remote_watchlist: Arc::new(NoopRemoteWatchlistRepository),
social_query: Arc::new(NoopSocialQueryPort),
social_query: self.social_query,
wrapup_stats: self.wrapup_stats,
wrapup_repo: self.wrapup_repo,
goal: Arc::new(domain::testing::NoopGoalRepository),
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
goal: self.goal_repo,
user_settings: self.user_settings_repo,
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
},
services: Services {
@@ -183,6 +296,7 @@ impl TestContextBuilder {
event_publisher: self.event_publisher,
diary_exporter: self.diary_exporter,
document_parser: self.document_parser,
review_logger: self.review_logger,
},
config: self.config,
}

View File

@@ -57,3 +57,7 @@ pub async fn execute(
role: user.role().as_str().into(),
})
}
#[cfg(test)]
#[path = "tests/get_current_profile.rs"]
mod tests;

View File

@@ -183,3 +183,139 @@ fn format_year_month_long(ym: &str) -> String {
};
format!("{} {}", month, parts[0])
}
#[cfg(test)]
#[path = "tests/get_profile.rs"]
mod tests;
#[cfg(test)]
mod helper_tests {
use super::*;
#[test]
fn format_year_month_long_all_months() {
assert_eq!(format_year_month_long("2024-01"), "January 2024");
assert_eq!(format_year_month_long("2024-02"), "February 2024");
assert_eq!(format_year_month_long("2024-03"), "March 2024");
assert_eq!(format_year_month_long("2024-04"), "April 2024");
assert_eq!(format_year_month_long("2024-05"), "May 2024");
assert_eq!(format_year_month_long("2024-06"), "June 2024");
assert_eq!(format_year_month_long("2024-07"), "July 2024");
assert_eq!(format_year_month_long("2024-08"), "August 2024");
assert_eq!(format_year_month_long("2024-09"), "September 2024");
assert_eq!(format_year_month_long("2024-10"), "October 2024");
assert_eq!(format_year_month_long("2024-11"), "November 2024");
assert_eq!(format_year_month_long("2024-12"), "December 2024");
}
#[test]
fn format_year_month_long_invalid() {
assert_eq!(format_year_month_long("invalid"), "invalid");
assert_eq!(format_year_month_long("2024-99"), "99 2024");
}
#[test]
fn feed_sort_to_direction_all_variants() {
use domain::ports::FeedSortBy;
assert!(matches!(
feed_sort_to_direction(FeedSortBy::Date),
SortDirection::Descending
));
assert!(matches!(
feed_sort_to_direction(FeedSortBy::DateAsc),
SortDirection::Ascending
));
assert!(matches!(
feed_sort_to_direction(FeedSortBy::Rating),
SortDirection::ByRatingDesc
));
assert!(matches!(
feed_sort_to_direction(FeedSortBy::RatingAsc),
SortDirection::ByRatingAsc
));
}
#[test]
fn group_by_month_empty() {
assert!(group_by_month(vec![]).is_empty());
}
#[test]
fn group_by_month_groups_entries() {
use chrono::NaiveDateTime;
use domain::models::{Movie, Review};
use domain::value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId};
let movie = Movie::from_persistence(
MovieId::generate(),
None,
MovieTitle::new("Test".into()).unwrap(),
ReleaseYear::new(2024).unwrap(),
None,
None,
);
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let jan =
NaiveDateTime::parse_from_str("2024-01-15 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
let jan2 =
NaiveDateTime::parse_from_str("2024-01-20 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
let mar =
NaiveDateTime::parse_from_str("2024-03-05 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
let r1 = Review::new(
movie.id().clone(),
uid.clone(),
Rating::new(4).unwrap(),
None,
jan,
)
.unwrap();
let r2 = Review::new(
movie.id().clone(),
uid.clone(),
Rating::new(3).unwrap(),
None,
jan2,
)
.unwrap();
let r3 = Review::new(
movie.id().clone(),
uid.clone(),
Rating::new(5).unwrap(),
None,
mar,
)
.unwrap();
let entries = vec![
DiaryEntry::new(movie.clone(), r1),
DiaryEntry::new(movie.clone(), r2),
DiaryEntry::new(movie.clone(), r3),
];
let result = group_by_month(entries);
// Reversed: March first, then January
assert_eq!(result.len(), 2);
assert_eq!(result[0].month_label, "March 2024");
assert_eq!(result[0].count, 1);
assert_eq!(result[1].month_label, "January 2024");
assert_eq!(result[1].count, 2);
}
#[test]
fn paged_user_filter_builds_correctly() {
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let filter = paged_user_filter(
uid.clone(),
SortDirection::Descending,
Some(20),
Some(5),
Some("blade".into()),
)
.unwrap();
assert_eq!(filter.user_id.unwrap().value(), uid.value());
assert_eq!(filter.search.as_deref(), Some("blade"));
}
}

View File

@@ -6,3 +6,7 @@ pub async fn execute(ctx: &AppContext, user_id: uuid::Uuid) -> Result<UserSettin
let uid = UserId::from_uuid(user_id);
ctx.repos.user_settings.get(&uid).await
}
#[cfg(test)]
#[path = "tests/get_settings.rs"]
mod tests;

View File

@@ -20,3 +20,7 @@ pub async fn execute(
remote_actors: actors_result?,
})
}
#[cfg(test)]
#[path = "tests/get_users.rs"]
mod tests;

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

View 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());
}

View 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());
}

View 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());
}

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

View 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());
}

View 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());
}

View File

@@ -90,3 +90,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
Ok(())
}
#[cfg(test)]
#[path = "tests/update_profile.rs"]
mod tests;

View File

@@ -19,3 +19,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Resul
.await?;
Ok(())
}
#[cfg(test)]
#[path = "tests/update_profile_fields.rs"]
mod tests;

View File

@@ -13,3 +13,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateUserSettingsCommand) -> Result
settings.set_federate_goals(cmd.federate_goals);
ctx.repos.user_settings.save(&settings).await
}
#[cfg(test)]
#[path = "tests/update_settings.rs"]
mod tests;

View File

@@ -17,3 +17,7 @@ pub async fn execute(
let page = PageParams::new(query.limit, query.offset)?;
ctx.repos.watchlist.get_for_user(&user_id, &page).await
}
#[cfg(test)]
#[path = "tests/get.rs"]
mod tests;

View File

@@ -84,3 +84,7 @@ async fn load_remote_watchlist(
limit: len,
})
}
#[cfg(test)]
#[path = "tests/get_page.rs"]
mod tests;

View File

@@ -10,3 +10,7 @@ pub async fn execute(ctx: &AppContext, query: IsOnWatchlistQuery) -> Result<bool
let movie_id = MovieId::from_uuid(query.movie_id);
ctx.repos.watchlist.contains(&user_id, &movie_id).await
}
#[cfg(test)]
#[path = "tests/is_on.rs"]
mod tests;

View File

@@ -19,3 +19,7 @@ pub async fn execute(ctx: &AppContext, cmd: RemoveFromWatchlistCommand) -> Resul
Ok(())
}
#[cfg(test)]
#[path = "tests/remove.rs"]
mod tests;

View File

@@ -85,3 +85,54 @@ async fn test_add_to_watchlist_already_present_is_idempotent() {
assert_eq!(watchlist.count(), 1, "idempotent add should not duplicate");
}
#[tokio::test]
async fn test_add_to_watchlist_with_manual_movie() {
let movies = InMemoryMovieRepository::new();
let watchlist = InMemoryWatchlistRepository::new();
let ctx = TestContextBuilder::new()
.with_movies(Arc::clone(&movies) as _)
.with_watchlist(Arc::clone(&watchlist) as _)
.build();
let cmd = AddToWatchlistCommand {
user_id: uuid::Uuid::new_v4(),
input: MovieInput {
movie_id: None,
external_metadata_id: None,
manual_title: Some("New Manual Movie".into()),
manual_release_year: Some(2024),
manual_director: None,
},
};
add::execute(&ctx, cmd).await.unwrap();
assert_eq!(watchlist.count(), 1);
assert_eq!(movies.count(), 1);
}
#[tokio::test]
async fn test_add_to_watchlist_movie_not_found_by_id() {
let movies = InMemoryMovieRepository::new();
let watchlist = InMemoryWatchlistRepository::new();
let ctx = TestContextBuilder::new()
.with_movies(Arc::clone(&movies) as _)
.with_watchlist(Arc::clone(&watchlist) as _)
.build();
let cmd = AddToWatchlistCommand {
user_id: uuid::Uuid::new_v4(),
input: MovieInput {
movie_id: Some(uuid::Uuid::new_v4()),
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
},
};
assert!(add::execute(&ctx, cmd).await.is_err());
}

View 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);
}

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

View 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);
}

View 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