feat: atomic review writes with events

This commit is contained in:
2026-10-26 00:40:43 +01:00
parent 0236a488fa
commit c8a1e8bfe4
19 changed files with 440 additions and 117 deletions

View File

@@ -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() {

View File

@@ -16,7 +16,6 @@ pub struct DeleteReviewDeps {
pub struct EditReviewDeps {
pub review: Arc<dyn ReviewRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct GetMovieSocialPageDeps {

View File

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

View File

@@ -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(())
}
}

View File

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

View File

@@ -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(

View File

@@ -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]

View File

@@ -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 { .. }))
);
}