watchlist add: event only if inserted; adr 0015 jetstream/relay accuracy; drop vacuous assert

This commit is contained in:
2026-10-26 01:56:38 +01:00
parent d0568d7933
commit bde8040cc3
7 changed files with 58 additions and 14 deletions

View File

@@ -39,7 +39,7 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.begin()
.await
.map_err(crate::shared::map_sqlx_error)?;
sqlx::query(
let inserted = sqlx::query(
"INSERT OR IGNORE INTO watchlist_entries (id, user_id, movie_id, added_at) \
VALUES (?, ?, ?, ?)",
)
@@ -49,8 +49,13 @@ impl WatchlistRepository for SqliteWatchlistRepository {
.bind(&added_at)
.execute(&mut *tx)
.await
.map_err(crate::shared::map_sqlx_error)?;
enqueue_events(&mut tx, events).await?;
.map_err(crate::shared::map_sqlx_error)?
.rows_affected()
> 0;
// A repeat add changed nothing, so it announces nothing.
if inserted {
enqueue_events(&mut tx, events).await?;
}
tx.commit().await.map_err(crate::shared::map_sqlx_error)
}

View File

@@ -200,6 +200,24 @@ async fn adding_a_watchlist_entry_writes_the_entry_and_its_event_in_one_commit()
assert_eq!(count(&pool, "event_queue").await, 1);
}
#[tokio::test]
async fn re_adding_a_watchlist_entry_enqueues_no_second_event() {
let (pool, user_id, movie) = watchlist_fixture().await;
let repo = SqliteWatchlistRepository::new(pool.clone());
let first = WatchlistEntry::new(user_id.clone(), movie.id().clone());
repo.add(&first, &[watchlist_added(&first, &movie)])
.await
.unwrap();
let again = WatchlistEntry::new(user_id, movie.id().clone());
repo.add(&again, &[watchlist_added(&again, &movie)])
.await
.unwrap();
assert_eq!(count(&pool, "watchlist_entries").await, 1);
assert_eq!(count(&pool, "event_queue").await, 1, "no phantom event");
}
#[tokio::test]
async fn a_failing_enqueue_rolls_back_the_watchlist_add() {
let (pool, user_id, movie) = watchlist_fixture().await;

View File

@@ -270,7 +270,6 @@ async fn a_refused_save_propagates_and_records_nothing() {
.await;
assert!(result.is_err());
assert_eq!(movies.review_count(), 0);
assert!(movies.recorded_events().is_empty());
}

View File

@@ -54,6 +54,11 @@ async fn adding_a_movie_already_on_the_watchlist_is_idempotent() {
add::execute(&deps, make_cmd()).await.unwrap();
add::execute(&deps, make_cmd()).await.unwrap();
assert_eq!(
movies.recorded_events().len(),
1,
"the repeat inserted nothing, so it announces nothing"
);
assert_eq!(
watchlist.watchlist_count(),
1,

View File

@@ -38,7 +38,7 @@ impl EventRelaySink for FakeSink {
}
let outage = self
.fail_first
.fetch_update(
.try_update(
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
|n| n.checked_sub(1),

View File

@@ -16,15 +16,23 @@ use super::InMemoryStore;
#[async_trait]
impl WatchlistRepository for InMemoryStore {
/// `INSERT OR IGNORE` on UNIQUE(user_id, movie_id): adding twice is a no-op.
/// `INSERT OR IGNORE` on UNIQUE(user_id, movie_id): adding twice is a no-op
/// and, like the adapter, records events only for the row it inserted.
async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError> {
let key = (entry.user_id.value(), entry.movie_id.value());
self.watchlist
.write()
.unwrap()
.entry(key)
.or_insert_with(|| entry.clone());
self.record_events(events);
let inserted = {
let mut entries = self.watchlist.write().unwrap();
match entries.entry(key) {
std::collections::hash_map::Entry::Vacant(slot) => {
slot.insert(entry.clone());
true
}
std::collections::hash_map::Entry::Occupied(_) => false,
}
};
if inserted {
self.record_events(events);
}
Ok(())
}