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©=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;