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