use crate::arn::TargetID;
use crate::plugin::PluginEvent;
use crate::store::{FailedEventStore, Key, Store};
use crate::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliverySnapshot};
use crate::{StoreError, Target, TargetError};
use async_trait::async_trait;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::{Notify, Semaphore};
pub type SharedQueuedStore = Arc<dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync>;
pub type ErrorFactory = Arc<dyn Fn() -> TargetError + Send + Sync>;
struct HealthDropGuard(Arc<AtomicUsize>);
impl Drop for HealthDropGuard {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
fn consume_failure_budget(budget: &AtomicUsize) -> bool {
budget
.try_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| remaining.checked_sub(1))
.is_ok()
}
#[derive(Clone)]
pub struct MockTarget {
id: TargetID,
enabled: bool,
active: Option<bool>,
health_delay: Duration,
health_started: Arc<Notify>,
health_gate: Option<Arc<Notify>>,
health_drops: Arc<AtomicUsize>,
enabled_calls: Arc<AtomicUsize>,
init_calls: Arc<AtomicUsize>,
init_failures_remaining: Arc<AtomicUsize>,
blocking_init: Option<Arc<Notify>>,
close_calls: Arc<AtomicUsize>,
close_started: Arc<Notify>,
block_on_close: Arc<AtomicBool>,
close_gate: Arc<Semaphore>,
close_failures_remaining: Arc<AtomicUsize>,
close_failure_error: Option<ErrorFactory>,
save_calls: Arc<AtomicUsize>,
save_failures_remaining: Arc<AtomicUsize>,
first_save_gate: Option<(Arc<Notify>, Arc<Notify>)>,
final_failures: Arc<AtomicU64>,
delivery_snapshot: Option<TargetDeliverySnapshot>,
store: Option<SharedQueuedStore>,
failed_store: Option<Arc<dyn FailedEventStore>>,
}
impl MockTarget {
pub fn new(id: &str, name: &str) -> Self {
Self {
id: TargetID::new(id.to_string(), name.to_string()),
enabled: true,
active: None,
health_delay: Duration::ZERO,
health_started: Arc::new(Notify::new()),
health_gate: None,
health_drops: Arc::new(AtomicUsize::new(0)),
enabled_calls: Arc::new(AtomicUsize::new(0)),
init_calls: Arc::new(AtomicUsize::new(0)),
init_failures_remaining: Arc::new(AtomicUsize::new(0)),
blocking_init: None,
close_calls: Arc::new(AtomicUsize::new(0)),
close_started: Arc::new(Notify::new()),
block_on_close: Arc::new(AtomicBool::new(false)),
close_gate: Arc::new(Semaphore::new(0)),
close_failures_remaining: Arc::new(AtomicUsize::new(0)),
close_failure_error: None,
save_calls: Arc::new(AtomicUsize::new(0)),
save_failures_remaining: Arc::new(AtomicUsize::new(0)),
first_save_gate: None,
final_failures: Arc::new(AtomicU64::new(0)),
delivery_snapshot: None,
store: None,
failed_store: None,
}
}
pub fn with_id(mut self, id: &str, name: &str) -> Self {
self.id = TargetID::new(id.to_string(), name.to_string());
self
}
pub fn disabled(mut self) -> Self {
self.enabled = false;
self
}
pub fn with_active(mut self, active: bool) -> Self {
self.active = Some(active);
self
}
pub fn with_health_delay(mut self, delay: Duration) -> Self {
self.health_delay = delay;
self
}
pub fn with_health_gate(mut self, release: Arc<Notify>) -> Self {
self.health_gate = Some(release);
self
}
pub fn with_init_failures(mut self, failures: usize) -> Self {
self.init_failures_remaining = Arc::new(AtomicUsize::new(failures));
self
}
pub fn with_blocking_init(mut self, entered: Arc<Notify>) -> Self {
self.blocking_init = Some(entered);
self
}
pub fn with_save_failures(mut self, failures: usize) -> Self {
self.save_failures_remaining = Arc::new(AtomicUsize::new(failures));
self
}
pub fn with_first_save_gate(mut self, entered: Arc<Notify>, release: Arc<Notify>) -> Self {
self.first_save_gate = Some((entered, release));
self
}
pub fn with_close_failures(mut self, failures: usize) -> Self {
self.close_failures_remaining = Arc::new(AtomicUsize::new(failures));
self
}
pub fn with_close_failure_error(mut self, factory: impl Fn() -> TargetError + Send + Sync + 'static) -> Self {
self.close_failure_error = Some(Arc::new(factory));
self
}
pub fn with_delivery_snapshot(mut self, snapshot: TargetDeliverySnapshot) -> Self {
self.delivery_snapshot = Some(snapshot);
self
}
pub fn with_store(mut self, store: SharedQueuedStore) -> Self {
self.store = Some(store);
self
}
pub fn with_failed_store(mut self, failed_store: Arc<dyn FailedEventStore>) -> Self {
self.failed_store = Some(failed_store);
self
}
pub fn target_id(&self) -> TargetID {
self.id.clone()
}
pub fn set_block_on_close(&self, block: bool) {
self.block_on_close.store(block, Ordering::SeqCst);
}
pub fn close_gate(&self) -> Arc<Semaphore> {
Arc::clone(&self.close_gate)
}
pub fn close_started(&self) -> Arc<Notify> {
Arc::clone(&self.close_started)
}
pub fn health_started(&self) -> Arc<Notify> {
Arc::clone(&self.health_started)
}
pub fn init_call_count(&self) -> usize {
self.init_calls.load(Ordering::SeqCst)
}
pub fn close_call_count(&self) -> usize {
self.close_calls.load(Ordering::SeqCst)
}
pub fn save_call_count(&self) -> usize {
self.save_calls.load(Ordering::SeqCst)
}
pub fn enabled_call_count(&self) -> usize {
self.enabled_calls.load(Ordering::SeqCst)
}
pub fn health_drop_count(&self) -> usize {
self.health_drops.load(Ordering::SeqCst)
}
pub fn final_failure_count(&self) -> u64 {
self.final_failures.load(Ordering::Relaxed)
}
}
#[async_trait]
impl<E> Target<E> for MockTarget
where
E: PluginEvent,
{
fn id(&self) -> TargetID {
self.id.clone()
}
async fn is_active(&self) -> Result<bool, TargetError> {
self.health_started.notify_one();
let _drop_guard = HealthDropGuard(Arc::clone(&self.health_drops));
if let Some(release) = &self.health_gate {
release.notified().await;
}
tokio::time::sleep(self.health_delay).await;
Ok(self.active.unwrap_or(self.enabled))
}
async fn save(&self, _event: Arc<EntityTarget<E>>) -> Result<(), TargetError> {
let call = self.save_calls.fetch_add(1, Ordering::SeqCst);
if call == 0
&& let Some((entered, release)) = &self.first_save_gate
{
entered.notify_one();
release.notified().await;
}
if consume_failure_budget(&self.save_failures_remaining) {
return Err(TargetError::Request("forced save failure".to_string()));
}
Ok(())
}
async fn send_raw_from_store(&self, _key: Key, _body: Vec<u8>, _meta: QueuedPayloadMeta) -> Result<(), TargetError> {
Ok(())
}
async fn close(&self) -> Result<(), TargetError> {
self.close_calls.fetch_add(1, Ordering::SeqCst);
self.close_started.notify_one();
if self.block_on_close.load(Ordering::SeqCst) {
let _permit = self.close_gate.acquire().await.expect("close gate should remain open");
}
if consume_failure_budget(&self.close_failures_remaining) {
return Err(match &self.close_failure_error {
Some(factory) => factory(),
None => TargetError::Storage("forced close failure".to_string()),
});
}
Ok(())
}
fn store(&self) -> Option<&(dyn Store<QueuedPayload, Error = StoreError, Key = Key> + Send + Sync)> {
self.store.as_deref()
}
fn failed_store(&self) -> Option<&dyn FailedEventStore> {
self.failed_store.as_deref()
}
fn clone_dyn(&self) -> Box<dyn Target<E> + Send + Sync> {
Box::new(self.clone())
}
async fn init(&self) -> Result<(), TargetError> {
self.init_calls.fetch_add(1, Ordering::SeqCst);
if let Some(entered) = &self.blocking_init {
entered.notify_one();
return std::future::pending().await;
}
if consume_failure_budget(&self.init_failures_remaining) {
return Err(TargetError::Initialization("forced init failure".to_string()));
}
Ok(())
}
fn is_enabled(&self) -> bool {
self.enabled_calls.fetch_add(1, Ordering::SeqCst);
self.enabled
}
fn delivery_snapshot(&self) -> TargetDeliverySnapshot {
match &self.delivery_snapshot {
Some(snapshot) => snapshot.clone(),
None => TargetDeliverySnapshot {
failed_store_length: self
.failed_store
.as_deref()
.map_or(0, |failed_store| failed_store.failed_len() as u64),
queue_length: self.store.as_deref().map_or(0, |store| store.len() as u64),
..TargetDeliverySnapshot::default()
},
}
}
fn record_final_failure(&self) {
self.final_failures.fetch_add(1, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::MockTarget;
use crate::Target;
use crate::target::EntityTarget;
use rustfs_s3_types::EventName;
use std::sync::Arc;
fn sample_event() -> Arc<EntityTarget<String>> {
Arc::new(EntityTarget {
object_name: "obj.txt".to_string(),
bucket_name: "bucket-a".to_string(),
event_name: EventName::ObjectCreatedPut,
data: "payload".to_string(),
})
}
#[tokio::test]
async fn defaults_are_inert() {
let target = MockTarget::new("primary", "webhook");
let handle: &dyn Target<String> = ⌖
assert_eq!(handle.id().to_string(), "primary:webhook");
assert!(handle.is_enabled());
assert!(handle.is_active().await.expect("the default probe succeeds"));
handle.init().await.expect("the default init succeeds");
handle.save(sample_event()).await.expect("the default save succeeds");
handle.close().await.expect("the default close succeeds");
assert!(handle.store().is_none());
assert!(handle.failed_store().is_none());
assert_eq!(target.init_call_count(), 1);
assert_eq!(target.save_call_count(), 1);
assert_eq!(target.close_call_count(), 1);
assert_eq!(target.final_failure_count(), 0);
}
#[tokio::test]
async fn clones_and_clone_dyn_share_the_same_counters() {
let target = MockTarget::new("primary", "webhook");
let observer = target.clone();
let boxed: Box<dyn Target<String> + Send + Sync> = Box::new(target);
let second = boxed.clone_dyn();
boxed.close().await.expect("close succeeds");
second.close().await.expect("close succeeds");
second.record_final_failure();
assert_eq!(observer.close_call_count(), 2);
assert_eq!(observer.final_failure_count(), 1);
}
#[tokio::test]
async fn init_failure_budget_fails_first_then_succeeds() {
let target = MockTarget::new("primary", "webhook").with_init_failures(2);
let handle: &dyn Target<String> = ⌖
assert!(handle.init().await.is_err());
assert!(handle.init().await.is_err());
handle.init().await.expect("the failure budget is spent, so init succeeds");
assert_eq!(target.init_call_count(), 3);
}
#[tokio::test]
async fn save_failure_budget_fails_first_then_succeeds() {
let target = MockTarget::new("primary", "webhook").with_save_failures(1);
let handle: &dyn Target<String> = ⌖
assert!(handle.save(sample_event()).await.is_err());
handle
.save(sample_event())
.await
.expect("the failure budget is spent, so save succeeds");
assert_eq!(target.save_call_count(), 2);
}
#[tokio::test]
async fn active_override_decouples_the_probe_from_enablement() {
let target = MockTarget::new("primary", "webhook").with_active(false);
let handle: &dyn Target<String> = ⌖
assert!(handle.is_enabled());
assert!(!handle.is_active().await.expect("the probe itself succeeds"));
assert_eq!(target.enabled_call_count(), 1, "every is_enabled call is counted");
}
#[tokio::test]
async fn with_id_renames_but_keeps_the_shared_state() {
let template = MockTarget::new("template", "webhook");
let renamed = template.clone().with_id("instance", "webhook");
assert_eq!(renamed.target_id().to_string(), "instance:webhook");
let handle: &dyn Target<String> = &renamed;
handle.close().await.expect("close succeeds");
assert_eq!(template.close_call_count(), 1, "a renamed clone still feeds the template's counters");
}
#[tokio::test]
async fn first_save_gate_blocks_only_the_first_save() {
let entered = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let target = MockTarget::new("primary", "webhook").with_first_save_gate(entered.clone(), release.clone());
let observer = target.clone();
let gated: Arc<dyn Target<String> + Send + Sync> = Arc::new(target);
let first = tokio::spawn({
let gated = Arc::clone(&gated);
async move { gated.save(sample_event()).await }
});
entered.notified().await;
assert_eq!(observer.save_call_count(), 1, "the gated save is counted before it parks");
gated
.save(sample_event())
.await
.expect("a later save passes straight through");
release.notify_one();
first
.await
.expect("the gated save task should join")
.expect("the gated save succeeds after release");
assert_eq!(observer.save_call_count(), 2);
}
#[tokio::test]
async fn health_gate_holds_the_probe_until_released() {
let release = Arc::new(tokio::sync::Notify::new());
let target = MockTarget::new("primary", "webhook").with_health_gate(release.clone());
let started = target.health_started();
let probing: Arc<dyn Target<String> + Send + Sync> = Arc::new(target);
let probe = tokio::spawn(async move { probing.is_active().await });
started.notified().await;
assert!(!probe.is_finished(), "the probe must stay in flight until released");
release.notify_one();
assert!(
probe
.await
.expect("the probe task should join")
.expect("the released probe succeeds"),
"the released probe reports the configured reachability"
);
}
#[tokio::test]
async fn close_failure_budget_uses_the_configured_error_shape() {
let target = MockTarget::new("primary", "webhook").with_close_failures(1);
let handle: &dyn Target<String> = ⌖
assert!(
matches!(handle.close().await, Err(crate::TargetError::Storage(_))),
"the default close failure is storage-flavored"
);
handle.close().await.expect("the failure budget is spent, so close succeeds");
assert_eq!(target.close_call_count(), 2);
let pinned = MockTarget::new("primary", "webhook")
.with_close_failures(usize::MAX)
.with_close_failure_error(|| crate::TargetError::Unknown("close failed".to_string()));
let pinned_handle: &dyn Target<String> = &pinned;
assert!(matches!(pinned_handle.close().await, Err(crate::TargetError::Unknown(_))));
}
#[tokio::test]
async fn delivery_snapshot_override_replaces_the_derived_snapshot() {
use crate::target::TargetDeliverySnapshot;
let plain = MockTarget::new("primary", "webhook");
let plain_handle: &dyn Target<String> = &plain;
assert_eq!(plain_handle.delivery_snapshot(), TargetDeliverySnapshot::default());
let fixed = TargetDeliverySnapshot {
failed_messages: 1,
failed_store_length: 7,
queue_length: 0,
total_messages: 3,
};
let target = MockTarget::new("primary", "webhook").with_delivery_snapshot(fixed.clone());
let handle: &dyn Target<String> = ⌖
assert_eq!(handle.delivery_snapshot(), fixed);
}
#[test]
fn test_support_feature_never_ships_by_default() {
let manifest = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"))
.expect("the crate manifest should be readable");
let default_features = manifest
.lines()
.map(str::trim)
.find(|line| line.starts_with("default = "))
.expect("the crate manifest should declare a default feature list");
assert_eq!(default_features, "default = []", "test-support must stay out of the default feature set");
let feature = manifest
.lines()
.map(str::trim)
.find(|line| line.starts_with("test-support = "))
.expect("the crate manifest should declare the test-support feature");
assert_eq!(
feature, "test-support = []",
"test-support must stay a pure cfg gate that activates no dependencies"
);
}
}