static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
use actix_web::{App, HttpResponse, HttpServer, Responder, web};
use serde_json::{json, Value};
use sqlx::PgPool;
use std::sync::{Arc, Mutex};
use webhooksmith::{EventStatus, NewEndpoint, WebhookEngine};
use webhooksmith_actix::{VerifiedWebhook, WebhookSecret};
#[derive(Clone, Default)]
struct Inbox {
received: Arc<Mutex<Vec<Value>>>,
}
impl Inbox {
fn push(&self, v: Value) {
self.received.lock().unwrap().push(v);
}
fn drain(&self) -> Vec<Value> {
self.received.lock().unwrap().drain(..).collect()
}
}
async fn receiver(
wh: VerifiedWebhook,
inbox: web::Data<Inbox>,
) -> impl Responder {
let body: Value = serde_json::from_slice(&wh.body).unwrap_or_default();
inbox.push(json!({
"event_type": wh.event_type,
"event_id": wh.event_id,
"timestamp": wh.timestamp,
"payload": body,
}));
HttpResponse::Ok().finish()
}
fn engine(pool: PgPool) -> WebhookEngine {
WebhookEngine::builder()
.pool(pool)
.allow_insecure_urls()
.build_sync()
}
async fn start_receiver(secret: &str) -> (String, Inbox, actix_web::dev::ServerHandle) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let inbox = Inbox::default();
let inbox_data = web::Data::new(inbox.clone());
let secret = secret.to_owned();
let server = HttpServer::new(move || {
App::new()
.app_data(WebhookSecret::new(&secret))
.app_data(inbox_data.clone())
.route("/hook", web::post().to(receiver))
})
.bind(format!("127.0.0.1:{port}"))
.expect("bind")
.disable_signals()
.run();
let handle = server.handle();
tokio::spawn(server);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
(format!("http://127.0.0.1:{port}"), inbox, handle)
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn real_send_and_receive(pool: PgPool) {
const SECRET: &str = "real_e2e_signing_secret_32chars_";
let (url, inbox, server) = start_receiver(SECRET).await;
let e = engine(pool);
let ep = e.register_with(NewEndpoint {
url: format!("{url}/hook"),
signing_secret: SECRET.into(),
..Default::default()
}).await.unwrap();
let ev = e.send("order.created", json!({"id": 42, "amount": 100}), ep.id)
.await.unwrap();
assert_eq!(ev.status, EventStatus::Pending);
let n = e.run_once().await.unwrap();
assert_eq!(n, 1);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let after = e.event(ev.id).await.unwrap().unwrap();
assert_eq!(after.status, EventStatus::Delivered,
"event must be delivered — real receiver returned 200");
let received = inbox.drain();
assert_eq!(received.len(), 1, "receiver must have gotten exactly 1 webhook");
assert_eq!(received[0]["event_type"], "order.created");
assert_eq!(received[0]["payload"]["id"], 42);
assert_eq!(received[0]["payload"]["amount"], 100);
assert!(!received[0]["event_id"].as_str().unwrap_or("").is_empty(),
"event_id header must be set");
server.stop(false).await;
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn real_wrong_secret_causes_failure(pool: PgPool) {
let (url, _inbox, server) = start_receiver("correct_secret_32chars_minimum__").await;
let e = engine(pool);
let ep = e.register_with(NewEndpoint {
url: format!("{url}/hook"),
signing_secret: "wrong_secret_32chars_minimum____".into(), initial_delay_ms: Some(60_000), ..Default::default()
}).await.unwrap();
let ev = e.send("order.created", json!({}), ep.id).await.unwrap();
e.run_once().await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let after = e.event(ev.id).await.unwrap().unwrap();
assert_eq!(after.status, EventStatus::Failed,
"event must be failed — receiver returned 401 (wrong secret)");
let log = e.delivery_log(ev.id).await.unwrap();
assert_eq!(log.len(), 1);
assert!(!log[0].success);
assert_eq!(log[0].response_status, Some(401));
server.stop(false).await;
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn real_broadcast_to_multiple_receivers(pool: PgPool) {
const SECRET: &str = "broadcast_e2e_secret_32chars____";
let (url1, inbox1, server1) = start_receiver(SECRET).await;
let (url2, inbox2, server2) = start_receiver(SECRET).await;
let e = engine(pool);
e.register_with(NewEndpoint {
url: format!("{url1}/hook"),
signing_secret: SECRET.into(),
..Default::default()
}).await.unwrap();
e.register_with(NewEndpoint {
url: format!("{url2}/hook"),
signing_secret: SECRET.into(),
..Default::default()
}).await.unwrap();
let events = e.broadcast("payment.captured", json!({"amount": 500})).await.unwrap();
assert_eq!(events.len(), 2);
e.run_once().await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
let r1 = inbox1.drain();
let r2 = inbox2.drain();
assert_eq!(r1.len(), 1, "receiver 1 must get exactly 1 webhook");
assert_eq!(r2.len(), 1, "receiver 2 must get exactly 1 webhook");
assert_eq!(r1[0]["event_type"], "payment.captured");
assert_eq!(r2[0]["event_type"], "payment.captured");
assert_eq!(r1[0]["payload"]["amount"], 500);
server1.stop(false).await;
server2.stop(false).await;
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn real_rollback_means_no_delivery(pool: PgPool) {
const SECRET: &str = "outbox_e2e_secret_32chars_______";
let (url, inbox, server) = start_receiver(SECRET).await;
let e = engine(pool.clone());
let ep = e.register_with(NewEndpoint {
url: format!("{url}/hook"),
signing_secret: SECRET.into(),
..Default::default()
}).await.unwrap();
let mut tx = pool.begin().await.unwrap();
e.send_in_tx("order.created", json!({"id": 99}), ep.id, &mut tx).await.unwrap();
tx.rollback().await.unwrap();
let n = e.run_once().await.unwrap();
assert_eq!(n, 0, "worker must not claim rolled-back event");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let received = inbox.drain();
assert!(received.is_empty(), "real receiver must get nothing after tx rollback");
server.stop(false).await;
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn real_idempotent_send_delivers_once(pool: PgPool) {
const SECRET: &str = "idempotent_e2e_32chars__________";
let (url, inbox, server) = start_receiver(SECRET).await;
let e = engine(pool);
let ep = e.register_with(NewEndpoint {
url: format!("{url}/hook"),
signing_secret: SECRET.into(),
..Default::default()
}).await.unwrap();
e.send_idempotent("order.created", json!({"id": 1}), ep.id, "order-idem-001").await.unwrap();
e.send_idempotent("order.created", json!({"id": 1}), ep.id, "order-idem-001").await.unwrap();
let n = e.run_once().await.unwrap();
assert_eq!(n, 1, "only one event must exist despite two idempotent sends");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let received = inbox.drain();
assert_eq!(received.len(), 1, "real receiver must get exactly 1 webhook");
server.stop(false).await;
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn sqlite_real_send_and_receive() {
const SECRET: &str = "sqlite_e2e_secret_32chars_______";
let (url, inbox, server) = start_receiver(SECRET).await;
let e = webhooksmith::SqliteEngine::builder()
.database_url("sqlite::memory:")
.allow_insecure_urls()
.build()
.await
.unwrap();
e.migrate().await.unwrap();
let ep = e.register_with(webhooksmith::NewEndpoint {
url: format!("{url}/hook"),
signing_secret: SECRET.into(),
..Default::default()
}).await.unwrap();
let ev = e.send("order.created", json!({"sqlite": true}), ep.id).await.unwrap();
let n = e.run_once().await.unwrap();
assert_eq!(n, 1);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let after = e.event(ev.id).await.unwrap().unwrap();
assert_eq!(after.status, webhooksmith::EventStatus::Delivered);
let received = inbox.drain();
assert_eq!(received.len(), 1);
assert_eq!(received[0]["payload"]["sqlite"], true);
server.stop(false).await;
}