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