Skip to main content

camel_component_mock/
assert.rs

1//! Non-panicking assertion surface for mock endpoints.
2//!
3//! [`MockAssertionError`] maps every assertion branch of
4//! [`crate::MockEndpointInner::assert_satisfied`] to one variant whose
5//! `Display` output is byte-identical to the panic message the panicking
6//! variant produces for the same condition.
7
8use crate::MockEndpointInner;
9use crate::expectations::BodyExpectation;
10use crate::matcher::compact_body;
11use camel_component_api::{Body, Exchange};
12use camel_matchers::CountBound;
13use camel_matchers::bound_holds;
14use camel_matchers::render_bound;
15
16/// Diagnostic cap for received-value and header-key lists.
17const DIAGNOSTIC_LIST_CAP: usize = 8;
18
19/// Received-state snapshot for a failed header expectation.
20///
21/// `actual_values` holds the `{:?}`-formatted values of the expected key
22/// across received exchanges that carry it, and `last_headers` holds the
23/// sorted key list of the last received exchange. Both cap at
24/// [`DIAGNOSTIC_LIST_CAP`] entries; overflow appends a `+N more` entry.
25/// `last_headers` is `None` when no exchange was received.
26struct HeaderDiagnostics {
27    received_count: usize,
28    actual_values: Vec<String>,
29    last_headers: Option<String>,
30}
31
32/// Collect the received-state diagnostics for the expected `key`.
33fn header_diagnostics(received: &[Exchange], key: &str) -> HeaderDiagnostics {
34    let mut actual_values: Vec<String> = received
35        .iter()
36        .filter_map(|ex| ex.input.headers.get(key))
37        .map(|v| format!("{v:?}"))
38        .collect();
39    let overflow = actual_values.len().saturating_sub(DIAGNOSTIC_LIST_CAP);
40    actual_values.truncate(DIAGNOSTIC_LIST_CAP);
41    if overflow > 0 {
42        actual_values.push(format!("+{overflow} more"));
43    }
44    let last_headers = received.last().map(|ex| {
45        let mut keys: Vec<&str> = ex.input.headers.keys().map(String::as_str).collect();
46        keys.sort_unstable();
47        let keys_overflow = keys.len().saturating_sub(DIAGNOSTIC_LIST_CAP);
48        keys.truncate(DIAGNOSTIC_LIST_CAP);
49        let mut rendered = keys.join(", ");
50        if keys_overflow > 0 {
51            rendered.push_str(&format!(", +{keys_overflow} more"));
52        }
53        rendered
54    });
55    HeaderDiagnostics {
56        received_count: received.len(),
57        actual_values,
58        last_headers,
59    }
60}
61
62/// Append the received-state clause shared by the header mismatch variants.
63fn write_header_received_clause(
64    f: &mut impl std::fmt::Write,
65    received_count: usize,
66    actual_values: &[String],
67    last_headers: &Option<String>,
68    key: &str,
69) -> std::fmt::Result {
70    if received_count == 0 {
71        return write!(f, " (received 0 exchanges)");
72    }
73    if !actual_values.is_empty() {
74        return write!(
75            f,
76            " (received {received_count} exchanges; '{key}' present with values: [{}])",
77            actual_values.join(", ")
78        );
79    }
80    write!(
81        f,
82        " (received {received_count} exchanges; '{key}' absent from all received exchanges; last exchange headers: [{}])",
83        last_headers.as_deref().unwrap_or("")
84    )
85}
86
87/// Error returned by [`MockEndpointInner::try_assert_satisfied`] when a
88/// recorded expectation is not satisfied (or is malformed).
89///
90/// Every assertion branch of [`MockEndpointInner::assert_satisfied`]
91/// corresponds to exactly one variant. The `Display` output of each variant
92/// equals the panic message the panicking variant produces for the same
93/// condition. Body detail fields are pre-formatted (`{:?}`) strings.
94#[non_exhaustive]
95#[derive(Debug)]
96pub enum MockAssertionError {
97    /// Exact count expectation (`expect_count`) not met.
98    CountMismatch {
99        /// Endpoint name.
100        endpoint: String,
101        /// Expected number of exchanges.
102        expected: usize,
103        /// Actual number of retained exchanges.
104        actual: usize,
105    },
106    /// Minimum count expectation (`expect_minimum_count`) not met.
107    MinimumCountNotMet {
108        /// Endpoint name.
109        endpoint: String,
110        /// Minimum number of exchanges expected.
111        minimum: usize,
112        /// Actual number of retained exchanges.
113        actual: usize,
114    },
115    /// An upper-bounded count expectation (`expect_maximum_count`, or a
116    /// [`CountBound::Range`] via `expect_bound`) not met.
117    ///
118    /// `bound` is the `camel_matchers::render_bound` rendering of the
119    /// recorded bound (e.g. `expected at most 2`, `expected between 1 and
120    /// 3`) so wording stays owned by the shared algebra.
121    CountBoundNotMet {
122        /// Endpoint name.
123        endpoint: String,
124        /// Rendered bound prefix from `camel_matchers::render_bound`.
125        bound: String,
126        /// Actual number of retained exchanges.
127        actual: usize,
128    },
129    /// Number of expected bodies differs from the number of received bodies.
130    BodyCountMismatch {
131        /// Endpoint name.
132        endpoint: String,
133        /// Expected number of bodies.
134        expected: usize,
135        /// Actual number of bodies.
136        actual: usize,
137    },
138    /// Ordered body at `index` does not match the expected body.
139    BodyMismatch {
140        /// Endpoint name.
141        endpoint: String,
142        /// Index of the mismatching body.
143        index: usize,
144        /// `{:?}`-formatted expected body.
145        expected: String,
146        /// `{:?}`-formatted actual body.
147        actual: String,
148    },
149    /// Expected body not found in any received exchange (anyOrder mode).
150    BodyNotFound {
151        /// Endpoint name.
152        endpoint: String,
153        /// `{:?}`-formatted expected body.
154        expected: String,
155        /// Number of received exchanges at evaluation.
156        received_count: usize,
157    },
158    /// Ordered body matcher at `index` did not match the received body.
159    ///
160    /// Mismatch-class: trips the fail-fast latch exactly like
161    /// [`MockAssertionError::BodyMismatch`].
162    BodyMatcherFailed {
163        /// Endpoint name.
164        endpoint: String,
165        /// Index of the mismatching expectation.
166        index: usize,
167        /// Display form of the failed matcher (contains its pattern or
168        /// value).
169        matcher: String,
170        /// Received body rendered whole (never truncated), plus a
171        /// ` (<note>)` suffix when the matcher reported a shape mismatch.
172        received: String,
173    },
174    /// Expected header key/value pair not found in any received exchange.
175    HeaderNotFound {
176        /// Endpoint name.
177        endpoint: String,
178        /// Header key.
179        key: String,
180        /// Expected header value.
181        value: serde_json::Value,
182        /// Number of received exchanges at evaluation.
183        received_count: usize,
184        /// `{:?}`-formatted values of `key` across received exchanges that
185        /// carry it: up to `DIAGNOSTIC_LIST_CAP` values plus a final
186        /// `+N more` entry on overflow.
187        actual_values: Vec<String>,
188        /// Sorted key list of the last received exchange: up to
189        /// `DIAGNOSTIC_LIST_CAP` keys plus a `+N more` suffix on
190        /// overflow; `None` when no exchange was received.
191        last_headers: Option<String>,
192    },
193    /// No received exchange has the named header matching the regex pattern.
194    HeaderRegexNotMatched {
195        /// Endpoint name.
196        endpoint: String,
197        /// Header key.
198        key: String,
199        /// Regex pattern.
200        pattern: String,
201        /// Number of received exchanges at evaluation.
202        received_count: usize,
203        /// `{:?}`-formatted values of `key` across received exchanges that
204        /// carry it: up to `DIAGNOSTIC_LIST_CAP` values plus a final
205        /// `+N more` entry on overflow.
206        actual_values: Vec<String>,
207        /// Sorted key list of the last received exchange: up to
208        /// `DIAGNOSTIC_LIST_CAP` keys plus a `+N more` suffix on
209        /// overflow; `None` when no exchange was received.
210        last_headers: Option<String>,
211    },
212    /// No received exchange has the named header satisfying the matcher.
213    ///
214    /// Mismatch-class: trips the fail-fast latch exactly like
215    /// [`MockAssertionError::HeaderNotFound`].
216    HeaderMatcherFailed {
217        /// Endpoint name.
218        endpoint: String,
219        /// Header key.
220        key: String,
221        /// Display form of the failed matcher (contains its pattern or
222        /// value).
223        matcher: String,
224        /// Received-state clause (same shape as
225        /// [`MockAssertionError::HeaderNotFound`]) plus a ` (<note>)`
226        /// suffix when the matcher reported a shape mismatch.
227        received: String,
228    },
229    /// Header regex pattern failed to compile.
230    ///
231    /// A malformed expectation is a caller programming error, not an
232    /// expectation mismatch: it does not trip the fail-fast latch.
233    InvalidHeaderPattern {
234        /// Endpoint name.
235        endpoint: String,
236        /// Header key.
237        key: String,
238        /// Regex pattern that failed to compile.
239        pattern: String,
240        /// Underlying regex compile error.
241        source: Box<dyn std::error::Error + Send + Sync>,
242    },
243    /// Body matcher regex pattern failed to compile.
244    ///
245    /// A malformed expectation is a caller programming error, not an
246    /// expectation mismatch: it does not trip the fail-fast latch.
247    InvalidBodyPattern {
248        /// Endpoint name.
249        endpoint: String,
250        /// Regex pattern that failed to compile.
251        pattern: String,
252        /// Underlying regex compile error.
253        source: Box<dyn std::error::Error + Send + Sync>,
254    },
255}
256
257impl std::fmt::Display for MockAssertionError {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        match self {
260            MockAssertionError::CountMismatch {
261                endpoint,
262                expected,
263                actual,
264            } => write!(
265                f,
266                "MockEndpoint '{endpoint}': expected {expected} exchanges, got {actual}"
267            ),
268            MockAssertionError::MinimumCountNotMet {
269                endpoint,
270                minimum,
271                actual,
272            } => write!(
273                f,
274                "MockEndpoint '{endpoint}': expected at least {minimum} exchanges, got {actual}"
275            ),
276            MockAssertionError::CountBoundNotMet {
277                endpoint,
278                bound,
279                actual,
280            } => write!(
281                f,
282                "MockEndpoint '{endpoint}': {bound} exchanges, got {actual}"
283            ),
284            MockAssertionError::BodyCountMismatch {
285                endpoint,
286                expected,
287                actual,
288            } => write!(
289                f,
290                "MockEndpoint '{endpoint}': expected {expected} bodies, got {actual}"
291            ),
292            MockAssertionError::BodyMismatch {
293                endpoint,
294                index,
295                expected,
296                actual,
297            } => write!(
298                f,
299                "MockEndpoint '{endpoint}': body[{index}] expected {expected}, got {actual}"
300            ),
301            MockAssertionError::BodyNotFound {
302                endpoint,
303                expected,
304                received_count,
305            } => write!(
306                f,
307                "MockEndpoint '{endpoint}': expected body {expected} not found in received exchanges (anyOrder mode) (received {received_count} exchanges)"
308            ),
309            MockAssertionError::BodyMatcherFailed {
310                endpoint,
311                index,
312                matcher,
313                received,
314            } => write!(
315                f,
316                "MockEndpoint '{endpoint}': body[{index}] expected {matcher}, got {received}"
317            ),
318            MockAssertionError::HeaderNotFound {
319                endpoint,
320                key,
321                value,
322                received_count,
323                actual_values,
324                last_headers,
325            } => {
326                write!(
327                    f,
328                    "MockEndpoint '{endpoint}': expected header '{key}' = {value} not found in any received exchange"
329                )?;
330                write_header_received_clause(f, *received_count, actual_values, last_headers, key)
331            }
332            MockAssertionError::HeaderRegexNotMatched {
333                endpoint,
334                key,
335                pattern,
336                received_count,
337                actual_values,
338                last_headers,
339            } => {
340                write!(
341                    f,
342                    "MockEndpoint '{endpoint}': no received exchange has header '{key}' matching regex {pattern:?}"
343                )?;
344                write_header_received_clause(f, *received_count, actual_values, last_headers, key)
345            }
346            MockAssertionError::HeaderMatcherFailed {
347                endpoint,
348                key,
349                matcher,
350                received,
351            } => {
352                write!(
353                    f,
354                    "MockEndpoint '{endpoint}': no received exchange has header '{key}' matching {matcher}"
355                )?;
356                write!(f, "{received}")
357            }
358            MockAssertionError::InvalidHeaderPattern {
359                endpoint,
360                pattern,
361                source,
362                ..
363            } => write!(
364                f,
365                "MockEndpoint '{endpoint}': invalid regex pattern {pattern:?}: {source}"
366            ),
367            MockAssertionError::InvalidBodyPattern {
368                endpoint,
369                pattern,
370                source,
371                ..
372            } => write!(
373                f,
374                "MockEndpoint '{endpoint}': invalid regex pattern {pattern:?}: {source}"
375            ),
376        }
377    }
378}
379
380impl std::error::Error for MockAssertionError {
381    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
382        match self {
383            MockAssertionError::InvalidHeaderPattern { source, .. }
384            | MockAssertionError::InvalidBodyPattern { source, .. } => Some(&**source),
385            _ => None,
386        }
387    }
388}
389
390impl MockEndpointInner {
391    /// Evaluate all recorded expectations against the received snapshot.
392    ///
393    /// Single evaluation path shared by
394    /// [`assert_satisfied`](crate::MockEndpointInner::assert_satisfied) and
395    /// [`try_assert_satisfied`](crate::MockEndpointInner::try_assert_satisfied):
396    /// the count bound (via `camel_matchers::bound_holds`), then — only
397    /// when expected bodies are registered — body-count and per-body
398    /// checks (exact bodies and body matchers share one ordered slot
399    /// list), then header, header-regex and header-matcher checks
400    /// (independent of the body gate).
401    ///
402    /// On a mismatch-class error the fail-fast latch is tripped first (when
403    /// `fail_fast` is enabled), then the error is returned. A malformed
404    /// expectation ([`MockAssertionError::InvalidHeaderPattern`] or
405    /// [`MockAssertionError::InvalidBodyPattern`]) is returned without
406    /// touching the latch.
407    /// Diagnostic payloads keep `MockAssertionError` above the
408    /// `result_large_err` size threshold (clippy allow mirrors
409    /// `do_try_segment.rs`).
410    #[allow(clippy::result_large_err)]
411    pub(crate) async fn evaluate_expectations(&self) -> Result<(), MockAssertionError> {
412        let received = self.get_received_exchanges().await;
413
414        let guard = self
415            .expectations
416            .lock()
417            .expect("expectations lock poisoned"); // allow-unwrap
418
419        // Count bound — checked before bodies; a mismatch short-circuits
420        // all later checks. Decided by the shared algebra on the current
421        // snapshot (the runner settles traffic before asserting; no
422        // polling).
423        if let Some(bound) = guard.count_bound.as_ref()
424            && !bound_holds(bound, received.len())
425        {
426            let actual = received.len();
427            let err = match bound {
428                CountBound::Exact(n) => MockAssertionError::CountMismatch {
429                    endpoint: self.name.clone(),
430                    expected: *n as usize,
431                    actual,
432                },
433                CountBound::AtLeast(m) => MockAssertionError::MinimumCountNotMet {
434                    endpoint: self.name.clone(),
435                    minimum: *m as usize,
436                    actual,
437                },
438                // Upper bounds (and any future bound form — the enum is
439                // `#[non_exhaustive]`) render through the shared
440                // `render_bound`, keeping the wording in camel-matchers.
441                other => MockAssertionError::CountBoundNotMet {
442                    endpoint: self.name.clone(),
443                    bound: render_bound(other),
444                    actual,
445                },
446            };
447            return self.latch_err(err);
448        }
449
450        // Body expectations — gated: no expected bodies ⇒ body-count and
451        // per-body checks are skipped.
452        if !guard.expected_bodies.is_empty() {
453            // Malformed regex in a body matcher: caller programming error,
454            // not a mismatch — surfaced before evaluation, never latched.
455            for expectation in &guard.expected_bodies {
456                if let BodyExpectation::Matcher(matcher) = expectation
457                    && let Some(pattern) = matcher.regex_pattern()
458                    && let Err(e) = regex::Regex::new(pattern)
459                {
460                    return Err(MockAssertionError::InvalidBodyPattern {
461                        endpoint: self.name.clone(),
462                        pattern: pattern.to_string(),
463                        source: Box::new(e),
464                    });
465                }
466            }
467            let received_bodies: Vec<&Body> = received.iter().map(|e| &e.input.body).collect();
468            if guard.expected_bodies.len() != received_bodies.len() {
469                return self.latch_err(MockAssertionError::BodyCountMismatch {
470                    endpoint: self.name.clone(),
471                    expected: guard.expected_bodies.len(),
472                    actual: received_bodies.len(),
473                });
474            }
475            if self.any_order {
476                // Match in any order — each expectation (exact body or
477                // matcher) must be satisfied by exactly one received body.
478                let mut unmatched: Vec<&Body> = received_bodies.clone();
479                for expectation in &guard.expected_bodies {
480                    let idx = unmatched
481                        .iter()
482                        .position(|actual| expectation_matches(expectation, actual));
483                    match idx {
484                        Some(i) => {
485                            unmatched.remove(i);
486                        }
487                        None => {
488                            return self.latch_err(MockAssertionError::BodyNotFound {
489                                endpoint: self.name.clone(),
490                                expected: expectation_display(expectation),
491                                received_count: received.len(),
492                            });
493                        }
494                    }
495                }
496            } else {
497                for (i, expectation) in guard.expected_bodies.iter().enumerate() {
498                    match expectation {
499                        BodyExpectation::Exact(expected) => {
500                            if !body_eq(expected, received_bodies[i]) {
501                                return self.latch_err(MockAssertionError::BodyMismatch {
502                                    endpoint: self.name.clone(),
503                                    index: i,
504                                    expected: format!("{expected:?}"),
505                                    actual: format!("{:?}", received_bodies[i]),
506                                });
507                            }
508                        }
509                        BodyExpectation::Matcher(matcher) => {
510                            if !matcher.matches(received_bodies[i]) {
511                                return self.latch_err(MockAssertionError::BodyMatcherFailed {
512                                    endpoint: self.name.clone(),
513                                    index: i,
514                                    matcher: matcher.to_string(),
515                                    received: received_body_with_note(
516                                        received_bodies[i],
517                                        matcher.mismatch_note(received_bodies[i]),
518                                    ),
519                                });
520                            }
521                        }
522                    }
523                }
524            }
525        }
526
527        // Expected headers (must all be present on at least one exchange).
528        for (key, value) in &guard.expected_headers {
529            let found = received
530                .iter()
531                .any(|ex| ex.input.headers.get(key).is_some_and(|v| v == value));
532            if !found {
533                let diag = header_diagnostics(&received, key);
534                return self.latch_err(MockAssertionError::HeaderNotFound {
535                    endpoint: self.name.clone(),
536                    key: key.clone(),
537                    value: value.clone(),
538                    received_count: diag.received_count,
539                    actual_values: diag.actual_values,
540                    last_headers: diag.last_headers,
541                });
542            }
543        }
544
545        // Expected header regexes.
546        for (key, pattern) in &guard.expected_header_regexes {
547            let re = match regex::Regex::new(pattern) {
548                Ok(re) => re,
549                // Malformed expectation: caller programming error, not a
550                // mismatch — the latch is not tripped.
551                Err(e) => {
552                    return Err(MockAssertionError::InvalidHeaderPattern {
553                        endpoint: self.name.clone(),
554                        key: key.clone(),
555                        pattern: pattern.clone(),
556                        source: Box::new(e),
557                    });
558                }
559            };
560            let found = received.iter().any(|ex| {
561                ex.input.headers.get(key).is_some_and(|v| {
562                    let s = match v {
563                        serde_json::Value::String(s) => s.clone(),
564                        other => other.to_string(),
565                    };
566                    re.is_match(&s)
567                })
568            });
569            if !found {
570                let diag = header_diagnostics(&received, key);
571                return self.latch_err(MockAssertionError::HeaderRegexNotMatched {
572                    endpoint: self.name.clone(),
573                    key: key.clone(),
574                    pattern: pattern.clone(),
575                    received_count: diag.received_count,
576                    actual_values: diag.actual_values,
577                    last_headers: diag.last_headers,
578                });
579            }
580        }
581
582        // Expected header matchers — after the exact-header checks, with
583        // the same any-exchange semantics. Note: `HeaderMatcher::Regex` is
584        // strict-string, unlike the coerced `expect_header_regex` engine above
585        // — the divergence is intentional.
586        for (key, matcher) in &guard.expected_header_matchers {
587            // Malformed regex: caller programming error, not a mismatch —
588            // never latched.
589            if let Some(pattern) = matcher.regex_pattern()
590                && let Err(e) = regex::Regex::new(pattern)
591            {
592                return Err(MockAssertionError::InvalidHeaderPattern {
593                    endpoint: self.name.clone(),
594                    key: key.clone(),
595                    pattern: pattern.to_string(),
596                    source: Box::new(e),
597                });
598            }
599            let found = received
600                .iter()
601                .any(|ex| matcher.matches(ex.input.headers.get(key)));
602            if !found {
603                let representative = received.iter().find_map(|ex| ex.input.headers.get(key));
604                return self.latch_err(MockAssertionError::HeaderMatcherFailed {
605                    endpoint: self.name.clone(),
606                    key: key.clone(),
607                    matcher: matcher.to_string(),
608                    received: received_header_with_note(
609                        &received,
610                        key,
611                        matcher.mismatch_note(representative),
612                    ),
613                });
614            }
615        }
616
617        Ok(())
618    }
619
620    /// Trip the fail-fast latch (when enabled) and wrap `err` for return.
621    ///
622    /// Single latch call site for every expectation-mismatch branch;
623    /// [`MockAssertionError::InvalidHeaderPattern`] and
624    /// [`MockAssertionError::InvalidBodyPattern`] deliberately bypass it.
625    #[allow(clippy::result_large_err)]
626    fn latch_err(&self, err: MockAssertionError) -> Result<(), MockAssertionError> {
627        self.set_fail_fast_on_mismatch();
628        Err(err)
629    }
630}
631
632/// Evaluate one expected-body slot (exact or matcher) against a body.
633fn expectation_matches(expectation: &BodyExpectation, actual: &Body) -> bool {
634    match expectation {
635        BodyExpectation::Exact(expected) => body_eq(expected, actual),
636        BodyExpectation::Matcher(matcher) => matcher.matches(actual),
637    }
638}
639
640/// Render an expectation for the any-order `BodyNotFound` diagnostic.
641fn expectation_display(expectation: &BodyExpectation) -> String {
642    match expectation {
643        BodyExpectation::Exact(expected) => format!("{expected:?}"),
644        BodyExpectation::Matcher(matcher) => matcher.to_string(),
645    }
646}
647
648/// Render a received body whole for matcher diagnostics, appending the
649/// matcher's shape-mismatch note when present.
650fn received_body_with_note(body: &Body, note: Option<&'static str>) -> String {
651    match note {
652        Some(note) => format!("{} ({note})", compact_body(body)),
653        None => compact_body(body),
654    }
655}
656
657/// Render the received-state clause for a header matcher failure (same
658/// shape as the exact-header mismatch variants), appending the matcher's
659/// shape-mismatch note when present.
660fn received_header_with_note(
661    received: &[Exchange],
662    key: &str,
663    note: Option<&'static str>,
664) -> String {
665    let diag = header_diagnostics(received, key);
666    let mut clause = String::new();
667    write_header_received_clause(
668        &mut clause,
669        diag.received_count,
670        &diag.actual_values,
671        &diag.last_headers,
672        key,
673    )
674    .expect("writing to a String cannot fail"); // allow-unwrap
675    if let Some(note) = note {
676        clause.push_str(&format!(" ({note})"));
677    }
678    clause
679}
680
681/// Compare two `Body` values for equality (used by expectation evaluation).
682pub(crate) fn body_eq(a: &camel_component_api::Body, b: &camel_component_api::Body) -> bool {
683    match (a, b) {
684        (camel_component_api::Body::Empty, camel_component_api::Body::Empty) => true,
685        (camel_component_api::Body::Text(a), camel_component_api::Body::Text(b)) => a == b,
686        (camel_component_api::Body::Json(a), camel_component_api::Body::Json(b)) => a == b,
687        (camel_component_api::Body::Xml(a), camel_component_api::Body::Xml(b)) => a == b,
688        (camel_component_api::Body::Bytes(a), camel_component_api::Body::Bytes(b)) => a == b,
689        _ => false,
690    }
691}