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