mod common;
use common::pg::start_pg;
use mailrs_outbound_queue::queue::{self, QueueStatus};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn claim_for_delivery_then_mark_delivered() {
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(
&pool,
"s@example.com",
"r@dest.com",
"dest.com",
b"raw",
None,
0,
false,
)
.await
.expect("enqueue");
let claimed = queue::claim_for_delivery(&pool, 0, 10)
.await
.expect("claim");
assert_eq!(claimed.len(), 1, "exactly one row claimed");
assert_eq!(claimed[0].id, id);
assert_eq!(
claimed[0].status,
QueueStatus::InFlight,
"claim atomically transitions to inflight"
);
let after_claim = queue::get_message(&pool, id).await.unwrap().unwrap();
assert_eq!(
after_claim.status,
QueueStatus::InFlight,
"row persisted as inflight"
);
queue::mark_delivered(&pool, id, 100)
.await
.expect("mark_delivered");
let after_deliver = queue::get_message(&pool, id).await.unwrap().unwrap();
assert_eq!(after_deliver.status, QueueStatus::Delivered);
assert_eq!(after_deliver.updated_at, 100);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn second_claim_after_inflight_returns_empty() {
let (_c, pool) = start_pg().await;
queue::enqueue_ex(
&pool,
"s@example.com",
"r@dest.com",
"dest.com",
b"raw",
None,
0,
false,
)
.await
.expect("enqueue");
let first = queue::claim_for_delivery(&pool, 0, 10)
.await
.expect("first claim");
assert_eq!(first.len(), 1);
let second = queue::claim_for_delivery(&pool, 0, 10)
.await
.expect("second claim");
assert!(second.is_empty(), "inflight rows are not re-claimed");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn crash_recovery_via_stale_inflight() {
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(
&pool,
"s@example.com",
"r@dest.com",
"dest.com",
b"raw",
None,
0,
false,
)
.await
.expect("enqueue");
let claimed = queue::claim_for_delivery(&pool, 0, 10)
.await
.expect("claim");
assert_eq!(claimed.len(), 1);
let recovered = queue::recover_stale_inflight(&pool, 700)
.await
.expect("recover");
assert_eq!(recovered, 1, "stale inflight is recovered");
let after = queue::get_message(&pool, id).await.unwrap().unwrap();
assert_eq!(after.status, QueueStatus::Pending, "row is back to pending");
assert_eq!(after.updated_at, 700);
let reclaimed = queue::claim_for_delivery(&pool, 700, 10)
.await
.expect("re-claim");
assert_eq!(reclaimed.len(), 1);
assert_eq!(reclaimed[0].id, id);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn recover_stale_inflight_leaves_fresh_rows_alone() {
let (_c, pool) = start_pg().await;
queue::enqueue_ex(
&pool,
"s@example.com",
"r@dest.com",
"dest.com",
b"raw",
None,
500,
false,
)
.await
.expect("enqueue");
let claimed = queue::claim_for_delivery(&pool, 500, 10)
.await
.expect("claim");
assert_eq!(claimed.len(), 1);
let recovered = queue::recover_stale_inflight(&pool, 600)
.await
.expect("recover");
assert_eq!(recovered, 0, "fresh inflight is not touched");
let still_inflight = queue::get_message(&pool, claimed[0].id)
.await
.unwrap()
.unwrap();
assert_eq!(still_inflight.status, QueueStatus::InFlight);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn two_workers_no_double_claim() {
let (_c, pool) = start_pg().await;
let mut expected: Vec<i64> = Vec::with_capacity(10);
for i in 0..10 {
let id = queue::enqueue_ex(
&pool,
"s@example.com",
&format!("r{i}@dest.com"),
"dest.com",
format!("raw {i}").as_bytes(),
None,
0,
false,
)
.await
.expect("enqueue");
expected.push(id);
}
expected.sort();
let pool_a = pool.clone();
let pool_b = pool.clone();
let (a, b) = tokio::join!(
async move {
queue::claim_for_delivery(&pool_a, 0, 100)
.await
.expect("claim a")
},
async move {
queue::claim_for_delivery(&pool_b, 0, 100)
.await
.expect("claim b")
},
);
let mut ids_a: Vec<i64> = a.iter().map(|m| m.id).collect();
let mut ids_b: Vec<i64> = b.iter().map(|m| m.id).collect();
ids_a.sort();
ids_b.sort();
for id in &ids_a {
assert!(!ids_b.contains(id), "id {id} double-claimed");
}
let mut union: Vec<i64> = ids_a.iter().copied().chain(ids_b.iter().copied()).collect();
union.sort();
assert_eq!(
union, expected,
"every pending id ended up in exactly one worker"
);
let still_pending = queue::dequeue(&pool, 0, 100).await.expect("dequeue");
assert!(
still_pending.is_empty(),
"no pending rows remain after both workers claimed"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mark_failed_increments_attempts_and_reschedules() {
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(
&pool,
"s@example.com",
"r@dest.com",
"dest.com",
b"raw",
None,
0,
false,
)
.await
.expect("enqueue");
queue::claim_for_delivery(&pool, 0, 10)
.await
.expect("claim");
queue::mark_failed(&pool, id, "451 try again later", 600, 10)
.await
.expect("mark_failed");
let row = queue::get_message(&pool, id).await.unwrap().unwrap();
assert_eq!(
row.status,
QueueStatus::Pending,
"failed → pending for retry"
);
assert_eq!(row.attempts, 1);
assert_eq!(row.last_error.as_deref(), Some("451 try again later"));
assert_eq!(row.next_retry, 600);
}