Skip to main content

camel_component_mock/
lib.rs

1//! # camel-component-mock
2//!
3//! Mock component for rust-camel — testing utility that records received
4//! exchanges for later assertion, useful for verifying route output in tests.
5//!
6//! Main types: `MockComponent`, `MockEndpoint`, `MockProducer`, `MockExpectations`.
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! use camel_component_mock::MockComponent;
12//! use camel_component_api::{Component, NoOpComponentContext, Exchange, Message};
13//!
14//! // Create a mock component and endpoint
15//! let component = MockComponent::new();
16//! let endpoint = component
17//!     .create_endpoint("mock:result", &NoOpComponentContext)
18//!     .unwrap();
19//!
20//! // In a real route, the producer would be used as a Tower service.
21//! // After sending exchanges, you can inspect them:
22//! let inner = component.get_endpoint("result").unwrap();
23//! // inner.assert_exchange_count(1).await;
24//! // inner.exchange(0).assert_body_text("hello");
25//! ```
26//!
27//! # `expectedCount` is inert in the live runtime path
28//!
29//! The `expectedCount` URI parameter records intent only: at first
30//! endpoint creation it registers an exact count expectation on the
31//! endpoint inner. It is enforced only when an explicit assertion method
32//! runs (`assert_satisfied` / `try_assert_satisfied`), never by the live
33//! producer — `poll_ready` and `call` do not consult it. Under
34//! `camel run`, where no test caller invokes assertions, `expectedCount`
35//! never rejects or drops traffic.
36
37use std::collections::{HashMap, VecDeque, hash_map::Entry};
38use std::sync::Arc;
39use std::sync::atomic::AtomicU64;
40
41use tokio::sync::{Mutex, Notify};
42
43use camel_api::component_metadata::ComponentMetadata;
44use camel_component_api::UriConfig;
45use camel_component_api::parse_uri;
46use camel_component_api::{CamelError, Component, Endpoint};
47use tracing::debug;
48
49/// Default maximum number of exchanges retained by a mock endpoint.
50const DEFAULT_MAX_RETAINED: usize = 10_000;
51
52// ---------------------------------------------------------------------------
53// MockConfig
54// ---------------------------------------------------------------------------
55
56/// Configuration for [`MockComponent`].
57///
58/// Controls how many exchanges are retained before the oldest are dropped,
59/// and other behavioural flags for assertions.
60///
61/// # Examples
62///
63/// ```rust
64/// use camel_component_mock::MockConfig;
65///
66/// let config = MockConfig {
67///     max_retained: 100,
68///     copy_on_exchange: true,
69///     fail_fast: false,
70///     assert_period_ms: 0,
71///     any_order: false,
72/// };
73/// ```
74#[derive(Clone, Debug)]
75pub struct MockConfig {
76    /// Maximum number of exchanges to retain. When exceeded, the oldest
77    /// exchange is dropped. Defaults to 10 000.
78    pub max_retained: usize,
79    /// When `true`, clone the exchange body before storing it in the received
80    /// exchanges list. This prevents aliasing when the caller mutates the
81    /// original exchange after sending. Defaults to `false`.
82    pub copy_on_exchange: bool,
83    /// When `true`, after the first failing assertion the mock stops processing
84    /// exchanges and records the error. Defaults to `false`.
85    pub fail_fast: bool,
86    /// Time in milliseconds to wait before asserting expectations (to allow
87    /// async processing to complete). Defaults to `0` (no wait).
88    pub assert_period_ms: u64,
89    /// When `true`, [`MockEndpointInner::assert_satisfied`] matches expected
90    /// bodies in any order rather than strict sequence. Defaults to `false`.
91    pub any_order: bool,
92}
93
94/// Private container for macro-derived `metadata()`.
95///
96/// Declares the five optional URI parameters (`retain`, `copy`,
97/// `failFast`, `expectedCount`, `anyOrder`) for the generated catalog.
98/// `create_endpoint` parses them manually (controlbus pattern), so the
99/// fields exist only to anchor the metadata derivation — the catalog
100/// parity test locks descriptor ↔ parser agreement.
101///
102/// The anchors are `Option<String>` so required-inference marks every
103/// param optional: absent params fall back to the component-level
104/// [`MockConfig`] fields (see the README param table), so no static
105/// `default = "..."` literal exists and a bare `mock:name` URI is valid.
106///
107/// Inertness contract: `expectedCount` records an exact count
108/// expectation at first endpoint creation; it is enforced only when an
109/// explicit assertion method runs (`assert_satisfied` /
110/// `try_assert_satisfied`), never by the live producer. Under
111/// `camel run` it never rejects or drops traffic. `copy` has no positive
112/// behavioral contrast (both producer branches clone identically) — its
113/// URI parsing is proven by malformed-value rejection and catalog
114/// parity.
115#[derive(Debug, Clone, UriConfig)]
116#[allow(dead_code)]
117#[uri_scheme = "mock"]
118#[uri_config(
119    skip_impl,
120    metadata(
121        scheme = "mock",
122        description = "Records exchanges for test assertions",
123        producer
124    ),
125    crate = "camel_component_api"
126)]
127struct MockUriConfig {
128    #[uri_param(name = "retain")]
129    pub _retain: Option<String>,
130
131    #[uri_param(name = "copy")]
132    pub _copy: Option<String>,
133
134    #[uri_param(name = "failFast")]
135    pub _fail_fast: Option<String>,
136
137    #[uri_param(name = "expectedCount")]
138    pub _expected_count: Option<String>,
139
140    #[uri_param(name = "anyOrder")]
141    pub _any_order: Option<String>,
142}
143
144impl Default for MockConfig {
145    fn default() -> Self {
146        Self {
147            max_retained: DEFAULT_MAX_RETAINED,
148            copy_on_exchange: false,
149            fail_fast: false,
150            assert_period_ms: 0,
151            any_order: false,
152        }
153    }
154}
155
156impl MockConfig {
157    /// Create a config with a custom retention limit.
158    pub fn new(max_retained: usize) -> Self {
159        Self {
160            max_retained,
161            ..Self::default()
162        }
163    }
164
165    /// Component metadata for the mock scheme, derived from
166    /// `#[uri_config(metadata(..))]` on `MockUriConfig`.
167    pub fn metadata() -> ComponentMetadata {
168        MockUriConfig::metadata()
169    }
170}
171
172// ---------------------------------------------------------------------------
173// MockExpectations / MockEndpoint internals
174// ---------------------------------------------------------------------------
175
176mod assert;
177mod expectations;
178mod inner;
179pub mod matcher;
180
181pub use assert::MockAssertionError;
182pub use expectations::MockExpectations;
183pub use inner::{ExchangeAssert, MockEndpoint, MockEndpointInner};
184pub use matcher::{BodyMatcher, HeaderMatcher};
185
186// ---------------------------------------------------------------------------
187// MockComponent
188// ---------------------------------------------------------------------------
189
190/// The Mock component is a testing utility that records every exchange it
191/// receives via its producer.  It exposes helpers to inspect and assert on
192/// the recorded exchanges.
193///
194/// URI format: `mock:name[?retain=N&copy=true|false&failFast=true|false&expectedCount=N&anyOrder=true|false]`
195///
196/// URI params override the component-level [`MockConfig`] fields; absent
197/// params fall back to them. All params are optional.
198///
199/// When `create_endpoint` is called multiple times with the same name, the
200/// returned endpoints share the same received-exchanges storage and the
201/// first creation's configuration wins — later calls with different params
202/// do not reconfigure the existing endpoint. This enables
203/// test assertions: create mock, register it, run routes, then inspect via
204/// `component.get_endpoint("name")`.
205#[derive(Clone)]
206pub struct MockComponent {
207    registry: Arc<std::sync::Mutex<HashMap<String, Arc<MockEndpointInner>>>>,
208    config: MockConfig,
209    /// Component-wide monotonic arrival counter, shared into every endpoint
210    /// inner at creation. Stamped once per recorded exchange inside the
211    /// endpoint's `received` lock so per-endpoint index order matches push
212    /// order by construction. Never reset — not even by endpoint `reset()`.
213    arrival_counter: Arc<AtomicU64>,
214}
215
216impl MockComponent {
217    pub fn new() -> Self {
218        Self::with_config(MockConfig::default())
219    }
220
221    /// Create a `MockComponent` with a custom [`MockConfig`].
222    pub fn with_config(config: MockConfig) -> Self {
223        Self {
224            registry: Arc::new(std::sync::Mutex::new(HashMap::new())),
225            config,
226            arrival_counter: Arc::new(AtomicU64::new(0)),
227        }
228    }
229
230    /// Retrieve a previously created endpoint's inner data by name.
231    ///
232    /// This is the primary way to inspect recorded exchanges in tests.
233    pub fn get_endpoint(&self, name: &str) -> Option<Arc<MockEndpointInner>> {
234        let registry = self
235            .registry
236            .lock()
237            .expect("mutex poisoned: another thread panicked while holding this lock"); // allow-unwrap
238        registry.get(name).cloned()
239    }
240}
241
242impl Default for MockComponent {
243    fn default() -> Self {
244        Self::new()
245    }
246}
247
248/// Parse a non-negative integer URI parameter value.
249fn parse_usize_param(uri_value: &str, name: &str) -> Result<usize, CamelError> {
250    uri_value.parse::<usize>().map_err(|_| {
251        CamelError::EndpointCreationFailed(format!(
252            "mock: invalid value for URI parameter '{name}': '{uri_value}' is not a non-negative integer"
253        ))
254    })
255}
256
257/// Parse a strict boolean URI parameter value (`true`/`false`,
258/// case-insensitive).
259fn parse_bool_param(uri_value: &str, name: &str) -> Result<bool, CamelError> {
260    match uri_value.to_ascii_lowercase().as_str() {
261        "true" => Ok(true),
262        "false" => Ok(false),
263        _ => Err(CamelError::EndpointCreationFailed(format!(
264            "mock: invalid value for URI parameter '{name}': '{uri_value}' is not a boolean (true|false)"
265        ))),
266    }
267}
268
269impl Component for MockComponent {
270    fn scheme(&self) -> &str {
271        "mock"
272    }
273
274    fn metadata(&self) -> ComponentMetadata {
275        MockConfig::metadata()
276    }
277
278    fn create_endpoint(
279        &self,
280        uri: &str,
281        _ctx: &dyn camel_component_api::ComponentContext,
282    ) -> Result<Box<dyn Endpoint>, CamelError> {
283        let parts = parse_uri(uri)?;
284        if parts.scheme != "mock" {
285            return Err(CamelError::InvalidUri(format!(
286                "expected scheme 'mock', got '{}'",
287                parts.scheme
288            )));
289        }
290
291        let name = parts.path;
292        if name.is_empty() {
293            return Err(CamelError::InvalidUri(
294                "mock endpoint name must be non-empty (use 'mock:<name>')".to_string(),
295            ));
296        }
297
298        // URI params override component config; absent params fall back to it.
299        // Resolved before the registry lock — malformed values fail creation
300        // without touching shared state.
301        let max_retained = match parts.params.get("retain") {
302            Some(v) => {
303                let n = parse_usize_param(v, "retain")?;
304                if n == 0 {
305                    return Err(CamelError::EndpointCreationFailed(
306                        "mock: URI parameter 'retain' must be >= 1, got 0".to_string(),
307                    ));
308                }
309                n
310            }
311            None => self.config.max_retained,
312        };
313        let copy_on_exchange = match parts.params.get("copy") {
314            Some(v) => parse_bool_param(v, "copy")?,
315            None => self.config.copy_on_exchange,
316        };
317        let fail_fast = match parts.params.get("failFast") {
318            Some(v) => parse_bool_param(v, "failFast")?,
319            None => self.config.fail_fast,
320        };
321        let any_order = match parts.params.get("anyOrder") {
322            Some(v) => parse_bool_param(v, "anyOrder")?,
323            None => self.config.any_order,
324        };
325        // Inert at creation time: resolved here, bound to a fresh inner
326        // below, enforced only by the explicit assertion methods.
327        let expected_count = match parts.params.get("expectedCount") {
328            Some(v) => Some(parse_usize_param(v, "expectedCount")?),
329            None => None,
330        };
331
332        let mut registry = self.registry.lock().map_err(|e| {
333            CamelError::EndpointCreationFailed(format!("mock registry lock poisoned: {e}"))
334        })?;
335        let assert_period_ms = self.config.assert_period_ms;
336        // First-creation-wins: an existing entry is returned unchanged, so
337        // conflicting params on a re-created name never reconfigure the
338        // inner. `fresh` marks a newly created inner — the only one a
339        // URI-registered expectedCount may bind to.
340        let (inner, fresh) = match registry.entry(name.clone()) {
341            Entry::Vacant(vacant) => {
342                let created = vacant.insert(Arc::new(MockEndpointInner {
343                    uri: uri.to_string(),
344                    name,
345                    received: Arc::new(Mutex::new(VecDeque::new())),
346                    notify: Arc::new(Notify::new()),
347                    max_retained,
348                    copy_on_exchange,
349                    fail_fast,
350                    fail_fast_error: Arc::new(std::sync::Mutex::new(None)),
351                    assert_period_ms,
352                    any_order,
353                    expectations: Arc::new(std::sync::Mutex::new(MockExpectations::new())),
354                    arrival_counter: Arc::clone(&self.arrival_counter),
355                    arrival_indices: Arc::new(Mutex::new(Vec::new())),
356                }));
357                (Arc::clone(created), true)
358            }
359            Entry::Occupied(occupied) => (Arc::clone(occupied.get()), false),
360        };
361
362        // expectedCount records intent only. It binds at first creation and
363        // is enforced exclusively by the assertion methods
364        // (`assert_satisfied` / `try_assert_satisfied`); the producer never
365        // consults it.
366        if fresh && let Some(n) = expected_count {
367            inner.expect_count(n);
368        }
369
370        debug!(endpoint_name = %inner.name, "mock endpoint created");
371        Ok(Box::new(MockEndpoint(inner)))
372    }
373}
374
375// ---------------------------------------------------------------------------
376// Tests
377// ---------------------------------------------------------------------------
378
379#[cfg(test)]
380mod tests;