//! Postmark webhook tests: bounce handling, spam complaints, token auth, suppression list. use crate::harness::TestHarness; const TOKEN: &str = "test-postmark-token"; const BROADCAST_TOKEN: &str = "test-broadcast-token"; /// Helper: POST a Postmark webhook payload with the given auth token. async fn post_webhook(h: &mut TestHarness, token: Option<&str>, body: &str) -> u16 { let mut headers = vec![("Content-Type", "application/json")]; let auth; if let Some(t) = token { auth = format!("Bearer {}", t); headers.push(("Authorization", &auth)); } let resp = h .client .request_with_headers("POST", "/postmark/webhook", Some(body), &headers) .await; resp.status.as_u16() } // ── Auth ── #[tokio::test] async fn webhook_missing_token_returns_401() { let mut h = TestHarness::with_postmark().await; let body = r#"{"RecordType":"Bounce","Email":"bounce@test.com","Type":"HardBounce"}"#; let status = post_webhook(&mut h, None, body).await; assert_eq!(status, 401); } #[tokio::test] async fn webhook_invalid_token_returns_401() { let mut h = TestHarness::with_postmark().await; let body = r#"{"RecordType":"Bounce","Email":"bounce@test.com","Type":"HardBounce"}"#; let status = post_webhook(&mut h, Some("wrong-token"), body).await; assert_eq!(status, 401); } #[tokio::test] async fn webhook_no_config_token_returns_401() { // Default harness has no postmark_webhook_token set let mut h = TestHarness::new().await; let body = r#"{"RecordType":"Bounce","Email":"bounce@test.com","Type":"HardBounce"}"#; let status = post_webhook(&mut h, Some(TOKEN), body).await; assert_eq!(status, 401); } #[tokio::test] async fn broadcast_token_accepted_by_webhook() { let mut h = TestHarness::with_postmark().await; let body = r#"{"RecordType":"Bounce","Email":"bounce@test.com","Type":"HardBounce"}"#; let status = post_webhook(&mut h, Some(BROADCAST_TOKEN), body).await; assert_eq!(status, 200); } // ── Hard Bounce ── #[tokio::test] async fn hard_bounce_adds_suppression() { let mut h = TestHarness::with_postmark().await; let body = r#"{"RecordType":"Bounce","Email":"hardbounce@example.com","Type":"HardBounce"}"#; let status = post_webhook(&mut h, Some(TOKEN), body).await; assert_eq!(status, 200); // Verify email is suppressed let suppressed: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM email_suppressions WHERE email = 'hardbounce@example.com')", ) .fetch_one(&h.db) .await .unwrap(); assert!(suppressed, "Hard-bounced email should be on suppression list"); } // ── Soft Bounce ── #[tokio::test] async fn soft_bounce_does_not_suppress() { let mut h = TestHarness::with_postmark().await; let body = r#"{"RecordType":"Bounce","Email":"softbounce@example.com","Type":"SoftBounce"}"#; let status = post_webhook(&mut h, Some(TOKEN), body).await; assert_eq!(status, 200); let suppressed: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM email_suppressions WHERE email = 'softbounce@example.com')", ) .fetch_one(&h.db) .await .unwrap(); assert!(!suppressed, "Soft bounce should NOT be suppressed"); } // ── Spam Complaint ── #[tokio::test] async fn spam_complaint_adds_suppression() { let mut h = TestHarness::with_postmark().await; let body = r#"{"RecordType":"SpamComplaint","Email":"spammer@example.com"}"#; let status = post_webhook(&mut h, Some(TOKEN), body).await; assert_eq!(status, 200); let reason: String = sqlx::query_scalar( "SELECT reason FROM email_suppressions WHERE email = 'spammer@example.com'", ) .fetch_one(&h.db) .await .unwrap(); assert_eq!(reason, "SpamComplaint"); } // ── Unhandled Types ── #[tokio::test] async fn unhandled_record_type_returns_200() { let mut h = TestHarness::with_postmark().await; let body = r#"{"RecordType":"Delivery","Email":"delivered@example.com"}"#; let status = post_webhook(&mut h, Some(TOKEN), body).await; assert_eq!(status, 200); // Should not create a suppression entry let suppressed: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM email_suppressions WHERE email = 'delivered@example.com')", ) .fetch_one(&h.db) .await .unwrap(); assert!(!suppressed); } // ── Idempotency ── #[tokio::test] async fn duplicate_suppression_is_idempotent() { let mut h = TestHarness::with_postmark().await; let body = r#"{"RecordType":"Bounce","Email":"dupe@example.com","Type":"HardBounce"}"#; // Send twice let s1 = post_webhook(&mut h, Some(TOKEN), body).await; let s2 = post_webhook(&mut h, Some(TOKEN), body).await; assert_eq!(s1, 200); assert_eq!(s2, 200); // Should still have exactly one entry let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM email_suppressions WHERE email = 'dupe@example.com'", ) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "Duplicate suppression should be idempotent"); }