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}