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:
@@ -3,6 +3,7 @@ use chrono::NaiveDateTime;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{ParsedPlaybackEvent, WatchEvent, WatchEventStatus, WebhookToken},
|
||||
value_objects::{UserId, WatchEventId, WebhookTokenId},
|
||||
};
|
||||
@@ -14,7 +15,7 @@ pub trait MediaServerParser: Send + Sync {
|
||||
|
||||
#[async_trait]
|
||||
pub trait WatchEventCommand: Send + Sync {
|
||||
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError>;
|
||||
async fn save(&self, event: &WatchEvent, events: &[DomainEvent]) -> Result<(), DomainError>;
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: &WatchEventId,
|
||||
|
||||
@@ -2,6 +2,7 @@ use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
MetadataSearchCriteria, Movie, MovieFilter, MovieProfile, MovieSummary,
|
||||
collections::{PageParams, Paginated},
|
||||
@@ -11,8 +12,12 @@ use crate::{
|
||||
|
||||
#[async_trait]
|
||||
pub trait MovieCommand: Send + Sync {
|
||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
|
||||
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
|
||||
async fn upsert_movie(&self, movie: &Movie, events: &[DomainEvent]) -> Result<(), DomainError>;
|
||||
async fn delete_movie(
|
||||
&self,
|
||||
movie_id: &MovieId,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -4,6 +4,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::wrapup::{
|
||||
DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus,
|
||||
},
|
||||
@@ -12,14 +13,23 @@ use crate::{
|
||||
|
||||
#[async_trait]
|
||||
pub trait WrapUpRepository: Send + Sync {
|
||||
async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError>;
|
||||
async fn create(
|
||||
&self,
|
||||
record: &WrapUpRecord,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError>;
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: &WrapUpId,
|
||||
status: &WrapUpStatus,
|
||||
error: Option<&str>,
|
||||
) -> Result<(), DomainError>;
|
||||
async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError>;
|
||||
async fn set_complete(
|
||||
&self,
|
||||
id: &WrapUpId,
|
||||
report: &WrapUpReport,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError>;
|
||||
async fn get_by_id(&self, id: &WrapUpId) -> Result<Option<WrapUpRecord>, DomainError>;
|
||||
async fn list_for_user(&self, user_id: Uuid) -> Result<Vec<WrapUpRecord>, DomainError>;
|
||||
async fn list_global(&self) -> Result<Vec<WrapUpRecord>, DomainError>;
|
||||
|
||||
@@ -393,7 +393,11 @@ pub struct PanicWatchEventCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl crate::ports::WatchEventCommand for PanicWatchEventCommand {
|
||||
async fn save(&self, _: &crate::models::WatchEvent) -> Result<(), DomainError> {
|
||||
async fn save(
|
||||
&self,
|
||||
_: &crate::models::WatchEvent,
|
||||
_: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
panic!("PanicWatchEventCommand called")
|
||||
}
|
||||
async fn update_status(
|
||||
@@ -487,10 +491,10 @@ pub struct PanicMovieCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl MovieCommand for PanicMovieCommand {
|
||||
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
|
||||
async fn upsert_movie(&self, _: &Movie, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||
panic!("PanicMovieCommand called")
|
||||
}
|
||||
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
||||
async fn delete_movie(&self, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||
panic!("PanicMovieCommand called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
//! Fakes whose every call fails with an infrastructure error, for failure-path tests.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
Goal, Movie, ProfileField, Review, WatchlistEntry, WatchlistWithMovie,
|
||||
Goal, Movie, ProfileField, Review, WatchEvent, WatchEventStatus, WatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{
|
||||
EventPublisher, GoalCommand, MovieCommand, ReviewRepository, UserProfileFieldsRepository,
|
||||
WatchlistRepository,
|
||||
WatchEventCommand, WatchlistRepository,
|
||||
},
|
||||
value_objects::{GoalId, MovieId, ReviewId, UserId},
|
||||
value_objects::{GoalId, MovieId, ReviewId, UserId, WatchEventId},
|
||||
};
|
||||
|
||||
fn refused() -> DomainError {
|
||||
@@ -45,10 +47,10 @@ pub struct RefusingMovieCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl MovieCommand for RefusingMovieCommand {
|
||||
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
|
||||
async fn upsert_movie(&self, _: &Movie, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||
Err(refused())
|
||||
}
|
||||
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
|
||||
async fn delete_movie(&self, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||
Err(refused())
|
||||
}
|
||||
}
|
||||
@@ -123,3 +125,29 @@ impl EventPublisher for RefusingEventPublisher {
|
||||
Err(refused())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RefusingWatchEventCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl WatchEventCommand for RefusingWatchEventCommand {
|
||||
async fn save(&self, _: &WatchEvent, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||
Err(refused())
|
||||
}
|
||||
async fn update_status(
|
||||
&self,
|
||||
_: &WatchEventId,
|
||||
_: WatchEventStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
Err(refused())
|
||||
}
|
||||
async fn update_status_batch(
|
||||
&self,
|
||||
_: &[WatchEventId],
|
||||
_: WatchEventStatus,
|
||||
) -> Result<u64, DomainError> {
|
||||
Err(refused())
|
||||
}
|
||||
async fn delete_non_pending_older_than(&self, _: NaiveDateTime) -> Result<u64, DomainError> {
|
||||
Err(refused())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
Movie, MovieFilter, MovieProfile, MovieSummary, PersistedWatchEvent, WatchEvent,
|
||||
collections::{PageParams, Paginated},
|
||||
@@ -17,7 +18,7 @@ impl MovieCommand for InMemoryStore {
|
||||
/// `movies.external_metadata_id` is UNIQUE: another movie row holding the
|
||||
/// same external id is a constraint violation, which the sqlite adapter
|
||||
/// surfaces as an infrastructure error.
|
||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
|
||||
async fn upsert_movie(&self, movie: &Movie, events: &[DomainEvent]) -> Result<(), DomainError> {
|
||||
let mut movies = self.movies.write().unwrap();
|
||||
if let Some(external) = movie.external_metadata_id() {
|
||||
let taken = movies.values().any(|m| {
|
||||
@@ -31,13 +32,19 @@ impl MovieCommand for InMemoryStore {
|
||||
}
|
||||
}
|
||||
movies.insert(movie.id().value(), movie.clone());
|
||||
drop(movies);
|
||||
self.record_events(events);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Schema: `reviews.movie_id REFERENCES movies(id)` has no cascade, so a
|
||||
/// movie with reviews cannot be deleted. `watchlist_entries` and the
|
||||
/// `movie_*` profile tables cascade; `watch_events.movie_id` is set to NULL.
|
||||
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> {
|
||||
async fn delete_movie(
|
||||
&self,
|
||||
movie_id: &MovieId,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let id = movie_id.value();
|
||||
if !self.movies.read().unwrap().contains_key(&id) {
|
||||
return Ok(());
|
||||
@@ -59,12 +66,14 @@ impl MovieCommand for InMemoryStore {
|
||||
.unwrap()
|
||||
.retain(|(_, movie), _| *movie != id);
|
||||
self.movie_profiles.write().unwrap().remove(&id);
|
||||
let mut events = self.watch_events.write().unwrap();
|
||||
for event in events.iter_mut() {
|
||||
let mut events_guard = self.watch_events.write().unwrap();
|
||||
for event in events_guard.iter_mut() {
|
||||
if event.movie_id().map(|m| m.value()) == Some(id) {
|
||||
*event = detached_from_movie(event);
|
||||
}
|
||||
}
|
||||
drop(events_guard);
|
||||
self.record_events(events);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{WatchEvent, WatchEventStatus, WebhookToken},
|
||||
ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository},
|
||||
value_objects::{UserId, WatchEventId, WebhookTokenId},
|
||||
@@ -13,8 +14,9 @@ use super::InMemoryStore;
|
||||
|
||||
#[async_trait]
|
||||
impl WatchEventCommand for InMemoryStore {
|
||||
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
|
||||
async fn save(&self, event: &WatchEvent, events: &[DomainEvent]) -> Result<(), DomainError> {
|
||||
self.watch_events.write().unwrap().push(event.clone());
|
||||
self.record_events(events);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{errors::DomainError, ports::WrapUpRepository, value_objects::WrapUpId};
|
||||
use crate::{
|
||||
errors::DomainError, events::DomainEvent, ports::WrapUpRepository, value_objects::WrapUpId,
|
||||
};
|
||||
|
||||
// ── PanicWrapUpStatsQuery ───────────────────────────────────────────────────
|
||||
|
||||
@@ -68,14 +70,22 @@ impl crate::ports::WrapUpStatsQuery for InMemoryWrapUpStatsQuery {
|
||||
|
||||
pub struct InMemoryWrapUpRepository {
|
||||
pub store: Mutex<Vec<crate::models::wrapup::WrapUpRecord>>,
|
||||
recorded: Mutex<Vec<DomainEvent>>,
|
||||
}
|
||||
|
||||
impl InMemoryWrapUpRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(Vec::new()),
|
||||
recorded: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Events recorded atomically with a change (the outbox rows the sqlite
|
||||
/// adapter writes in the same transaction).
|
||||
pub fn recorded_events(&self) -> Vec<DomainEvent> {
|
||||
self.recorded.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -83,8 +93,10 @@ impl WrapUpRepository for InMemoryWrapUpRepository {
|
||||
async fn create(
|
||||
&self,
|
||||
record: &crate::models::wrapup::WrapUpRecord,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().push(record.clone());
|
||||
self.recorded.lock().unwrap().extend_from_slice(events);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -108,12 +120,15 @@ impl WrapUpRepository for InMemoryWrapUpRepository {
|
||||
&self,
|
||||
id: &WrapUpId,
|
||||
report: &crate::models::wrapup::WrapUpReport,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.store.lock().unwrap();
|
||||
if let Some(rec) = store.iter_mut().find(|r| r.id == *id) {
|
||||
rec.status = crate::models::wrapup::WrapUpStatus::Ready;
|
||||
rec.report = Some(report.clone());
|
||||
rec.completed_at = Some(chrono::Utc::now().naive_utc());
|
||||
drop(store);
|
||||
self.recorded.lock().unwrap().extend_from_slice(events);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DomainError::NotFound("wrapup record".into()))
|
||||
@@ -197,7 +212,11 @@ pub struct PanicWrapUpRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl WrapUpRepository for PanicWrapUpRepository {
|
||||
async fn create(&self, _: &crate::models::wrapup::WrapUpRecord) -> Result<(), DomainError> {
|
||||
async fn create(
|
||||
&self,
|
||||
_: &crate::models::wrapup::WrapUpRecord,
|
||||
_: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
panic!("PanicWrapUpRepository called")
|
||||
}
|
||||
async fn update_status(
|
||||
@@ -212,6 +231,7 @@ impl WrapUpRepository for PanicWrapUpRepository {
|
||||
&self,
|
||||
_: &WrapUpId,
|
||||
_: &crate::models::wrapup::WrapUpReport,
|
||||
_: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
panic!("PanicWrapUpRepository called")
|
||||
}
|
||||
@@ -245,3 +265,77 @@ impl WrapUpRepository for PanicWrapUpRepository {
|
||||
panic!("PanicWrapUpRepository called")
|
||||
}
|
||||
}
|
||||
|
||||
// ── RefusingWrapUpWrites ───────────────────────────────────────────────────
|
||||
|
||||
/// Delegates to an in-memory repository but refuses the two writes that carry
|
||||
/// outbox events (`create`, `set_complete`), leaving `update_status` and reads
|
||||
/// working so a refusal can be observed mid-flow.
|
||||
pub struct RefusingWrapUpWrites {
|
||||
pub inner: Arc<InMemoryWrapUpRepository>,
|
||||
}
|
||||
|
||||
impl RefusingWrapUpWrites {
|
||||
pub fn new(inner: Arc<InMemoryWrapUpRepository>) -> Arc<Self> {
|
||||
Arc::new(Self { inner })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WrapUpRepository for RefusingWrapUpWrites {
|
||||
async fn create(
|
||||
&self,
|
||||
_: &crate::models::wrapup::WrapUpRecord,
|
||||
_: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
Err(DomainError::InfrastructureError("refused".into()))
|
||||
}
|
||||
async fn update_status(
|
||||
&self,
|
||||
id: &WrapUpId,
|
||||
status: &crate::models::wrapup::WrapUpStatus,
|
||||
error: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.inner.update_status(id, status, error).await
|
||||
}
|
||||
async fn set_complete(
|
||||
&self,
|
||||
_: &WrapUpId,
|
||||
_: &crate::models::wrapup::WrapUpReport,
|
||||
_: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
Err(DomainError::InfrastructureError("refused".into()))
|
||||
}
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
id: &WrapUpId,
|
||||
) -> Result<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
|
||||
self.inner.get_by_id(id).await
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<crate::models::wrapup::WrapUpRecord>, DomainError> {
|
||||
self.inner.list_for_user(user_id).await
|
||||
}
|
||||
async fn list_global(&self) -> Result<Vec<crate::models::wrapup::WrapUpRecord>, DomainError> {
|
||||
self.inner.list_global().await
|
||||
}
|
||||
async fn find_existing(
|
||||
&self,
|
||||
user_id: Option<Uuid>,
|
||||
start: chrono::NaiveDate,
|
||||
end: chrono::NaiveDate,
|
||||
) -> Result<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
|
||||
self.inner.find_existing(user_id, start, end).await
|
||||
}
|
||||
async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> {
|
||||
self.inner.delete(id).await
|
||||
}
|
||||
async fn delete_failed_older_than(
|
||||
&self,
|
||||
before: chrono::NaiveDateTime,
|
||||
) -> Result<u64, DomainError> {
|
||||
self.inner.delete_failed_older_than(before).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user