//! The relay claims outbox rows with the SQLite queue's own atomic claim and //! settles each row from the sink's verdict, with fakes at the sink boundary. mod support; use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; use domain::{ errors::DomainError, events::{DomainEvent, EventId}, ports::{EventPublisher, EventRelaySink}, value_objects::UserId, }; use sqlite::SqliteEventQueue; use uuid::Uuid; #[derive(Default)] struct FakeSink { fail: bool, seen: Mutex>>, } #[async_trait] impl EventRelaySink for FakeSink { async fn relay(&self, id: Option<&EventId>, _event: &DomainEvent) -> Result<(), DomainError> { self.seen.lock().unwrap().push(id.cloned()); if self.fail { Err(DomainError::InfrastructureError("bus down".into())) } else { Ok(()) } } } async fn outbox() -> (sqlx::SqlitePool, Arc, SqliteEventQueue) { let pool = support::memory_pool().await; sqlite::migrate(&pool).await.unwrap(); let config = config::EventsConfig { queue_poll_interval_ms: 10, ..Default::default() }; let queue = SqliteEventQueue::create( pool.clone(), sqlite::DbEventQueueConfig::from_config(&config), ); (pool.clone(), Arc::new(queue.clone()), queue) } async fn row(pool: &sqlx::SqlitePool) -> (String, i32, Option) { sqlx::query_as("SELECT status, attempts, event_id FROM event_queue") .fetch_one(pool) .await .unwrap() } async fn wait_for(pool: &sqlx::SqlitePool, status: &str) { for _ in 0..200 { if row(pool).await.0 == status { return; } tokio::time::sleep(Duration::from_millis(10)).await; } panic!("row never reached {status}: {:?}", row(pool).await); } fn event() -> DomainEvent { DomainEvent::UserUpdated { user_id: UserId::from_uuid(Uuid::new_v4()), } } #[tokio::test] async fn relay_marks_rows_done_only_after_publish_succeeds() { let (pool, publisher, queue) = outbox().await; publisher.publish(&event()).await.unwrap(); let sink = Arc::new(FakeSink::default()); let relay = bootstrap::outbox_relay::spawn(Arc::new(queue), sink.clone()); wait_for(&pool, "done").await; relay.abort(); let (_, attempts, event_id) = row(&pool).await; assert_eq!(attempts, 0); let seen = sink.seen.lock().unwrap(); assert_eq!(seen.len(), 1); assert_eq!( seen[0].as_ref().map(|id| id.value().to_string()), event_id, "the sink gets the row's own event id" ); } #[tokio::test] async fn a_failed_publish_leaves_the_row_pending_with_an_attempt_counted() { let (pool, publisher, queue) = outbox().await; publisher.publish(&event()).await.unwrap(); let sink = Arc::new(FakeSink { fail: true, ..Default::default() }); let relay = bootstrap::outbox_relay::spawn(Arc::new(queue), sink.clone()); for _ in 0..200 { if row(&pool).await.1 == 1 { break; } tokio::time::sleep(Duration::from_millis(10)).await; } relay.abort(); let (status, attempts, _) = row(&pool).await; assert_eq!((status.as_str(), attempts), ("pending", 1)); }