#![cfg(all(test, feature = "backend-postgres"))]
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use sqlx::PgPool;
use tokio::sync::OnceCell;
use super::*;
use crate::events::{EngineEventBus, EventFilter, NewEvent, PruneOpts, Subsystem};
static NS_COUNTER: AtomicU64 = AtomicU64::new(0);
static SCHEMA_READY: OnceCell<()> = OnceCell::const_new();
fn test_db_url() -> Option<String> {
std::env::var("TEST_DATABASE_URL")
.or_else(|_| std::env::var("ASSAY_PG_TEST_URL"))
.ok()
}
fn unique_ns(prefix: &str) -> String {
let n = NS_COUNTER.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
format!("{prefix}_{pid}_{n}")
}
async fn prepare_pool() -> PgPool {
let url = test_db_url().expect("TEST_DATABASE_URL not set");
let pool = PgPool::connect(&url).await.unwrap();
SCHEMA_READY
.get_or_init(|| async {
if let Err(e) = sqlx::query("CREATE SCHEMA IF NOT EXISTS engine")
.execute(&pool)
.await
{
let is_dup = e
.as_database_error()
.map(|d| d.code().as_deref() == Some("23505"))
.unwrap_or(false);
if !is_dup {
panic!("create schema engine: {e}");
}
}
sqlx::query(
"CREATE TABLE IF NOT EXISTS engine.events (
id BIGSERIAL PRIMARY KEY,
ts DOUBLE PRECISION NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()),
namespace TEXT NOT NULL,
subsystem TEXT NOT NULL,
kind TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_engine_events_ns_id ON engine.events(namespace, id)",
)
.execute(&pool)
.await
.unwrap();
})
.await;
pool
}
#[tokio::test(flavor = "multi_thread")]
async fn append_then_read_round_trip() {
let Some(url) = test_db_url() else {
eprintln!("skipped: TEST_DATABASE_URL not set");
return;
};
let ns = unique_ns("append");
let pool = prepare_pool().await;
let bus = PgEngineEventBus::new(pool.clone(), &url).await.unwrap();
let id = bus
.publish_committed(NewEvent {
namespace: &ns,
subsystem: Subsystem::Workflow,
kind: "workflow_created",
payload: serde_json::json!({ "workflow_id": "wf-1" }),
})
.await
.unwrap();
let evs = bus
.read_since(&ns, None, &EventFilter::default(), 10)
.await
.unwrap();
assert_eq!(evs.len(), 1);
assert_eq!(evs[0].id, id);
assert_eq!(evs[0].namespace, ns);
assert_eq!(evs[0].kind, "workflow_created");
assert_eq!(evs[0].payload, serde_json::json!({ "workflow_id": "wf-1" }));
}
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn cursor_replay_skips_earlier() {
let Some(url) = test_db_url() else {
eprintln!("skipped: TEST_DATABASE_URL not set");
return;
};
let ns = unique_ns("cursor");
let pool = prepare_pool().await;
let bus = PgEngineEventBus::new(pool.clone(), &url).await.unwrap();
let id1 = bus
.publish_committed(NewEvent {
namespace: &ns,
subsystem: Subsystem::Workflow,
kind: "a",
payload: serde_json::json!({}),
})
.await
.unwrap();
let id2 = bus
.publish_committed(NewEvent {
namespace: &ns,
subsystem: Subsystem::Workflow,
kind: "b",
payload: serde_json::json!({}),
})
.await
.unwrap();
let evs = bus
.read_since(&ns, Some(id1), &EventFilter::default(), 10)
.await
.unwrap();
assert_eq!(evs.len(), 1);
assert_eq!(evs[0].id, id2);
}
#[tokio::test(flavor = "multi_thread")]
async fn subscribe_receives_local_publish() {
let Some(url) = test_db_url() else {
eprintln!("skipped: TEST_DATABASE_URL not set");
return;
};
let ns = unique_ns("sublocal");
let pool = prepare_pool().await;
let bus = PgEngineEventBus::new(pool.clone(), &url).await.unwrap();
let mut rx = bus.subscribe(&ns);
bus.publish_committed(NewEvent {
namespace: &ns,
subsystem: Subsystem::Workflow,
kind: "workflow_created",
payload: serde_json::json!({}),
})
.await
.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
assert!(!remaining.is_zero(), "timed out waiting for own publish");
let ev = tokio::time::timeout(remaining, rx.recv())
.await
.expect("recv timed out")
.unwrap();
if ev.namespace == ns {
assert_eq!(ev.kind, "workflow_created");
break;
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn subscribe_receives_cross_node_notify() {
let Some(url) = test_db_url() else {
eprintln!("skipped: TEST_DATABASE_URL not set");
return;
};
let ns = unique_ns("cross");
let pool = prepare_pool().await;
let bus_a = PgEngineEventBus::new(pool.clone(), &url).await.unwrap();
let bus_b = PgEngineEventBus::new(pool.clone(), &url).await.unwrap();
let mut rx_b = bus_b.subscribe(&ns);
tokio::time::sleep(Duration::from_millis(500)).await;
bus_a
.publish_committed(NewEvent {
namespace: &ns,
subsystem: Subsystem::Workflow,
kind: "x",
payload: serde_json::json!({}),
})
.await
.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
assert!(!remaining.is_zero(), "cross-node NOTIFY not received within 10s");
let ev = tokio::time::timeout(remaining, rx_b.recv())
.await
.expect("recv timed out")
.unwrap();
if ev.namespace == ns {
assert_eq!(ev.kind, "x");
break;
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn prune_removes_older_than_cutoff() {
let Some(url) = test_db_url() else {
eprintln!("skipped: TEST_DATABASE_URL not set");
return;
};
let ns = unique_ns("prune");
let pool = prepare_pool().await;
let bus = PgEngineEventBus::new(pool.clone(), &url).await.unwrap();
bus.publish_committed(NewEvent {
namespace: &ns,
subsystem: Subsystem::Workflow,
kind: "x",
payload: serde_json::json!({}),
})
.await
.unwrap();
let n = bus
.prune_with(PruneOpts::new(f64::MAX).namespace(&ns))
.await
.unwrap();
assert_eq!(n, 1, "prune_with should have removed exactly our row");
let rest = bus
.read_since(&ns, None, &EventFilter::default(), 10)
.await
.unwrap();
assert!(rest.is_empty());
}
#[tokio::test(flavor = "multi_thread")]
async fn listener_still_works_across_bus_drop() {
let Some(url) = test_db_url() else {
eprintln!("skipped: TEST_DATABASE_URL not set");
return;
};
let ns = unique_ns("reconnect");
let pool = prepare_pool().await;
{
let bus1 = PgEngineEventBus::new(pool.clone(), &url).await.unwrap();
drop(bus1);
}
let bus2 = PgEngineEventBus::new(pool.clone(), &url).await.unwrap();
let mut rx = bus2.subscribe(&ns);
tokio::time::sleep(Duration::from_millis(500)).await;
bus2.publish_committed(NewEvent {
namespace: &ns,
subsystem: Subsystem::Workflow,
kind: "z",
payload: serde_json::json!({}),
})
.await
.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
assert!(!remaining.is_zero(), "reconnect smoke: event not received");
let ev = tokio::time::timeout(remaining, rx.recv())
.await
.expect("recv timed out")
.unwrap();
if ev.namespace == ns {
assert_eq!(ev.kind, "z");
break;
}
}
}
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn cursor_before_oldest_returns_gone() {
let Some(url) = test_db_url() else {
eprintln!("skipped: TEST_DATABASE_URL not set");
return;
};
let ns = unique_ns("gone");
let pool = prepare_pool().await;
let bus = PgEngineEventBus::new(pool.clone(), &url).await.unwrap();
bus.publish_committed(NewEvent {
namespace: &ns,
subsystem: Subsystem::Workflow,
kind: "x",
payload: serde_json::json!({}),
})
.await
.unwrap();
sqlx::query("DELETE FROM engine.events WHERE namespace = $1")
.bind(&ns)
.execute(&pool)
.await
.unwrap();
let new_id = bus
.publish_committed(NewEvent {
namespace: &ns,
subsystem: Subsystem::Workflow,
kind: "y",
payload: serde_json::json!({}),
})
.await
.unwrap();
let err = bus
.read_since(&ns, Some(new_id - 100), &EventFilter::default(), 10)
.await
.unwrap_err();
assert!(err.after < err.oldest);
}