mod helpers;
use obix::{MailboxConfig, out::Outbox};
use serde::{Deserialize, Serialize};
use serial_test::file_serial;
use helpers::{TestTables, init_outbox, init_pool};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
enum SourceEvent {
Posted(u64),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
enum DestEvent {
Mapped(u64),
}
async fn outbox_row_count(pool: &sqlx::PgPool) -> anyhow::Result<i64> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM persistent_outbox_events")
.fetch_one(pool)
.await?;
Ok(count)
}
#[tokio::test]
#[file_serial]
async fn cursor_sees_only_events_published_after_creation() -> anyhow::Result<()> {
let pool = init_pool().await?;
let outbox = init_outbox::<SourceEvent>(
&pool,
MailboxConfig::builder()
.build()
.expect("Couldn't build MailboxConfig"),
)
.await?;
let mut op = outbox.begin_op().await?;
outbox
.publish_persisted_in_op(&mut op, SourceEvent::Posted(0))
.await?;
let mut cursor = outbox.cursor(&op)?;
outbox
.publish_all_persisted(&mut op, [SourceEvent::Posted(1), SourceEvent::Posted(2)])
.await?;
let new = outbox.new_events(&op, &mut cursor);
assert_eq!(new, [SourceEvent::Posted(1), SourceEvent::Posted(2)]);
assert!(outbox.new_events(&op, &mut cursor).is_empty());
outbox
.publish_persisted_in_op(&mut op, SourceEvent::Posted(3))
.await?;
assert_eq!(
outbox.new_events(&op, &mut cursor),
[SourceEvent::Posted(3)]
);
op.commit().await?;
Ok(())
}
#[tokio::test]
#[file_serial]
async fn peek_does_not_advance() -> anyhow::Result<()> {
let pool = init_pool().await?;
let outbox = init_outbox::<SourceEvent>(
&pool,
MailboxConfig::builder()
.build()
.expect("Couldn't build MailboxConfig"),
)
.await?;
let mut op = outbox.begin_op().await?;
let mut cursor = outbox.cursor(&op)?;
outbox
.publish_all_persisted(&mut op, [SourceEvent::Posted(1), SourceEvent::Posted(2)])
.await?;
assert_eq!(outbox.peek_new(&op, &cursor).len(), 2);
assert_eq!(outbox.peek_new(&op, &cursor).len(), 2);
assert_eq!(outbox.new_events(&op, &mut cursor).len(), 2);
assert!(outbox.peek_new(&op, &cursor).is_empty());
op.commit().await?;
Ok(())
}
#[tokio::test]
#[file_serial]
async fn cursor_is_scoped_to_its_outbox() -> anyhow::Result<()> {
let pool = init_pool().await?;
let source = init_outbox::<SourceEvent>(
&pool,
MailboxConfig::builder()
.build()
.expect("Couldn't build MailboxConfig"),
)
.await?;
let dest = Outbox::<DestEvent, TestTables>::init(
&pool,
MailboxConfig::builder()
.build()
.expect("Couldn't build MailboxConfig"),
)
.await?;
let mut op = source.begin_op().await?;
let mut dest_cursor = dest.cursor(&op)?;
source
.publish_persisted_in_op(&mut op, SourceEvent::Posted(1))
.await?;
assert!(
dest.new_events(&op, &mut dest_cursor).is_empty(),
"a cursor must not see another outbox's events"
);
dest.publish_persisted_in_op(&mut op, DestEvent::Mapped(1))
.await?;
assert_eq!(
dest.new_events(&op, &mut dest_cursor),
[DestEvent::Mapped(1)]
);
op.commit().await?;
Ok(())
}
#[tokio::test]
#[file_serial]
async fn mapped_republish_commits_atomically() -> anyhow::Result<()> {
let pool = init_pool().await?;
let source = init_outbox::<SourceEvent>(
&pool,
MailboxConfig::builder()
.build()
.expect("Couldn't build MailboxConfig"),
)
.await?;
let dest = Outbox::<DestEvent, TestTables>::init(
&pool,
MailboxConfig::builder()
.build()
.expect("Couldn't build MailboxConfig"),
)
.await?;
let mut op = source.begin_op().await?;
let mut cursor = source.cursor(&op)?;
source
.publish_all_persisted(
&mut op,
[
SourceEvent::Posted(1),
SourceEvent::Posted(2),
SourceEvent::Posted(3),
],
)
.await?;
let mapped = source.map_new(&op, &mut cursor, |e| match e {
SourceEvent::Posted(n) if *n % 2 == 1 => Some(DestEvent::Mapped(*n)),
_ => None,
});
assert_eq!(mapped, [DestEvent::Mapped(1), DestEvent::Mapped(3)]);
dest.publish_all_persisted(&mut op, mapped).await?;
op.commit().await?;
assert_eq!(outbox_row_count(&pool).await?, 5);
Ok(())
}
#[tokio::test]
#[file_serial]
async fn mapped_republish_rolls_back_atomically() -> anyhow::Result<()> {
let pool = init_pool().await?;
let source = init_outbox::<SourceEvent>(
&pool,
MailboxConfig::builder()
.build()
.expect("Couldn't build MailboxConfig"),
)
.await?;
let dest = Outbox::<DestEvent, TestTables>::init(
&pool,
MailboxConfig::builder()
.build()
.expect("Couldn't build MailboxConfig"),
)
.await?;
{
let mut op = source.begin_op().await?;
let mut cursor = source.cursor(&op)?;
source
.publish_all_persisted(&mut op, [SourceEvent::Posted(1), SourceEvent::Posted(2)])
.await?;
let mapped = source.map_new(&op, &mut cursor, |e| {
let SourceEvent::Posted(n) = e;
Some(DestEvent::Mapped(*n))
});
dest.publish_all_persisted(&mut op, mapped).await?;
}
assert_eq!(
outbox_row_count(&pool).await?,
0,
"neither source nor mapped events may survive a rollback"
);
Ok(())
}
#[tokio::test]
#[file_serial]
async fn cursor_errors_on_op_without_hook_support() -> anyhow::Result<()> {
let pool = init_pool().await?;
let outbox = init_outbox::<SourceEvent>(
&pool,
MailboxConfig::builder()
.build()
.expect("Couldn't build MailboxConfig"),
)
.await?;
let mut tx = pool.begin().await?;
assert!(
matches!(
outbox.cursor(&tx),
Err(obix::out::CursorError::HooksUnsupported)
),
"a bare transaction supports no commit hooks — cursor() must fail loudly"
);
outbox
.publish_persisted_in_op(&mut tx, SourceEvent::Posted(1))
.await?;
tx.commit().await?;
assert_eq!(outbox_row_count(&pool).await?, 1);
Ok(())
}