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

@@ -1,6 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent,
models::{Review, ReviewSource}, models::{Review, ReviewSource},
ports::ReviewRepository, ports::ReviewRepository,
value_objects::{ReviewId, UserId}, value_objects::{ReviewId, UserId},
@@ -10,7 +11,7 @@ use sqlx::SqlitePool;
use rows::ReviewRow; use rows::ReviewRow;
pub(crate) mod rows; pub(crate) mod rows;
use crate::shared::datetime_to_str; use crate::shared::{datetime_to_str, enqueue_events};
pub struct SqliteReviewRepository { pub struct SqliteReviewRepository {
pool: SqlitePool, pool: SqlitePool,
@@ -24,7 +25,11 @@ impl SqliteReviewRepository {
#[async_trait] #[async_trait]
impl ReviewRepository for SqliteReviewRepository { impl ReviewRepository for SqliteReviewRepository {
async fn save_review(&self, review: &Review) -> Result<(), DomainError> { async fn save_review(
&self,
review: &Review,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let id = review.id().value().to_string(); let id = review.id().value().to_string();
let movie_id = review.movie_id().value().to_string(); let movie_id = review.movie_id().value().to_string();
let user_id = review.user_id().value().to_string(); let user_id = review.user_id().value().to_string();
@@ -37,6 +42,11 @@ impl ReviewRepository for SqliteReviewRepository {
ReviewSource::Remote { actor_url } => Some(actor_url.clone()), ReviewSource::Remote { actor_url } => Some(actor_url.clone()),
}; };
let mut tx = self
.pool
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query( sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers) "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
@@ -51,11 +61,11 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&remote_actor_url) .bind(&remote_actor_url)
.bind(review.watch_medium().map(|wm| wm.to_string())) .bind(review.watch_medium().map(|wm| wm.to_string()))
.bind(review.spoilers()) .bind(review.spoilers())
.execute(&self.pool) .execute(&mut *tx)
.await .await
.map_err(crate::shared::map_sqlx_error)?; .map_err(crate::shared::map_sqlx_error)?;
enqueue_events(&mut tx, events).await?;
Ok(()) tx.commit().await.map_err(crate::shared::map_sqlx_error)
} }
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> { async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
@@ -72,13 +82,22 @@ impl ReviewRepository for SqliteReviewRepository {
.transpose() .transpose()
} }
async fn update_review(&self, review: &Review) -> Result<(), DomainError> { async fn update_review(
&self,
review: &Review,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let id = review.id().value().to_string(); let id = review.id().value().to_string();
let rating = review.rating().value() as i64; let rating = review.rating().value() as i64;
let comment = review.comment().map(|c| c.value().to_string()); let comment = review.comment().map(|c| c.value().to_string());
let watched_at = datetime_to_str(review.watched_at()); let watched_at = datetime_to_str(review.watched_at());
let watch_medium = review.watch_medium().map(|wm| wm.to_string()); let watch_medium = review.watch_medium().map(|wm| wm.to_string());
let mut tx = self
.pool
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query( sqlx::query(
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?, spoilers = ? WHERE id = ?", "UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?, spoilers = ? WHERE id = ?",
) )
@@ -88,21 +107,31 @@ impl ReviewRepository for SqliteReviewRepository {
.bind(&watch_medium) .bind(&watch_medium)
.bind(review.spoilers()) .bind(review.spoilers())
.bind(&id) .bind(&id)
.execute(&self.pool) .execute(&mut *tx)
.await .await
.map_err(crate::shared::map_sqlx_error)?; .map_err(crate::shared::map_sqlx_error)?;
enqueue_events(&mut tx, events).await?;
Ok(()) tx.commit().await.map_err(crate::shared::map_sqlx_error)
} }
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { async fn delete_review(
&self,
review_id: &ReviewId,
events: &[DomainEvent],
) -> Result<(), DomainError> {
let id = review_id.value().to_string(); let id = review_id.value().to_string();
sqlx::query("DELETE FROM reviews WHERE id = ?") let mut tx = self
.bind(&id) .pool
.execute(&self.pool) .begin()
.await .await
.map_err(crate::shared::map_sqlx_error)?; .map_err(crate::shared::map_sqlx_error)?;
Ok(()) sqlx::query("DELETE FROM reviews WHERE id = ?")
.bind(&id)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
enqueue_events(&mut tx, events).await?;
tx.commit().await.map_err(crate::shared::map_sqlx_error)
} }
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> { async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {

View File

@@ -199,7 +199,7 @@ async fn seed_user_with_dependents(
parts parts
.review .review
.save_review(&test_review(movie.id(), &user_id, 4)) .save_review(&test_review(movie.id(), &user_id, 4), &[])
.await .await
.unwrap(); .unwrap();
(user_id, movie) (user_id, movie)
@@ -302,7 +302,7 @@ async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cas
parts.movie_command.upsert_movie(&movie).await.unwrap(); parts.movie_command.upsert_movie(&movie).await.unwrap();
parts parts
.review .review
.save_review(&test_review(movie.id(), user.id(), 5)) .save_review(&test_review(movie.id(), user.id(), 5), &[])
.await .await
.unwrap(); .unwrap();

View File

@@ -8,6 +8,8 @@ mod movie_dedup_test;
mod movie_profile_repository_test; mod movie_profile_repository_test;
#[path = "repositories/person_adapter_test.rs"] #[path = "repositories/person_adapter_test.rs"]
mod person_adapter_test; mod person_adapter_test;
#[path = "repositories/review_repository_test.rs"]
mod review_repository_test;
#[path = "repositories/search_adapter_test.rs"] #[path = "repositories/search_adapter_test.rs"]
mod search_adapter_test; mod search_adapter_test;
#[path = "repositories/social_repository_test.rs"] #[path = "repositories/social_repository_test.rs"]

View File

@@ -0,0 +1,82 @@
use std::str::FromStr;
use domain::{
events::DomainEvent,
ports::{MovieCommand, ReviewRepository, UserRepository},
testing::{test_movie, test_review, test_user},
};
use sqlite::{SqliteMovieRepository, SqliteReviewRepository, SqliteUserRepository};
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
async fn migrated_pool() -> SqlitePool {
let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(opts)
.await
.unwrap();
sqlite::migrate(&pool).await.unwrap();
pool
}
async fn count(pool: &SqlitePool, table: &str) -> i64 {
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
.fetch_one(pool)
.await
.unwrap()
}
fn logged(review: &domain::models::Review) -> DomainEvent {
DomainEvent::ReviewLogged {
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
}
}
#[tokio::test]
async fn saving_a_review_writes_the_review_and_its_event_in_one_commit() {
let pool = migrated_pool().await;
let movie = test_movie("Heat");
let user = test_user("alice");
SqliteUserRepository::new(pool.clone())
.save(&user)
.await
.unwrap();
SqliteMovieRepository::new(pool.clone())
.upsert_movie(&movie)
.await
.unwrap();
let review = test_review(movie.id(), user.id(), 4);
SqliteReviewRepository::new(pool.clone())
.save_review(&review, &[logged(&review)])
.await
.unwrap();
assert_eq!(count(&pool, "reviews").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn a_failed_review_save_leaves_no_event_row() {
let pool = migrated_pool().await;
let review = test_review(
&domain::value_objects::MovieId::generate(),
&domain::value_objects::UserId::generate(),
4,
);
let result = SqliteReviewRepository::new(pool.clone())
.save_review(&review, &[logged(&review)])
.await;
assert!(result.is_err(), "FK violation must fail the save");
assert_eq!(count(&pool, "reviews").await, 0);
assert_eq!(count(&pool, "event_queue").await, 0);
}

View File

@@ -20,18 +20,15 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul
} }
let movie_id = review.movie_id().clone(); let movie_id = review.movie_id().clone();
deps.review.delete_review(&review_id).await?; deps.review
.delete_review(
if let Err(e) = deps &review_id,
.event_publisher &[DomainEvent::ReviewDeleted {
.publish(&DomainEvent::ReviewDeleted { review_id: review_id.clone(),
review_id: review_id.clone(), user_id: requesting_user_id.clone(),
user_id: requesting_user_id.clone(), }],
}) )
.await .await?;
{
tracing::warn!("failed to publish ReviewDeleted: {e}");
}
let history = deps.diary.get_review_history(&movie_id).await?; let history = deps.diary.get_review_history(&movie_id).await?;
if history.viewings().is_empty() { if history.viewings().is_empty() {

View File

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

View File

@@ -37,21 +37,12 @@ pub async fn execute(deps: &EditReviewDeps, cmd: EditReviewCommand) -> Result<()
spoilers: cmd.spoilers, spoilers: cmd.spoilers,
}); });
deps.review.update_review(&updated).await?; let event = DomainEvent::ReviewUpdated {
review_id: updated.id().clone(),
if let Err(e) = deps movie_id: updated.movie_id().clone(),
.event_publisher user_id: updated.user_id().clone(),
.publish(&DomainEvent::ReviewUpdated { rating: updated.rating().clone(),
review_id: updated.id().clone(), watched_at: *updated.watched_at(),
movie_id: updated.movie_id().clone(), };
user_id: updated.user_id().clone(), deps.review.update_review(&updated, &[event]).await
rating: updated.rating().clone(),
watched_at: *updated.watched_at(),
})
.await
{
tracing::warn!("failed to publish ReviewUpdated: {e}");
}
Ok(())
} }

View File

@@ -75,7 +75,6 @@ impl ReviewLogger for DefaultReviewLogger {
cmd.watch_medium, cmd.watch_medium,
cmd.spoilers, cmd.spoilers,
)?; )?;
self.review_repo.save_review(&review).await?;
let review_event = DomainEvent::ReviewLogged { let review_event = DomainEvent::ReviewLogged {
review_id: review.id().clone(), review_id: review.id().clone(),
movie_id: review.movie_id().clone(), movie_id: review.movie_id().clone(),
@@ -84,6 +83,17 @@ impl ReviewLogger for DefaultReviewLogger {
watched_at: *review.watched_at(), 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 let was_on_watchlist = self
.watchlist_repo .watchlist_repo
.remove_if_present(review.user_id(), review.movie_id()) .remove_if_present(review.user_id(), review.movie_id())
@@ -98,15 +108,6 @@ impl ReviewLogger for DefaultReviewLogger {
.await; .await;
} }
if let Some(ext_id) = movie.external_metadata_id() { Ok(())
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
} }
} }

View File

@@ -23,7 +23,7 @@ async fn deleting_a_review_removes_it() {
let review = test_review(movie.id(), &user_id, 4); let review = test_review(movie.id(), &user_id, 4);
movies.upsert_movie(&movie).await.unwrap(); movies.upsert_movie(&movie).await.unwrap();
reviews.save_review(&review).await.unwrap(); reviews.save_review(&review, &[]).await.unwrap();
diary.seed_history(movie.clone(), vec![]); diary.seed_history(movie.clone(), vec![]);
let deps = DeleteReviewDeps { let deps = DeleteReviewDeps {
@@ -62,7 +62,7 @@ async fn deleting_another_users_review_is_unauthorized() {
let other_id = uuid::Uuid::new_v4(); let other_id = uuid::Uuid::new_v4();
let review = test_review(&movie_id, &owner_id, 4); let review = test_review(&movie_id, &owner_id, 4);
reviews.save_review(&review).await.unwrap(); reviews.save_review(&review, &[]).await.unwrap();
let deps = DeleteReviewDeps { let deps = DeleteReviewDeps {
review: Arc::clone(&reviews) as _, 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!(result.is_err(), "wrong user should not be able to delete");
assert_eq!(reviews.review_count(), 1, "review should still exist"); 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::{ use domain::{
models::ReviewEdit, models::ReviewEdit,
ports::ReviewRepository, ports::ReviewRepository,
testing::{InMemoryStore, NoopEventPublisher, test_review}, testing::{InMemoryStore, test_review},
value_objects::{Comment, MovieId, ReviewId, UserId, WatchMedium}, value_objects::{Comment, MovieId, ReviewId, UserId, WatchMedium},
}; };
use application::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review}; use application::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review};
async fn setup() -> ( async fn setup() -> (Arc<InMemoryStore>, ReviewId, UserId) {
Arc<InMemoryStore>,
Arc<NoopEventPublisher>,
ReviewId,
UserId,
) {
let reviews = InMemoryStore::new(); let reviews = InMemoryStore::new();
let events = NoopEventPublisher::new();
let user_id = UserId::generate(); let user_id = UserId::generate();
let review = test_review(&MovieId::generate(), &user_id, 3).apply_edit(ReviewEdit { let review = test_review(&MovieId::generate(), &user_id, 3).apply_edit(ReviewEdit {
comment: Some(Some(Comment::new("original comment".into()).unwrap())), comment: Some(Some(Comment::new("original comment".into()).unwrap())),
@@ -25,23 +19,22 @@ async fn setup() -> (
..Default::default() ..Default::default()
}); });
let review_id = review.id().clone(); let review_id = review.id().clone();
reviews.save_review(&review).await.unwrap(); reviews.save_review(&review, &[]).await.unwrap();
(reviews, events, review_id, user_id) (reviews, review_id, user_id)
} }
fn deps(reviews: &Arc<InMemoryStore>, events: &Arc<NoopEventPublisher>) -> EditReviewDeps { fn deps(reviews: &Arc<InMemoryStore>) -> EditReviewDeps {
EditReviewDeps { EditReviewDeps {
review: Arc::clone(reviews) as _, review: Arc::clone(reviews) as _,
event_publisher: Arc::clone(events) as _,
} }
} }
#[tokio::test] #[tokio::test]
async fn edit_own_review_updates_rating() { 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( edit_review::execute(
&deps(&reviews, &events), &deps(&reviews),
EditReviewCommand { EditReviewCommand {
review_id: review_id.value(), review_id: review_id.value(),
requesting_user_id: user_id.value(), requesting_user_id: user_id.value(),
@@ -60,9 +53,8 @@ async fn edit_own_review_updates_rating() {
#[tokio::test] #[tokio::test]
async fn edit_nonexistent_review_returns_not_found() { async fn edit_nonexistent_review_returns_not_found() {
let reviews = InMemoryStore::new(); 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 .await
.unwrap_err(); .unwrap_err();
@@ -71,10 +63,10 @@ async fn edit_nonexistent_review_returns_not_found() {
#[tokio::test] #[tokio::test]
async fn edit_other_users_review_returns_forbidden() { 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( let err = edit_review::execute(
&deps(&reviews, &events), &deps(&reviews),
EditReviewCommand { EditReviewCommand {
review_id: review_id.value(), review_id: review_id.value(),
..edit_review_command(5) ..edit_review_command(5)
@@ -86,12 +78,59 @@ async fn edit_other_users_review_returns_forbidden() {
assert!(matches!(err, domain::errors::DomainError::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] #[tokio::test]
async fn edit_publishes_review_updated_event() { async fn editing_a_review_saves_the_change_and_its_event_together() {
let (reviews, events, review_id, user_id) = setup().await; let (reviews, review_id, user_id) = setup().await;
assert!(reviews.recorded_events().is_empty());
edit_review::execute( edit_review::execute(
&deps(&reviews, &events), &deps(&reviews),
EditReviewCommand { EditReviewCommand {
review_id: review_id.value(), review_id: review_id.value(),
requesting_user_id: user_id.value(), requesting_user_id: user_id.value(),
@@ -101,20 +140,45 @@ async fn edit_publishes_review_updated_event() {
.await .await
.unwrap(); .unwrap();
let published = events.published(); let updated = reviews.get_review_by_id(&review_id).await.unwrap().unwrap();
assert_eq!(published.len(), 1); assert_eq!(updated.rating().value(), 1);
let recorded = reviews.recorded_events();
assert_eq!(recorded.len(), 1);
assert!(matches!( assert!(matches!(
published[0], recorded[0],
domain::events::DomainEvent::ReviewUpdated { .. } 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] #[tokio::test]
async fn edit_sets_watch_medium() { 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( edit_review::execute(
&deps(&reviews, &events), &deps(&reviews),
EditReviewCommand { EditReviewCommand {
rating: None, rating: None,
review_id: review_id.value(), review_id: review_id.value(),
@@ -132,10 +196,9 @@ async fn edit_sets_watch_medium() {
#[tokio::test] #[tokio::test]
async fn editing_a_review_fails_when_the_store_refuses_to_load_it() { 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 { let deps = EditReviewDeps {
review: Arc::new(domain::testing::RefusingReviewRepository), review: Arc::new(domain::testing::RefusingReviewRepository),
event_publisher: Arc::clone(&events) as _,
}; };
let result = edit_review::execute( 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(); log_review::execute(&logger, cmd).await.unwrap();
assert_eq!(reviews.review_count(), 1, "review should be saved"); 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] #[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 { .. })), .any(|e| matches!(e, domain::events::DomainEvent::MovieDiscovered { .. })),
"should publish MovieDiscovered for new movie with external_metadata_id" "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] #[tokio::test]
@@ -241,8 +234,7 @@ async fn logging_a_review_fails_when_the_review_store_refuses() {
} }
#[tokio::test] #[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 movies = InMemoryStore::new();
let logger = DefaultReviewLogger::new( let logger = DefaultReviewLogger::new(
Arc::clone(&movies) as _, 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), 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 let result = logger
.log_review(log_review_command_manual(Uuid::new_v4(), "Test Film", 2024)) .log_review(log_review_command_manual(Uuid::new_v4(), "Test Film", 2024))
.await; .await;
assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused")); assert!(result.is_err());
assert_eq!( assert_eq!(movies.review_count(), 0);
movies.review_count(), assert!(movies.recorded_events().is_empty());
1, }
"review is saved before publishing"
#[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 { .. }))
); );
} }

View File

@@ -94,7 +94,6 @@ pub fn build_deps(
}, },
edit_review: EditReviewDeps { edit_review: EditReviewDeps {
review: repos.review.clone(), review: repos.review.clone(),
event_publisher: services.event_publisher.clone(),
}, },
get_movie_social_page: GetMovieSocialPageDeps { get_movie_social_page: GetMovieSocialPageDeps {
movie_query: repos.movie_query.clone(), movie_query: repos.movie_query.clone(),

View File

@@ -2,6 +2,7 @@ use async_trait::async_trait;
use crate::{ use crate::{
errors::DomainError, errors::DomainError,
events::DomainEvent,
models::{ models::{
DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FeedSortBy, FollowingFilter, MovieStats, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FeedSortBy, FollowingFilter, MovieStats,
Review, ReviewHistory, UserStats, UserTrends, Review, ReviewHistory, UserStats, UserTrends,
@@ -42,10 +43,19 @@ pub trait DiaryQuery: Send + Sync {
#[async_trait] #[async_trait]
pub trait ReviewRepository: Send + Sync { pub trait ReviewRepository: Send + Sync {
async fn save_review(&self, review: &Review) -> Result<(), DomainError>; async fn save_review(&self, review: &Review, events: &[DomainEvent])
-> Result<(), DomainError>;
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>; async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
async fn update_review(&self, review: &Review) -> Result<(), DomainError>; async fn update_review(
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>; &self,
review: &Review,
events: &[DomainEvent],
) -> Result<(), DomainError>;
async fn delete_review(
&self,
review_id: &ReviewId,
events: &[DomainEvent],
) -> Result<(), DomainError>;
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError>; async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError>;
} }

View File

@@ -538,16 +538,16 @@ pub struct PanicReviewRepository;
#[async_trait] #[async_trait]
impl ReviewRepository for PanicReviewRepository { impl ReviewRepository for PanicReviewRepository {
async fn save_review(&self, _: &Review) -> Result<(), DomainError> { async fn save_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> {
panic!("PanicReviewRepository called") panic!("PanicReviewRepository called")
} }
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> { async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> {
panic!("PanicReviewRepository called") panic!("PanicReviewRepository called")
} }
async fn update_review(&self, _: &Review) -> Result<(), DomainError> { async fn update_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> {
panic!("PanicReviewRepository called") panic!("PanicReviewRepository called")
} }
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { async fn delete_review(&self, _: &ReviewId, _: &[DomainEvent]) -> Result<(), DomainError> {
panic!("PanicReviewRepository called") panic!("PanicReviewRepository called")
} }
async fn get_all_reviews_for_user(&self, _: &UserId) -> Result<Vec<Review>, DomainError> { async fn get_all_reviews_for_user(&self, _: &UserId) -> Result<Vec<Review>, DomainError> {

View File

@@ -21,16 +21,16 @@ pub struct RefusingReviewRepository;
#[async_trait] #[async_trait]
impl ReviewRepository for RefusingReviewRepository { impl ReviewRepository for RefusingReviewRepository {
async fn save_review(&self, _: &Review) -> Result<(), DomainError> { async fn save_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> {
Err(refused()) Err(refused())
} }
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> { async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> {
Err(refused()) Err(refused())
} }
async fn update_review(&self, _: &Review) -> Result<(), DomainError> { async fn update_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> {
Err(refused()) Err(refused())
} }
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { async fn delete_review(&self, _: &ReviewId, _: &[DomainEvent]) -> Result<(), DomainError> {
Err(refused()) Err(refused())
} }
async fn get_all_reviews_for_user(&self, _: &UserId) -> Result<Vec<Review>, DomainError> { async fn get_all_reviews_for_user(&self, _: &UserId) -> Result<Vec<Review>, DomainError> {

View File

@@ -72,6 +72,7 @@ pub struct InMemoryStore {
pub(super) follows: RwLock<Vec<(Uuid, SocialIdentity, FollowState)>>, pub(super) follows: RwLock<Vec<(Uuid, SocialIdentity, FollowState)>>,
pub(super) blocked: RwLock<Vec<(Uuid, SocialIdentity)>>, pub(super) blocked: RwLock<Vec<(Uuid, SocialIdentity)>>,
pub(super) processed_events: RwLock<HashMap<(Uuid, String), LedgerRow>>, pub(super) processed_events: RwLock<HashMap<(Uuid, String), LedgerRow>>,
pub(super) recorded_events: RwLock<Vec<crate::events::DomainEvent>>,
} }
impl InMemoryStore { impl InMemoryStore {
@@ -94,6 +95,7 @@ impl InMemoryStore {
follows: RwLock::default(), follows: RwLock::default(),
blocked: RwLock::default(), blocked: RwLock::default(),
processed_events: RwLock::default(), processed_events: RwLock::default(),
recorded_events: RwLock::default(),
}) })
} }
@@ -103,6 +105,19 @@ impl InMemoryStore {
store store
} }
/// Events recorded atomically with a change (the outbox rows the sqlite
/// adapter would have written in the same transaction), in write order.
pub fn recorded_events(&self) -> Vec<crate::events::DomainEvent> {
self.recorded_events.read().unwrap().clone()
}
pub(super) fn record_events(&self, events: &[crate::events::DomainEvent]) {
self.recorded_events
.write()
.unwrap()
.extend_from_slice(events);
}
pub fn movie_count(&self) -> usize { pub fn movie_count(&self) -> usize {
self.movies.read().unwrap().len() self.movies.read().unwrap().len()
} }

View File

@@ -2,6 +2,7 @@ use async_trait::async_trait;
use crate::{ use crate::{
errors::DomainError, errors::DomainError,
events::DomainEvent,
models::Review, models::Review,
ports::ReviewRepository, ports::ReviewRepository,
value_objects::{ReviewId, UserId}, value_objects::{ReviewId, UserId},
@@ -11,11 +12,16 @@ use super::InMemoryStore;
#[async_trait] #[async_trait]
impl ReviewRepository for InMemoryStore { impl ReviewRepository for InMemoryStore {
async fn save_review(&self, review: &Review) -> Result<(), DomainError> { async fn save_review(
&self,
review: &Review,
events: &[DomainEvent],
) -> Result<(), DomainError> {
self.reviews self.reviews
.write() .write()
.unwrap() .unwrap()
.insert(review.id().value(), review.clone()); .insert(review.id().value(), review.clone());
self.record_events(events);
Ok(()) Ok(())
} }
@@ -28,16 +34,26 @@ impl ReviewRepository for InMemoryStore {
.cloned()) .cloned())
} }
async fn update_review(&self, review: &Review) -> Result<(), DomainError> { async fn update_review(
&self,
review: &Review,
events: &[DomainEvent],
) -> Result<(), DomainError> {
self.reviews self.reviews
.write() .write()
.unwrap() .unwrap()
.insert(review.id().value(), review.clone()); .insert(review.id().value(), review.clone());
self.record_events(events);
Ok(()) Ok(())
} }
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { async fn delete_review(
&self,
review_id: &ReviewId,
events: &[DomainEvent],
) -> Result<(), DomainError> {
self.reviews.write().unwrap().remove(&review_id.value()); self.reviews.write().unwrap().remove(&review_id.value());
self.record_events(events);
Ok(()) Ok(())
} }

View File

@@ -144,7 +144,7 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
.unwrap(); .unwrap();
store store
.save_review(&test_review(movie.id(), &user_id, 4)) .save_review(&test_review(movie.id(), &user_id, 4), &[])
.await .await
.unwrap(); .unwrap();
store store
@@ -239,7 +239,7 @@ async fn seed_movie_with_dependents(
.unwrap(); .unwrap();
if with_review { if with_review {
store store
.save_review(&test_review(movie.id(), user.id(), 5)) .save_review(&test_review(movie.id(), user.id(), 5), &[])
.await .await
.unwrap(); .unwrap();
} }