static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
use webhooksmith::{EventStatus, WebhookEngine};
use serde_json::json;
use sqlx::PgPool;
use std::time::Duration;
use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
fn engine_with_timeouts(pool: PgPool, http: Duration, stuck: Duration) -> WebhookEngine {
WebhookEngine::builder()
.pool(pool)
.allow_insecure_urls()
.http_timeout(http)
.stuck_timeout(stuck)
.build_sync()
}
async fn insert_endpoint(engine: &WebhookEngine, url: &str) -> uuid::Uuid {
sqlx::query_scalar!(
"INSERT INTO webhook_endpoints (url, signing_secret) VALUES ($1, 'timeout_test_secret_32chars') RETURNING id",
url,
)
.fetch_one(engine.pool())
.await
.unwrap()
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn short_http_timeout_records_failure(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_millis(500)))
.mount(&server)
.await;
let engine = engine_with_timeouts(pool, Duration::from_millis(100), Duration::from_secs(120));
let endpoint_id = insert_endpoint(&engine, &format!("{}/hook", server.uri())).await;
let event = engine.send("test.event", json!({}), endpoint_id).await.unwrap();
engine.run_once().await.unwrap();
let updated = engine.event(event.id).await.unwrap().unwrap();
assert_eq!(
updated.status,
EventStatus::Failed,
"delivery must fail when HTTP timeout fires"
);
let log = engine.delivery_log(event.id).await.unwrap();
assert_eq!(log.len(), 1);
assert!(!log[0].success, "attempt must be marked unsuccessful");
assert!(log[0].error.is_some(), "timeout error must be recorded");
assert!(
log[0].duration_ms.unwrap_or(0) < 400,
"delivery must have stopped before 400ms (timeout fired at 100ms)"
);
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn generous_http_timeout_allows_slow_endpoint(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_millis(200)))
.mount(&server)
.await;
let engine = engine_with_timeouts(pool, Duration::from_secs(1), Duration::from_secs(120));
let endpoint_id = insert_endpoint(&engine, &format!("{}/hook", server.uri())).await;
let event = engine.send("test.event", json!({}), endpoint_id).await.unwrap();
engine.run_once().await.unwrap();
let updated = engine.event(event.id).await.unwrap().unwrap();
assert_eq!(updated.status, EventStatus::Delivered, "slow-but-not-timed-out endpoint must succeed");
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn short_stuck_timeout_fires_reaper(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let engine = engine_with_timeouts(pool, Duration::from_secs(1), Duration::from_secs(5));
let endpoint_id = insert_endpoint(&engine, &format!("{}/hook", server.uri())).await;
let event = engine.send("test.event", json!({}), endpoint_id).await.unwrap();
sqlx::query!(
"UPDATE webhook_events SET status='delivering', delivering_since=NOW()-INTERVAL '6 seconds' WHERE id=$1",
event.id
)
.execute(engine.pool())
.await
.unwrap();
engine.run_once().await.unwrap();
let updated = engine.event(event.id).await.unwrap().unwrap();
assert_eq!(
updated.status,
EventStatus::Delivered,
"reaper must have rescued the stuck event and it must be delivered"
);
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn long_stuck_timeout_does_not_rescue_recently_stuck_event(pool: PgPool) {
let engine = engine_with_timeouts(pool, Duration::from_secs(30), Duration::from_secs(60));
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(0) .mount(&server)
.await;
let endpoint_id = insert_endpoint(&engine, &format!("{}/hook", server.uri())).await;
let event = engine.send("test.event", json!({}), endpoint_id).await.unwrap();
sqlx::query!(
"UPDATE webhook_events SET status='delivering', delivering_since=NOW()-INTERVAL '10 seconds' WHERE id=$1",
event.id
)
.execute(engine.pool())
.await
.unwrap();
let n = engine.run_once().await.unwrap();
assert_eq!(n, 0, "reaper must not fire when event has been stuck for less than stuck_timeout");
let updated = engine.event(event.id).await.unwrap().unwrap();
assert_eq!(updated.status, EventStatus::Delivering, "event must still be in delivering");
server.verify().await;
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn default_timeouts_have_correct_relationship(pool: PgPool) {
let engine = WebhookEngine::builder()
.pool(pool)
.build_sync();
assert!(
webhooksmith::worker::DEFAULT_HTTP_TIMEOUT < webhooksmith::worker::DEFAULT_STUCK_TIMEOUT,
"HTTP timeout must be less than stuck timeout to avoid premature reaper resets"
);
let _ = engine; }
#[test]
#[should_panic(expected = "http_timeout")]
fn http_timeout_equal_to_stuck_timeout_panics() {
let t = Duration::from_secs(30);
let _ = WebhookEngine::builder().http_timeout(t).stuck_timeout(t).build_sync();
}
#[test]
#[should_panic(expected = "http_timeout")]
fn http_timeout_greater_than_stuck_timeout_panics() {
let _ = WebhookEngine::builder()
.http_timeout(Duration::from_secs(60))
.stuck_timeout(Duration::from_secs(30))
.build_sync();
}