Skip to main content

camel_component_mock/
inner.rs

1//! Endpoint internals for the mock component.
2//!
3//! Holds the shared per-endpoint state ([`MockEndpointInner`]), the thin
4//! [`MockEndpoint`] wrapper, the recording [`MockProducer`], and the
5//! synchronous [`ExchangeAssert`] handle. These types are re-exported from the
6//! crate root; the public API is unchanged.
7
8use std::collections::VecDeque;
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12use std::task::{Context, Poll};
13
14use tokio::sync::{Mutex, Notify};
15use tower::Service;
16
17use camel_component_api::{BoxProcessor, CamelError, Exchange};
18use camel_component_api::{Consumer, Endpoint, ProducerContext, RuntimeObservability};
19use tracing::debug;
20
21use crate::MockAssertionError;
22use crate::MockExpectations;
23
24// ---------------------------------------------------------------------------
25// MockEndpoint / MockEndpointInner
26// ---------------------------------------------------------------------------
27
28/// A mock endpoint that records all exchanges sent to it.
29///
30/// This is a thin wrapper around `Arc<MockEndpointInner>`. Multiple
31/// `MockEndpoint` instances created with the same name share the same inner
32/// storage.
33pub struct MockEndpoint(pub(crate) Arc<MockEndpointInner>);
34
35/// The actual data behind a mock endpoint. Shared across all `MockEndpoint`
36/// instances created with the same name via `MockComponent`.
37///
38/// Use `get_received_exchanges` and `assert_exchange_count` to inspect
39/// recorded exchanges in tests.
40pub struct MockEndpointInner {
41    pub(crate) uri: String,
42    pub(crate) name: String,
43    pub(crate) received: Arc<Mutex<VecDeque<Exchange>>>,
44    pub(crate) notify: Arc<Notify>,
45    pub(crate) max_retained: usize,
46    pub(crate) copy_on_exchange: bool,
47    pub(crate) fail_fast: bool,
48    pub(crate) fail_fast_error: Arc<std::sync::Mutex<Option<CamelError>>>,
49    pub(crate) assert_period_ms: u64,
50    pub(crate) any_order: bool,
51    pub(crate) expectations: Arc<std::sync::Mutex<MockExpectations>>,
52}
53
54impl MockEndpointInner {
55    /// Return a snapshot of all exchanges retained so far.
56    pub async fn get_received_exchanges(&self) -> Vec<Exchange> {
57        self.received.lock().await.iter().cloned().collect()
58    }
59
60    /// Return the number of currently retained exchanges.
61    pub async fn received_count(&self) -> usize {
62        self.received.lock().await.len()
63    }
64
65    /// Clear all retained exchanges and reset internal counters.
66    ///
67    /// Useful between test cases to reuse the same mock endpoint.
68    pub async fn reset(&self) {
69        self.received.lock().await.clear();
70        let mut guard = self
71            .fail_fast_error
72            .lock()
73            .expect("fail_fast_error lock poisoned"); // allow-unwrap
74        *guard = None;
75    }
76
77    /// Assert that exactly `expected` exchanges have been received.
78    ///
79    /// # Panics
80    ///
81    /// Panics if the count does not match.
82    pub async fn assert_exchange_count(&self, expected: usize) {
83        let actual = self.received.lock().await.len();
84        assert_eq!(
85            actual, expected,
86            "MockEndpoint expected {expected} exchanges, got {actual}"
87        );
88    }
89
90    /// Wait until at least `count` exchanges have been received, or panic on timeout.
91    ///
92    /// Uses `tokio::sync::Notify` — no polling. Returns immediately if `count`
93    /// exchanges are already present.
94    ///
95    /// # Panics
96    ///
97    /// Panics if `timeout` elapses before `count` exchanges arrive.
98    pub async fn await_exchanges(&self, count: usize, timeout: std::time::Duration) {
99        let deadline = tokio::time::Instant::now() + timeout;
100        loop {
101            {
102                let received = self.received.lock().await;
103                if received.len() >= count {
104                    return;
105                }
106            }
107            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
108            if remaining.is_zero() {
109                // Re-check in case the final exchange arrived between the lock drop
110                // above and entering the select — Notify does not buffer permits.
111                let got = self.received.lock().await.len();
112                if got >= count {
113                    return;
114                }
115                panic!(
116                    "MockEndpoint '{}': timed out waiting for {} exchanges (got {} after {:?})",
117                    self.name, count, got, timeout
118                );
119            }
120            tokio::select! {
121                _ = self.notify.notified() => {}
122                _ = tokio::time::sleep(remaining) => {}
123            }
124        }
125    }
126
127    /// Wait for exchanges with a configurable timeout derived from `assert_period_ms`.
128    ///
129    /// If `assert_period_ms` is 0, uses the provided `fallback` duration.
130    /// Otherwise, waits for `assert_period_ms` milliseconds before checking.
131    pub async fn await_exchanges_with_timeout(&self, count: usize, fallback: std::time::Duration) {
132        let duration = if self.assert_period_ms > 0 {
133            std::time::Duration::from_millis(self.assert_period_ms)
134        } else {
135            fallback
136        };
137        self.await_exchanges(count, duration).await;
138    }
139
140    /// Return an [`ExchangeAssert`] for the exchange at `idx`.
141    ///
142    /// # Panics
143    ///
144    /// Panics if `idx` is out of bounds. Always call [`await_exchanges`] first
145    /// to ensure the exchange has been received.
146    ///
147    /// Panics immediately if called from a current-thread tokio runtime.
148    /// Use `#[tokio::test(flavor = "multi_thread")]` or the async accessors
149    /// [`get_received_exchanges`] / [`await_exchanges`] instead.
150    ///
151    /// [`await_exchanges`]: MockEndpointInner::await_exchanges
152    pub fn exchange(&self, idx: usize) -> ExchangeAssert {
153        if let Ok(handle) = tokio::runtime::Handle::try_current()
154            && handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread
155        {
156            panic!(
157                "MockEndpoint '{}': exchange(idx) cannot be used from a current-thread tokio runtime; use #[tokio::test(flavor = \"multi_thread\")] or the async accessors get_received_exchanges()/await_exchanges()",
158                self.name
159            );
160        }
161        let received = tokio::task::block_in_place(|| self.received.blocking_lock());
162        if idx >= received.len() {
163            panic!(
164                "MockEndpoint '{}': exchange index {} out of bounds (got {} exchanges)",
165                self.name,
166                idx,
167                received.len()
168            );
169        }
170        ExchangeAssert {
171            exchange: received[idx].clone(),
172            idx,
173            endpoint_name: self.name.clone(),
174        }
175    }
176
177    /// Set an exact count expectation: `assert_satisfied` panics unless the
178    /// number of retained exchanges equals `n`.
179    pub fn expect_count(&self, n: usize) {
180        let mut guard = self
181            .expectations
182            .lock()
183            .expect("expectations lock poisoned"); // allow-unwrap
184        guard.set_expected_count(n);
185    }
186
187    /// Set a minimum count expectation: `assert_satisfied` panics unless at
188    /// least `n` exchanges are retained.
189    pub fn expect_minimum_count(&self, n: usize) {
190        let mut guard = self
191            .expectations
192            .lock()
193            .expect("expectations lock poisoned"); // allow-unwrap
194        guard.set_minimum_count(n);
195    }
196
197    /// Add an expected body to the expectations list.
198    pub fn expect_body(&self, body: camel_component_api::Body) {
199        let mut guard = self
200            .expectations
201            .lock()
202            .expect("expectations lock poisoned"); // allow-unwrap
203        guard.push_body(body);
204    }
205
206    /// Add an expected header key-value pair to the expectations list.
207    pub fn expect_header(&self, key: &str, value: impl Into<serde_json::Value>) {
208        let mut guard = self
209            .expectations
210            .lock()
211            .expect("expectations lock poisoned"); // allow-unwrap
212        guard.push_header(key.to_string(), value.into());
213    }
214
215    /// Add an expected header regex pattern to the expectations list.
216    ///
217    /// After `await_exchanges()`, `assert_satisfied()` checks whether any
218    /// received exchange has the named header matching the given regex pattern.
219    pub fn expect_header_regex(&self, key: &str, pattern: &str) {
220        let mut guard = self
221            .expectations
222            .lock()
223            .expect("expectations lock poisoned"); // allow-unwrap
224        guard.push_header_regex(key.to_string(), pattern.to_string());
225    }
226
227    /// Assert that all registered expectations are satisfied.
228    ///
229    /// # Panics
230    ///
231    /// Panics if an expected exchange count (exact or minimum, see
232    /// [`expect_count`](Self::expect_count) and
233    /// [`expect_minimum_count`](Self::expect_minimum_count)) is not met, if
234    /// expected bodies do not match received bodies (in order or any order
235    /// depending on `any_order` config), if expected headers are missing, or
236    /// if header regex patterns do not match.
237    pub async fn assert_satisfied(&self) {
238        if let Err(e) = self.evaluate_expectations().await {
239            panic!("{e}");
240        }
241    }
242
243    /// Assert that all registered expectations are satisfied without
244    /// panicking.
245    ///
246    /// Performs the same checks as [`assert_satisfied`](Self::assert_satisfied)
247    /// (see it for the full list) and evaluates the same fail-fast latch
248    /// rules, but returns the mismatch as [`MockAssertionError`] instead of
249    /// panicking.
250    ///
251    /// # Errors
252    ///
253    /// Returns `Err(MockAssertionError)` when any expectation is not met or
254    /// an expectation is malformed (e.g. a header regex pattern that fails
255    /// to compile).
256    pub async fn try_assert_satisfied(&self) -> Result<(), MockAssertionError> {
257        self.evaluate_expectations().await
258    }
259
260    /// Return the stored fail-fast error, if any.
261    pub fn fail_fast_error(&self) -> Option<CamelError> {
262        let guard = self
263            .fail_fast_error
264            .lock()
265            .expect("fail_fast_error lock poisoned"); // allow-unwrap
266        guard.clone()
267    }
268
269    /// Manually trip the fail-fast latch.
270    ///
271    /// Sets the internal `fail_fast_error` to `Some(error)`. The `MockProducer`
272    /// treats the presence of any error here as a sentinel — the actual
273    /// `CamelError` value is never propagated to the caller; a fixed
274    /// "fail-fast mode" message is returned instead. Use this hook when a
275    /// downstream component wants to short-circuit further processing on this
276    /// endpoint.
277    pub fn trigger_fail_fast(&self, error: CamelError) {
278        let mut guard = self
279            .fail_fast_error
280            .lock()
281            .expect("fail_fast_error lock poisoned"); // allow-unwrap
282        *guard = Some(error);
283    }
284
285    /// When `fail_fast` is enabled, record the assertion-mismatch sentinel
286    /// before panicking. This ensures any concurrent or subsequent
287    /// `MockProducer::poll_ready` / `call` invocation rejects with the fixed
288    /// "fail-fast mode" message instead of being blocked on a panic-orphaned
289    /// lock or a stale `None` sentinel.
290    pub(crate) fn set_fail_fast_on_mismatch(&self) {
291        if self.fail_fast {
292            let mut guard = self
293                .fail_fast_error
294                .lock()
295                .expect("fail_fast_error lock poisoned"); // allow-unwrap
296            *guard = Some(CamelError::ProcessorError(
297                "assert_satisfied expectation mismatch".to_string(),
298            ));
299        }
300    }
301}
302
303impl Endpoint for MockEndpoint {
304    fn uri(&self) -> &str {
305        &self.0.uri
306    }
307
308    fn create_consumer(
309        &self,
310        _rt: Arc<dyn RuntimeObservability>,
311    ) -> Result<Box<dyn Consumer>, CamelError> {
312        Err(CamelError::EndpointCreationFailed(
313            "mock endpoint does not support consumers (it is a sink)".to_string(),
314        ))
315    }
316
317    fn create_producer(
318        &self,
319        _rt: Arc<dyn RuntimeObservability>,
320        _ctx: &ProducerContext,
321    ) -> Result<BoxProcessor, CamelError> {
322        Ok(BoxProcessor::new(MockProducer {
323            name: self.0.name.clone(),
324            received: Arc::clone(&self.0.received),
325            notify: Arc::clone(&self.0.notify),
326            max_retained: self.0.max_retained,
327            copy_on_exchange: self.0.copy_on_exchange,
328            fail_fast: self.0.fail_fast,
329            fail_fast_error: Arc::clone(&self.0.fail_fast_error),
330        }))
331    }
332}
333
334// ---------------------------------------------------------------------------
335// MockProducer
336// ---------------------------------------------------------------------------
337
338/// A producer that simply records each exchange it processes.
339#[derive(Clone)]
340struct MockProducer {
341    name: String,
342    received: Arc<Mutex<VecDeque<Exchange>>>,
343    notify: Arc<Notify>,
344    max_retained: usize,
345    copy_on_exchange: bool,
346    fail_fast: bool,
347    fail_fast_error: Arc<std::sync::Mutex<Option<CamelError>>>,
348}
349
350impl Service<Exchange> for MockProducer {
351    type Response = Exchange;
352    type Error = CamelError;
353    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
354
355    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
356        // In fail-fast mode, reject new exchanges if a previous one failed
357        if self.fail_fast
358            && let Ok(guard) = self.fail_fast_error.lock()
359            && guard.is_some()
360        {
361            return Poll::Ready(Err(CamelError::ProcessorError(
362                "mock endpoint in fail-fast mode: a previous exchange caused an error".to_string(),
363            )));
364        }
365        Poll::Ready(Ok(()))
366    }
367
368    fn call(&mut self, exchange: Exchange) -> Self::Future {
369        let name = self.name.clone();
370        let received = Arc::clone(&self.received);
371        let notify = Arc::clone(&self.notify);
372        let max_retained = self.max_retained;
373        let copy_on_exchange = self.copy_on_exchange;
374        let fail_fast = self.fail_fast;
375        let fail_fast_error = Arc::clone(&self.fail_fast_error);
376        Box::pin(async move {
377            // In fail-fast mode, check if a previous error was recorded
378            if fail_fast
379                && let Ok(guard) = fail_fast_error.lock()
380                && guard.is_some()
381            {
382                return Err(CamelError::ProcessorError(
383                    "mock endpoint in fail-fast mode: a previous exchange caused an error"
384                        .to_string(),
385                ));
386            }
387
388            let correlation_id = exchange
389                .input
390                .headers
391                .get("CamelCorrelationId")
392                .and_then(|v| v.as_str())
393                .map(|s| s.to_string());
394
395            let exchange_to_store = if copy_on_exchange {
396                let mut cloned = exchange.clone();
397                // Deep-clone the body to break aliasing
398                cloned.input.body = clone_body(&exchange.input.body);
399                cloned
400            } else {
401                exchange.clone()
402            };
403
404            let mut guard = received.lock().await;
405            if guard.len() >= max_retained {
406                tracing::warn!(
407                    endpoint_name = %name,
408                    max = max_retained,
409                    "max retained exchanges reached, dropping oldest"
410                );
411                guard.pop_front();
412            }
413            guard.push_back(exchange_to_store);
414            let count = guard.len();
415            drop(guard);
416
417            debug!(
418                endpoint_name = %name,
419                count = %count,
420                correlation_id = correlation_id.as_deref().unwrap_or("none"),
421                "exchange recorded on mock"
422            );
423            notify.notify_waiters();
424
425            Ok(exchange)
426        })
427    }
428}
429
430/// Deep-clone a `Body` value.
431pub(crate) fn clone_body(body: &camel_component_api::Body) -> camel_component_api::Body {
432    match body {
433        camel_component_api::Body::Empty => camel_component_api::Body::Empty,
434        camel_component_api::Body::Text(s) => camel_component_api::Body::Text(s.clone()),
435        camel_component_api::Body::Json(v) => camel_component_api::Body::Json(v.clone()),
436        camel_component_api::Body::Xml(s) => camel_component_api::Body::Xml(s.clone()),
437        camel_component_api::Body::Bytes(b) => camel_component_api::Body::Bytes(b.clone()),
438        camel_component_api::Body::Stream(s) => camel_component_api::Body::Stream(s.clone()),
439        // Safety net for future #[non_exhaustive] variants; all current variants
440        // are handled explicitly above.
441        _ => camel_component_api::Body::Empty,
442    }
443}
444
445// ---------------------------------------------------------------------------
446// ExchangeAssert
447// ---------------------------------------------------------------------------
448
449/// A handle for making synchronous assertions on a recorded exchange.
450///
451/// Obtain one via [`MockEndpointInner::exchange`] after calling
452/// [`MockEndpointInner::await_exchanges`].
453///
454/// All methods panic with descriptive messages on failure, making test output
455/// self-explanatory without additional context.
456pub struct ExchangeAssert {
457    exchange: Exchange,
458    idx: usize,
459    endpoint_name: String,
460}
461
462impl ExchangeAssert {
463    fn location(&self) -> String {
464        format!(
465            "MockEndpoint '{}' exchange[{}]",
466            self.endpoint_name, self.idx
467        )
468    }
469
470    /// Assert that the body is `Body::Text` equal to `expected`.
471    pub fn assert_body_text(self, expected: &str) -> Self {
472        match self.exchange.input.body.as_text() {
473            Some(actual) if actual == expected => {}
474            Some(actual) => panic!(
475                "{}: expected body text {:?}, got {:?}",
476                self.location(),
477                expected,
478                actual
479            ),
480            None => panic!(
481                "{}: expected body text {:?}, but body is not Body::Text (got {:?})",
482                self.location(),
483                expected,
484                self.exchange.input.body
485            ),
486        }
487        self
488    }
489
490    /// Assert that the body is `Body::Json` equal to `expected`.
491    pub fn assert_body_json(self, expected: serde_json::Value) -> Self {
492        match &self.exchange.input.body {
493            camel_component_api::Body::Json(actual) if *actual == expected => {}
494            camel_component_api::Body::Json(actual) => panic!(
495                "{}: expected body JSON {}, got {}",
496                self.location(),
497                expected,
498                actual
499            ),
500            other => panic!(
501                "{}: expected body JSON {}, but body is not Body::Json (got {:?})",
502                self.location(),
503                expected,
504                other
505            ),
506        }
507        self
508    }
509
510    /// Assert that the body is `Body::Bytes` equal to `expected`.
511    pub fn assert_body_bytes(self, expected: &[u8]) -> Self {
512        match &self.exchange.input.body {
513            camel_component_api::Body::Bytes(actual) if actual.as_ref() == expected => {}
514            camel_component_api::Body::Bytes(actual) => panic!(
515                "{}: expected body bytes {:?}, got {:?}",
516                self.location(),
517                expected,
518                actual
519            ),
520            other => panic!(
521                "{}: expected body bytes {:?}, but body is not Body::Bytes (got {:?})",
522                self.location(),
523                expected,
524                other
525            ),
526        }
527        self
528    }
529
530    /// Assert that header `key` exists and equals `expected`.
531    ///
532    /// # Panics
533    ///
534    /// Panics if the header is missing or its value does not match `expected`.
535    pub fn assert_header(self, key: &str, expected: serde_json::Value) -> Self {
536        match self.exchange.input.headers.get(key) {
537            Some(actual) if *actual == expected => {}
538            Some(actual) => panic!(
539                "{}: expected header {:?} = {}, got {}",
540                self.location(),
541                key,
542                expected,
543                actual
544            ),
545            None => panic!(
546                "{}: expected header {:?} = {}, but header is absent",
547                self.location(),
548                key,
549                expected
550            ),
551        }
552        self
553    }
554
555    /// Assert that header `key` is present (any value).
556    ///
557    /// # Panics
558    ///
559    /// Panics if the header key is absent.
560    pub fn assert_header_exists(self, key: &str) -> Self {
561        if !self.exchange.input.headers.contains_key(key) {
562            panic!(
563                "{}: expected header {:?} to be present, but it was absent",
564                self.location(),
565                key
566            );
567        }
568        self
569    }
570
571    /// Assert that the exchange has an error (`exchange.error` is `Some`).
572    ///
573    /// # Panics
574    ///
575    /// Panics if `exchange.error` is `None`.
576    pub fn assert_has_error(self) -> Self {
577        if self.exchange.error.is_none() {
578            panic!(
579                "{}: expected exchange to have an error, but error is None",
580                self.location()
581            );
582        }
583        self
584    }
585
586    /// Assert that the exchange has no error (`exchange.error` is `None`).
587    ///
588    /// # Panics
589    ///
590    /// Panics if `exchange.error` is `Some`.
591    pub fn assert_no_error(self) -> Self {
592        if let Some(ref err) = self.exchange.error {
593            panic!(
594                "{}: expected exchange to have no error, but got: {}",
595                self.location(),
596                err
597            );
598        }
599        self
600    }
601}