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}