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 camel_component_api::Exchange;
10
11/// Diagnostic cap for received-value and header-key lists.
12const DIAGNOSTIC_LIST_CAP: usize = 8;
13
14/// Received-state snapshot for a failed header expectation.
15///
16/// `actual_values` holds the `{:?}`-formatted values of the expected key
17/// across received exchanges that carry it, and `last_headers` holds the
18/// sorted key list of the last received exchange. Both cap at
19/// [`DIAGNOSTIC_LIST_CAP`] entries; overflow appends a `+N more` entry.
20/// `last_headers` is `None` when no exchange was received.
21struct HeaderDiagnostics {
22    received_count: usize,
23    actual_values: Vec<String>,
24    last_headers: Option<String>,
25}
26
27/// Collect the received-state diagnostics for the expected `key`.
28fn header_diagnostics(received: &[Exchange], key: &str) -> HeaderDiagnostics {
29    let mut actual_values: Vec<String> = received
30        .iter()
31        .filter_map(|ex| ex.input.headers.get(key))
32        .map(|v| format!("{v:?}"))
33        .collect();
34    let overflow = actual_values.len().saturating_sub(DIAGNOSTIC_LIST_CAP);
35    actual_values.truncate(DIAGNOSTIC_LIST_CAP);
36    if overflow > 0 {
37        actual_values.push(format!("+{overflow} more"));
38    }
39    let last_headers = received.last().map(|ex| {
40        let mut keys: Vec<&str> = ex.input.headers.keys().map(String::as_str).collect();
41        keys.sort_unstable();
42        let keys_overflow = keys.len().saturating_sub(DIAGNOSTIC_LIST_CAP);
43        keys.truncate(DIAGNOSTIC_LIST_CAP);
44        let mut rendered = keys.join(", ");
45        if keys_overflow > 0 {
46            rendered.push_str(&format!(", +{keys_overflow} more"));
47        }
48        rendered
49    });
50    HeaderDiagnostics {
51        received_count: received.len(),
52        actual_values,
53        last_headers,
54    }
55}
56
57/// Append the received-state clause shared by the header mismatch variants.
58fn write_header_received_clause(
59    f: &mut std::fmt::Formatter<'_>,
60    received_count: usize,
61    actual_values: &[String],
62    last_headers: &Option<String>,
63    key: &str,
64) -> std::fmt::Result {
65    if received_count == 0 {
66        return write!(f, " (received 0 exchanges)");
67    }
68    if !actual_values.is_empty() {
69        return write!(
70            f,
71            " (received {received_count} exchanges; '{key}' present with values: [{}])",
72            actual_values.join(", ")
73        );
74    }
75    write!(
76        f,
77        " (received {received_count} exchanges; '{key}' absent from all received exchanges; last exchange headers: [{}])",
78        last_headers.as_deref().unwrap_or("")
79    )
80}
81
82/// Error returned by [`MockEndpointInner::try_assert_satisfied`] when a
83/// recorded expectation is not satisfied (or is malformed).
84///
85/// Every assertion branch of [`MockEndpointInner::assert_satisfied`]
86/// corresponds to exactly one variant. The `Display` output of each variant
87/// equals the panic message the panicking variant produces for the same
88/// condition. Body detail fields are pre-formatted (`{:?}`) strings.
89#[non_exhaustive]
90#[derive(Debug)]
91pub enum MockAssertionError {
92    /// Exact count expectation (`expect_count`) not met.
93    CountMismatch {
94        /// Endpoint name.
95        endpoint: String,
96        /// Expected number of exchanges.
97        expected: usize,
98        /// Actual number of retained exchanges.
99        actual: usize,
100    },
101    /// Minimum count expectation (`expect_minimum_count`) not met.
102    MinimumCountNotMet {
103        /// Endpoint name.
104        endpoint: String,
105        /// Minimum number of exchanges expected.
106        minimum: usize,
107        /// Actual number of retained exchanges.
108        actual: usize,
109    },
110    /// Number of expected bodies differs from the number of received bodies.
111    BodyCountMismatch {
112        /// Endpoint name.
113        endpoint: String,
114        /// Expected number of bodies.
115        expected: usize,
116        /// Actual number of bodies.
117        actual: usize,
118    },
119    /// Ordered body at `index` does not match the expected body.
120    BodyMismatch {
121        /// Endpoint name.
122        endpoint: String,
123        /// Index of the mismatching body.
124        index: usize,
125        /// `{:?}`-formatted expected body.
126        expected: String,
127        /// `{:?}`-formatted actual body.
128        actual: String,
129    },
130    /// Expected body not found in any received exchange (anyOrder mode).
131    BodyNotFound {
132        /// Endpoint name.
133        endpoint: String,
134        /// `{:?}`-formatted expected body.
135        expected: String,
136        /// Number of received exchanges at evaluation.
137        received_count: usize,
138    },
139    /// Expected header key/value pair not found in any received exchange.
140    HeaderNotFound {
141        /// Endpoint name.
142        endpoint: String,
143        /// Header key.
144        key: String,
145        /// Expected header value.
146        value: serde_json::Value,
147        /// Number of received exchanges at evaluation.
148        received_count: usize,
149        /// `{:?}`-formatted values of `key` across received exchanges that
150        /// carry it: up to [`DIAGNOSTIC_LIST_CAP`] values plus a final
151        /// `+N more` entry on overflow.
152        actual_values: Vec<String>,
153        /// Sorted key list of the last received exchange: up to
154        /// [`DIAGNOSTIC_LIST_CAP`] keys plus a `+N more` suffix on
155        /// overflow; `None` when no exchange was received.
156        last_headers: Option<String>,
157    },
158    /// No received exchange has the named header matching the regex pattern.
159    HeaderRegexNotMatched {
160        /// Endpoint name.
161        endpoint: String,
162        /// Header key.
163        key: String,
164        /// Regex pattern.
165        pattern: String,
166        /// Number of received exchanges at evaluation.
167        received_count: usize,
168        /// `{:?}`-formatted values of `key` across received exchanges that
169        /// carry it: up to [`DIAGNOSTIC_LIST_CAP`] values plus a final
170        /// `+N more` entry on overflow.
171        actual_values: Vec<String>,
172        /// Sorted key list of the last received exchange: up to
173        /// [`DIAGNOSTIC_LIST_CAP`] keys plus a `+N more` suffix on
174        /// overflow; `None` when no exchange was received.
175        last_headers: Option<String>,
176    },
177    /// Header regex pattern failed to compile.
178    ///
179    /// A malformed expectation is a caller programming error, not an
180    /// expectation mismatch: it does not trip the fail-fast latch.
181    InvalidHeaderPattern {
182        /// Endpoint name.
183        endpoint: String,
184        /// Header key.
185        key: String,
186        /// Regex pattern that failed to compile.
187        pattern: String,
188        /// Underlying regex compile error.
189        source: Box<dyn std::error::Error + Send + Sync>,
190    },
191}
192
193impl std::fmt::Display for MockAssertionError {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        match self {
196            MockAssertionError::CountMismatch {
197                endpoint,
198                expected,
199                actual,
200            } => write!(
201                f,
202                "MockEndpoint '{endpoint}': expected {expected} exchanges, got {actual}"
203            ),
204            MockAssertionError::MinimumCountNotMet {
205                endpoint,
206                minimum,
207                actual,
208            } => write!(
209                f,
210                "MockEndpoint '{endpoint}': expected at least {minimum} exchanges, got {actual}"
211            ),
212            MockAssertionError::BodyCountMismatch {
213                endpoint,
214                expected,
215                actual,
216            } => write!(
217                f,
218                "MockEndpoint '{endpoint}': expected {expected} bodies, got {actual}"
219            ),
220            MockAssertionError::BodyMismatch {
221                endpoint,
222                index,
223                expected,
224                actual,
225            } => write!(
226                f,
227                "MockEndpoint '{endpoint}': body[{index}] expected {expected}, got {actual}"
228            ),
229            MockAssertionError::BodyNotFound {
230                endpoint,
231                expected,
232                received_count,
233            } => write!(
234                f,
235                "MockEndpoint '{endpoint}': expected body {expected} not found in received exchanges (anyOrder mode) (received {received_count} exchanges)"
236            ),
237            MockAssertionError::HeaderNotFound {
238                endpoint,
239                key,
240                value,
241                received_count,
242                actual_values,
243                last_headers,
244            } => {
245                write!(
246                    f,
247                    "MockEndpoint '{endpoint}': expected header '{key}' = {value} not found in any received exchange"
248                )?;
249                write_header_received_clause(f, *received_count, actual_values, last_headers, key)
250            }
251            MockAssertionError::HeaderRegexNotMatched {
252                endpoint,
253                key,
254                pattern,
255                received_count,
256                actual_values,
257                last_headers,
258            } => {
259                write!(
260                    f,
261                    "MockEndpoint '{endpoint}': no received exchange has header '{key}' matching regex {pattern:?}"
262                )?;
263                write_header_received_clause(f, *received_count, actual_values, last_headers, key)
264            }
265            MockAssertionError::InvalidHeaderPattern {
266                endpoint,
267                pattern,
268                source,
269                ..
270            } => write!(
271                f,
272                "MockEndpoint '{endpoint}': invalid regex pattern {pattern:?}: {source}"
273            ),
274        }
275    }
276}
277
278impl std::error::Error for MockAssertionError {
279    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
280        match self {
281            MockAssertionError::InvalidHeaderPattern { source, .. } => Some(&**source),
282            _ => None,
283        }
284    }
285}
286
287impl MockEndpointInner {
288    /// Evaluate all recorded expectations against the received snapshot.
289    ///
290    /// Single evaluation path shared by
291    /// [`assert_satisfied`](crate::MockEndpointInner::assert_satisfied) and
292    /// [`try_assert_satisfied`](crate::MockEndpointInner::try_assert_satisfied):
293    /// exact count, minimum count, then — only when expected bodies are
294    /// registered — body-count and per-body checks, then header and
295    /// header-regex checks (independent of the body gate).
296    ///
297    /// On a mismatch-class error the fail-fast latch is tripped first (when
298    /// `fail_fast` is enabled), then the error is returned. A malformed
299    /// expectation ([`MockAssertionError::InvalidHeaderPattern`]) is returned
300    /// without touching the latch.
301    /// Diagnostic payloads keep `MockAssertionError` above the
302    /// `result_large_err` size threshold (clippy allow mirrors
303    /// `do_try_segment.rs`).
304    #[allow(clippy::result_large_err)]
305    pub(crate) async fn evaluate_expectations(&self) -> Result<(), MockAssertionError> {
306        let received = self.get_received_exchanges().await;
307
308        let guard = self
309            .expectations
310            .lock()
311            .expect("expectations lock poisoned"); // allow-unwrap
312
313        // Exact count expectation — checked before bodies; a mismatch
314        // short-circuits all later checks.
315        if let Some(n) = guard.expected_count
316            && received.len() != n
317        {
318            return self.latch_err(MockAssertionError::CountMismatch {
319                endpoint: self.name.clone(),
320                expected: n,
321                actual: received.len(),
322            });
323        }
324
325        // Minimum count expectation.
326        if let Some(m) = guard.minimum_count
327            && received.len() < m
328        {
329            return self.latch_err(MockAssertionError::MinimumCountNotMet {
330                endpoint: self.name.clone(),
331                minimum: m,
332                actual: received.len(),
333            });
334        }
335
336        // Body expectations — gated: no expected bodies ⇒ body-count and
337        // per-body checks are skipped.
338        if !guard.expected_bodies.is_empty() {
339            let received_bodies: Vec<_> = received.iter().map(|e| &e.input.body).collect();
340            if guard.expected_bodies.len() != received_bodies.len() {
341                return self.latch_err(MockAssertionError::BodyCountMismatch {
342                    endpoint: self.name.clone(),
343                    expected: guard.expected_bodies.len(),
344                    actual: received_bodies.len(),
345                });
346            }
347            if self.any_order {
348                // Match in any order — each expected body must appear exactly once.
349                let mut unmatched: Vec<_> = received_bodies.iter().collect();
350                for expected in &guard.expected_bodies {
351                    let idx = unmatched
352                        .iter()
353                        .position(|actual| body_eq(expected, actual));
354                    match idx {
355                        Some(i) => {
356                            unmatched.remove(i);
357                        }
358                        None => {
359                            return self.latch_err(MockAssertionError::BodyNotFound {
360                                endpoint: self.name.clone(),
361                                expected: format!("{expected:?}"),
362                                received_count: received.len(),
363                            });
364                        }
365                    }
366                }
367            } else {
368                for (i, expected) in guard.expected_bodies.iter().enumerate() {
369                    if !body_eq(expected, received_bodies[i]) {
370                        return self.latch_err(MockAssertionError::BodyMismatch {
371                            endpoint: self.name.clone(),
372                            index: i,
373                            expected: format!("{expected:?}"),
374                            actual: format!("{:?}", received_bodies[i]),
375                        });
376                    }
377                }
378            }
379        }
380
381        // Expected headers (must all be present on at least one exchange).
382        for (key, value) in &guard.expected_headers {
383            let found = received
384                .iter()
385                .any(|ex| ex.input.headers.get(key).is_some_and(|v| v == value));
386            if !found {
387                let diag = header_diagnostics(&received, key);
388                return self.latch_err(MockAssertionError::HeaderNotFound {
389                    endpoint: self.name.clone(),
390                    key: key.clone(),
391                    value: value.clone(),
392                    received_count: diag.received_count,
393                    actual_values: diag.actual_values,
394                    last_headers: diag.last_headers,
395                });
396            }
397        }
398
399        // Expected header regexes.
400        for (key, pattern) in &guard.expected_header_regexes {
401            let re = match regex::Regex::new(pattern) {
402                Ok(re) => re,
403                // Malformed expectation: caller programming error, not a
404                // mismatch — the latch is not tripped.
405                Err(e) => {
406                    return Err(MockAssertionError::InvalidHeaderPattern {
407                        endpoint: self.name.clone(),
408                        key: key.clone(),
409                        pattern: pattern.clone(),
410                        source: Box::new(e),
411                    });
412                }
413            };
414            let found = received.iter().any(|ex| {
415                ex.input.headers.get(key).is_some_and(|v| {
416                    let s = match v {
417                        serde_json::Value::String(s) => s.clone(),
418                        other => other.to_string(),
419                    };
420                    re.is_match(&s)
421                })
422            });
423            if !found {
424                let diag = header_diagnostics(&received, key);
425                return self.latch_err(MockAssertionError::HeaderRegexNotMatched {
426                    endpoint: self.name.clone(),
427                    key: key.clone(),
428                    pattern: pattern.clone(),
429                    received_count: diag.received_count,
430                    actual_values: diag.actual_values,
431                    last_headers: diag.last_headers,
432                });
433            }
434        }
435
436        Ok(())
437    }
438
439    /// Trip the fail-fast latch (when enabled) and wrap `err` for return.
440    ///
441    /// Single latch call site for every expectation-mismatch branch;
442    /// [`MockAssertionError::InvalidHeaderPattern`] deliberately bypasses it.
443    #[allow(clippy::result_large_err)]
444    fn latch_err(&self, err: MockAssertionError) -> Result<(), MockAssertionError> {
445        self.set_fail_fast_on_mismatch();
446        Err(err)
447    }
448}
449
450/// Compare two `Body` values for equality (used by expectation evaluation).
451fn body_eq(a: &camel_component_api::Body, b: &camel_component_api::Body) -> bool {
452    match (a, b) {
453        (camel_component_api::Body::Empty, camel_component_api::Body::Empty) => true,
454        (camel_component_api::Body::Text(a), camel_component_api::Body::Text(b)) => a == b,
455        (camel_component_api::Body::Json(a), camel_component_api::Body::Json(b)) => a == b,
456        (camel_component_api::Body::Xml(a), camel_component_api::Body::Xml(b)) => a == b,
457        (camel_component_api::Body::Bytes(a), camel_component_api::Body::Bytes(b)) => a == b,
458        _ => false,
459    }
460}