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

@@ -3,7 +3,7 @@ use std::sync::Arc;
use domain::models::{WatchEvent, WatchEventSource};
use domain::ports::{MovieCommand, WatchEventCommand};
use domain::testing::{InMemoryStore, NoopEventPublisher};
use domain::testing::InMemoryStore;
use domain::value_objects::UserId;
use uuid::Uuid;
@@ -42,7 +42,7 @@ async fn confirms_watch_event_via_review_logger() {
None,
);
let event_id = event.id().value();
watch_events.save(&event).await.unwrap();
watch_events.save(&event, &[]).await.unwrap();
let result = confirm::execute(
&deps(&watch_events, noop_logger()),
@@ -86,7 +86,7 @@ async fn confirms_event_with_external_metadata_id_and_no_movie_id() {
None,
);
let event_id = event.id().value();
watch_events.save(&event).await.unwrap();
watch_events.save(&event, &[]).await.unwrap();
let result = confirm::execute(
&deps(&watch_events, noop_logger()),
@@ -121,7 +121,7 @@ async fn rejects_other_users_event() {
None,
);
let event_id = event.id().value();
watch_events.save(&event).await.unwrap();
watch_events.save(&event, &[]).await.unwrap();
let result = confirm::execute(
&deps(&watch_events, noop_logger()),
@@ -148,7 +148,6 @@ async fn fails_when_event_not_found() {
#[tokio::test]
async fn confirms_event_with_movie_id() {
let watch_events = InMemoryStore::new();
let events = NoopEventPublisher::new();
let uid = Uuid::new_v4();
let movie_uuid = Uuid::new_v4();
@@ -162,7 +161,7 @@ async fn confirms_event_with_movie_id() {
Some(domain::value_objects::MovieId::from_uuid(movie_uuid)),
);
let event_id = event.id().value();
watch_events.save(&event).await.unwrap();
watch_events.save(&event, &[]).await.unwrap();
// Also seed movie repo so review_logger can find it
let movies = domain::testing::InMemoryStore::new();
@@ -174,7 +173,7 @@ async fn confirms_event_with_movie_id() {
None,
None,
);
movies.upsert_movie(&movie).await.unwrap();
movies.upsert_movie(&movie, &[]).await.unwrap();
// Build a real review logger
let reviews = domain::testing::InMemoryStore::new();
@@ -186,7 +185,6 @@ async fn confirms_event_with_movie_id() {
Arc::clone(&reviews) as _,
Arc::clone(&watchlist) as _,
Arc::new(domain::testing::FakeMetadataClient) as _,
Arc::clone(&events) as _,
));
let result = confirm::execute(
@@ -214,7 +212,7 @@ async fn confirms_event_without_movie_id_and_without_external_metadata_id() {
None,
);
let event_id = event.id().value();
watch_events.save(&event).await.unwrap();
watch_events.save(&event, &[]).await.unwrap();
let result = confirm::execute(
&deps(&watch_events, noop_logger()),
@@ -260,8 +258,8 @@ async fn confirms_multiple_events() {
);
let id2 = event2.id().value();
watch_events.save(&event1).await.unwrap();
watch_events.save(&event2).await.unwrap();
watch_events.save(&event1, &[]).await.unwrap();
watch_events.save(&event2, &[]).await.unwrap();
let result = confirm::execute(
&deps(&watch_events, noop_logger()),
@@ -302,7 +300,7 @@ async fn confirms_event_without_year() {
None,
);
let event_id = event.id().value();
watch_events.save(&event).await.unwrap();
watch_events.save(&event, &[]).await.unwrap();
let result = confirm::execute(
&deps(&watch_events, noop_logger()),

View File

@@ -70,8 +70,8 @@ async fn dismisses_existing_events() {
);
let id1 = e1.id().value();
let id2 = e2.id().value();
watch_events.save(&e1).await.unwrap();
watch_events.save(&e2).await.unwrap();
watch_events.save(&e1, &[]).await.unwrap();
watch_events.save(&e2, &[]).await.unwrap();
let result = dismiss::execute(
&deps(&watch_events),

View File

@@ -46,7 +46,7 @@ async fn returns_pending_events() {
Utc::now().naive_utc(),
None,
);
events.save(&event).await.unwrap();
events.save(&event, &[]).await.unwrap();
let result = get_queue::execute(&deps(&events), GetWatchQueueQuery { user_id })
.await

View File

@@ -2,8 +2,7 @@ use crate::support::generate_webhook_token_command;
use std::sync::Arc;
use domain::models::WatchEventSource;
use domain::ports::EventPublisher;
use domain::testing::{InMemoryStore, NoopEventPublisher};
use domain::testing::{InMemoryStore, RefusingWatchEventCommand};
use uuid::Uuid;
use application::integrations::commands::IngestWatchEventCommand;
@@ -27,10 +26,9 @@ impl domain::ports::MediaServerParser for FakeParser {
}
#[tokio::test]
async fn ingests_watch_event() {
async fn ingesting_saves_the_watch_event_and_records_its_event_together() {
let tokens = InMemoryStore::new();
let watch_events = Arc::clone(&tokens);
let event_publisher: Arc<dyn EventPublisher> = NoopEventPublisher::new();
let user_id = Uuid::new_v4();
let generated = generate_token::execute(
@@ -46,7 +44,6 @@ async fn ingests_watch_event() {
webhook_token: Arc::clone(&tokens) as _,
watch_event_command: Arc::clone(&watch_events) as _,
watch_event_query: Arc::clone(&watch_events) as _,
event_publisher: Arc::clone(&event_publisher),
};
let result = ingest::execute(
@@ -61,19 +58,58 @@ async fn ingests_watch_event() {
.await;
assert!(result.is_ok());
assert_eq!(watch_events.rows_in("watch_events"), 1);
let recorded = tokens.recorded_events();
assert_eq!(recorded.len(), 1);
assert!(matches!(
&recorded[0],
domain::events::DomainEvent::WatchEventIngested { title, .. } if title == "Test"
));
}
#[tokio::test]
async fn a_refused_save_propagates_and_records_nothing() {
let tokens = InMemoryStore::new();
let user_id = Uuid::new_v4();
let generated = generate_token::execute(
&GenerateWebhookTokenDeps {
webhook_token: Arc::clone(&tokens) as _,
},
generate_webhook_token_command(user_id),
)
.await
.unwrap();
let deps = IngestWatchEventDeps {
webhook_token: Arc::clone(&tokens) as _,
watch_event_command: Arc::new(RefusingWatchEventCommand),
watch_event_query: Arc::clone(&tokens) as _,
};
let result = ingest::execute(
&deps,
IngestWatchEventCommand {
token: generated.token_plaintext,
raw_payload: vec![],
source: WatchEventSource::Jellyfin,
},
&FakeParser,
)
.await;
assert!(result.is_err());
assert_eq!(tokens.rows_in("watch_events"), 0);
assert!(tokens.recorded_events().is_empty());
}
#[tokio::test]
async fn rejects_invalid_token() {
let tokens = InMemoryStore::new();
let watch_events = Arc::clone(&tokens);
let event_publisher: Arc<dyn EventPublisher> = NoopEventPublisher::new();
let deps = IngestWatchEventDeps {
webhook_token: Arc::clone(&tokens) as _,
watch_event_command: Arc::clone(&watch_events) as _,
watch_event_query: Arc::clone(&watch_events) as _,
event_publisher: Arc::clone(&event_publisher),
};
let result = ingest::execute(