use crate::common;
use std::sync::{Arc, Mutex};
use crate::common::OrderCreated;
use reliar_core::Envelope;
use reliar_outbox::OutboxEnqueue;
use reliar_store_postgres::{
MigrateOptions, PostgresOutboxError, PostgresOutboxSettings, PostgresOutboxStore, migrate,
};
use sqlx::PgPool;
use sqlx::postgres::PgConnectOptions;
use tracing::field::{Field, Visit};
use tracing_subscriber::layer::{Context, SubscriberExt};
#[derive(Default, Clone)]
struct Recorded(Arc<Mutex<Vec<String>>>);
struct Recorder(Recorded);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Recorder {
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
struct MessageVisitor(String);
impl Visit for MessageVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0 = format!("{value:?}");
}
}
}
let mut visitor = MessageVisitor(String::new());
event.record(&mut visitor);
self.0.0.lock().unwrap().push(visitor.0);
}
}
async fn with_recording_subscriber<Fut: std::future::Future<Output = ()>>(f: Fut) -> Vec<String> {
let recorded = Recorded::default();
let subscriber = tracing_subscriber::registry().with(Recorder(recorded.clone()));
let _guard = common::install_recording_subscriber(subscriber);
f.await;
recorded.0.lock().unwrap().clone()
}
async fn pool_without_search_path() -> PgPool {
let base = common::fresh_unmigrated_db().await;
reliar_store_postgres::migrate(&base, reliar_store_postgres::MigrateOptions::default())
.await
.unwrap();
let options: PgConnectOptions = base
.connect_options()
.as_ref()
.clone()
.options([("search_path", "public")]);
PgPool::connect_with(options).await.unwrap()
}
async fn construction_fails_fast_without_search_path() {
let pool = pool_without_search_path().await;
let err = PostgresOutboxStore::new(pool).await.unwrap_err();
match err {
PostgresOutboxError::SchemaNotOnSearchPath { configured, .. } => {
assert_eq!(configured, "reliar");
}
other => panic!("expected SchemaNotOnSearchPath, got {other:?}"),
}
}
async fn construction_succeeds_with_search_path_set() {
let pool = common::fresh_db().await;
PostgresOutboxStore::new(pool)
.await
.expect("construction succeeds once outbox resolves to the configured schema");
}
async fn connect_refuses_a_schema_missing_the_message_id_column() {
let pool = common::fresh_unmigrated_db().await;
common::apply_migration_prefix(&pool, 4).await;
let options: PgConnectOptions = pool
.connect_options()
.as_ref()
.clone()
.options([("search_path", "reliar,public")]);
let store_pool = PgPool::connect_with(options).await.unwrap();
let err = PostgresOutboxStore::new(store_pool).await.unwrap_err();
match &err {
PostgresOutboxError::SchemaOutOfDate { schema, missing } => {
assert_eq!(schema, "reliar");
assert_eq!(*missing, "message_id");
}
other => panic!("expected SchemaOutOfDate, got {other:?}"),
}
let message = err.to_string();
assert!(
message.contains("migrate"),
"the message must name migrate() as the remedy: {message:?}"
);
}
async fn connect_refuses_a_schema_stuck_between_0005_and_0009() {
let pool = common::fresh_unmigrated_db().await;
common::apply_migration_prefix(&pool, 9).await;
let options: PgConnectOptions = pool
.connect_options()
.as_ref()
.clone()
.options([("search_path", "reliar,public")]);
let store_pool = PgPool::connect_with(options.clone()).await.unwrap();
let err = PostgresOutboxStore::new(store_pool).await.unwrap_err();
match &err {
PostgresOutboxError::SchemaOutOfDate { schema, missing } => {
assert_eq!(schema, "reliar");
assert_eq!(*missing, "id");
}
other => panic!("expected SchemaOutOfDate {{ missing: \"id\" }}, got {other:?}"),
}
migrate(&pool, MigrateOptions::default())
.await
.expect("migrate() completes 0010");
let store_pool = PgPool::connect_with(options).await.unwrap();
PostgresOutboxStore::new(store_pool)
.await
.expect("connect succeeds once 0010 has completed");
}
async fn warns_when_a_same_named_table_exists_elsewhere() {
let base = common::fresh_unmigrated_db().await;
reliar_store_postgres::migrate(&base, reliar_store_postgres::MigrateOptions::default())
.await
.unwrap();
sqlx::query("CREATE TABLE public.outbox (id uuid)")
.execute(&base)
.await
.unwrap();
let options: PgConnectOptions = base
.connect_options()
.as_ref()
.clone()
.options([("search_path", "reliar,public")]);
let pool = PgPool::connect_with(options).await.unwrap();
let messages = with_recording_subscriber(async {
PostgresOutboxStore::new(pool).await.unwrap();
})
.await;
assert!(
messages.iter().any(|m| m.contains("outbox")),
"expected a warning naming the duplicate `outbox` table; got {messages:?}"
);
}
async fn enqueue_sets_search_path_restores_the_callers_value() {
let pool = common::fresh_db().await;
let settings = PostgresOutboxSettings::default().enqueue_sets_search_path(true);
let store = PostgresOutboxStore::with_settings(pool.clone(), settings)
.await
.unwrap();
let mut tx = pool.begin().await.unwrap();
let before: String = sqlx::query_scalar("SELECT current_setting('search_path')")
.fetch_one(&mut *tx)
.await
.unwrap();
let envelope = Envelope::builder(OrderCreated { order_id: 1 }).build();
store.enqueue_envelope(&mut tx, envelope).await.unwrap();
let after: String = sqlx::query_scalar("SELECT current_setting('search_path')")
.fetch_one(&mut *tx)
.await
.unwrap();
assert_eq!(
before, after,
"enqueue must restore the caller's search_path"
);
tx.commit().await.unwrap();
}
async fn duplicate_id_surfaces_correctly_with_enqueue_sets_search_path() {
let pool = common::fresh_db().await;
let settings = PostgresOutboxSettings::default().enqueue_sets_search_path(true);
let store = PostgresOutboxStore::with_settings(pool.clone(), settings)
.await
.unwrap();
let envelope = Envelope::builder(OrderCreated { order_id: 1 }).build();
let envelope_id = envelope.id;
let mut tx = pool.begin().await.unwrap();
store
.enqueue_envelope(&mut tx, envelope.clone())
.await
.unwrap();
tx.commit().await.unwrap();
let mut tx = pool.begin().await.unwrap();
match store.enqueue_envelope(&mut tx, envelope).await {
Err(reliar_store_postgres::EnqueueError::Duplicate { id }) => assert_eq!(id, envelope_id),
other => panic!("expected EnqueueError::Duplicate, got {other:?}"),
}
}
pub(crate) fn trials(rt: &'static tokio::runtime::Runtime) -> Vec<libtest_mimic::Trial> {
vec![
libtest_mimic::Trial::test(
"outbox_schema_verification::construction_fails_fast_without_search_path",
move || {
rt.block_on(construction_fails_fast_without_search_path());
Ok(())
},
),
libtest_mimic::Trial::test(
"outbox_schema_verification::construction_succeeds_with_search_path_set",
move || {
rt.block_on(construction_succeeds_with_search_path_set());
Ok(())
},
),
libtest_mimic::Trial::test(
"outbox_schema_verification::connect_refuses_a_schema_missing_the_message_id_column",
move || {
rt.block_on(connect_refuses_a_schema_missing_the_message_id_column());
Ok(())
},
),
libtest_mimic::Trial::test(
"outbox_schema_verification::connect_refuses_a_schema_stuck_between_0005_and_0009",
move || {
rt.block_on(connect_refuses_a_schema_stuck_between_0005_and_0009());
Ok(())
},
),
libtest_mimic::Trial::test(
"outbox_schema_verification::enqueue_sets_search_path_restores_the_callers_value",
move || {
rt.block_on(enqueue_sets_search_path_restores_the_callers_value());
Ok(())
},
),
libtest_mimic::Trial::test(
"outbox_schema_verification::duplicate_id_surfaces_correctly_with_enqueue_sets_search_path",
move || {
rt.block_on(duplicate_id_surfaces_correctly_with_enqueue_sets_search_path());
Ok(())
},
),
]
}
pub(crate) fn recorder_trials(rt: &'static tokio::runtime::Runtime) -> Vec<libtest_mimic::Trial> {
vec![libtest_mimic::Trial::test(
"outbox_schema_verification::warns_when_a_same_named_table_exists_elsewhere",
move || {
rt.block_on(warns_when_a_same_named_table_exists_elsewhere());
Ok(())
},
)]
}