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