static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
use webhooksmith::WebhookEngine;
use serde_json::json;
use sqlx::PgPool;
use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
fn engine(pool: PgPool) -> WebhookEngine {
WebhookEngine::builder()
.pool(pool)
.allow_insecure_urls()
.build_sync()
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn deleting_endpoint_cascade_deletes_its_events(pool: PgPool) {
let engine = engine(pool);
let server = MockServer::start().await;
let endpoint = engine
.register(&format!("{}/hook", server.uri()), "lifecycle_test_secret_32chars")
.await
.unwrap();
let event = engine.send("test.event", json!({}), endpoint.id).await.unwrap();
assert!(engine.event(event.id).await.unwrap().is_some(), "event must exist before delete");
sqlx::query!("DELETE FROM webhook_endpoints WHERE id = $1", endpoint.id)
.execute(engine.pool())
.await
.unwrap();
let found = engine.event(event.id).await.unwrap();
assert!(found.is_none(), "event must be gone after endpoint cascade-delete");
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn run_once_ok_after_endpoint_deleted(pool: PgPool) {
let engine = engine(pool);
let server = MockServer::start().await;
let endpoint = engine
.register(&format!("{}/hook", server.uri()), "lifecycle_test_secret_32chars")
.await
.unwrap();
for i in 0..5 {
engine.send("test.event", json!({"i": i}), endpoint.id).await.unwrap();
}
sqlx::query!("DELETE FROM webhook_endpoints WHERE id = $1", endpoint.id)
.execute(engine.pool())
.await
.unwrap();
let result = engine.run_once().await;
assert!(result.is_ok(), "run_once must not error after endpoint deletion");
assert_eq!(result.unwrap(), 0, "no events remain after cascade-delete");
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn cleanup_graceful_when_event_cascade_deleted(pool: PgPool) {
let server = MockServer::start().await;
let endpoint_id: uuid::Uuid = sqlx::query_scalar!(
"INSERT INTO webhook_endpoints (url, signing_secret) VALUES ($1, 'lifecycle_test_secret_32chars') RETURNING id",
&format!("{}/hook", server.uri()),
)
.fetch_one(&pool)
.await
.unwrap();
let event_id: uuid::Uuid = sqlx::query_scalar!(
"INSERT INTO webhook_events (endpoint_id, event_type, payload) VALUES ($1, 'test', '{}') RETURNING id",
endpoint_id,
)
.fetch_one(&pool)
.await
.unwrap();
sqlx::query!("DELETE FROM webhook_endpoints WHERE id = $1", endpoint_id)
.execute(&pool)
.await
.unwrap();
let count: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM webhook_events WHERE id = $1", event_id)
.fetch_one(&pool)
.await
.unwrap()
.unwrap_or(0);
assert_eq!(count, 0, "event must be cascade-deleted");
let engine = engine(pool);
let result = engine.run_once().await;
assert!(result.is_ok(), "run_once must succeed even after cascade-delete");
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn reaper_handles_post_cascade_gracefully(pool: PgPool) {
let engine = engine(pool);
let server = MockServer::start().await;
let endpoint = engine
.register(&format!("{}/hook", server.uri()), "lifecycle_test_secret_32chars")
.await
.unwrap();
let event = engine.send("test.event", json!({}), endpoint.id).await.unwrap();
sqlx::query!(
"UPDATE webhook_events SET status='delivering', delivering_since = NOW() - INTERVAL '200 seconds' WHERE id=$1",
event.id
)
.execute(engine.pool())
.await
.unwrap();
sqlx::query!("DELETE FROM webhook_endpoints WHERE id=$1", endpoint.id)
.execute(engine.pool())
.await
.unwrap();
let recovered = engine.recover_stuck_deliveries(std::time::Duration::from_secs(120)).await.unwrap();
assert_eq!(recovered, 0, "reaper must find 0 stuck events after cascade-delete");
let result = engine.run_once().await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn deleting_one_endpoint_does_not_affect_other_endpoints(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(1) .mount(&server)
.await;
let engine = engine(pool);
let ep_a = engine
.register(&format!("{}/hook", server.uri()), "lifecycle_test_secret_32chars")
.await
.unwrap();
let ep_b = engine
.register(&format!("{}/hook", server.uri()), "lifecycle_test_secret_32chars")
.await
.unwrap();
engine.send("test.event", json!({"a": 1}), ep_a.id).await.unwrap();
engine.send("test.event", json!({"b": 2}), ep_b.id).await.unwrap();
sqlx::query!("DELETE FROM webhook_endpoints WHERE id=$1", ep_a.id)
.execute(engine.pool())
.await
.unwrap();
let n = engine.run_once().await.unwrap();
assert_eq!(n, 1, "only endpoint B's event must be delivered");
server.verify().await;
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn broadcast_partial_deletion_delivers_remaining(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(2) .mount(&server)
.await;
let engine = engine(pool);
for _ in 0..3 {
engine
.register(&format!("{}/hook", server.uri()), "lifecycle_test_secret_32chars")
.await
.unwrap();
}
let events = engine.broadcast("test.event", json!({})).await.unwrap();
assert_eq!(events.len(), 3);
let first_endpoint_id: uuid::Uuid = sqlx::query_scalar!(
"SELECT endpoint_id FROM webhook_events WHERE id=$1",
events[0].id
)
.fetch_one(engine.pool())
.await
.unwrap();
sqlx::query!("DELETE FROM webhook_endpoints WHERE id=$1", first_endpoint_id)
.execute(engine.pool())
.await
.unwrap();
let n = engine.run_once().await.unwrap();
assert_eq!(n, 2, "only 2 of 3 events must be delivered after one endpoint deleted");
server.verify().await;
let delivered: i64 =
sqlx::query_scalar!("SELECT COUNT(*) FROM webhook_events WHERE status='delivered'")
.fetch_one(engine.pool())
.await
.unwrap()
.unwrap_or(0);
assert_eq!(delivered, 2);
}