refactor: group use cases into DDD bounded contexts
Flat use_cases/ (44 files) + monolithic commands.rs/queries.rs split into diary/, movies/, watchlist/, import/, auth/, users/, integrations/, search/, person/, federation/ — each with own commands.rs, queries.rs, and use case modules. Inline tests extracted to sibling tests/ dirs.
This commit is contained in:
28
crates/application/src/diary/commands.rs
Normal file
28
crates/application/src/diary/commands.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct MovieInput {
|
||||
pub movie_id: Option<Uuid>,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub manual_title: Option<String>,
|
||||
pub manual_release_year: Option<u16>,
|
||||
pub manual_director: Option<String>,
|
||||
}
|
||||
|
||||
pub struct LogReviewCommand {
|
||||
pub user_id: Uuid,
|
||||
pub input: MovieInput,
|
||||
pub rating: u8,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
pub struct DeleteReviewCommand {
|
||||
pub review_id: Uuid,
|
||||
pub requesting_user_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncPosterCommand {
|
||||
pub movie_id: Uuid,
|
||||
}
|
||||
61
crates/application/src/diary/delete_review.rs
Normal file
61
crates/application/src/diary/delete_review.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use crate::{context::AppContext, diary::commands::DeleteReviewCommand};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
value_objects::{ReviewId, UserId},
|
||||
};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), DomainError> {
|
||||
let review_id = ReviewId::from_uuid(cmd.review_id);
|
||||
let requesting_user_id = UserId::from_uuid(cmd.requesting_user_id);
|
||||
|
||||
let review = ctx
|
||||
.repos
|
||||
.review
|
||||
.get_review_by_id(&review_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("review {}", cmd.review_id)))?;
|
||||
|
||||
if review.user_id() != &requesting_user_id {
|
||||
return Err(DomainError::Unauthorized("not your review".into()));
|
||||
}
|
||||
|
||||
let movie_id = review.movie_id().clone();
|
||||
ctx.repos.review.delete_review(&review_id).await?;
|
||||
|
||||
if let Err(e) = ctx
|
||||
.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::ReviewDeleted {
|
||||
review_id: review_id.clone(),
|
||||
user_id: requesting_user_id.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!("failed to publish ReviewDeleted: {e}");
|
||||
}
|
||||
|
||||
let history = ctx.repos.diary.get_review_history(&movie_id).await?;
|
||||
if history.viewings().is_empty() {
|
||||
let poster_path = history.movie().poster_path().cloned();
|
||||
ctx.repos.movie.delete_movie(&movie_id).await?;
|
||||
// best-effort: movie is already deleted, so publish failure is non-fatal
|
||||
if let Err(e) = ctx
|
||||
.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::MovieDeleted {
|
||||
movie_id,
|
||||
poster_path,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!("failed to publish MovieDeleted event: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/delete_review.rs"]
|
||||
mod tests;
|
||||
15
crates/application/src/diary/export_diary.rs
Normal file
15
crates/application/src/diary/export_diary.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
|
||||
use crate::{context::AppContext, diary::queries::ExportQuery};
|
||||
|
||||
pub async fn execute(ctx: &AppContext, query: ExportQuery) -> Result<Vec<u8>, DomainError> {
|
||||
let entries = ctx
|
||||
.repos
|
||||
.diary
|
||||
.get_user_history(&UserId::from_uuid(query.user_id))
|
||||
.await?;
|
||||
ctx.services
|
||||
.diary_exporter
|
||||
.serialize_entries(&entries, query.format)
|
||||
.await
|
||||
}
|
||||
71
crates/application/src/diary/get_activity_feed.rs
Normal file
71
crates/application/src/diary/get_activity_feed.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use crate::{context::AppContext, diary::queries::GetActivityFeedQuery};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
FeedEntry,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::FollowingFilter,
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
query: GetActivityFeedQuery,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let page = PageParams::new(Some(query.limit), Some(query.offset))?;
|
||||
|
||||
let following = build_following_filter(ctx, &query).await;
|
||||
|
||||
ctx.repos
|
||||
.diary
|
||||
.query_activity_feed_filtered(
|
||||
&page,
|
||||
&query.sort_by,
|
||||
query.search.as_deref(),
|
||||
following.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn build_following_filter(
|
||||
_ctx: &AppContext,
|
||||
query: &GetActivityFeedQuery,
|
||||
) -> Option<FollowingFilter> {
|
||||
#[cfg(not(feature = "federation"))]
|
||||
{
|
||||
let _ = query;
|
||||
return None;
|
||||
}
|
||||
#[cfg(feature = "federation")]
|
||||
{
|
||||
if !query.filter_following {
|
||||
return None;
|
||||
}
|
||||
let viewer_id = match query.viewer_user_id {
|
||||
Some(id) => id,
|
||||
None => return None,
|
||||
};
|
||||
let urls = _ctx
|
||||
.repos
|
||||
.social_query
|
||||
.get_accepted_following_urls(viewer_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let base_url = &_ctx.config.base_url;
|
||||
let mut local_ids = vec![viewer_id];
|
||||
let mut remote_urls = Vec::new();
|
||||
for url in urls {
|
||||
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
|
||||
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix)
|
||||
{
|
||||
local_ids.push(parsed_id);
|
||||
continue;
|
||||
}
|
||||
remote_urls.push(url);
|
||||
}
|
||||
Some(FollowingFilter {
|
||||
local_user_ids: local_ids,
|
||||
remote_actor_urls: remote_urls,
|
||||
})
|
||||
}
|
||||
}
|
||||
29
crates/application/src/diary/get_diary.rs
Normal file
29
crates/application/src/diary/get_diary.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, SortDirection,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
|
||||
use crate::{context::AppContext, diary::queries::GetDiaryQuery};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
query: GetDiaryQuery,
|
||||
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||
let page = PageParams::new(query.limit, query.offset)?;
|
||||
let movie_id = query.movie_id.map(MovieId::from_uuid);
|
||||
let user_id = query.user_id.map(UserId::from_uuid);
|
||||
|
||||
let filter = DiaryFilter {
|
||||
sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
|
||||
page,
|
||||
movie_id,
|
||||
user_id,
|
||||
search: None,
|
||||
};
|
||||
|
||||
ctx.repos.diary.query_diary(&filter).await
|
||||
}
|
||||
45
crates/application/src/diary/get_movie_social_page.rs
Normal file
45
crates/application/src/diary/get_movie_social_page.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
FeedEntry, Movie, MovieProfile, MovieStats,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::{context::AppContext, diary::queries::GetMovieSocialPageQuery};
|
||||
|
||||
pub struct MovieSocialPageResult {
|
||||
pub movie: Movie,
|
||||
pub stats: MovieStats,
|
||||
pub reviews: Paginated<FeedEntry>,
|
||||
pub profile: Option<MovieProfile>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
query: GetMovieSocialPageQuery,
|
||||
) -> Result<MovieSocialPageResult, DomainError> {
|
||||
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||
let page = PageParams::new(Some(query.limit), Some(query.offset))?;
|
||||
|
||||
let movie = ctx
|
||||
.repos
|
||||
.movie
|
||||
.get_movie_by_id(&movie_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", query.movie_id)))?;
|
||||
|
||||
let (stats, reviews, profile) = tokio::try_join!(
|
||||
ctx.repos.diary.get_movie_stats(&movie_id),
|
||||
ctx.repos.diary.get_movie_social_feed(&movie_id, &page),
|
||||
ctx.repos.movie_profile.get_by_movie_id(&movie_id),
|
||||
)?;
|
||||
|
||||
Ok(MovieSocialPageResult {
|
||||
movie,
|
||||
stats,
|
||||
reviews,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
23
crates/application/src/diary/get_review_history.rs
Normal file
23
crates/application/src/diary/get_review_history.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::ReviewHistory,
|
||||
services::review_history::{ReviewHistoryAnalyzer, Trend},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::{context::AppContext, diary::queries::GetReviewHistoryQuery};
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
query: GetReviewHistoryQuery,
|
||||
) -> Result<(ReviewHistory, Trend), DomainError> {
|
||||
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||
|
||||
let mut history = ctx.repos.diary.get_review_history(&movie_id).await?;
|
||||
|
||||
let trend = ReviewHistoryAnalyzer::rating_trend(&history)?;
|
||||
|
||||
ReviewHistoryAnalyzer::sort_chronologically(&mut history);
|
||||
|
||||
Ok((history, trend))
|
||||
}
|
||||
98
crates/application/src/diary/log_review.rs
Normal file
98
crates/application/src/diary/log_review.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Movie, Review},
|
||||
value_objects::{Comment, MovieId, Rating, UserId},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
context::AppContext,
|
||||
diary::commands::LogReviewCommand,
|
||||
diary::movie_resolver::{MovieResolver, MovieResolverDeps},
|
||||
};
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
10
crates/application/src/diary/mod.rs
Normal file
10
crates/application/src/diary/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod commands;
|
||||
pub mod delete_review;
|
||||
pub mod export_diary;
|
||||
pub mod get_activity_feed;
|
||||
pub mod get_diary;
|
||||
pub mod get_movie_social_page;
|
||||
pub mod get_review_history;
|
||||
pub mod log_review;
|
||||
pub mod movie_resolver;
|
||||
pub mod queries;
|
||||
185
crates/application/src/diary/movie_resolver.rs
Normal file
185
crates/application/src/diary/movie_resolver.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::Movie,
|
||||
ports::{MetadataClient, MetadataSearchCriteria, MovieRepository},
|
||||
value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use crate::diary::commands::MovieInput;
|
||||
|
||||
pub struct MovieResolverDeps<'a> {
|
||||
pub repository: &'a dyn MovieRepository,
|
||||
pub metadata_client: &'a dyn MetadataClient,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ResolutionStrategy: Send + Sync {
|
||||
fn can_handle(&self, input: &MovieInput) -> bool;
|
||||
async fn resolve(
|
||||
&self,
|
||||
input: &MovieInput,
|
||||
deps: &MovieResolverDeps<'_>,
|
||||
) -> Result<Option<(Movie, bool)>, DomainError>;
|
||||
}
|
||||
|
||||
pub struct ExternalIdStrategy;
|
||||
pub struct TitleSearchStrategy;
|
||||
pub struct ManualMovieStrategy;
|
||||
|
||||
pub struct MovieResolver {
|
||||
strategies: Vec<Box<dyn ResolutionStrategy>>,
|
||||
}
|
||||
|
||||
impl MovieResolver {
|
||||
pub fn default_pipeline() -> Self {
|
||||
Self {
|
||||
strategies: vec![
|
||||
Box::new(ExternalIdStrategy),
|
||||
Box::new(TitleSearchStrategy),
|
||||
Box::new(ManualMovieStrategy),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve(
|
||||
&self,
|
||||
input: &MovieInput,
|
||||
deps: &MovieResolverDeps<'_>,
|
||||
) -> Result<(Movie, bool), DomainError> {
|
||||
for strategy in &self.strategies {
|
||||
if strategy.can_handle(input)
|
||||
&& let Some(result) = strategy.resolve(input, deps).await?
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
Err(DomainError::ValidationError(
|
||||
"Manual title required if TMDB fetch fails or is omitted".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResolutionStrategy for ExternalIdStrategy {
|
||||
fn can_handle(&self, input: &MovieInput) -> bool {
|
||||
input.external_metadata_id.is_some()
|
||||
}
|
||||
|
||||
async fn resolve(
|
||||
&self,
|
||||
input: &MovieInput,
|
||||
deps: &MovieResolverDeps<'_>,
|
||||
) -> Result<Option<(Movie, bool)>, DomainError> {
|
||||
let ext_id_str = input.external_metadata_id.as_deref().unwrap();
|
||||
let tmdb_id = ExternalMetadataId::new(ext_id_str.to_string())?;
|
||||
|
||||
if let Some(m) = deps.repository.get_movie_by_external_id(&tmdb_id).await? {
|
||||
return Ok(Some((m, false)));
|
||||
}
|
||||
|
||||
match deps
|
||||
.metadata_client
|
||||
.fetch_movie_metadata(&MetadataSearchCriteria::ImdbId(tmdb_id))
|
||||
.await
|
||||
{
|
||||
Ok(m) => Ok(Some((m, true))),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch from TMDB, falling back to manual entry: {:?}",
|
||||
e
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResolutionStrategy for TitleSearchStrategy {
|
||||
fn can_handle(&self, input: &MovieInput) -> bool {
|
||||
input.manual_title.is_some()
|
||||
}
|
||||
|
||||
async fn resolve(
|
||||
&self,
|
||||
input: &MovieInput,
|
||||
deps: &MovieResolverDeps<'_>,
|
||||
) -> Result<Option<(Movie, bool)>, DomainError> {
|
||||
let title = input.manual_title.as_deref().unwrap();
|
||||
let criteria = MetadataSearchCriteria::Title {
|
||||
title: MovieTitle::new(title.to_string())?,
|
||||
year: input
|
||||
.manual_release_year
|
||||
.map(ReleaseYear::new)
|
||||
.transpose()?,
|
||||
};
|
||||
match deps.metadata_client.fetch_movie_metadata(&criteria).await {
|
||||
Ok(m) => {
|
||||
// Movie may already exist in DB under this external_metadata_id
|
||||
if let Some(ext_id) = m.external_metadata_id()
|
||||
&& let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await?
|
||||
{
|
||||
return Ok(Some((existing, false)));
|
||||
}
|
||||
Ok(Some((m, true)))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("OMDb title search failed, falling back to manual: {:?}", e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResolutionStrategy for ManualMovieStrategy {
|
||||
fn can_handle(&self, input: &MovieInput) -> bool {
|
||||
input.manual_title.is_some()
|
||||
}
|
||||
|
||||
async fn resolve(
|
||||
&self,
|
||||
input: &MovieInput,
|
||||
deps: &MovieResolverDeps<'_>,
|
||||
) -> Result<Option<(Movie, bool)>, DomainError> {
|
||||
let title_str = match &input.manual_title {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let year_val = input.manual_release_year.ok_or_else(|| {
|
||||
DomainError::ValidationError(
|
||||
"Manual release year required if TMDB fetch fails or is omitted".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let title = MovieTitle::new(title_str.clone())?;
|
||||
let release_year = ReleaseYear::new(year_val)?;
|
||||
|
||||
let candidates = deps
|
||||
.repository
|
||||
.get_movies_by_title_and_year(&title, &release_year)
|
||||
.await?;
|
||||
|
||||
let matched = candidates
|
||||
.into_iter()
|
||||
.find(|m| m.is_manual_match(&title, &release_year, input.manual_director.as_deref()));
|
||||
|
||||
if let Some(existing) = matched {
|
||||
Ok(Some((existing, false)))
|
||||
} else {
|
||||
let new_movie = Movie::new(
|
||||
None,
|
||||
title,
|
||||
release_year,
|
||||
input.manual_director.clone(),
|
||||
None,
|
||||
);
|
||||
Ok(Some((new_movie, true)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/movie_resolver.rs"]
|
||||
mod tests;
|
||||
34
crates/application/src/diary/queries.rs
Normal file
34
crates/application/src/diary/queries.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use domain::models::SortDirection;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct GetDiaryQuery {
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
pub sort_by: Option<SortDirection>,
|
||||
pub movie_id: Option<Uuid>,
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub struct GetReviewHistoryQuery {
|
||||
pub movie_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct GetActivityFeedQuery {
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
pub sort_by: domain::ports::FeedSortBy,
|
||||
pub search: Option<String>,
|
||||
pub viewer_user_id: Option<Uuid>,
|
||||
pub filter_following: bool,
|
||||
}
|
||||
|
||||
pub struct ExportQuery {
|
||||
pub user_id: Uuid,
|
||||
pub format: domain::models::ExportFormat,
|
||||
}
|
||||
|
||||
pub struct GetMovieSocialPageQuery {
|
||||
pub movie_id: uuid::Uuid,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
104
crates/application/src/diary/tests/delete_review.rs
Normal file
104
crates/application/src/diary/tests/delete_review.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use domain::{
|
||||
models::{Movie, Review},
|
||||
ports::{MovieRepository, ReviewRepository},
|
||||
testing::{
|
||||
FakeDiaryRepository, InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher,
|
||||
},
|
||||
value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
diary::commands::DeleteReviewCommand, diary::delete_review, test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
fn make_movie() -> Movie {
|
||||
Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Terminator".into()).unwrap(),
|
||||
ReleaseYear::new(1984).unwrap(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn make_review(movie_id: MovieId, user_id: UserId) -> Review {
|
||||
Review::new(
|
||||
movie_id,
|
||||
user_id,
|
||||
Rating::new(4).unwrap(),
|
||||
None,
|
||||
Utc::now().naive_utc(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_review_removes_it() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let diary = FakeDiaryRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let movie = make_movie();
|
||||
let user_id = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let review = make_review(movie.id().clone(), user_id.clone());
|
||||
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
reviews.save_review(&review).await.unwrap();
|
||||
diary.seed_history(movie.clone(), vec![]);
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(&movies) as _)
|
||||
.with_reviews(Arc::clone(&reviews) as _)
|
||||
.with_diary(Arc::clone(&diary) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
delete_review::execute(
|
||||
&ctx,
|
||||
DeleteReviewCommand {
|
||||
review_id: review.id().value(),
|
||||
requesting_user_id: user_id.value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reviews.count(), 0, "review should be deleted");
|
||||
assert!(
|
||||
movies.get_movie_by_id(movie.id()).await.unwrap().is_none(),
|
||||
"movie should be deleted when no reviews remain"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_review_wrong_user_is_unauthorized() {
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
|
||||
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4());
|
||||
let owner_id = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let other_id = uuid::Uuid::new_v4();
|
||||
let review = make_review(movie_id, owner_id);
|
||||
|
||||
reviews.save_review(&review).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_reviews(Arc::clone(&reviews) as _)
|
||||
.build();
|
||||
|
||||
let result = delete_review::execute(
|
||||
&ctx,
|
||||
DeleteReviewCommand {
|
||||
review_id: review.id().value(),
|
||||
requesting_user_id: other_id,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "wrong user should not be able to delete");
|
||||
assert_eq!(reviews.count(), 1, "review should still exist");
|
||||
}
|
||||
111
crates/application/src/diary/tests/log_review.rs
Normal file
111
crates/application/src/diary/tests/log_review.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use domain::{
|
||||
models::Movie,
|
||||
value_objects::{MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use domain::ports::MovieRepository;
|
||||
use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher};
|
||||
|
||||
use crate::{
|
||||
diary::commands::{LogReviewCommand, MovieInput},
|
||||
diary::log_review,
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
fn movie_input_manual(title: &str, year: u16) -> MovieInput {
|
||||
MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: None,
|
||||
manual_title: Some(title.to_string()),
|
||||
manual_release_year: Some(year),
|
||||
manual_director: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn movie_input_by_id(id: uuid::Uuid) -> MovieInput {
|
||||
MovieInput {
|
||||
movie_id: Some(id),
|
||||
external_metadata_id: None,
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
manual_director: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_review_creates_movie_and_review() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(&movies) as _)
|
||||
.with_reviews(Arc::clone(&reviews) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _)
|
||||
.build();
|
||||
|
||||
let user_id = uuid::Uuid::new_v4();
|
||||
let cmd = LogReviewCommand {
|
||||
user_id,
|
||||
input: movie_input_manual("Blade Runner", 1982),
|
||||
rating: 4,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
log_review::execute(&ctx, cmd).await.unwrap();
|
||||
|
||||
assert_eq!(reviews.count(), 1, "review should be saved");
|
||||
assert!(!events.published().is_empty(), "events should be published");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_review_reuses_existing_movie() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
|
||||
let existing_movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Alien".into()).unwrap(),
|
||||
ReleaseYear::new(1979).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let movie_uuid = existing_movie.id().value();
|
||||
movies.upsert_movie(&existing_movie).await.unwrap();
|
||||
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_movies(Arc::clone(&movies) as _)
|
||||
.with_reviews(Arc::clone(&reviews) as _)
|
||||
.build();
|
||||
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: uuid::Uuid::new_v4(),
|
||||
input: movie_input_by_id(movie_uuid),
|
||||
rating: 5,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
|
||||
log_review::execute(&ctx, cmd).await.unwrap();
|
||||
|
||||
assert_eq!(movies.count(), 1, "no duplicate movie");
|
||||
assert_eq!(reviews.count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_review_with_invalid_rating_fails() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let cmd = LogReviewCommand {
|
||||
user_id: uuid::Uuid::new_v4(),
|
||||
input: movie_input_manual("Some Film", 2000),
|
||||
rating: 6,
|
||||
comment: None,
|
||||
watched_at: Utc::now().naive_utc(),
|
||||
};
|
||||
let result = log_review::execute(&ctx, cmd).await;
|
||||
assert!(result.is_err(), "rating > 5 should fail");
|
||||
}
|
||||
364
crates/application/src/diary/tests/movie_resolver.rs
Normal file
364
crates/application/src/diary/tests/movie_resolver.rs
Normal file
@@ -0,0 +1,364 @@
|
||||
use super::*;
|
||||
use crate::diary::commands::MovieInput;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::Movie,
|
||||
ports::{MetadataSearchCriteria, MovieRepository},
|
||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear},
|
||||
};
|
||||
|
||||
fn make_input(ext_id: Option<&str>, title: Option<&str>, year: Option<u16>) -> MovieInput {
|
||||
MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: ext_id.map(String::from),
|
||||
manual_title: title.map(String::from),
|
||||
manual_release_year: year,
|
||||
manual_director: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_movie() -> Movie {
|
||||
Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Inception".to_string()).unwrap(),
|
||||
ReleaseYear::new(2010).unwrap(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
struct RepoWithExternalMovie(Movie);
|
||||
struct RepoEmpty;
|
||||
struct RepoWithTitleMatch(Movie);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MovieRepository for RepoWithExternalMovie {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
Ok(Some(self.0.clone()))
|
||||
}
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
_: &MovieTitle,
|
||||
_: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn list_movies(
|
||||
&self,
|
||||
_: &domain::models::collections::PageParams,
|
||||
_: &domain::models::MovieFilter,
|
||||
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
|
||||
{
|
||||
panic!("unexpected")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MovieRepository for RepoEmpty {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
_: &MovieTitle,
|
||||
_: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn list_movies(
|
||||
&self,
|
||||
_: &domain::models::collections::PageParams,
|
||||
_: &domain::models::MovieFilter,
|
||||
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
|
||||
{
|
||||
panic!("unexpected")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MovieRepository for RepoWithTitleMatch {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
_: &MovieTitle,
|
||||
_: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, DomainError> {
|
||||
Ok(vec![self.0.clone()])
|
||||
}
|
||||
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
async fn list_movies(
|
||||
&self,
|
||||
_: &domain::models::collections::PageParams,
|
||||
_: &domain::models::MovieFilter,
|
||||
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
|
||||
{
|
||||
panic!("unexpected")
|
||||
}
|
||||
}
|
||||
|
||||
struct MetaReturnsMovie(Movie);
|
||||
struct MetaErrors;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataClient for MetaReturnsMovie {
|
||||
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataClient for MetaErrors {
|
||||
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"metadata unavailable".into(),
|
||||
))
|
||||
}
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError> {
|
||||
panic!("unexpected")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ExternalIdStrategy ---
|
||||
|
||||
#[test]
|
||||
fn external_id_strategy_can_handle_cmd_with_id() {
|
||||
let input = make_input(Some("tt123"), None, None);
|
||||
assert!(ExternalIdStrategy.can_handle(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_id_strategy_cannot_handle_cmd_without_id() {
|
||||
let input = make_input(None, Some("Inception"), Some(2010));
|
||||
assert!(!ExternalIdStrategy.can_handle(&input));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_id_strategy_returns_cached_movie() {
|
||||
let movie = make_movie();
|
||||
let repo = RepoWithExternalMovie(movie.clone());
|
||||
let meta = MetaErrors;
|
||||
let deps = MovieResolverDeps {
|
||||
repository: &repo,
|
||||
metadata_client: &meta,
|
||||
};
|
||||
let input = make_input(Some("tt123"), None, None);
|
||||
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
|
||||
assert!(matches!(result, Some((_, false))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_id_strategy_fetches_from_metadata_when_not_cached() {
|
||||
let movie = make_movie();
|
||||
let repo = RepoEmpty;
|
||||
let meta = MetaReturnsMovie(movie);
|
||||
let deps = MovieResolverDeps {
|
||||
repository: &repo,
|
||||
metadata_client: &meta,
|
||||
};
|
||||
let input = make_input(Some("tt123"), None, None);
|
||||
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
|
||||
assert!(matches!(result, Some((_, true))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_id_strategy_falls_through_on_metadata_error() {
|
||||
let repo = RepoEmpty;
|
||||
let meta = MetaErrors;
|
||||
let deps = MovieResolverDeps {
|
||||
repository: &repo,
|
||||
metadata_client: &meta,
|
||||
};
|
||||
let input = make_input(Some("tt123"), None, None);
|
||||
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// --- TitleSearchStrategy ---
|
||||
|
||||
#[test]
|
||||
fn title_strategy_can_handle_cmd_with_title() {
|
||||
let input = make_input(None, Some("Inception"), Some(2010));
|
||||
assert!(TitleSearchStrategy.can_handle(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_strategy_cannot_handle_cmd_without_title() {
|
||||
let input = make_input(Some("tt123"), None, None);
|
||||
assert!(!TitleSearchStrategy.can_handle(&input));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn title_strategy_fetches_from_metadata() {
|
||||
let movie = make_movie();
|
||||
let repo = RepoEmpty;
|
||||
let meta = MetaReturnsMovie(movie);
|
||||
let deps = MovieResolverDeps {
|
||||
repository: &repo,
|
||||
metadata_client: &meta,
|
||||
};
|
||||
let input = make_input(None, Some("Inception"), Some(2010));
|
||||
let result = TitleSearchStrategy.resolve(&input, &deps).await.unwrap();
|
||||
assert!(matches!(result, Some((_, true))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn title_strategy_falls_through_on_metadata_error() {
|
||||
let repo = RepoEmpty;
|
||||
let meta = MetaErrors;
|
||||
let deps = MovieResolverDeps {
|
||||
repository: &repo,
|
||||
metadata_client: &meta,
|
||||
};
|
||||
let input = make_input(None, Some("Inception"), Some(2010));
|
||||
let result = TitleSearchStrategy.resolve(&input, &deps).await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// --- ManualMovieStrategy ---
|
||||
|
||||
#[test]
|
||||
fn manual_strategy_can_handle_cmd_with_title() {
|
||||
let input = make_input(None, Some("Inception"), Some(2010));
|
||||
assert!(ManualMovieStrategy.can_handle(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_strategy_cannot_handle_cmd_without_title() {
|
||||
let input = make_input(Some("tt123"), None, None);
|
||||
assert!(!ManualMovieStrategy.can_handle(&input));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_strategy_returns_existing_movie() {
|
||||
let movie = make_movie();
|
||||
let repo = RepoWithTitleMatch(movie.clone());
|
||||
let meta = MetaErrors;
|
||||
let deps = MovieResolverDeps {
|
||||
repository: &repo,
|
||||
metadata_client: &meta,
|
||||
};
|
||||
let input = make_input(None, Some("Inception"), Some(2010));
|
||||
let result = ManualMovieStrategy.resolve(&input, &deps).await.unwrap();
|
||||
assert!(matches!(result, Some((_, false))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_strategy_creates_new_movie_when_no_match() {
|
||||
let repo = RepoEmpty;
|
||||
let meta = MetaErrors;
|
||||
let deps = MovieResolverDeps {
|
||||
repository: &repo,
|
||||
metadata_client: &meta,
|
||||
};
|
||||
let input = make_input(None, Some("Inception"), Some(2010));
|
||||
let result = ManualMovieStrategy.resolve(&input, &deps).await.unwrap();
|
||||
assert!(matches!(result, Some((_, true))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_strategy_errors_without_year() {
|
||||
let repo = RepoEmpty;
|
||||
let meta = MetaErrors;
|
||||
let deps = MovieResolverDeps {
|
||||
repository: &repo,
|
||||
metadata_client: &meta,
|
||||
};
|
||||
let input = make_input(None, Some("Inception"), None);
|
||||
assert!(ManualMovieStrategy.resolve(&input, &deps).await.is_err());
|
||||
}
|
||||
|
||||
// --- MovieResolver pipeline ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_returns_error_when_no_strategy_matches() {
|
||||
let repo = RepoEmpty;
|
||||
let meta = MetaErrors;
|
||||
let deps = MovieResolverDeps {
|
||||
repository: &repo,
|
||||
metadata_client: &meta,
|
||||
};
|
||||
let input = make_input(None, None, None);
|
||||
let result = MovieResolver::default_pipeline()
|
||||
.resolve(&input, &deps)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_uses_cached_movie_when_external_id_matches() {
|
||||
let movie = make_movie();
|
||||
let repo = RepoWithExternalMovie(movie.clone());
|
||||
let meta = MetaErrors;
|
||||
let deps = MovieResolverDeps {
|
||||
repository: &repo,
|
||||
metadata_client: &meta,
|
||||
};
|
||||
let input = make_input(Some("tt123"), None, None);
|
||||
let (_, is_new) = MovieResolver::default_pipeline()
|
||||
.resolve(&input, &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!is_new);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolver_falls_through_to_manual_when_external_and_title_both_fail() {
|
||||
let repo = RepoEmpty;
|
||||
let meta = MetaErrors;
|
||||
let deps = MovieResolverDeps {
|
||||
repository: &repo,
|
||||
metadata_client: &meta,
|
||||
};
|
||||
let input = make_input(Some("tt123"), Some("Inception"), Some(2010));
|
||||
let (_, is_new) = MovieResolver::default_pipeline()
|
||||
.resolve(&input, &deps)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(is_new);
|
||||
}
|
||||
Reference in New Issue
Block a user