feat: atomic review writes with events
This commit is contained in:
@@ -20,18 +20,15 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul
|
||||
}
|
||||
|
||||
let movie_id = review.movie_id().clone();
|
||||
deps.review.delete_review(&review_id).await?;
|
||||
|
||||
if let Err(e) = deps
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::ReviewDeleted {
|
||||
review_id: review_id.clone(),
|
||||
user_id: requesting_user_id.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!("failed to publish ReviewDeleted: {e}");
|
||||
}
|
||||
deps.review
|
||||
.delete_review(
|
||||
&review_id,
|
||||
&[DomainEvent::ReviewDeleted {
|
||||
review_id: review_id.clone(),
|
||||
user_id: requesting_user_id.clone(),
|
||||
}],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let history = deps.diary.get_review_history(&movie_id).await?;
|
||||
if history.viewings().is_empty() {
|
||||
|
||||
@@ -16,7 +16,6 @@ pub struct DeleteReviewDeps {
|
||||
|
||||
pub struct EditReviewDeps {
|
||||
pub review: Arc<dyn ReviewRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct GetMovieSocialPageDeps {
|
||||
|
||||
@@ -37,21 +37,12 @@ pub async fn execute(deps: &EditReviewDeps, cmd: EditReviewCommand) -> Result<()
|
||||
spoilers: cmd.spoilers,
|
||||
});
|
||||
|
||||
deps.review.update_review(&updated).await?;
|
||||
|
||||
if let Err(e) = deps
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::ReviewUpdated {
|
||||
review_id: updated.id().clone(),
|
||||
movie_id: updated.movie_id().clone(),
|
||||
user_id: updated.user_id().clone(),
|
||||
rating: updated.rating().clone(),
|
||||
watched_at: *updated.watched_at(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!("failed to publish ReviewUpdated: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
let event = DomainEvent::ReviewUpdated {
|
||||
review_id: updated.id().clone(),
|
||||
movie_id: updated.movie_id().clone(),
|
||||
user_id: updated.user_id().clone(),
|
||||
rating: updated.rating().clone(),
|
||||
watched_at: *updated.watched_at(),
|
||||
};
|
||||
deps.review.update_review(&updated, &[event]).await
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
cmd.watch_medium,
|
||||
cmd.spoilers,
|
||||
)?;
|
||||
self.review_repo.save_review(&review).await?;
|
||||
let review_event = DomainEvent::ReviewLogged {
|
||||
review_id: review.id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
@@ -84,6 +83,17 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
watched_at: *review.watched_at(),
|
||||
};
|
||||
|
||||
// Events ride the review save (the decisive write) so they commit with it.
|
||||
let mut events = Vec::with_capacity(2);
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
events.push(DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
});
|
||||
}
|
||||
events.push(review_event);
|
||||
self.review_repo.save_review(&review, &events).await?;
|
||||
|
||||
let was_on_watchlist = self
|
||||
.watchlist_repo
|
||||
.remove_if_present(review.user_id(), review.movie_id())
|
||||
@@ -98,15 +108,6 @@ impl ReviewLogger for DefaultReviewLogger {
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(ext_id) = movie.external_metadata_id() {
|
||||
self.event_publisher
|
||||
.publish(&DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie.id().clone(),
|
||||
external_metadata_id: ext_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
self.event_publisher.publish(&review_event).await
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ async fn deleting_a_review_removes_it() {
|
||||
let review = test_review(movie.id(), &user_id, 4);
|
||||
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
reviews.save_review(&review).await.unwrap();
|
||||
reviews.save_review(&review, &[]).await.unwrap();
|
||||
diary.seed_history(movie.clone(), vec![]);
|
||||
|
||||
let deps = DeleteReviewDeps {
|
||||
@@ -62,7 +62,7 @@ async fn deleting_another_users_review_is_unauthorized() {
|
||||
let other_id = uuid::Uuid::new_v4();
|
||||
let review = test_review(&movie_id, &owner_id, 4);
|
||||
|
||||
reviews.save_review(&review).await.unwrap();
|
||||
reviews.save_review(&review, &[]).await.unwrap();
|
||||
|
||||
let deps = DeleteReviewDeps {
|
||||
review: Arc::clone(&reviews) as _,
|
||||
@@ -83,3 +83,71 @@ async fn deleting_another_users_review_is_unauthorized() {
|
||||
assert!(result.is_err(), "wrong user should not be able to delete");
|
||||
assert_eq!(reviews.review_count(), 1, "review should still exist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_review_removes_it_and_records_its_event_together() {
|
||||
let movies = InMemoryStore::new();
|
||||
let diary = FakeDiaryQuery::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let movie = test_movie_with_year("Terminator", 1984);
|
||||
let user_id = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let review = test_review(movie.id(), &user_id, 4);
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
movies.save_review(&review, &[]).await.unwrap();
|
||||
diary.seed_history(movie.clone(), vec![review.clone()]);
|
||||
let deps = DeleteReviewDeps {
|
||||
review: Arc::clone(&movies) as _,
|
||||
diary: diary.clone() as _,
|
||||
movie_command: Arc::clone(&movies) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
|
||||
delete_review::execute(
|
||||
&deps,
|
||||
DeleteReviewCommand {
|
||||
review_id: review.id().value(),
|
||||
requesting_user_id: user_id.value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(movies.review_count(), 0);
|
||||
let recorded = movies.recorded_events();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert!(matches!(
|
||||
recorded[0],
|
||||
domain::events::DomainEvent::ReviewDeleted { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_delete_records_neither_the_removal_nor_its_event() {
|
||||
let movies = InMemoryStore::new();
|
||||
let diary = FakeDiaryQuery::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let user_id = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let review = test_review(&MovieId::generate(), &user_id, 4);
|
||||
movies.save_review(&review, &[]).await.unwrap();
|
||||
let deps = DeleteReviewDeps {
|
||||
review: Arc::new(crate::edit_review_test::WriteRefusingReviews(Arc::clone(
|
||||
&movies,
|
||||
))),
|
||||
diary: diary as _,
|
||||
movie_command: Arc::clone(&movies) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
|
||||
let result = delete_review::execute(
|
||||
&deps,
|
||||
DeleteReviewCommand {
|
||||
review_id: review.id().value(),
|
||||
requesting_user_id: user_id.value(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(movies.review_count(), 1);
|
||||
assert!(movies.recorded_events().is_empty());
|
||||
}
|
||||
|
||||
@@ -4,20 +4,14 @@ use std::sync::Arc;
|
||||
use domain::{
|
||||
models::ReviewEdit,
|
||||
ports::ReviewRepository,
|
||||
testing::{InMemoryStore, NoopEventPublisher, test_review},
|
||||
testing::{InMemoryStore, test_review},
|
||||
value_objects::{Comment, MovieId, ReviewId, UserId, WatchMedium},
|
||||
};
|
||||
|
||||
use application::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review};
|
||||
|
||||
async fn setup() -> (
|
||||
Arc<InMemoryStore>,
|
||||
Arc<NoopEventPublisher>,
|
||||
ReviewId,
|
||||
UserId,
|
||||
) {
|
||||
async fn setup() -> (Arc<InMemoryStore>, ReviewId, UserId) {
|
||||
let reviews = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let user_id = UserId::generate();
|
||||
let review = test_review(&MovieId::generate(), &user_id, 3).apply_edit(ReviewEdit {
|
||||
comment: Some(Some(Comment::new("original comment".into()).unwrap())),
|
||||
@@ -25,23 +19,22 @@ async fn setup() -> (
|
||||
..Default::default()
|
||||
});
|
||||
let review_id = review.id().clone();
|
||||
reviews.save_review(&review).await.unwrap();
|
||||
(reviews, events, review_id, user_id)
|
||||
reviews.save_review(&review, &[]).await.unwrap();
|
||||
(reviews, review_id, user_id)
|
||||
}
|
||||
|
||||
fn deps(reviews: &Arc<InMemoryStore>, events: &Arc<NoopEventPublisher>) -> EditReviewDeps {
|
||||
fn deps(reviews: &Arc<InMemoryStore>) -> EditReviewDeps {
|
||||
EditReviewDeps {
|
||||
review: Arc::clone(reviews) as _,
|
||||
event_publisher: Arc::clone(events) as _,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_own_review_updates_rating() {
|
||||
let (reviews, events, review_id, user_id) = setup().await;
|
||||
let (reviews, review_id, user_id) = setup().await;
|
||||
|
||||
edit_review::execute(
|
||||
&deps(&reviews, &events),
|
||||
&deps(&reviews),
|
||||
EditReviewCommand {
|
||||
review_id: review_id.value(),
|
||||
requesting_user_id: user_id.value(),
|
||||
@@ -60,9 +53,8 @@ async fn edit_own_review_updates_rating() {
|
||||
#[tokio::test]
|
||||
async fn edit_nonexistent_review_returns_not_found() {
|
||||
let reviews = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let err = edit_review::execute(&deps(&reviews, &events), edit_review_command(5))
|
||||
let err = edit_review::execute(&deps(&reviews), edit_review_command(5))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
@@ -71,10 +63,10 @@ async fn edit_nonexistent_review_returns_not_found() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_other_users_review_returns_forbidden() {
|
||||
let (reviews, events, review_id, _user_id) = setup().await;
|
||||
let (reviews, review_id, _user_id) = setup().await;
|
||||
|
||||
let err = edit_review::execute(
|
||||
&deps(&reviews, &events),
|
||||
&deps(&reviews),
|
||||
EditReviewCommand {
|
||||
review_id: review_id.value(),
|
||||
..edit_review_command(5)
|
||||
@@ -86,12 +78,59 @@ async fn edit_other_users_review_returns_forbidden() {
|
||||
assert!(matches!(err, domain::errors::DomainError::Forbidden(_)));
|
||||
}
|
||||
|
||||
/// Loads from the store but refuses every write.
|
||||
pub(crate) struct WriteRefusingReviews(pub(crate) Arc<InMemoryStore>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ReviewRepository for WriteRefusingReviews {
|
||||
async fn save_review(
|
||||
&self,
|
||||
_: &domain::models::Review,
|
||||
_: &[domain::events::DomainEvent],
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
Err(domain::errors::DomainError::InfrastructureError(
|
||||
"refused".into(),
|
||||
))
|
||||
}
|
||||
async fn get_review_by_id(
|
||||
&self,
|
||||
id: &ReviewId,
|
||||
) -> Result<Option<domain::models::Review>, domain::errors::DomainError> {
|
||||
self.0.get_review_by_id(id).await
|
||||
}
|
||||
async fn update_review(
|
||||
&self,
|
||||
_: &domain::models::Review,
|
||||
_: &[domain::events::DomainEvent],
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
Err(domain::errors::DomainError::InfrastructureError(
|
||||
"refused".into(),
|
||||
))
|
||||
}
|
||||
async fn delete_review(
|
||||
&self,
|
||||
_: &ReviewId,
|
||||
_: &[domain::events::DomainEvent],
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
Err(domain::errors::DomainError::InfrastructureError(
|
||||
"refused".into(),
|
||||
))
|
||||
}
|
||||
async fn get_all_reviews_for_user(
|
||||
&self,
|
||||
id: &UserId,
|
||||
) -> Result<Vec<domain::models::Review>, domain::errors::DomainError> {
|
||||
self.0.get_all_reviews_for_user(id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_publishes_review_updated_event() {
|
||||
let (reviews, events, review_id, user_id) = setup().await;
|
||||
async fn editing_a_review_saves_the_change_and_its_event_together() {
|
||||
let (reviews, review_id, user_id) = setup().await;
|
||||
assert!(reviews.recorded_events().is_empty());
|
||||
|
||||
edit_review::execute(
|
||||
&deps(&reviews, &events),
|
||||
&deps(&reviews),
|
||||
EditReviewCommand {
|
||||
review_id: review_id.value(),
|
||||
requesting_user_id: user_id.value(),
|
||||
@@ -101,20 +140,45 @@ async fn edit_publishes_review_updated_event() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
assert_eq!(published.len(), 1);
|
||||
let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap();
|
||||
assert_eq!(updated.rating().value(), 1);
|
||||
let recorded = reviews.recorded_events();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert!(matches!(
|
||||
published[0],
|
||||
recorded[0],
|
||||
domain::events::DomainEvent::ReviewUpdated { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_edit_records_neither_the_change_nor_its_event() {
|
||||
let (reviews, review_id, user_id) = setup().await;
|
||||
let deps = EditReviewDeps {
|
||||
review: Arc::new(WriteRefusingReviews(Arc::clone(&reviews))),
|
||||
};
|
||||
|
||||
let result = edit_review::execute(
|
||||
&deps,
|
||||
EditReviewCommand {
|
||||
review_id: review_id.value(),
|
||||
requesting_user_id: user_id.value(),
|
||||
..edit_review_command(1)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let unchanged = reviews.get_review_by_id(&review_id).await.unwrap().unwrap();
|
||||
assert_eq!(unchanged.rating().value(), 3);
|
||||
assert!(reviews.recorded_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_sets_watch_medium() {
|
||||
let (reviews, events, review_id, user_id) = setup().await;
|
||||
let (reviews, review_id, user_id) = setup().await;
|
||||
|
||||
edit_review::execute(
|
||||
&deps(&reviews, &events),
|
||||
&deps(&reviews),
|
||||
EditReviewCommand {
|
||||
rating: None,
|
||||
review_id: review_id.value(),
|
||||
@@ -132,10 +196,9 @@ async fn edit_sets_watch_medium() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn editing_a_review_fails_when_the_store_refuses_to_load_it() {
|
||||
let (_, events, review_id, user_id) = setup().await;
|
||||
let (_, review_id, user_id) = setup().await;
|
||||
let deps = EditReviewDeps {
|
||||
review: Arc::new(domain::testing::RefusingReviewRepository),
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
|
||||
let result = edit_review::execute(
|
||||
|
||||
@@ -39,7 +39,10 @@ async fn logging_a_review_creates_the_movie_and_the_review() {
|
||||
log_review::execute(&logger, cmd).await.unwrap();
|
||||
|
||||
assert_eq!(reviews.review_count(), 1, "review should be saved");
|
||||
assert!(!events.published().is_empty(), "events should be published");
|
||||
assert!(
|
||||
!movies.recorded_events().is_empty(),
|
||||
"events should be recorded with the review"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -212,13 +212,6 @@ async fn publishes_movie_discovered_for_new_movie_with_external_id() {
|
||||
.any(|e| matches!(e, domain::events::DomainEvent::MovieDiscovered { .. })),
|
||||
"should publish MovieDiscovered for new movie with external_metadata_id"
|
||||
);
|
||||
assert!(
|
||||
published.iter().any(|e| matches!(
|
||||
e,
|
||||
domain::events::DomainEvent::MovieEnrichmentRequested { .. }
|
||||
)),
|
||||
"should publish MovieEnrichmentRequested"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -241,8 +234,7 @@ async fn logging_a_review_fails_when_the_review_store_refuses() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logging_a_review_when_event_publishing_fails_returns_the_error_but_keeps_the_saved_review()
|
||||
{
|
||||
async fn logging_a_review_saves_the_review_and_its_event_together() {
|
||||
let movies = InMemoryStore::new();
|
||||
let logger = DefaultReviewLogger::new(
|
||||
Arc::clone(&movies) as _,
|
||||
@@ -253,14 +245,70 @@ async fn logging_a_review_when_event_publishing_fails_returns_the_error_but_keep
|
||||
Arc::new(domain::testing::RefusingEventPublisher),
|
||||
);
|
||||
|
||||
logger
|
||||
.log_review(log_review_command_manual(Uuid::new_v4(), "Test Film", 2024))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(movies.review_count(), 1);
|
||||
let recorded = movies.recorded_events();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert!(matches!(
|
||||
recorded[0],
|
||||
domain::events::DomainEvent::ReviewLogged { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_save_records_neither_the_review_nor_its_event() {
|
||||
let movies = InMemoryStore::new();
|
||||
let logger = DefaultReviewLogger::new(
|
||||
Arc::clone(&movies) as _,
|
||||
Arc::clone(&movies) as _,
|
||||
Arc::new(domain::testing::RefusingReviewRepository),
|
||||
Arc::clone(&movies) as _,
|
||||
Arc::new(FakeMetadataClient),
|
||||
NoopEventPublisher::new(),
|
||||
);
|
||||
|
||||
let result = logger
|
||||
.log_review(log_review_command_manual(Uuid::new_v4(), "Test Film", 2024))
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused"));
|
||||
assert_eq!(
|
||||
movies.review_count(),
|
||||
1,
|
||||
"review is saved before publishing"
|
||||
assert!(result.is_err());
|
||||
assert_eq!(movies.review_count(), 0);
|
||||
assert!(movies.recorded_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enrichment_request_rides_the_review_save() {
|
||||
let movies = InMemoryStore::new();
|
||||
let logger = DefaultReviewLogger::new(
|
||||
Arc::clone(&movies) as _,
|
||||
Arc::clone(&movies) as _,
|
||||
Arc::clone(&movies) as _,
|
||||
Arc::clone(&movies) as _,
|
||||
Arc::new(MetadataClientWithExternalId),
|
||||
NoopEventPublisher::new(),
|
||||
);
|
||||
|
||||
logger
|
||||
.log_review(log_review_command_manual(
|
||||
Uuid::new_v4(),
|
||||
"Discovered Film",
|
||||
2024,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let recorded = movies.recorded_events();
|
||||
assert!(recorded.iter().any(|e| matches!(
|
||||
e,
|
||||
domain::events::DomainEvent::MovieEnrichmentRequested { .. }
|
||||
)));
|
||||
assert!(
|
||||
recorded
|
||||
.iter()
|
||||
.any(|e| matches!(e, domain::events::DomainEvent::ReviewLogged { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user