fix: NewRemoteReview param struct, atomic review+orphan movie delete

This commit is contained in:
2026-10-26 01:19:09 +01:00
parent 9a4d0c1714
commit 7f9c252d35
18 changed files with 261 additions and 116 deletions

View File

@@ -51,10 +51,15 @@ pub trait ReviewRepository: Send + Sync {
review: &Review,
events: &[DomainEvent],
) -> Result<(), DomainError>;
async fn delete_review(
/// Deletes the review and, in the same transaction, its movie when no
/// review of any kind references it any more. `review_events` are always
/// recorded; `orphan_events` only when the movie row was deleted.
async fn delete_review_and_orphaned_movie(
&self,
review_id: &ReviewId,
events: &[DomainEvent],
movie_id: &MovieId,
review_events: &[DomainEvent],
orphan_events: &[DomainEvent],
) -> Result<(), DomainError>;
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError>;
}

View File

@@ -557,7 +557,13 @@ impl ReviewRepository for PanicReviewRepository {
async fn update_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> {
panic!("PanicReviewRepository called")
}
async fn delete_review(&self, _: &ReviewId, _: &[DomainEvent]) -> Result<(), DomainError> {
async fn delete_review_and_orphaned_movie(
&self,
_: &ReviewId,
_: &MovieId,
_: &[DomainEvent],
_: &[DomainEvent],
) -> Result<(), DomainError> {
panic!("PanicReviewRepository called")
}
async fn get_all_reviews_for_user(&self, _: &UserId) -> Result<Vec<Review>, DomainError> {

View File

@@ -37,7 +37,13 @@ impl ReviewRepository for RefusingReviewRepository {
async fn update_review(&self, _: &Review, _: &[DomainEvent]) -> Result<(), DomainError> {
Err(refused())
}
async fn delete_review(&self, _: &ReviewId, _: &[DomainEvent]) -> Result<(), DomainError> {
async fn delete_review_and_orphaned_movie(
&self,
_: &ReviewId,
_: &MovieId,
_: &[DomainEvent],
_: &[DomainEvent],
) -> Result<(), DomainError> {
Err(refused())
}
async fn get_all_reviews_for_user(&self, _: &UserId) -> Result<Vec<Review>, DomainError> {

View File

@@ -60,6 +60,15 @@ impl MovieCommand for InMemoryStore {
"Database operation failed".into(),
));
}
self.remove_movie_with_cascades(id);
self.record_events(events);
Ok(())
}
}
impl InMemoryStore {
/// Removes the movie row and applies the schema's cascades.
pub(super) fn remove_movie_with_cascades(&self, id: uuid::Uuid) {
self.movies.write().unwrap().remove(&id);
self.watchlist
.write()
@@ -72,9 +81,6 @@ impl MovieCommand for InMemoryStore {
*event = detached_from_movie(event);
}
}
drop(events_guard);
self.record_events(events);
Ok(())
}
}

View File

@@ -5,7 +5,7 @@ use crate::{
events::DomainEvent,
models::Review,
ports::ReviewRepository,
value_objects::{ReviewId, UserId},
value_objects::{MovieId, ReviewId, UserId},
};
use super::InMemoryStore;
@@ -47,13 +47,24 @@ impl ReviewRepository for InMemoryStore {
Ok(())
}
async fn delete_review(
async fn delete_review_and_orphaned_movie(
&self,
review_id: &ReviewId,
events: &[DomainEvent],
movie_id: &MovieId,
review_events: &[DomainEvent],
orphan_events: &[DomainEvent],
) -> Result<(), DomainError> {
self.reviews.write().unwrap().remove(&review_id.value());
self.record_events(events);
let movie = movie_id.value();
let still_referenced = {
let mut reviews = self.reviews.write().unwrap();
reviews.remove(&review_id.value());
reviews.values().any(|r| r.movie_id().value() == movie)
};
self.record_events(review_events);
if !still_referenced && self.movies.read().unwrap().contains_key(&movie) {
self.remove_movie_with_cascades(movie);
self.record_events(orphan_events);
}
Ok(())
}

View File

@@ -517,3 +517,61 @@ async fn saving_a_watch_event_records_its_events() {
assert_eq!(store.rows_in("watch_events"), 1);
assert_eq!(store.recorded_events().len(), 1);
}
#[tokio::test]
async fn deleting_the_last_review_removes_the_movie_and_records_both_events() {
let store = InMemoryStore::new();
let (movie, user_id) = seed_movie_with_dependents(&store, true).await;
let review = store
.get_all_reviews_for_user(&user_id)
.await
.unwrap()
.remove(0);
store
.delete_review_and_orphaned_movie(
review.id(),
movie.id(),
&[domain::events::DomainEvent::ReviewDeleted {
review_id: review.id().clone(),
user_id: user_id.clone(),
}],
&[movie_deleted(&movie)],
)
.await
.unwrap();
assert_eq!(store.review_count(), 0);
assert_eq!(store.movie_count(), 0);
assert_eq!(store.recorded_events().len(), 2);
}
#[tokio::test]
async fn deleting_a_review_while_another_remains_keeps_the_movie_and_records_one_event() {
let store = InMemoryStore::new();
let (movie, user_id) = seed_movie_with_dependents(&store, true).await;
let review = store
.get_all_reviews_for_user(&user_id)
.await
.unwrap()
.remove(0);
let other = test_review(movie.id(), &UserId::generate(), 3);
store.save_review(&other, &[]).await.unwrap();
store
.delete_review_and_orphaned_movie(
review.id(),
movie.id(),
&[domain::events::DomainEvent::ReviewDeleted {
review_id: review.id().clone(),
user_id: user_id.clone(),
}],
&[movie_deleted(&movie)],
)
.await
.unwrap();
assert_eq!(store.review_count(), 1);
assert_eq!(store.movie_count(), 1);
assert_eq!(store.recorded_events().len(), 1);
}