feat: atomic review writes with events
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Review, ReviewSource},
|
||||
ports::ReviewRepository,
|
||||
value_objects::{ReviewId, UserId},
|
||||
@@ -10,7 +11,7 @@ use sqlx::SqlitePool;
|
||||
use rows::ReviewRow;
|
||||
|
||||
pub(crate) mod rows;
|
||||
use crate::shared::datetime_to_str;
|
||||
use crate::shared::{datetime_to_str, enqueue_events};
|
||||
|
||||
pub struct SqliteReviewRepository {
|
||||
pool: SqlitePool,
|
||||
@@ -24,7 +25,11 @@ impl SqliteReviewRepository {
|
||||
|
||||
#[async_trait]
|
||||
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 movie_id = review.movie_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()),
|
||||
};
|
||||
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -51,11 +61,11 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
.bind(&remote_actor_url)
|
||||
.bind(review.watch_medium().map(|wm| wm.to_string()))
|
||||
.bind(review.spoilers())
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
enqueue_events(&mut tx, events).await?;
|
||||
tx.commit().await.map_err(crate::shared::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
@@ -72,13 +82,22 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
.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 rating = review.rating().value() as i64;
|
||||
let comment = review.comment().map(|c| c.value().to_string());
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
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(
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?, spoilers = ? WHERE id = ?",
|
||||
)
|
||||
@@ -88,21 +107,31 @@ impl ReviewRepository for SqliteReviewRepository {
|
||||
.bind(&watch_medium)
|
||||
.bind(review.spoilers())
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(crate::shared::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
enqueue_events(&mut tx, events).await?;
|
||||
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();
|
||||
sqlx::query("DELETE FROM reviews WHERE id = ?")
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.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> {
|
||||
|
||||
@@ -199,7 +199,7 @@ async fn seed_user_with_dependents(
|
||||
|
||||
parts
|
||||
.review
|
||||
.save_review(&test_review(movie.id(), &user_id, 4))
|
||||
.save_review(&test_review(movie.id(), &user_id, 4), &[])
|
||||
.await
|
||||
.unwrap();
|
||||
(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
|
||||
.review
|
||||
.save_review(&test_review(movie.id(), user.id(), 5))
|
||||
.save_review(&test_review(movie.id(), user.id(), 5), &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ mod movie_dedup_test;
|
||||
mod movie_profile_repository_test;
|
||||
#[path = "repositories/person_adapter_test.rs"]
|
||||
mod person_adapter_test;
|
||||
#[path = "repositories/review_repository_test.rs"]
|
||||
mod review_repository_test;
|
||||
#[path = "repositories/search_adapter_test.rs"]
|
||||
mod search_adapter_test;
|
||||
#[path = "repositories/social_repository_test.rs"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user