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). Diagnostic payloads keep the error above the
256    /// `result_large_err` size threshold (clippy allow mirrors
257    /// `do_try_segment.rs`).
258    #[allow(clippy::result_large_err)]
259    pub async fn try_assert_satisfied(&self) -> Result<(), MockAssertionError> {
260        self.evaluate_expectations().await
261    }
262
263    /// Return the stored fail-fast error, if any.
264    pub fn fail_fast_error(&self) -> Option<CamelError> {
265        let guard = self
266            .fail_fast_error
267            .lock()
268            .expect("fail_fast_error lock poisoned"); // allow-unwrap
269        guard.clone()
270    }
271
272    /// Manually trip the fail-fast latch.
273    ///
274    /// Sets the internal `fail_fast_error` to `Some(error)`. The `MockProducer`
275    /// treats the presence of any error here as a sentinel — the actual
276    /// `CamelError` value is never propagated to the caller; a fixed
277    /// "fail-fast mode" message is returned instead. Use this hook when a
278    /// downstream component wants to short-circuit further processing on this
279    /// endpoint.
280    pub fn trigger_fail_fast(&self, error: CamelError) {
281        let mut guard = self
282            .fail_fast_error
283            .lock()
284            .expect("fail_fast_error lock poisoned"); // allow-unwrap
285        *guard = Some(error);
286    }
287
288    /// When `fail_fast` is enabled, record the assertion-mismatch sentinel
289    /// before panicking. This ensures any concurrent or subsequent
290    /// `MockProducer::poll_ready` / `call` invocation rejects with the fixed
291    /// "fail-fast mode" message instead of being blocked on a panic-orphaned
292    /// lock or a stale `None` sentinel.
293    pub(crate) fn set_fail_fast_on_mismatch(&self) {
294        if self.fail_fast {
295            let mut guard = self
296                .fail_fast_error
297                .lock()
298                .expect("fail_fast_error lock poisoned"); // allow-unwrap
299            *guard = Some(CamelError::ProcessorError(
300                "assert_satisfied expectation mismatch".to_string(),
301            ));
302        }
303    }
304}
305
306impl Endpoint for MockEndpoint {
307    fn uri(&self) -> &str {
308        &self.0.uri
309    }
310
311    fn create_consumer(
312        &self,
313        _rt: Arc<dyn RuntimeObservability>,
314    ) -> Result<Box<dyn Consumer>, CamelError> {
315        Err(CamelError::EndpointCreationFailed(
316            "mock endpoint does not support consumers (it is a sink)".to_string(),
317        ))
318    }
319
320    fn create_producer(
321        &self,
322        _rt: Arc<dyn RuntimeObservability>,
323        _ctx: &ProducerContext,
324    ) -> Result<BoxProcessor, CamelError> {
325        Ok(BoxProcessor::new(MockProducer {
326            name: self.0.name.clone(),
327            received: Arc::clone(&self.0.received),
328            notify: Arc::clone(&self.0.notify),
329            max_retained: self.0.max_retained,
330            copy_on_exchange: self.0.copy_on_exchange,
331            fail_fast: self.0.fail_fast,
332            fail_fast_error: Arc::clone(&self.0.fail_fast_error),
333        }))
334    }
335}
336
337// ---------------------------------------------------------------------------
338// MockProducer
339// ---------------------------------------------------------------------------
340
341/// A producer that simply records each exchange it processes.
342#[derive(Clone)]
343struct MockProducer {
344    name: String,
345    received: Arc<Mutex<VecDeque<Exchange>>>,
346    notify: Arc<Notify>,
347    max_retained: usize,
348    copy_on_exchange: bool,
349    fail_fast: bool,
350    fail_fast_error: Arc<std::sync::Mutex<Option<CamelError>>>,
351}
352
353impl Service<Exchange> for MockProducer {
354    type Response = Exchange;
355    type Error = CamelError;
356    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
357
358    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
359        // In fail-fast mode, reject new exchanges if a previous one failed
360        if self.fail_fast
361            && let Ok(guard) = self.fail_fast_error.lock()
362            && guard.is_some()
363        {
364            return Poll::Ready(Err(CamelError::ProcessorError(
365                "mock endpoint in fail-fast mode: a previous exchange caused an error".to_string(),
366            )));
367        }
368        Poll::Ready(Ok(()))
369    }
370
371    fn call(&mut self, exchange: Exchange) -> Self::Future {
372        let name = self.name.clone();
373        let received = Arc::clone(&self.received);
374        let notify = Arc::clone(&self.notify);
375        let max_retained = self.max_retained;
376        let copy_on_exchange = self.copy_on_exchange;
377        let fail_fast = self.fail_fast;
378        let fail_fast_error = Arc::clone(&self.fail_fast_error);
379        Box::pin(async move {
380            // In fail-fast mode, check if a previous error was recorded
381            if fail_fast
382                && let Ok(guard) = fail_fast_error.lock()
383                && guard.is_some()
384            {
385                return Err(CamelError::ProcessorError(
386                    "mock endpoint in fail-fast mode: a previous exchange caused an error"
387                        .to_string(),
388                ));
389            }
390
391            let correlation_id = exchange
392                .input
393                .headers
394                .get("CamelCorrelationId")
395                .and_then(|v| v.as_str())
396                .map(|s| s.to_string());
397
398            let exchange_to_store = if copy_on_exchange {
399                let mut cloned = exchange.clone();
400                // Deep-clone the body to break aliasing
401                cloned.input.body = clone_body(&exchange.input.body);
402                cloned
403            } else {
404                exchange.clone()
405            };
406
407            let mut guard = received.lock().await;
408            if guard.len() >= max_retained {
409                tracing::warn!(
410                    endpoint_name = %name,
411                    max = max_retained,
412                    "max retained exchanges reached, dropping oldest"
413                );
414                guard.pop_front();
415            }
416            guard.push_back(exchange_to_store);
417            let count = guard.len();
418            drop(guard);
419
420            debug!(
421                endpoint_name = %name,
422                count = %count,
423                correlation_id = correlation_id.as_deref().unwrap_or("none"),
424                "exchange recorded on mock"
425            );
426            notify.notify_waiters();
427
428            Ok(exchange)
429        })
430    }
431}
432
433/// Deep-clone a `Body` value.
434pub(crate) fn clone_body(body: &camel_component_api::Body) -> camel_component_api::Body {
435    match body {
436        camel_component_api::Body::Empty => camel_component_api::Body::Empty,
437        camel_component_api::Body::Text(s) => camel_component_api::Body::Text(s.clone()),
438        camel_component_api::Body::Json(v) => camel_component_api::Body::Json(v.clone()),
439        camel_component_api::Body::Xml(s) => camel_component_api::Body::Xml(s.clone()),
440        camel_component_api::Body::Bytes(b) => camel_component_api::Body::Bytes(b.clone()),
441        camel_component_api::Body::Stream(s) => camel_component_api::Body::Stream(s.clone()),
442        // Safety net for future #[non_exhaustive] variants; all current variants
443        // are handled explicitly above.
444        _ => camel_component_api::Body::Empty,
445    }
446}
447
448// ---------------------------------------------------------------------------
449// ExchangeAssert
450// ---------------------------------------------------------------------------
451
452/// A handle for making synchronous assertions on a recorded exchange.
453///
454/// Obtain one via [`MockEndpointInner::exchange`] after calling
455/// [`MockEndpointInner::await_exchanges`].
456///
457/// All methods panic with descriptive messages on failure, making test output
458/// self-explanatory without additional context.
459pub struct ExchangeAssert {
460    exchange: Exchange,
461    idx: usize,
462    endpoint_name: String,
463}
464
465impl ExchangeAssert {
466    fn location(&self) -> String {
467        format!(
468            "MockEndpoint '{}' exchange[{}]",
469            self.endpoint_name, self.idx
470        )
471    }
472
473    /// Assert that the body is `Body::Text` equal to `expected`.
474    pub fn assert_body_text(self, expected: &str) -> Self {
475        match self.exchange.input.body.as_text() {
476            Some(actual) if actual == expected => {}
477            Some(actual) => panic!(
478                "{}: expected body text {:?}, got {:?}",
479                self.location(),
480                expected,
481                actual
482            ),
483            None => panic!(
484                "{}: expected body text {:?}, but body is not Body::Text (got {:?})",
485                self.location(),
486                expected,
487                self.exchange.input.body
488            ),
489        }
490        self
491    }
492
493    /// Assert that the body is `Body::Json` equal to `expected`.
494    pub fn assert_body_json(self, expected: serde_json::Value) -> Self {
495        match &self.exchange.input.body {
496            camel_component_api::Body::Json(actual) if *actual == expected => {}
497            camel_component_api::Body::Json(actual) => panic!(
498                "{}: expected body JSON {}, got {}",
499                self.location(),
500                expected,
501                actual
502            ),
503            other => panic!(
504                "{}: expected body JSON {}, but body is not Body::Json (got {:?})",
505                self.location(),
506                expected,
507                other
508            ),
509        }
510        self
511    }
512
513    /// Assert that the body is `Body::Bytes` equal to `expected`.
514    pub fn assert_body_bytes(self, expected: &[u8]) -> Self {
515        match &self.exchange.input.body {
516            camel_component_api::Body::Bytes(actual) if actual.as_ref() == expected => {}
517            camel_component_api::Body::Bytes(actual) => panic!(
518                "{}: expected body bytes {:?}, got {:?}",
519                self.location(),
520                expected,
521                actual
522            ),
523            other => panic!(
524                "{}: expected body bytes {:?}, but body is not Body::Bytes (got {:?})",
525                self.location(),
526                expected,
527                other
528            ),
529        }
530        self
531    }
532
533    /// Assert that header `key` exists and equals `expected`.
534    ///
535    /// # Panics
536    ///
537    /// Panics if the header is missing or its value does not match `expected`.
538    pub fn assert_header(self, key: &str, expected: serde_json::Value) -> Self {
539        match self.exchange.input.headers.get(key) {
540            Some(actual) if *actual == expected => {}
541            Some(actual) => panic!(
542                "{}: expected header {:?} = {}, got {}",
543                self.location(),
544                key,
545                expected,
546                actual
547            ),
548            None => panic!(
549                "{}: expected header {:?} = {}, but header is absent",
550                self.location(),
551                key,
552                expected
553            ),
554        }
555        self
556    }
557
558    /// Assert that header `key` is present (any value).
559    ///
560    /// # Panics
561    ///
562    /// Panics if the header key is absent.
563    pub fn assert_header_exists(self, key: &str) -> Self {
564        if !self.exchange.input.headers.contains_key(key) {
565            panic!(
566                "{}: expected header {:?} to be present, but it was absent",
567                self.location(),
568                key
569            );
570        }
571        self
572    }
573
574    /// Assert that the exchange has an error (`exchange.error` is `Some`).
575    ///
576    /// # Panics
577    ///
578    /// Panics if `exchange.error` is `None`.
579    pub fn assert_has_error(self) -> Self {
580        if self.exchange.error.is_none() {
581            panic!(
582                "{}: expected exchange to have an error, but error is None",
583                self.location()
584            );
585        }
586        self
587    }
588
589    /// Assert that the exchange has no error (`exchange.error` is `None`).
590    ///
591    /// # Panics
592    ///
593    /// Panics if `exchange.error` is `Some`.
594    pub fn assert_no_error(self) -> Self {
595        if let Some(ref err) = self.exchange.error {
596            panic!(
597                "{}: expected exchange to have no error, but got: {}",
598                self.location(),
599                err
600            );
601        }
602        self
603    }
604}