// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Builder-style [`Target`] mock shared by this crate's unit tests and, behind the off-by-default //! `test-support` cargo feature, by downstream test suites. //! //! The module is compiled only under `cfg(test)` or when a dependent explicitly opts in via the //! `test-support` feature, so the mock can never reach a production binary. Every knob defaults //! off: a plain [`MockTarget::new`] is an enabled, reachable, storeless target whose delivery //! methods all succeed immediately. The mock emits no tracing events. 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}; /// The queued-payload store handle a [`MockTarget`] serves from its [`Target::store`] accessor. pub type SharedQueuedStore = Arc + Send + Sync>; /// Builds the error a failing mock operation returns, so a test that pins an error variant can /// shape the failure instead of matching the mock's default. pub type ErrorFactory = Arc TargetError + Send + Sync>; /// Increments its counter when dropped, however the owning future ends, so a test that holds the /// probe open past its caller's deadline can prove the cancelled health future was actually /// dropped instead of left running. struct HealthDropGuard(Arc); impl Drop for HealthDropGuard { fn drop(&mut self) { self.0.fetch_add(1, Ordering::SeqCst); } } /// Consumes one unit of a failure budget, returning true while the budget is not exhausted. /// A budget of `usize::MAX` behaves as "always fail" for any realistic call count. fn consume_failure_budget(budget: &AtomicUsize) -> bool { budget .try_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| remaining.checked_sub(1)) .is_ok() } /// A configurable mock implementation of [`Target`]. /// /// All observable state (call counters, gates, signals) lives behind shared handles, so a clone /// kept aside keeps observing the original after it is boxed into a runtime, and /// [`Target::clone_dyn`] clones observe the same counters. Builder methods consume `self` and /// each knob defaults off; accessors read the shared state from any clone. #[derive(Clone)] pub struct MockTarget { id: TargetID, enabled: bool, /// Overrides the [`Target::is_active`] result; defaults to the enabled flag. active: Option, health_delay: Duration, health_started: Arc, /// When set, `is_active` waits on this handle (after notifying `health_started`) before /// answering, so a test can hold a probe in flight and release it on demand. health_gate: Option>, health_drops: Arc, enabled_calls: Arc, init_calls: Arc, init_failures_remaining: Arc, /// When set, `init` notifies the handle on entry and then never returns. blocking_init: Option>, close_calls: Arc, close_started: Arc, block_on_close: Arc, close_gate: Arc, close_failures_remaining: Arc, close_failure_error: Option, save_calls: Arc, save_failures_remaining: Arc, /// When set, the first `save` (counted across all clones) notifies the first handle and then /// waits on the second before returning; later saves pass straight through. first_save_gate: Option<(Arc, Arc)>, final_failures: Arc, delivery_snapshot: Option, store: Option, failed_store: Option>, } impl MockTarget { /// Creates an enabled, reachable, storeless mock identified as `:`. 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, } } /// Replaces the mock's identity while keeping every shared counter and gate, so a plugin /// factory can clone one observed template per constructed instance and bind the instance id /// the registry hands it. pub fn with_id(mut self, id: &str, name: &str) -> Self { self.id = TargetID::new(id.to_string(), name.to_string()); self } /// Marks the target disabled: `is_enabled` returns false and `health` short-circuits. pub fn disabled(mut self) -> Self { self.enabled = false; self } /// Overrides the `is_active` result independently of the enabled flag. pub fn with_active(mut self, active: bool) -> Self { self.active = Some(active); self } /// Makes `is_active` sleep for `delay` (after notifying [`Self::health_started`]) before /// answering, so probe timeouts and cancellation can be exercised under a paused clock. pub fn with_health_delay(mut self, delay: Duration) -> Self { self.health_delay = delay; self } /// Makes `is_active` wait on `release` after notifying [`Self::health_started`], so a test /// can hold a probe in flight and release it on demand. Independent of the health delay. pub fn with_health_gate(mut self, release: Arc) -> Self { self.health_gate = Some(release); self } /// Fails the first `failures` `init` calls with [`TargetError::Initialization`], then /// succeeds. Pass `usize::MAX` for a target whose init always fails. pub fn with_init_failures(mut self, failures: usize) -> Self { self.init_failures_remaining = Arc::new(AtomicUsize::new(failures)); self } /// Makes `init` notify `entered` and then never return, so cancellation of an in-flight /// initialization can be exercised. pub fn with_blocking_init(mut self, entered: Arc) -> Self { self.blocking_init = Some(entered); self } /// Fails the first `failures` `save` calls with [`TargetError::Request`], then succeeds. /// Pass `usize::MAX` for a target whose save always fails. pub fn with_save_failures(mut self, failures: usize) -> Self { self.save_failures_remaining = Arc::new(AtomicUsize::new(failures)); self } /// Gates the first `save` (counted across all clones): it notifies `entered` and then waits on /// `release` before returning. Later saves pass straight through. Several mocks may share one /// `entered`/`release` pair to gate their first saves collectively. pub fn with_first_save_gate(mut self, entered: Arc, release: Arc) -> Self { self.first_save_gate = Some((entered, release)); self } /// Fails the first `failures` `close` calls with [`TargetError::Storage`] (or the error shaped /// by [`Self::with_close_failure_error`]), then succeeds. Pass `usize::MAX` for a target whose /// close always fails. Counting, [`Self::close_started`], and the close gate still run before /// the failure fires. pub fn with_close_failures(mut self, failures: usize) -> Self { self.close_failures_remaining = Arc::new(AtomicUsize::new(failures)); self } /// Shapes the error a failing `close` returns, for tests that pin the error variant. 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 } /// Serves a fixed snapshot from `delivery_snapshot()` instead of deriving one from the /// attached stores. pub fn with_delivery_snapshot(mut self, snapshot: TargetDeliverySnapshot) -> Self { self.delivery_snapshot = Some(snapshot); self } /// Serves `store` from the [`Target::store`] accessor. The caller owns the backing store and /// its directory lifecycle; the mock only hands out the reference. pub fn with_store(mut self, store: SharedQueuedStore) -> Self { self.store = Some(store); self } /// Serves `failed_store` from the [`Target::failed_store`] accessor. Pass the same underlying /// store as [`Self::with_store`] to model a target whose live queue also parks terminal /// failures. pub fn with_failed_store(mut self, failed_store: Arc) -> Self { self.failed_store = Some(failed_store); self } /// Returns the mock's identity without needing an event-type annotation. pub fn target_id(&self) -> TargetID { self.id.clone() } /// When `block` is set, `close` waits on [`Self::close_gate`] after counting and signalling, /// until the gate receives a permit. Takes effect for closes that start after the call. pub fn set_block_on_close(&self, block: bool) { self.block_on_close.store(block, Ordering::SeqCst); } /// The semaphore a blocked `close` waits on; add a permit to release it. pub fn close_gate(&self) -> Arc { Arc::clone(&self.close_gate) } /// Notified once every time a `close` call starts. pub fn close_started(&self) -> Arc { Arc::clone(&self.close_started) } /// Notified once every time an `is_active` probe starts. pub fn health_started(&self) -> Arc { Arc::clone(&self.health_started) } /// How many times `init` was called across all clones. pub fn init_call_count(&self) -> usize { self.init_calls.load(Ordering::SeqCst) } /// How many times `close` was called across all clones. pub fn close_call_count(&self) -> usize { self.close_calls.load(Ordering::SeqCst) } /// How many times `save` was called across all clones. pub fn save_call_count(&self) -> usize { self.save_calls.load(Ordering::SeqCst) } /// How many times `is_enabled` was called across all clones, so a test can assert whether a /// dispatcher consulted (selected) this target at all. pub fn enabled_call_count(&self) -> usize { self.enabled_calls.load(Ordering::SeqCst) } /// How many `is_active` futures finished or were dropped mid-flight across all clones. pub fn health_drop_count(&self) -> usize { self.health_drops.load(Ordering::SeqCst) } /// How many final delivery failures were recorded across all clones. pub fn final_failure_count(&self) -> u64 { self.final_failures.load(Ordering::Relaxed) } } #[async_trait] impl Target for MockTarget where E: PluginEvent, { fn id(&self) -> TargetID { self.id.clone() } async fn is_active(&self) -> Result { self.health_started.notify_one(); // Held across the gate and delay so an aborted probe future is observable via // health_drop_count. 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>) -> 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, _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 + Send + Sync)> { self.store.as_deref() } fn failed_store(&self) -> Option<&dyn FailedEventStore> { self.failed_store.as_deref() } fn clone_dyn(&self) -> Box + 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 { // The fixed snapshot wins when configured; otherwise mirror the trait's default impl, // deriving the depths from the attached stores. 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> { Arc::new(EntityTarget { object_name: "obj.txt".to_string(), bucket_name: "bucket-a".to_string(), event_name: EventName::ObjectCreatedPut, data: "payload".to_string(), }) } // Leak-guard contract, part one: the mock is compiled only under cfg(test) or the // off-by-default `test-support` feature (see lib.rs), so production builds cannot even name // it. This test documents the default surface a consumer gets when it does opt in. #[tokio::test] async fn defaults_are_inert() { let target = MockTarget::new("primary", "webhook"); let handle: &dyn Target = ⌖ 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 + 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 = ⌖ 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 = ⌖ 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 = ⌖ 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 = &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 + 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 + 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 = ⌖ 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 = &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 = &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 = ⌖ assert_eq!(handle.delivery_snapshot(), fixed); } // Leak-guard contract, part two: the `test-support` feature must never ship by default and // must stay a pure cfg gate, so no production dependency edge can drag the mock in. #[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" ); } }