#![allow(clippy::unwrap_used)]
use std::{
collections::HashMap,
sync::{Arc, Barrier},
};
use fraiseql_observers::{ActionConfig, DeadLetterQueue, DlqItem, EntityEvent, EventKind};
use uuid::Uuid;
use super::InMemoryDlq;
fn test_event() -> EntityEvent {
EntityEvent::new(
EventKind::Created,
"TestEntity".to_string(),
Uuid::new_v4(),
serde_json::json!({}),
)
}
fn test_action() -> ActionConfig {
ActionConfig::Webhook {
url: Some("http://localhost/hook".to_string()),
url_env: None,
headers: HashMap::new(),
body_template: None,
signing_secret: None,
signing_secret_env: None,
}
}
async fn push(dlq: &InMemoryDlq) -> Uuid {
dlq.push(test_event(), test_action(), "boom".to_string()).await.unwrap()
}
#[tokio::test]
async fn unbounded_dlq_grows_without_limit() {
let dlq = InMemoryDlq::new_with_max(None);
for _ in 0..5 {
push(&dlq).await;
}
assert_eq!(dlq.count(), 5);
assert_eq!(dlq.overflow_count(), 0);
}
#[tokio::test]
async fn capped_dlq_drops_newest_at_capacity() {
let dlq = InMemoryDlq::new_with_max(Some(2));
let first = push(&dlq).await;
let second = push(&dlq).await;
push(&dlq).await;
assert_eq!(dlq.count(), 2, "cap should hold the queue at 2 entries");
assert_eq!(dlq.overflow_count(), 1, "the dropped entry should bump the overflow counter");
let ids: Vec<Uuid> = dlq.list_all().into_iter().map(|i| i.id).collect();
assert!(
ids.contains(&first) && ids.contains(&second),
"the first two entries are retained"
);
}
#[tokio::test]
async fn mark_retry_failed_keeps_item_and_records_failure() {
let dlq = InMemoryDlq::new_with_max(None);
let id = push(&dlq).await;
dlq.mark_retry_failed(id, "second failure").await.unwrap();
let item = dlq.get(id).expect("item must still be present after a failed retry");
assert_eq!(item.attempts, 1, "attempts should be incremented");
assert_eq!(item.error_message, "second failure", "error_message should be updated");
assert_eq!(dlq.count(), 1);
}
#[tokio::test]
async fn mark_success_removes_item() {
let dlq = InMemoryDlq::new_with_max(None);
let id = push(&dlq).await;
dlq.mark_success(id).await.unwrap();
assert!(dlq.get(id).is_none(), "a succeeded item should be removed");
assert_eq!(dlq.count(), 0);
}
#[tokio::test]
async fn try_claim_removes_and_is_idempotent() {
let dlq = InMemoryDlq::new_with_max(None);
let id = push(&dlq).await;
assert!(dlq.try_claim(id).is_some(), "first claim returns the item");
assert_eq!(dlq.count(), 0, "claim removes the item");
assert!(dlq.try_claim(id).is_none(), "second claim finds nothing");
}
#[tokio::test]
async fn try_claim_is_atomic_under_concurrency() {
let dlq = Arc::new(InMemoryDlq::new_with_max(None));
let id = push(&dlq).await;
let n = 8;
let barrier = Arc::new(Barrier::new(n));
#[allow(clippy::needless_collect)] let handles: Vec<_> = (0..n)
.map(|_| {
let dlq = Arc::clone(&dlq);
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
dlq.try_claim(id).is_some()
})
})
.collect();
let winners = handles.into_iter().map(|h| h.join().unwrap()).filter(|&won| won).count();
assert_eq!(winners, 1, "exactly one of {n} concurrent claimers should win");
assert_eq!(dlq.count(), 0);
}
#[tokio::test]
async fn reinsert_bypasses_the_cap() {
let dlq = InMemoryDlq::new_with_max(Some(1));
push(&dlq).await;
let claimed = DlqItem {
id: Uuid::new_v4(),
event: test_event(),
action: test_action(),
error_message: "retry failed".to_string(),
attempts: 1,
};
dlq.reinsert(claimed);
assert_eq!(dlq.count(), 2, "reinsert must bypass the cap");
assert_eq!(dlq.overflow_count(), 0, "reinsert is not an overflow");
}
mod listener_selection {
use fraiseql_observers::config::TransportKind;
use super::super::{ListenerSelection, listener_selection};
#[test]
fn postgres_uses_the_change_log_listener() {
assert_eq!(
listener_selection(TransportKind::Postgres),
ListenerSelection::PostgresChangeLog,
);
}
#[test]
fn nats_uses_the_transport_stream_not_the_pg_listener() {
assert_eq!(listener_selection(TransportKind::Nats), ListenerSelection::TransportStream,);
}
#[test]
fn in_memory_uses_the_transport_stream() {
assert_eq!(listener_selection(TransportKind::InMemory), ListenerSelection::TransportStream,);
}
}
mod transport_requires_broker {
use fraiseql_observers::config::TransportKind;
use super::super::{ObserverRuntime, ObserverRuntimeConfig};
fn runtime_with(kind: TransportKind) -> ObserverRuntime {
let pool = sqlx::PgPool::connect_lazy("postgres://u:u@127.0.0.1:1/db")
.expect("lazy pool construction does not connect");
let mut config = ObserverRuntimeConfig::new(pool);
config.transport.transport = kind;
ObserverRuntime::new(config)
}
#[tokio::test]
async fn postgres_start_failure_is_not_boot_fatal() {
assert!(!runtime_with(TransportKind::Postgres).transport_requires_broker());
}
#[tokio::test]
async fn nats_start_failure_is_boot_fatal() {
assert!(runtime_with(TransportKind::Nats).transport_requires_broker());
}
}
mod nats_unrunnable_gate {
use fraiseql_observers::config::TransportKind;
use super::super::{ObserverRuntime, ObserverRuntimeConfig};
#[tokio::test]
async fn nats_that_cannot_run_fails_start_with_no_pg_fallback() {
let pool = sqlx::PgPool::connect_lazy("postgres://u:u@127.0.0.1:1/db")
.expect("lazy pool construction does not connect");
let mut config = ObserverRuntimeConfig::new(pool);
config.transport.transport = TransportKind::Nats;
config.transport.nats.url = "nats://127.0.0.1:4222".to_string();
let mut runtime = ObserverRuntime::new(config);
let result = runtime.start().await;
assert!(
result.is_err(),
"NATS that cannot run must fail start(), not fall back to PostgreSQL"
);
assert!(
!runtime.is_running(),
"runtime must not report running after a failed NATS start"
);
assert!(
runtime.transport_requires_broker(),
"a NATS transport is broker-backed, so the start failure is boot-fatal in production"
);
}
}
mod log_payload_truncation {
use super::super::{MAX_LOG_PAYLOAD_BYTES, truncate_log_payload};
#[test]
fn small_payload_is_passed_through_unchanged() {
let data = serde_json::json!({"id": "abc", "status": "new"});
assert_eq!(truncate_log_payload(&data), data);
}
#[test]
fn oversized_payload_is_replaced_with_a_size_marker() {
let big = "x".repeat(MAX_LOG_PAYLOAD_BYTES + 1_024);
let data = serde_json::json!({ "blob": big });
let out = truncate_log_payload(&data);
assert_eq!(out["_truncated"], serde_json::Value::Bool(true));
let recorded = out["_original_size_bytes"].as_u64().unwrap();
assert!(
recorded > u64::try_from(MAX_LOG_PAYLOAD_BYTES).unwrap(),
"marker must record the original (oversized) byte length"
);
assert!(out.get("blob").is_none());
}
}