camel_integration_test/runner.rs
1//! The scenario action runner (ADR-0069 §5, §7).
2//!
3//! Executes a scenario's ordered actions against a
4//! [`PartnerRouter`](crate::adapters::PartnerRouter): `send`
5//! dispatches through the adapter, `receive` awaits with the action's
6//! deadline and applies `extract` into [`ScenarioVars`], `sleep` uses
7//! tokio time, and `validate` evaluates the matcher grammar against
8//! the last received message or an extracted variable, or asserts a
9//! partner's recorded-request count (feature `http`).
10//!
11//! Failure taxonomy (ADR-0069 §7), encoded by variant and named in
12//! `Display`, never by message text alone:
13//!
14//! - Verdict class — the scenario ran and the system under test
15//! failed it: [`ScenarioFailure::ReceiveTimeout`],
16//! [`ScenarioFailure::ValidationMismatch`],
17//! [`ScenarioFailure::VarUnresolved`].
18//! - Apparatus class — the scenario never got a meaningful answer:
19//! [`ScenarioFailure::ActionTransport`],
20//! [`ScenarioFailure::PartnerStartup`],
21//! [`ScenarioFailure::ShutdownFailure`].
22//!
23//! Verdict-class failures map to exit 1 at the CLI; apparatus-class
24//! failures map to exit 2, as do doc-validation failures before the
25//! runner ever runs.
26//!
27//! Every await is bounded: `receive` carries the action deadline, and
28//! `send` is bounded by [`SEND_DEADLINE`].
29
30use std::collections::BTreeMap;
31use std::time::Duration;
32
33use camel_api::Value;
34
35#[cfg(feature = "http")]
36use crate::adapters::http::HttpWireRequest;
37use crate::adapters::{
38 IncomingMessage, OutgoingMessage, PartnerRouter, ReceiveError, TransportError,
39};
40use crate::document::{
41 EndpointRef, Expectation, PartnerExpectation, Provisioning, ScenarioAction, ScenarioDocument,
42 ScenarioTarget, ValidateExpectation,
43};
44
45/// The bounded deadline for every `send` action (ADR-0069 §7: every
46/// adapter operation carries a deadline).
47const SEND_DEADLINE: Duration = Duration::from_secs(30);
48
49/// The poll interval of a partner validate with a deadline
50/// (feature `http`): a fresh recorded-request snapshot every 100 ms
51/// until the filtered count settles or the deadline passes. The sleep
52/// between snapshots means the poll never busy-waits.
53#[cfg(feature = "http")]
54const PARTNER_POLL_INTERVAL: Duration = Duration::from_millis(100);
55
56/// Mutable run state carried across actions: scenario variables set by
57/// `extract`, and the last message received per endpoint for
58/// `lastReceived` validation.
59#[derive(Debug, Default)]
60pub struct ScenarioVars {
61 /// Variables extracted from received messages, by name.
62 variables: BTreeMap<String, Value>,
63 /// Last message received per endpoint URI.
64 last_received: BTreeMap<String, IncomingMessage>,
65}
66
67impl ScenarioVars {
68 /// Empty run state.
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 /// The variable set by an earlier `extract`, if any.
74 pub fn get(&self, name: &str) -> Option<&Value> {
75 self.variables.get(name)
76 }
77
78 /// Sets a variable, overwriting any earlier value.
79 pub fn set(&mut self, name: impl Into<String>, value: Value) {
80 self.variables.insert(name.into(), value);
81 }
82
83 /// The last message received on the endpoint URI, if any.
84 pub fn last_received(&self, endpoint: &str) -> Option<&IncomingMessage> {
85 self.last_received.get(endpoint)
86 }
87
88 /// Records the last message received on an endpoint URI.
89 fn remember(&mut self, endpoint: String, message: IncomingMessage) {
90 self.last_received.insert(endpoint, message);
91 }
92}
93
94/// Resolves `${name}` placeholders in a scenario string against `vars`.
95///
96/// Grammar: `$${` escapes to a literal `${`; `${name}` substitutes the
97/// variable when `name` matches `[A-Za-z0-9_]+` and is immediately
98/// followed by `}`. Anything else — including `${env:FOO}`, where a
99/// colon follows the name — stays literal, so `${env:}` never resolves
100/// in scenarios. A non-string variable substitutes its JSON
101/// representation (`Value::to_string`), so a number 42 yields `42`.
102/// Substituted text is not re-scanned. An unset variable fails with
103/// [`ScenarioFailure::VarUnresolved`].
104pub(crate) fn resolve_placeholders(
105 input: &str,
106 vars: &ScenarioVars,
107) -> Result<String, ScenarioFailure> {
108 let bytes = input.as_bytes();
109 let mut out = Vec::with_capacity(bytes.len());
110 let mut i = 0;
111 while i < bytes.len() {
112 if bytes[i] == b'$' {
113 // `$${` escapes to a literal `${`.
114 if i + 2 < bytes.len() && bytes[i + 1] == b'$' && bytes[i + 2] == b'{' {
115 out.extend_from_slice(b"${");
116 i += 3;
117 continue;
118 }
119 // `${name}` with name in [A-Za-z0-9_]+ immediately followed
120 // by `}`; a colon or any other character after the name
121 // keeps the whole span literal.
122 if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
123 let name_start = i + 2;
124 let mut j = name_start;
125 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
126 j += 1;
127 }
128 if j > name_start && j < bytes.len() && bytes[j] == b'}' {
129 let name = &input[name_start..j];
130 match vars.get(name) {
131 Some(value) => {
132 let replacement = stringify(value);
133 out.extend_from_slice(replacement.as_bytes());
134 i = j + 1;
135 continue;
136 }
137 None => {
138 return Err(ScenarioFailure::VarUnresolved {
139 name: name.to_string(),
140 });
141 }
142 }
143 }
144 }
145 out.push(b'$');
146 i += 1;
147 continue;
148 }
149 out.push(bytes[i]);
150 i += 1;
151 }
152 // The output is a byte-for-byte copy of the input except for
153 // substituted spans, so it stays valid UTF-8.
154 Ok(String::from_utf8(out).expect("placeholder output preserves input UTF-8")) // allow-unwrap
155}
156
157/// Recursively interpolates `${name}` placeholders in a value: maps
158/// and arrays are rebuilt with interpolated values, string leaves go
159/// through [`resolve_placeholders`], and every other leaf is cloned
160/// untouched. An unset variable propagates
161/// [`ScenarioFailure::VarUnresolved`] from any depth.
162pub(crate) fn interpolate_value(
163 value: &Value,
164 vars: &ScenarioVars,
165) -> Result<Value, ScenarioFailure> {
166 match value {
167 Value::String(text) => Ok(Value::String(resolve_placeholders(text, vars)?)),
168 Value::Array(items) => items
169 .iter()
170 .map(|item| interpolate_value(item, vars))
171 .collect::<Result<Vec<_>, _>>()
172 .map(Value::Array),
173 Value::Object(map) => {
174 let rebuilt = map
175 .iter()
176 .map(|(key, item)| Ok((key.clone(), interpolate_value(item, vars)?)))
177 .collect::<Result<_, _>>()?;
178 Ok(Value::Object(rebuilt))
179 }
180 other => Ok(other.clone()),
181 }
182}
183
184/// The outcome of a scenario that ran to completion: every action
185/// succeeded and every validation passed.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187#[non_exhaustive]
188pub enum ScenarioVerdict {
189 /// All actions completed and all validations passed.
190 Pass,
191}
192
193/// Why a scenario failed (ADR-0069 §7). Verdict-class variants mean
194/// the system under test failed the scenario; apparatus-class
195/// variants mean the scenario never got a meaningful answer. The
196/// CLI maps verdict-class failures to exit 1 and apparatus-class
197/// failures to exit 2; doc validation also maps to exit 2.
198#[derive(Debug, Clone, PartialEq, thiserror::Error)]
199#[non_exhaustive]
200pub enum ScenarioFailure {
201 /// Nothing reached the partner before the deadline (verdict
202 /// class, `receive-timeout`).
203 #[error("receive-timeout: {endpoint} delivered nothing within {deadline:?}")]
204 ReceiveTimeout {
205 /// The endpoint URI that delivered nothing.
206 endpoint: String,
207 /// The deadline that elapsed.
208 deadline: Duration,
209 },
210 /// A validation failed (verdict class, `validation-mismatch`).
211 #[error("validation-mismatch: action {action}: {detail}")]
212 ValidationMismatch {
213 /// Index of the failing action, zero-based.
214 action: usize,
215 /// What was expected and what arrived.
216 detail: String,
217 },
218 /// A referenced variable was never set (verdict class,
219 /// `scenario-var-unresolved`).
220 #[error("scenario-var-unresolved: {name}")]
221 VarUnresolved {
222 /// The variable name no `extract` ever set.
223 name: String,
224 },
225 /// A send or receive failed at the transport before any assertion
226 /// ran (apparatus class, `action-transport-failure`).
227 #[error("action-transport-failure: action {action}: {source}")]
228 ActionTransport {
229 /// Index of the failing action, zero-based.
230 action: usize,
231 /// The transport failure.
232 source: TransportError,
233 },
234 /// A partner listener bound but its handler failed to start
235 /// (apparatus class, `partner-startup-failure`). Reserved in v1:
236 /// no adapter separates bind from handler start, and the CLI maps
237 /// bind failures to `partner-bind-failure` doc errors.
238 #[error("partner-startup-failure: {message}")]
239 PartnerStartup {
240 /// Startup failure detail.
241 message: String,
242 },
243 /// Teardown of the boot or a partner timed out or erred after the
244 /// verdict was recorded (apparatus class, `shutdown-failure`).
245 #[error("shutdown-failure: {message}")]
246 ShutdownFailure {
247 /// Teardown failure detail.
248 message: String,
249 },
250}
251
252/// Fills the harness bind variables into `vars` (ADR-0069 §9): every
253/// wired reference with `provisioning: harness` and a `bindVar` gets
254/// its partner's bound `host:port` authority from the router, so a
255/// scenario string can address the partner as
256/// `http://${NAME}/path`.
257///
258/// Two-layer split: the scenario variable carries `host:port` only;
259/// the env-tier binding that route files interpolate keeps its
260/// `http://host:port` form (owned by the CLI driver, unchanged here).
261/// A reference with no registered adapter or no bound authority is
262/// skipped: the variable stays unset, and a later use fails with the
263/// verdict-class `VarUnresolved`.
264pub fn fill_bind_vars(wired: &[EndpointRef], router: &PartnerRouter, vars: &mut ScenarioVars) {
265 for reference in wired {
266 if reference.provisioning != Some(Provisioning::Harness) {
267 continue;
268 }
269 let Some(bind_var) = reference.bind_var.as_deref() else {
270 continue;
271 };
272 let Some(authority) = router
273 .adapter(&reference.endpoint)
274 .and_then(|adapter| adapter.bound_authority())
275 else {
276 continue;
277 };
278 vars.set(bind_var, Value::String(authority));
279 }
280}
281
282/// Runs a scenario's actions in order against the router.
283///
284/// On success every action completed; on failure the variant names
285/// the ADR-0069 §7 class. `vars` carries extraction results and
286/// last-received state both into and out of the run.
287pub async fn run_scenario(
288 doc: &ScenarioDocument,
289 router: &PartnerRouter,
290 vars: &mut ScenarioVars,
291) -> Result<ScenarioVerdict, ScenarioFailure> {
292 for (index, action) in doc.scenario.iter().enumerate() {
293 run_action(action, index, router, vars).await?;
294 }
295 Ok(ScenarioVerdict::Pass)
296}
297
298/// The outcome of executing a whole scenario document
299/// (ADR-0069 sections 5 and 7).
300///
301/// [`run_scenario_document`](self::run_scenario_document) fills
302/// `per_action` with one outcome per executed action and stops at the
303/// first failure; `verdict` is `Some(Pass)` only when every action
304/// passed. `final_failure` is the post-verdict slot: the caller that
305/// owns the boot (the CLI, after `BootHandle::shutdown`) records a
306/// `ShutdownFailure` there without masking the recorded verdict.
307#[derive(Debug, Clone, PartialEq)]
308pub struct DocumentOutcome {
309 /// One outcome per executed action, in action order; actions after
310 /// the first failure never ran.
311 pub per_action: Vec<Result<ScenarioVerdict, ScenarioFailure>>,
312 /// `Some(Pass)` when every action completed; `None` after any
313 /// failure.
314 pub verdict: Option<ScenarioVerdict>,
315 /// Post-verdict shutdown failure, recorded by the boot-owning
316 /// caller; empty when teardown is clean or never ran.
317 pub final_failure: Option<ScenarioFailure>,
318}
319
320/// Executes a scenario document's actions in order against the
321/// router, one recorded outcome per action, stopping at the first
322/// failure (the whole-document contract, library-level).
323///
324/// Partners route through `router`; a `send` addressed to a context
325/// component reaches the booted system under test through the
326/// context-stimulus adapter the caller registered for that endpoint
327/// (see [`crate::adapters`]). The single-action
328/// [`run_scenario`] loop and this loop share [`run_action`].
329pub async fn run_scenario_document(
330 doc: &ScenarioDocument,
331 router: &PartnerRouter,
332 vars: &mut ScenarioVars,
333) -> DocumentOutcome {
334 let mut per_action = Vec::with_capacity(doc.scenario.len());
335 let mut failed = false;
336 for (index, action) in doc.scenario.iter().enumerate() {
337 if failed {
338 break;
339 }
340 match run_action(action, index, router, vars).await {
341 Ok(()) => per_action.push(Ok(ScenarioVerdict::Pass)),
342 Err(failure) => {
343 per_action.push(Err(failure));
344 failed = true;
345 }
346 }
347 }
348 let verdict = if failed {
349 None
350 } else {
351 Some(ScenarioVerdict::Pass)
352 };
353 DocumentOutcome {
354 per_action,
355 verdict,
356 final_failure: None,
357 }
358}
359
360/// Executes one action at its scenario index. The shared primitive of
361/// [`run_scenario`] and [`run_scenario_document`]; every failure
362/// carries the action index.
363async fn run_action(
364 action: &ScenarioAction,
365 index: usize,
366 router: &PartnerRouter,
367 vars: &mut ScenarioVars,
368) -> Result<(), ScenarioFailure> {
369 match action {
370 ScenarioAction::Send {
371 to,
372 body,
373 headers,
374 method,
375 } => {
376 send_action(
377 index,
378 to,
379 body.as_ref(),
380 headers.as_ref(),
381 method,
382 router,
383 vars,
384 )
385 .await?;
386 }
387 ScenarioAction::Receive {
388 from,
389 deadline,
390 extract,
391 } => {
392 receive_action(index, from, *deadline, extract.as_ref(), router, vars).await?;
393 }
394 ScenarioAction::Sleep { duration } => {
395 tokio::time::sleep(*duration).await;
396 }
397 ScenarioAction::Validate {
398 target,
399 expectation,
400 deadline,
401 } => {
402 validate_action(index, target, expectation, *deadline, router, vars).await?;
403 }
404 }
405 Ok(())
406}
407
408/// Dispatches a `send` action, bounded by [`SEND_DEADLINE`].
409///
410/// The endpoint reference, the body's string leaves, and the header
411/// values are the complete interpolation surface: each resolves its
412/// `${name}` placeholders against `vars` before dispatch, and an
413/// unresolved variable fails with the verdict-class `VarUnresolved`.
414/// The dial target comes from the router's address math: a
415/// harness-declared `:0` reference (or a dynamic reference resolving
416/// to a partner authority) dials the partner's bound address with the
417/// interpolated path preserved; anything else dials the interpolated
418/// URI literally.
419async fn send_action(
420 index: usize,
421 to: &EndpointRef,
422 body: Option<&Value>,
423 headers: Option<&BTreeMap<String, Value>>,
424 method: &str,
425 router: &PartnerRouter,
426 vars: &ScenarioVars,
427) -> Result<(), ScenarioFailure> {
428 let declared = to.endpoint.as_str();
429 let interpolated = resolve_placeholders(declared, vars)?;
430 let body = body
431 .map(|value| interpolate_value(value, vars))
432 .transpose()?;
433 let headers = headers
434 .map(|map| -> Result<BTreeMap<String, Value>, ScenarioFailure> {
435 map.iter()
436 .map(|(name, value)| Ok((name.clone(), interpolate_value(value, vars)?)))
437 .collect()
438 })
439 .transpose()?;
440 let msg = OutgoingMessage {
441 body: body.unwrap_or(Value::Null),
442 headers: headers.unwrap_or_default(),
443 method: method.to_string(),
444 };
445 let bounded =
446 tokio::time::timeout(SEND_DEADLINE, router.send(declared, &interpolated, msg)).await;
447 let sent = bounded.map_err(|_| ScenarioFailure::ActionTransport {
448 action: index,
449 source: TransportError::Deadline {
450 after: SEND_DEADLINE,
451 },
452 })?;
453 sent.map_err(|source| ScenarioFailure::ActionTransport {
454 action: index,
455 source,
456 })
457}
458
459/// Awaits a `receive` action until the deadline, records the message,
460/// and applies `extract` into `vars`.
461async fn receive_action(
462 index: usize,
463 from: &EndpointRef,
464 deadline: Duration,
465 extract: Option<&BTreeMap<String, String>>,
466 router: &PartnerRouter,
467 vars: &mut ScenarioVars,
468) -> Result<(), ScenarioFailure> {
469 // The lane is read under the two-key contract: the declared
470 // string names the registered lane when it can, and the
471 // interpolated URI resolves a dynamic reference's lane by
472 // authority (`lane_key_for`).
473 let declared = from.endpoint.as_str();
474 let interpolated = resolve_placeholders(declared, vars)?;
475 let message = router
476 .receive(declared, &interpolated, deadline)
477 .await
478 .map_err(|source| match source {
479 ReceiveError::Timeout(_) => ScenarioFailure::ReceiveTimeout {
480 endpoint: from.endpoint.clone(),
481 deadline,
482 },
483 ReceiveError::Transport(source) => ScenarioFailure::ActionTransport {
484 action: index,
485 source,
486 },
487 })?;
488 if let Some(extract) = extract {
489 for (name, selector) in extract {
490 let value = select_from(&message, selector).ok_or_else(|| {
491 ScenarioFailure::ValidationMismatch {
492 action: index,
493 detail: format!(
494 "extract of `{selector}` into variable `{name}` resolved to nothing"
495 ),
496 }
497 })?;
498 vars.set(name.clone(), value);
499 }
500 }
501 vars.remember(from.endpoint.clone(), message);
502 Ok(())
503}
504
505/// Evaluates a `validate` action (ADR-0069 §5).
506///
507/// The `partner` target asserts the exact filtered count of the
508/// requests the harness partner recorded, read as the router's
509/// snapshot — one immediate read without a deadline, a polled one
510/// with it ([`partner_validate_action`]). Every other target applies
511/// the message grammar against `vars`; the deadline is partner-only
512/// (the grammar rejected it on these targets at parse time, so the
513/// message arm ignores it). Mismatch details name the validation
514/// subject — the variable's name, the receiving endpoint, or the
515/// partner URI — so a corrupted-header regression is diagnosable from
516/// the failure text.
517async fn validate_action(
518 index: usize,
519 target: &ScenarioTarget,
520 expectation: &ValidateExpectation,
521 deadline: Option<Duration>,
522 router: &PartnerRouter,
523 vars: &ScenarioVars,
524) -> Result<(), ScenarioFailure> {
525 match (target, expectation) {
526 // The parser pairs a `partner` target with the partner count
527 // grammar; this arm reads the router's snapshot and owns the
528 // deadline.
529 (ScenarioTarget::Partner(endpoint), ValidateExpectation::Partner(expected)) => {
530 partner_validate_action(index, &endpoint.endpoint, expected, deadline, router).await
531 }
532 (_, ValidateExpectation::Message(expectation)) => {
533 let (value, subject) = match target {
534 ScenarioTarget::LastReceived(endpoint) => (
535 vars.last_received(&endpoint.endpoint)
536 .map(|message| message.body.clone())
537 .ok_or_else(|| ScenarioFailure::ValidationMismatch {
538 action: index,
539 detail: format!(
540 "no message has been received on {} to validate",
541 endpoint.endpoint
542 ),
543 })?,
544 format!("body last received on {}", endpoint.endpoint),
545 ),
546 ScenarioTarget::Variable(name) => (
547 vars.get(name)
548 .cloned()
549 .ok_or_else(|| ScenarioFailure::VarUnresolved { name: name.clone() })?,
550 format!("variable `{name}`"),
551 ),
552 // Taken by the arm above: the grammar never pairs a
553 // `partner` target with the message expectation.
554 ScenarioTarget::Partner(_) => return Err(unpaired_validate(index)),
555 };
556 match expectation {
557 Expectation::Equals(expected) => {
558 if &value == expected {
559 Ok(())
560 } else {
561 Err(ScenarioFailure::ValidationMismatch {
562 action: index,
563 detail: format!("{subject}: expected {expected}, got {value}"),
564 })
565 }
566 }
567 Expectation::Regex(pattern) => {
568 let regex = regex::Regex::new(pattern).map_err(|error| {
569 ScenarioFailure::ValidationMismatch {
570 action: index,
571 detail: format!("invalid regex `{pattern}`: {error}"),
572 }
573 })?;
574 if regex.is_match(&stringify(&value)) {
575 Ok(())
576 } else {
577 Err(ScenarioFailure::ValidationMismatch {
578 action: index,
579 detail: format!("{subject}: `{pattern}` did not match {value}"),
580 })
581 }
582 }
583 Expectation::Contains(needle) => check(
584 index,
585 stringify(&value).contains(needle),
586 format!("{subject}: did not contain `{needle}`: {value}"),
587 ),
588 Expectation::StartsWith(prefix) => check(
589 index,
590 stringify(&value).starts_with(prefix),
591 format!("{subject}: did not start with `{prefix}`: {value}"),
592 ),
593 Expectation::EndsWith(suffix) => check(
594 index,
595 stringify(&value).ends_with(suffix),
596 format!("{subject}: did not end with `{suffix}`: {value}"),
597 ),
598 Expectation::Exists => {
599 if value == Value::Null {
600 Err(ScenarioFailure::ValidationMismatch {
601 action: index,
602 detail: format!("{subject}: expected a value, got null"),
603 })
604 } else {
605 Ok(())
606 }
607 }
608 Expectation::JsonSubset(pattern) => check(
609 index,
610 json_subset(pattern, &value),
611 format!("{subject}: not a superset of {pattern}: {value}"),
612 ),
613 }
614 }
615 // The parser never pairs a partner expectation with a
616 // non-partner target.
617 _ => Err(unpaired_validate(index)),
618 }
619}
620
621/// The failure for a target/expectation pairing the grammar never
622/// produces: the parser pairs `partner` targets with the partner
623/// count grammar and every other target with the message grammar, so
624/// only a caller bypassing the parser reaches these arms.
625fn unpaired_validate(index: usize) -> ScenarioFailure {
626 ScenarioFailure::ValidationMismatch {
627 action: index,
628 detail: "validate target kind does not pair with the expectation kind".to_string(),
629 }
630}
631
632/// Counts the recorded requests that pass both filters (feature
633/// `http`): `method` compares ASCII-case-insensitively (wire records
634/// are uppercased; the expectation may declare any casing), `path`
635/// compares the path-and-query exactly, and `None` passes everything.
636#[cfg(feature = "http")]
637pub(crate) fn matching_requests(
638 requests: &[HttpWireRequest],
639 method: Option<&str>,
640 path: Option<&str>,
641) -> usize {
642 requests
643 .iter()
644 .filter(|request| {
645 method.is_none_or(|m| m.eq_ignore_ascii_case(&request.method))
646 && path.is_none_or(|p| p == request.path)
647 })
648 .count()
649}
650
651/// Asserts the partner count expectation against the router's
652/// recorded-request snapshot for the declared endpoint key
653/// (ADR-0069 §5: what crossed the wire is the normative proof).
654///
655/// The snapshot filters by the expectation's `method` and `path`
656/// ([`matching_requests`]) and asserts exact equality on the filtered
657/// count — arrivals only add, so a count above the expected one fails
658/// at every snapshot and never settles back. Without a deadline the
659/// assertion reads one immediate snapshot. With one it polls at
660/// [`PARTNER_POLL_INTERVAL`] and, on expiry, one final snapshot
661/// decides with its own count the reported actual. Every snapshot
662/// clones out of the recorder's lock before any await, so no lock
663/// spans an await point and the poll sleeps between snapshots.
664#[cfg(feature = "http")]
665async fn partner_validate_action(
666 index: usize,
667 uri: &str,
668 expected: &PartnerExpectation,
669 deadline: Option<Duration>,
670 router: &PartnerRouter,
671) -> Result<(), ScenarioFailure> {
672 let expected_count = usize::try_from(expected.count).unwrap_or(usize::MAX);
673 let count = || {
674 matching_requests(
675 &router.recorded_requests(uri),
676 expected.method.as_deref(),
677 expected.path.as_deref(),
678 )
679 };
680 let mismatch = |actual: usize| ScenarioFailure::ValidationMismatch {
681 action: index,
682 detail: partner_mismatch_detail(uri, expected, actual),
683 };
684 match deadline {
685 // No deadline: one immediate snapshot decides.
686 None => {
687 let actual = count();
688 if actual == expected_count {
689 Ok(())
690 } else {
691 Err(mismatch(actual))
692 }
693 }
694 // Poll until some snapshot's count equals the expectation or
695 // the deadline passes; the final snapshot then decides.
696 Some(deadline) => {
697 let until = tokio::time::Instant::now() + deadline;
698 loop {
699 if count() == expected_count {
700 return Ok(());
701 }
702 let now = tokio::time::Instant::now();
703 if now >= until {
704 let actual = count();
705 return if actual == expected_count {
706 Ok(())
707 } else {
708 Err(mismatch(actual))
709 };
710 }
711 tokio::time::sleep((until - now).min(PARTNER_POLL_INTERVAL)).await;
712 }
713 }
714 }
715}
716
717/// Partner verification needs the http adapter's recording (feature
718/// `http`); without the feature the arm fails with the verdict-class
719/// mismatch instead of passing silently.
720#[cfg(not(feature = "http"))]
721async fn partner_validate_action(
722 index: usize,
723 uri: &str,
724 expected: &PartnerExpectation,
725 deadline: Option<Duration>,
726 router: &PartnerRouter,
727) -> Result<(), ScenarioFailure> {
728 let _ = (uri, expected, deadline, router);
729 Err(ScenarioFailure::ValidationMismatch {
730 action: index,
731 detail: "partner validation requires the `http` feature".to_string(),
732 })
733}
734
735/// The mismatch detail of a failed partner count assertion: the
736/// partner URI, the applied filters (`method`, `path`) when set, and
737/// the expected-versus-actual counts.
738#[cfg(feature = "http")]
739fn partner_mismatch_detail(uri: &str, expected: &PartnerExpectation, actual: usize) -> String {
740 let mut detail = format!("partner {uri}");
741 if expected.method.is_some() || expected.path.is_some() {
742 let filters = [
743 expected
744 .method
745 .as_deref()
746 .map(|method| format!("method {method}")),
747 expected.path.as_deref().map(|path| format!("path {path}")),
748 ];
749 let joined = filters.into_iter().flatten().collect::<Vec<_>>().join(", ");
750 detail.push_str(&format!(" ({joined})"));
751 }
752 detail.push_str(&format!(", expected {}, actual {actual}", expected.count));
753 detail
754}
755
756/// Turns a validation predicate into a [`ScenarioFailure`] on `false`.
757fn check(index: usize, passed: bool, detail: String) -> Result<(), ScenarioFailure> {
758 if passed {
759 Ok(())
760 } else {
761 Err(ScenarioFailure::ValidationMismatch {
762 action: index,
763 detail,
764 })
765 }
766}
767
768/// Reads a value out of a received message by dotted selector.
769///
770/// Grammar: the first segment selects `body`, `headers`, `status`,
771/// `method`, or `path`; the rest is a literal header name
772/// (`headers.X-Id`, dots allowed in the name) or a dotted object path
773/// under the body (`body.user.id`). A bare `body` or `headers` selects
774/// the whole part.
775///
776/// Header lookup is ASCII-case-insensitive: adapters differ in header
777/// casing (hyper lowercases wire names; the fake preserves author
778/// casing), and the same selector must behave identically per adapter.
779/// Wire recording stays lowercase.
780fn select_from(message: &IncomingMessage, selector: &str) -> Option<Value> {
781 let (head, rest) = match selector.split_once('.') {
782 Some((head, rest)) => (head, Some(rest)),
783 None => (selector, None),
784 };
785 match head {
786 "body" => match rest {
787 None => Some(message.body.clone()),
788 Some(path) => walk_path(&message.body, path),
789 },
790 "headers" => match rest {
791 None => Some(Value::Object(
792 message
793 .headers
794 .iter()
795 .map(|(name, value)| (name.clone(), value.clone()))
796 .collect(),
797 )),
798 Some(name) => lookup_header(&message.headers, name),
799 },
800 // The transport-scalar heads carry no sub-path: `status.why` is
801 // not part of the grammar and resolves to nothing.
802 "status" if rest.is_none() => Some(
803 message
804 .status
805 .map_or(Value::Null, |code| Value::Number(code.into())),
806 ),
807 "method" if rest.is_none() => {
808 Some(message.method.clone().map_or(Value::Null, Value::String))
809 }
810 "path" if rest.is_none() => Some(message.path.clone().map_or(Value::Null, Value::String)),
811 _ => None,
812 }
813}
814
815/// Case-insensitive header lookup: the first header whose name matches
816/// the selector ASCII-case-insensitively wins; header maps are
817/// case-unique per adapter, so the fold is deterministic.
818fn lookup_header(headers: &BTreeMap<String, Value>, name: &str) -> Option<Value> {
819 headers
820 .iter()
821 .find(|(key, _)| key.eq_ignore_ascii_case(name))
822 .map(|(_, value)| value.clone())
823}
824
825/// Walks a dotted object path under a body value; arrays and scalars
826/// resolve to nothing.
827fn walk_path(value: &Value, path: &str) -> Option<Value> {
828 let mut current = value;
829 for key in path.split('.') {
830 current = current.as_object()?.get(key)?;
831 }
832 Some(current.clone())
833}
834
835/// Renders a value for string matchers: strings as-is, anything else
836/// as its JSON form.
837fn stringify(value: &Value) -> String {
838 match value {
839 Value::String(text) => text.clone(),
840 other => other.to_string(),
841 }
842}
843
844/// Recursive-subset match: every key in `pattern` must exist in
845/// `actual` with a recursively subset-matching value; values outside
846/// `pattern` are ignored. Non-object patterns compare by equality.
847fn json_subset(pattern: &Value, actual: &Value) -> bool {
848 match (pattern, actual) {
849 (Value::Object(pattern_object), Value::Object(actual_object)) => {
850 pattern_object.iter().all(|(key, pattern_value)| {
851 actual_object
852 .get(key)
853 .is_some_and(|actual_value| json_subset(pattern_value, actual_value))
854 })
855 }
856 _ => pattern == actual,
857 }
858}