feat: atomic wrapup, movie, integration writes

Ride writes: WrapUpRequested/Completed (wrapup create/set_complete),
MovieDiscovered (resolve upsert, new+ext id only), PosterSynced (poster
handler upsert), MovieDeleted (delete_movie), WatchEventIngested (save).
Deps drop event_publisher: wrapup, ingest, delete_review, watchlist add,
review logger.

Remaining .publish( sites:
announce-only: users/delete_account UserDeleted, person/get +
get_credits PersonEnrichmentRequested, jobs/enrichment_staleness,
http-axum search, movies/sync_poster + poster_sync_handler +
image-converter backfill + users/update_profile ImageStored (object
storage), activitypub federation_event_bridge (reacting to events).
rides write, deferred: social/execute (see report),
activitypub review_handler MovieEnrichmentRequested (after
save_remote_review).
This commit is contained in:
2026-10-26 01:02:34 +01:00
parent 2f133afb96
commit 6c295866a0
51 changed files with 1281 additions and 329 deletions

View File

@@ -70,7 +70,7 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
let movie = test_movie("Heat");
UserRepository::save(&**store, &user).await.unwrap();
UserRepository::save(&**store, &other).await.unwrap();
store.upsert_movie(&movie).await.unwrap();
store.upsert_movie(&movie, &[]).await.unwrap();
store
.add(
@@ -118,6 +118,7 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
test_watched_at(),
None,
),
&[],
)
.await
.unwrap();
@@ -221,7 +222,7 @@ async fn seed_movie_with_dependents(
let user = test_user("alice");
UserRepository::save(&**store, &user).await.unwrap();
let movie = test_movie("Heat");
store.upsert_movie(&movie).await.unwrap();
store.upsert_movie(&movie, &[]).await.unwrap();
store
.add(
&WatchlistEntry::new(user.id().clone(), movie.id().clone()),
@@ -241,6 +242,7 @@ async fn seed_movie_with_dependents(
test_watched_at(),
Some(movie.id().clone()),
),
&[],
)
.await
.unwrap();
@@ -261,7 +263,7 @@ async fn deleting_a_movie_removes_watchlist_entries_and_enrichment_profile() {
assert!(store.rows_in(table) > 0, "{table} should be seeded");
}
store.delete_movie(movie.id()).await.unwrap();
store.delete_movie(movie.id(), &[]).await.unwrap();
assert_eq!(store.movie_count(), 0);
for table in MOVIE_CASCADE_TABLES {
@@ -274,7 +276,7 @@ async fn deleting_a_movie_detaches_watch_events_instead_of_removing_them() {
let store = InMemoryStore::new();
let (movie, user_id) = seed_movie_with_dependents(&store, false).await;
store.delete_movie(movie.id()).await.unwrap();
store.delete_movie(movie.id(), &[]).await.unwrap();
let pending = store.list_pending(&user_id).await.unwrap();
assert_eq!(pending.len(), 1);
@@ -286,7 +288,7 @@ async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cas
let store = InMemoryStore::new();
let (movie, _) = seed_movie_with_dependents(&store, true).await;
let result = store.delete_movie(movie.id()).await;
let result = store.delete_movie(movie.id(), &[]).await;
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
assert_eq!(store.movie_count(), 1);
@@ -367,12 +369,15 @@ async fn upserting_a_different_movie_with_a_taken_external_id_is_an_infrastructu
let store = InMemoryStore::new();
let external = ExternalMetadataId::new("tt0113277".into()).unwrap();
store
.upsert_movie(&test_movie_with_external_id("Heat", 1995, external.clone()))
.upsert_movie(
&test_movie_with_external_id("Heat", 1995, external.clone()),
&[],
)
.await
.unwrap();
let result = store
.upsert_movie(&test_movie_with_external_id("Heat 2", 2030, external))
.upsert_movie(&test_movie_with_external_id("Heat 2", 2030, external), &[])
.await;
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
@@ -395,3 +400,117 @@ async fn creating_a_refresh_session_with_a_taken_token_is_an_infrastructure_erro
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
}
fn movie_discovered(movie: &Movie) -> domain::events::DomainEvent {
domain::events::DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ExternalMetadataId::new("tmdb:1".into()).unwrap(),
}
}
fn movie_deleted(movie: &Movie) -> domain::events::DomainEvent {
domain::events::DomainEvent::MovieDeleted {
movie_id: movie.id().clone(),
poster_path: None,
}
}
#[tokio::test]
async fn upserting_a_movie_records_its_events() {
let store = InMemoryStore::new();
let movie = test_movie("Heat");
store
.upsert_movie(&movie, &[movie_discovered(&movie)])
.await
.unwrap();
assert_eq!(store.movie_count(), 1);
assert_eq!(store.recorded_events().len(), 1);
}
#[tokio::test]
async fn a_refused_movie_upsert_records_no_events() {
let store = InMemoryStore::new();
let external = ExternalMetadataId::new("tt0113277".into()).unwrap();
let first = test_movie_with_external_id("Heat", 1995, external.clone());
store.upsert_movie(&first, &[]).await.unwrap();
let clash = test_movie_with_external_id("Heat 2", 2030, external);
let result = store
.upsert_movie(&clash, &[movie_discovered(&clash)])
.await;
assert!(result.is_err());
assert!(store.recorded_events().is_empty());
}
#[tokio::test]
async fn deleting_a_movie_records_its_events() {
let store = InMemoryStore::new();
let movie = test_movie("Heat");
store.upsert_movie(&movie, &[]).await.unwrap();
store
.delete_movie(movie.id(), &[movie_deleted(&movie)])
.await
.unwrap();
assert_eq!(store.recorded_events().len(), 1);
}
#[tokio::test]
async fn deleting_an_absent_movie_records_no_events() {
let store = InMemoryStore::new();
let movie = test_movie("Heat");
store
.delete_movie(movie.id(), &[movie_deleted(&movie)])
.await
.unwrap();
assert!(store.recorded_events().is_empty());
}
#[tokio::test]
async fn a_refused_movie_delete_records_no_events() {
let store = InMemoryStore::new();
let (movie, _) = seed_movie_with_dependents(&store, true).await;
let result = store
.delete_movie(movie.id(), &[movie_deleted(&movie)])
.await;
assert!(result.is_err());
assert!(store.recorded_events().is_empty());
}
#[tokio::test]
async fn saving_a_watch_event_records_its_events() {
let store = InMemoryStore::new();
let user_id = UserId::generate();
let event = WatchEvent::new(
user_id.clone(),
"Heat".into(),
Some(1995),
None,
WatchEventSource::Jellyfin,
test_watched_at(),
None,
);
WatchEventCommand::save(
&*store,
&event,
&[domain::events::DomainEvent::WatchEventIngested {
user_id,
title: "Heat".into(),
source: "jellyfin".into(),
}],
)
.await
.unwrap();
assert_eq!(store.rows_in("watch_events"), 1);
assert_eq!(store.recorded_events().len(), 1);
}