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}