Skip to main content

sendra_core/assertions/
mod.rs

1//! Declarative checks a request makes against the response it gets back.
2//!
3//! A request file may carry an `assertions` block:
4//!
5//! ```yaml
6//! method: GET
7//! url: https://httpbin.org/get
8//! assertions:
9//!   status: 200
10//!   status_in: [200, 201, 204]
11//!   headers:
12//!     content-type: application/json   # present, with this exact value
13//!     x-request-id:                    # present, value not checked
14//!   body_contains: '"url"'
15//!   body_matches: '^\{"url"'
16//!   elapsed_ms_under: 2000
17//!   json:
18//!     $.headers.Accept: application/json           # bare value: equality
19//!     $.count: { greater_than: 5 }                 # operator object: comparison
20//!     $.id: { matches: '^[0-9a-f-]{36}$' }         # operator object: regex
21//!   not:
22//!     status: 404
23//! ```
24//!
25//! Every key is optional and a request with no `assertions` block behaves
26//! exactly as it did before this module existed.
27//!
28//! Two things need a word up front, because they are not obvious from the
29//! schema alone:
30//!
31//! **`json:` disambiguates bare values from operators by shape.** A path's
32//! expected value is read as an equality check unless it is a YAML mapping
33//! with exactly one key drawn from `greater_than`, `greater_than_or_equal`,
34//! `less_than`, `less_than_or_equal`, `contains`, `length` or `matches`, in
35//! which case it is that operator instead. This means an equality check
36//! against a genuine one-key object shaped like `{greater_than: 5}` is not
37//! expressible — a real limitation, accepted because the alternative (a
38//! separate block for operators) would make every path assertion say twice
39//! which kind it is, and `{greater_than: 5}` as a literal expected value is
40//! not a shape real APIs return. A multi-key object (`{id: 1, name: ada}`)
41//! is never ambiguous and is always equality, exactly as before.
42//!
43//! There is deliberately no `not_equal` operator: `not: {json: {$.count:
44//! 5}}` already says "not equal to 5" precisely, and a dedicated operator
45//! would only be a shorter spelling of the negation wrapper that already
46//! exists — unlike `greater_than`/`less_than`, which are comparisons `not:`
47//! cannot express at all (`not: {json: {$.count: {greater_than: 5}}}` means
48//! `<= 5`, not `< 5`, and there is no operator-free way to write "less than
49//! 5" as a negation).
50//!
51//! **`not:` wraps a whole assertions block, not one assertion.** It takes
52//! the same keys as the top level (minus `not` itself — nesting `not` inside
53//! `not` is a parse error, not a double negative) and negates each one
54//! independently: `not: {status: 404, body_contains: error}` is "status is
55//! not 404" *and* "body does not contain `error`", not "status is 404 and
56//! body contains `error`" negated as a pair. A wrapper was chosen over a
57//! `not_status` / `not_body_contains` key for every assertion type because
58//! it composes for free with whatever assertion kind is added next, rather
59//! than doubling the schema's key count every time one is.
60//!
61//! A hard error — a malformed JSON path, an invalid regex (whether from
62//! `body_matches` or a `matches` operator), a body that is not JSON, a JSON
63//! path selecting zero or several values, a comparison or `length` operator
64//! applied to a value of the wrong type — is not something `not:` can turn
65//! into a pass. These are facts about the request or the file, not a
66//! condition to be true or false, so `not: {json: {$.a: {greater_than: 5}}}`
67//! against a body where `$.a` is a string still fails, the same way it would
68//! unwrapped.
69//!
70//! **Evaluation never fails.** [`Assertions::evaluate`] returns an
71//! [`AssertionReport`] and no `Result`: everything that could go wrong — a body
72//! that is not JSON, a JSON path that does not parse, a header that is not there
73//! — is a *failed assertion with a message*, not an error in the surrounding
74//! run. The response has already arrived by the time any of this happens, so
75//! there is nothing left to abort; the only useful thing to do with a broken
76//! expectation is to say precisely how it broke, next to the ones that held.
77//!
78//! **Nothing here decides an exit code.** Evaluating and reporting is all this
79//! module does; whether a failed assertion should fail the process is a
80//! front-end decision, and today the answer is no. See the exit-code table in
81//! `sendra-cli`.
82
83use std::collections::BTreeMap;
84
85use regex::Regex;
86use serde::{Deserialize, Serialize};
87
88use crate::Response;
89
90mod json;
91
92#[cfg(test)]
93mod test_support;
94
95use json::check_json_path;
96
97/// The `assertions` block of a request, exactly as it appears on disk.
98///
99/// Each field is a separate *kind* of check, and each entry within a field is
100/// one assertion — `headers` with three entries is three assertions, reported
101/// individually. All of them are evaluated on every response; none short-circuit
102/// the others, because "which of my expectations held" is the question this
103/// feature exists to answer and stopping at the first failure would answer it
104/// only partially.
105///
106/// Unknown keys are rejected, like everywhere else in Sendra's schema: an
107/// assertion silently ignored because of a typo is worse than no assertion at
108/// all, since it reads as a check that is passing.
109#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111#[serde(deny_unknown_fields)]
112pub struct Assertions {
113    /// The exact status code the response must carry.
114    ///
115    /// Equality against one code rather than a class (`2xx`) or a range: the
116    /// two are not the same assertion, and "this endpoint answers 201" is the
117    /// one worth writing down. A class matcher can be added as its own key
118    /// later without changing what this one means.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub status: Option<u16>,
121
122    /// The status code must be one of these.
123    ///
124    /// Its own key rather than folding into `status` (a list there would
125    /// change what a bare `status: 200` means) — `status` is "exactly this
126    /// code", `status_in` is "one of these codes", and a file should be able
127    /// to write either without the other's presence changing its meaning.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub status_in: Option<Vec<u16>>,
130
131    /// Headers the response must carry, by name.
132    ///
133    /// A value asserts the header is present *and* equal to it; a null value
134    /// (`x-request-id:` with nothing after it) asserts only that the header is
135    /// there. One key covers both because they are the same assertion with and
136    /// without an expectation about the value, and a second key
137    /// (`headers_present`) would make the file say twice what the value's
138    /// presence already says.
139    ///
140    /// Names are matched case-insensitively, because HTTP header names are.
141    /// Values are matched exactly: `content-type: application/json` does *not*
142    /// match `application/json; charset=utf-8`. That is the strict reading, and
143    /// the honest one — a substring match would quietly accept
144    /// `application/json-seq` too. When a server decorates a value, assert the
145    /// whole value or drop to presence-only.
146    ///
147    /// A repeated header (`set-cookie`) passes if *any* of its values matches.
148    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
149    pub headers: BTreeMap<String, Option<String>>,
150
151    /// A substring the response body must contain, matched case-sensitively on
152    /// the body as printed.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub body_contains: Option<String>,
155
156    /// A regular expression the response body must match, anywhere in it —
157    /// the same "somewhere in the body" reach as `body_contains`, not an
158    /// anchored whole-body match, so `body_matches: '"id":\s*\d+'` finds that
159    /// pattern wherever it sits.
160    ///
161    /// The engine is [`regex`](https://docs.rs/regex), already in the
162    /// dependency tree as a transitive dependency of `jsonpath-rust` — this
163    /// adds no new crate, only a direct declaration of one already being
164    /// built.
165    ///
166    /// The pattern is checked when the assertion runs, not when the file is
167    /// loaded, for the same reason a JSON path is: a stricter release of
168    /// `regex` should not start rejecting files that used to load, for a
169    /// request Sendra could still send. An invalid pattern is a failed
170    /// assertion naming the parse error, not a panic or a load-time error.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub body_matches: Option<String>,
173
174    /// The response must have arrived in under this many milliseconds.
175    ///
176    /// Backed by [`Response::elapsed`], which is wall-clock time for the
177    /// request as sent — DNS, connect and TLS included, the same number a
178    /// person timing the request by hand would get. A strict "under", not
179    /// "at or under": a threshold is normally chosen as a round number the
180    /// response should beat, and `elapsed_ms_under: 500` reads as "faster
181    /// than half a second," which an exact 500ms response is not.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub elapsed_ms_under: Option<u64>,
184
185    /// JSON path expressions mapped to the value each must select.
186    ///
187    /// ```yaml
188    /// json:
189    ///   $.user.id: 42
190    ///   $.user.name: ada
191    ///   $.tags: [a, b]
192    /// ```
193    ///
194    /// The expected value is written as YAML and held as a [`serde_json::Value`]
195    /// — parsed once, when the file is loaded, into the form it will be compared
196    /// against, so a value that has no JSON equivalent is a parse error naming
197    /// the file rather than a surprise at response time.
198    ///
199    /// A path must select **exactly one** value. Nothing matched, or several
200    /// matched, is a failure with that stated: `$.users[*].id` against three
201    /// users is a question with no single answer, and picking the first would
202    /// make the assertion depend on ordering the author never specified.
203    ///
204    /// The engine is [`jsonpath-rust`](https://docs.rs/jsonpath-rust), chosen
205    /// over `serde_json_path`, the other RFC 9535 implementation, on
206    /// maintenance and stability: at the time of writing jsonpath-rust is at
207    /// `1.0` with releases landing this year, while `serde_json_path` has not
208    /// released since February 2025 and is still pre-`1.0`. Both are correct
209    /// and both query `serde_json::Value` directly, which is what keeps this
210    /// dependency swappable if that ever changes: it is confined to
211    /// [`Assertions::evaluate`], behind a path string and a value comparison.
212    /// Beyond a bare equality value, a path may map to an *operator object*
213    /// with exactly one of these keys:
214    ///
215    /// ```yaml
216    /// json:
217    ///   $.count: { greater_than: 5 }             # numeric: actual > 5
218    ///   $.count: { greater_than_or_equal: 5 }    # numeric: actual >= 5
219    ///   $.count: { less_than: 5 }                # numeric: actual < 5
220    ///   $.count: { less_than_or_equal: 5 }       # numeric: actual <= 5
221    ///   $.tags: { contains: b }   # substring of a string, or array membership
222    ///   $.tags: { length: 2 }                     # array/string length equals 2
223    ///   $.tags: { length: { greater_than: 1 } }   # length compared, not just equal
224    ///   $.id: { matches: '^[0-9a-f-]{36}$' }      # regex, scoped to this path's
225    ///                                              # string value — see `body_matches`
226    ///                                              # for the whole-body equivalent
227    /// ```
228    ///
229    /// See the module docs for exactly how a bare value is told apart from an
230    /// operator, and what that costs.
231    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
232    pub json: BTreeMap<String, serde_json::Value>,
233
234    /// Every assertion in this block, inverted: passes exactly when the
235    /// wrapped one would have failed, and vice versa. See the module docs for
236    /// what a wrapper buys over a `not_`-prefixed key per assertion type, and
237    /// for why a hard error underneath — a malformed path, an invalid regex,
238    /// a type mismatch — is not something this can turn into a pass.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub not: Option<NotAssertions>,
241}
242
243/// The inner block of a `not:` wrapper: every key `Assertions` has, except
244/// `not` itself.
245///
246/// A separate type rather than `not: Option<Box<Assertions>>` with a runtime
247/// check against a nested `not`, so that `not: {not: {...}}` is rejected by
248/// the same `deny_unknown_fields` machinery as every other unknown key,
249/// rather than by a bespoke check that could drift from it.
250#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
251#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
252#[serde(deny_unknown_fields)]
253pub struct NotAssertions {
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub status: Option<u16>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub status_in: Option<Vec<u16>>,
258    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
259    pub headers: BTreeMap<String, Option<String>>,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub body_contains: Option<String>,
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub body_matches: Option<String>,
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub elapsed_ms_under: Option<u64>,
266    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
267    pub json: BTreeMap<String, serde_json::Value>,
268}
269
270impl NotAssertions {
271    fn is_empty(&self) -> bool {
272        self.status.is_none()
273            && self.status_in.is_none()
274            && self.headers.is_empty()
275            && self.body_contains.is_none()
276            && self.body_matches.is_none()
277            && self.elapsed_ms_under.is_none()
278            && self.json.is_empty()
279    }
280
281    fn fields(&self) -> Fields<'_> {
282        Fields {
283            status: self.status,
284            status_in: self.status_in.as_deref(),
285            headers: &self.headers,
286            body_contains: self.body_contains.as_deref(),
287            body_matches: self.body_matches.as_deref(),
288            elapsed_ms_under: self.elapsed_ms_under,
289            json: &self.json,
290        }
291    }
292}
293
294/// The checkable fields shared by [`Assertions`] and [`NotAssertions`],
295/// borrowed rather than duplicated so [`push_checks`] has exactly one
296/// implementation for both the plain block and the `not:` block underneath
297/// it.
298struct Fields<'a> {
299    status: Option<u16>,
300    status_in: Option<&'a [u16]>,
301    headers: &'a BTreeMap<String, Option<String>>,
302    body_contains: Option<&'a str>,
303    body_matches: Option<&'a str>,
304    elapsed_ms_under: Option<u64>,
305    json: &'a BTreeMap<String, serde_json::Value>,
306}
307
308/// Which kind of check produced a result, for a front-end that wants to group,
309/// filter or colour by kind rather than parse the rendered text.
310///
311/// Negation does not add variants of its own: `not: {status: 404}` reports as
312/// [`AssertionKind::Status`], the same kind a bare `status: 404` would,
313/// because it is a statement about the same thing, only inverted — the
314/// [`AssertionResult::expectation`] wording is what says which.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum AssertionKind {
317    Status,
318    StatusIn,
319    Header,
320    BodyContains,
321    BodyMatches,
322    ElapsedMsUnder,
323    JsonPath,
324}
325
326/// One assertion, evaluated.
327///
328/// Both strings are rendered in core rather than in the CLI so that every
329/// front-end says the same thing about the same failure, and so the wording
330/// lives next to the comparison that produced it. A front-end decides layout,
331/// colour and symbols; it does not decide what "got 404" means.
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct AssertionResult {
334    pub kind: AssertionKind,
335
336    /// What was asserted, as a phrase: `status is 200`.
337    pub expectation: String,
338
339    /// Why it did not hold — `got 404` — or `None` if it did.
340    ///
341    /// An `Option` rather than a `bool` plus a message, so a result cannot be
342    /// constructed claiming to have failed with nothing to say about it.
343    pub failure: Option<String>,
344}
345
346impl AssertionResult {
347    pub fn passed(&self) -> bool {
348        self.failure.is_none()
349    }
350
351    fn pass(kind: AssertionKind, expectation: String) -> Self {
352        Self {
353            kind,
354            expectation,
355            failure: None,
356        }
357    }
358
359    fn fail(kind: AssertionKind, expectation: String, failure: String) -> Self {
360        Self {
361            kind,
362            expectation,
363            failure: Some(failure),
364        }
365    }
366}
367
368/// Picks the expectation text a negated check should show: the negative
369/// wording under `not:`, the positive one otherwise. The wording is the same
370/// whichever way the check itself comes out — pass or fail always says what
371/// was asserted, never what happened — so one call up front covers both
372/// outcomes.
373fn expectation_text(positive: String, negative: String, negate: bool) -> String {
374    if negate {
375        negative
376    } else {
377        positive
378    }
379}
380
381/// Turns a plain (unnegated) condition into the [`AssertionResult`] a check
382/// function returns, honouring `negate`.
383///
384/// `holds` is whether the condition as written — ignoring `not:` — is true of
385/// the response. Whether that is a pass depends on `negate`: a plain check
386/// passes when `holds`, a negated one passes when `!holds`. `detail_if_false`
387/// explains a plain failure (`holds` was false); `detail_if_true` explains a
388/// negated failure (`holds` was true, which is exactly what `not:` forbade).
389/// Both are computed unconditionally since every detail here is a cheap
390/// `format!`, not worth deferring behind a closure.
391fn finish(
392    kind: AssertionKind,
393    holds: bool,
394    expectation: String,
395    negate: bool,
396    detail_if_false: String,
397    detail_if_true: String,
398) -> AssertionResult {
399    let failed = if negate { holds } else { !holds };
400    if !failed {
401        AssertionResult::pass(kind, expectation)
402    } else {
403        let detail = if negate {
404            detail_if_true
405        } else {
406            detail_if_false
407        };
408        AssertionResult::fail(kind, expectation, detail)
409    }
410}
411
412/// Every assertion on one request, evaluated against one response, in a fixed
413/// order: status, `status_in`, headers, `body_contains`, `body_matches`,
414/// `elapsed_ms_under`, then JSON paths — with the entries of each map in
415/// sorted order — followed by the same order again for the `not:` block, if
416/// there is one. Deterministic because the output is read by people and
417/// diffed by scripts, and neither is served by an order that depends on how a
418/// `BTreeMap` happened to be filled.
419#[derive(Debug, Clone, PartialEq, Eq, Default)]
420pub struct AssertionReport {
421    results: Vec<AssertionResult>,
422}
423
424impl AssertionReport {
425    pub fn results(&self) -> &[AssertionResult] {
426        &self.results
427    }
428
429    /// No assertions were written, so nothing was checked.
430    ///
431    /// Distinct from [`passed`](Self::passed), which an empty report also
432    /// answers `true` — vacuously. A front-end prints nothing at all for an
433    /// empty report: a request with no assertions must look exactly as it did
434    /// before assertions existed.
435    pub fn is_empty(&self) -> bool {
436        self.results.is_empty()
437    }
438
439    pub fn len(&self) -> usize {
440        self.results.len()
441    }
442
443    /// Every assertion held (vacuously true when there are none).
444    pub fn passed(&self) -> bool {
445        self.results.iter().all(AssertionResult::passed)
446    }
447
448    pub fn passed_count(&self) -> usize {
449        self.results.iter().filter(|result| result.passed()).count()
450    }
451
452    pub fn failed_count(&self) -> usize {
453        self.results.len() - self.passed_count()
454    }
455
456    /// Just the assertions that did not hold, in evaluation order.
457    pub fn failures(&self) -> impl Iterator<Item = &AssertionResult> {
458        self.results.iter().filter(|result| !result.passed())
459    }
460}
461
462impl Assertions {
463    /// True when the block asserts nothing — `assertions: {}`, or a block whose
464    /// every key was omitted.
465    pub fn is_empty(&self) -> bool {
466        self.status.is_none()
467            && self.status_in.is_none()
468            && self.headers.is_empty()
469            && self.body_contains.is_none()
470            && self.body_matches.is_none()
471            && self.elapsed_ms_under.is_none()
472            && self.json.is_empty()
473            && self.not.as_ref().is_none_or(NotAssertions::is_empty)
474    }
475
476    fn fields(&self) -> Fields<'_> {
477        Fields {
478            status: self.status,
479            status_in: self.status_in.as_deref(),
480            headers: &self.headers,
481            body_contains: self.body_contains.as_deref(),
482            body_matches: self.body_matches.as_deref(),
483            elapsed_ms_under: self.elapsed_ms_under,
484            json: &self.json,
485        }
486    }
487
488    /// Check every assertion against `response` and report all of them.
489    ///
490    /// The response must be the one that actually came back from the request as
491    /// sent — after variable substitution and after config was applied — since
492    /// that is the request the assertions were written about.
493    pub fn evaluate(&self, response: &Response) -> AssertionReport {
494        let mut results = Vec::new();
495        push_checks(&mut results, self.fields(), false, response);
496        if let Some(not) = &self.not {
497            push_checks(&mut results, not.fields(), true, response);
498        }
499        AssertionReport { results }
500    }
501}
502
503/// Runs one block's worth of checks — the plain block or the `not:` one — in
504/// the fixed order documented on [`AssertionReport`], appending each result to
505/// `results`. Shared by both so the order and the set of checks cannot drift
506/// between a block and its negation.
507fn push_checks(
508    results: &mut Vec<AssertionResult>,
509    fields: Fields<'_>,
510    negate: bool,
511    response: &Response,
512) {
513    if let Some(expected) = fields.status {
514        results.push(check_status(expected, response, negate));
515    }
516
517    if let Some(allowed) = fields.status_in {
518        results.push(check_status_in(allowed, response, negate));
519    }
520
521    for (name, expected) in fields.headers {
522        results.push(check_header(name, expected.as_deref(), response, negate));
523    }
524
525    if let Some(needle) = fields.body_contains {
526        results.push(check_body_contains(needle, response, negate));
527    }
528
529    if let Some(pattern) = fields.body_matches {
530        results.push(check_body_matches(pattern, response, negate));
531    }
532
533    if let Some(threshold_ms) = fields.elapsed_ms_under {
534        results.push(check_elapsed_ms_under(threshold_ms, response, negate));
535    }
536
537    if !fields.json.is_empty() {
538        // Parsed once for the whole block, not once per path: the body does
539        // not change between assertions, and a body that is not JSON should
540        // report the same reason against every path rather than a different
541        // one each time.
542        let body = serde_json::from_str::<serde_json::Value>(&response.body);
543        for (path, expected) in fields.json {
544            results.push(check_json_path(
545                path,
546                expected,
547                body.as_ref(),
548                response,
549                negate,
550            ));
551        }
552    }
553}
554
555fn check_status(expected: u16, response: &Response, negate: bool) -> AssertionResult {
556    let holds = response.status == expected;
557    let expectation = expectation_text(
558        format!("status is {expected}"),
559        format!("status is not {expected}"),
560        negate,
561    );
562    let detail = format!("got {}", response.status);
563    finish(
564        AssertionKind::Status,
565        holds,
566        expectation,
567        negate,
568        detail.clone(),
569        detail,
570    )
571}
572
573fn check_status_in(allowed: &[u16], response: &Response, negate: bool) -> AssertionResult {
574    let holds = allowed.contains(&response.status);
575    let list = allowed
576        .iter()
577        .map(u16::to_string)
578        .collect::<Vec<_>>()
579        .join(", ");
580    let expectation = expectation_text(
581        format!("status is one of [{list}]"),
582        format!("status is not one of [{list}]"),
583        negate,
584    );
585    let detail = format!("got {}", response.status);
586    finish(
587        AssertionKind::StatusIn,
588        holds,
589        expectation,
590        negate,
591        detail.clone(),
592        detail,
593    )
594}
595
596fn check_header(
597    name: &str,
598    expected: Option<&str>,
599    response: &Response,
600    negate: bool,
601) -> AssertionResult {
602    let expectation = expectation_text(
603        match expected {
604            Some(value) => format!("header `{name}` is `{value}`"),
605            None => format!("header `{name}` is present"),
606        },
607        match expected {
608            Some(value) => format!("header `{name}` is not `{value}`"),
609            None => format!("header `{name}` is not present"),
610        },
611        negate,
612    );
613
614    // Every value the response carries under this name; more than one is legal
615    // (`set-cookie`), so the assertion holds if any of them matches.
616    let seen: Vec<&str> = response
617        .headers
618        .iter()
619        .filter(|(header, _)| header.eq_ignore_ascii_case(name))
620        .map(|(_, value)| value.as_str())
621        .collect();
622
623    let holds = match expected {
624        None => !seen.is_empty(),
625        Some(expected) => seen.contains(&expected),
626    };
627
628    let detail_if_false = if seen.is_empty() {
629        // Name the headers that *are* there, the way a missing request name
630        // lists the names a collection does have: the answer is usually a
631        // casing or spelling difference visible the moment both are on screen.
632        let present = response
633            .headers
634            .iter()
635            .map(|(header, _)| header.as_str())
636            .collect::<Vec<_>>()
637            .join(", ");
638        if present.is_empty() {
639            "the response carries no headers at all".to_string()
640        } else {
641            format!("not present (the response has: {present})")
642        }
643    } else {
644        format!(
645            "got {}",
646            seen.iter()
647                .map(|value| format!("`{value}`"))
648                .collect::<Vec<_>>()
649                .join(", ")
650        )
651    };
652    // `holds` true implies `seen` is non-empty in both the presence-only and
653    // value-match cases, so this is always the "found these values" detail —
654    // exactly what a negated assertion needs to say about what it forbade.
655    let detail_if_true = format!(
656        "got {}",
657        seen.iter()
658            .map(|value| format!("`{value}`"))
659            .collect::<Vec<_>>()
660            .join(", ")
661    );
662
663    finish(
664        AssertionKind::Header,
665        holds,
666        expectation,
667        negate,
668        detail_if_false,
669        detail_if_true,
670    )
671}
672
673fn check_body_contains(needle: &str, response: &Response, negate: bool) -> AssertionResult {
674    let holds = response.body.contains(needle);
675    let expectation = expectation_text(
676        format!("body contains `{needle}`"),
677        format!("body does not contain `{needle}`"),
678        negate,
679    );
680    finish(
681        AssertionKind::BodyContains,
682        holds,
683        expectation,
684        negate,
685        format!("not found in the {}-byte body", response.body.len()),
686        format!("found in the {}-byte body", response.body.len()),
687    )
688}
689
690fn check_body_matches(pattern: &str, response: &Response, negate: bool) -> AssertionResult {
691    let expectation = expectation_text(
692        format!("body matches `{pattern}`"),
693        format!("body does not match `{pattern}`"),
694        negate,
695    );
696
697    // Checked here, not at load time, for the same reason a JSON path is: see
698    // the note on `check_json_path`. An invalid pattern is wrong about every
699    // response there could ever be, so it is reported the same way whether or
700    // not this assertion is wrapped in `not:`.
701    let regex = match Regex::new(pattern) {
702        Ok(regex) => regex,
703        Err(err) => {
704            return AssertionResult::fail(
705                AssertionKind::BodyMatches,
706                expectation,
707                format!("not a valid regular expression: {err}"),
708            );
709        }
710    };
711
712    let holds = regex.is_match(&response.body);
713    finish(
714        AssertionKind::BodyMatches,
715        holds,
716        expectation,
717        negate,
718        format!("no match in the {}-byte body", response.body.len()),
719        format!("matched in the {}-byte body", response.body.len()),
720    )
721}
722
723fn check_elapsed_ms_under(threshold_ms: u64, response: &Response, negate: bool) -> AssertionResult {
724    let elapsed_ms = response.elapsed.as_millis();
725    let holds = elapsed_ms < u128::from(threshold_ms);
726    let expectation = expectation_text(
727        format!("elapsed time is under {threshold_ms}ms"),
728        format!("elapsed time is not under {threshold_ms}ms"),
729        negate,
730    );
731    let detail = format!("took {elapsed_ms}ms");
732    finish(
733        AssertionKind::ElapsedMsUnder,
734        holds,
735        expectation,
736        negate,
737        detail.clone(),
738        detail,
739    )
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745    use test_support::{assertions, json_response, only_failure, response};
746
747    #[test]
748    fn a_matching_status_passes() {
749        let report = assertions("status: 200").evaluate(&json_response());
750        assert!(report.passed(), "{report:?}");
751        assert_eq!(report.len(), 1);
752        assert_eq!(report.results()[0].expectation, "status is 200");
753    }
754
755    #[test]
756    fn a_different_status_fails_and_says_what_it_got() {
757        let report = assertions("status: 200").evaluate(&response(404, &[], ""));
758        assert!(!report.passed());
759        let failure = only_failure(&report);
760        assert_eq!(failure.kind, AssertionKind::Status);
761        assert_eq!(failure.expectation, "status is 200");
762        assert_eq!(failure.failure.as_deref(), Some("got 404"));
763    }
764
765    #[test]
766    fn a_header_value_match_passes_regardless_of_name_casing() {
767        // HTTP header names are case-insensitive, and which casing a server
768        // sends is not something a request file should have to know.
769        let report =
770            assertions("headers:\n  Content-Type: application/json\n").evaluate(&json_response());
771        assert!(report.passed(), "{report:?}");
772    }
773
774    #[test]
775    fn a_header_with_a_null_value_asserts_only_presence() {
776        let report = assertions("headers:\n  content-type:\n").evaluate(&json_response());
777        assert!(report.passed(), "{report:?}");
778        assert_eq!(
779            report.results()[0].expectation,
780            "header `content-type` is present"
781        );
782    }
783
784    #[test]
785    fn a_missing_header_fails_and_lists_the_ones_that_are_there() {
786        let report = assertions("headers:\n  x-request-id:\n").evaluate(&json_response());
787        let failure = only_failure(&report);
788        assert_eq!(failure.kind, AssertionKind::Header);
789        let detail = failure.failure.as_deref().unwrap();
790        assert!(detail.contains("not present"), "got {detail}");
791        assert!(detail.contains("content-type"), "got {detail}");
792    }
793
794    #[test]
795    fn a_header_with_the_wrong_value_fails_and_shows_the_value_it_found() {
796        let report = assertions("headers:\n  content-type: text/html\n").evaluate(&json_response());
797        let failure = only_failure(&report);
798        assert_eq!(
799            failure.failure.as_deref(),
800            Some("got `application/json`"),
801            "the value seen is the whole point of the message"
802        );
803    }
804
805    #[test]
806    fn a_header_value_is_matched_exactly_not_by_prefix() {
807        // The documented strictness: a decorated content-type is a different
808        // value, and quietly accepting it would make the assertion mean
809        // something the file does not say.
810        let decorated = response(
811            200,
812            &[("content-type", "application/json; charset=utf-8")],
813            "",
814        );
815        let report =
816            assertions("headers:\n  content-type: application/json\n").evaluate(&decorated);
817        assert!(!report.passed(), "a prefix must not count as a match");
818    }
819
820    #[test]
821    fn a_repeated_header_passes_if_any_value_matches() {
822        let repeated = response(200, &[("set-cookie", "a=1"), ("set-cookie", "b=2")], "");
823        let report = assertions("headers:\n  set-cookie: b=2\n").evaluate(&repeated);
824        assert!(report.passed(), "{report:?}");
825
826        let report = assertions("headers:\n  set-cookie: c=3\n").evaluate(&repeated);
827        let detail = only_failure(&report).failure.clone().unwrap();
828        assert_eq!(detail, "got `a=1`, `b=2`", "both values should be shown");
829    }
830
831    #[test]
832    fn body_contains_passes_on_a_substring_and_fails_otherwise() {
833        let response = response(200, &[], "the operation was a success");
834
835        let report = assertions("body_contains: success").evaluate(&response);
836        assert!(report.passed(), "{report:?}");
837
838        let report = assertions("body_contains: failure").evaluate(&response);
839        let failure = only_failure(&report);
840        assert_eq!(failure.kind, AssertionKind::BodyContains);
841        assert_eq!(failure.expectation, "body contains `failure`");
842        assert!(
843            failure.failure.as_deref().unwrap().contains("27-byte body"),
844            "got {failure:?}"
845        );
846    }
847
848    #[test]
849    fn body_contains_is_case_sensitive() {
850        let report = assertions("body_contains: SUCCESS").evaluate(&response(200, &[], "success"));
851        assert!(!report.passed(), "matching is on the bytes as they arrived");
852    }
853
854    #[test]
855    fn an_unknown_assertion_key_is_a_parse_error() {
856        // A typo'd assertion reads as a check that is passing, which is the
857        // worst way for it to fail.
858        let err = serde_yaml::from_str::<Assertions>("body_contain: success\n")
859            .expect_err("a typo must not be silently ignored");
860        assert!(err.to_string().contains("body_contain"), "got {err}");
861    }
862
863    #[test]
864    fn an_expected_json_value_with_no_json_equivalent_is_a_parse_error() {
865        // Held as a `serde_json::Value`, so an expected value with no JSON form
866        // is rejected when the file is read rather than when the response
867        // arrives. A sequence used as a mapping key is legal YAML and has no
868        // JSON equivalent at all.
869        let err = serde_yaml::from_str::<Assertions>("json:\n  $.a:\n    ? [x, y]\n    : one\n")
870            .expect_err("a sequence key has no JSON equivalent");
871        assert!(!err.to_string().is_empty());
872    }
873
874    #[test]
875    fn a_scalar_key_in_an_expected_value_is_read_as_the_string_json_would_use() {
876        // YAML allows non-string mapping keys and JSON does not, so `1:` is
877        // read as `"1"` — the coercion any YAML-to-JSON conversion makes, and
878        // the one that matches the object it will be compared against.
879        let assertions = assertions("json:\n  $.a:\n    1: one\n");
880        assert_eq!(
881            assertions.json["$.a"],
882            serde_json::json!({"1": "one"}),
883            "a scalar key becomes its string form"
884        );
885    }
886
887    // --- status_in -----------------------------------------------------
888
889    #[test]
890    fn status_in_passes_when_the_status_is_one_of_the_list() {
891        let report = assertions("status_in: [200, 201, 204]").evaluate(&response(201, &[], ""));
892        assert!(report.passed(), "{report:?}");
893        assert_eq!(
894            report.results()[0].expectation,
895            "status is one of [200, 201, 204]"
896        );
897    }
898
899    #[test]
900    fn status_in_fails_and_says_what_it_got_when_the_status_is_not_listed() {
901        let report = assertions("status_in: [200, 201, 204]").evaluate(&response(404, &[], ""));
902        let failure = only_failure(&report);
903        assert_eq!(failure.kind, AssertionKind::StatusIn);
904        assert_eq!(failure.failure.as_deref(), Some("got 404"));
905    }
906
907    // --- body_matches ----------------------------------------------------
908
909    #[test]
910    fn body_matches_passes_on_a_regex_match_and_fails_otherwise() {
911        let body = response(200, &[], "request id: 4471");
912
913        let report = assertions(r"body_matches: 'id:\s*\d+'").evaluate(&body);
914        assert!(report.passed(), "{report:?}");
915
916        let report = assertions(r"body_matches: 'id:\s*[a-z]+'").evaluate(&body);
917        let failure = only_failure(&report);
918        assert_eq!(failure.kind, AssertionKind::BodyMatches);
919        assert!(
920            failure.failure.as_deref().unwrap().contains("16-byte body"),
921            "{failure:?}"
922        );
923    }
924
925    #[test]
926    fn an_invalid_regex_is_a_failed_assertion_not_a_panic() {
927        let report = assertions("body_matches: '['").evaluate(&response(200, &[], "anything"));
928        let failure = only_failure(&report);
929        assert!(
930            failure
931                .failure
932                .as_deref()
933                .unwrap()
934                .contains("not a valid regular expression"),
935            "{failure:?}"
936        );
937    }
938
939    // --- elapsed_ms_under --------------------------------------------------
940
941    #[test]
942    fn elapsed_ms_under_passes_when_faster_than_the_threshold() {
943        let mut fast = response(200, &[], "");
944        fast.elapsed = std::time::Duration::from_millis(10);
945        let report = assertions("elapsed_ms_under: 1000").evaluate(&fast);
946        assert!(report.passed(), "{report:?}");
947        assert_eq!(
948            report.results()[0].expectation,
949            "elapsed time is under 1000ms"
950        );
951    }
952
953    #[test]
954    fn elapsed_ms_under_fails_when_slower_than_the_threshold() {
955        let mut slow = response(200, &[], "");
956        slow.elapsed = std::time::Duration::from_millis(1500);
957        let report = assertions("elapsed_ms_under: 1000").evaluate(&slow);
958        let failure = only_failure(&report);
959        assert_eq!(failure.kind, AssertionKind::ElapsedMsUnder);
960        assert_eq!(failure.failure.as_deref(), Some("took 1500ms"));
961    }
962
963    #[test]
964    fn every_assertion_is_reported_not_just_the_first_failure() {
965        // The acceptance criterion: a mixed block reports all of its parts, in
966        // a fixed order, whichever of them failed.
967        let report = assertions(
968            "\
969status: 201
970headers:
971  content-type: application/json
972  x-missing: whatever
973body_contains: ada
974json:
975  $.user.id: 42
976  $.user.name: grace
977",
978        )
979        .evaluate(&json_response());
980
981        assert_eq!(report.len(), 6);
982        assert_eq!(report.passed_count(), 3);
983        assert_eq!(report.failed_count(), 3);
984        assert!(!report.passed());
985
986        // Fixed order: status, headers (sorted), body_contains, json (sorted).
987        let expectations: Vec<&str> = report
988            .results()
989            .iter()
990            .map(|result| result.expectation.as_str())
991            .collect();
992        assert_eq!(
993            expectations,
994            vec![
995                "status is 201",
996                "header `content-type` is `application/json`",
997                "header `x-missing` is `whatever`",
998                "body contains `ada`",
999                "`$.user.id` is 42",
1000                "`$.user.name` is \"grace\"",
1001            ]
1002        );
1003
1004        let failed: Vec<&str> = report
1005            .failures()
1006            .map(|result| result.expectation.as_str())
1007            .collect();
1008        assert_eq!(
1009            failed,
1010            vec![
1011                "status is 201",
1012                "header `x-missing` is `whatever`",
1013                "`$.user.name` is \"grace\"",
1014            ],
1015            "the passing assertions must not hide the failing ones, or vice versa"
1016        );
1017    }
1018
1019    #[test]
1020    fn an_empty_report_is_vacuously_passing_and_knows_it_is_empty() {
1021        let report = Assertions::default().evaluate(&json_response());
1022        assert!(report.is_empty(), "nothing was asserted");
1023        assert!(report.passed(), "and so nothing failed");
1024        assert_eq!(report.failed_count(), 0);
1025    }
1026
1027    // --- negation ---------------------------------------------------------
1028
1029    #[test]
1030    fn not_status_passes_when_the_status_differs_and_fails_when_it_matches() {
1031        let report = assertions("not:\n  status: 404\n").evaluate(&response(200, &[], ""));
1032        assert!(report.passed(), "{report:?}");
1033        assert_eq!(report.results()[0].expectation, "status is not 404");
1034
1035        let report = assertions("not:\n  status: 404\n").evaluate(&response(404, &[], ""));
1036        let failure = only_failure(&report);
1037        assert_eq!(failure.kind, AssertionKind::Status);
1038        assert_eq!(failure.expectation, "status is not 404");
1039        assert_eq!(failure.failure.as_deref(), Some("got 404"));
1040    }
1041
1042    #[test]
1043    fn not_body_contains_passes_when_absent_and_fails_when_present() {
1044        let ok = response(200, &[], "all good");
1045        let report = assertions("not:\n  body_contains: error\n").evaluate(&ok);
1046        assert!(report.passed(), "{report:?}");
1047        assert_eq!(
1048            report.results()[0].expectation,
1049            "body does not contain `error`"
1050        );
1051
1052        let bad = response(200, &[], "an error occurred");
1053        let report = assertions("not:\n  body_contains: error\n").evaluate(&bad);
1054        let failure = only_failure(&report);
1055        assert_eq!(failure.expectation, "body does not contain `error`");
1056        assert!(
1057            failure.failure.as_deref().unwrap().contains("found in the"),
1058            "{failure:?}"
1059        );
1060    }
1061
1062    #[test]
1063    fn not_json_path_negates_equality() {
1064        let report = assertions("not:\n  json:\n    $.user.id: 7\n").evaluate(&json_response());
1065        assert!(report.passed(), "{report:?}");
1066        assert_eq!(report.results()[0].expectation, "`$.user.id` is not 7");
1067
1068        let report = assertions("not:\n  json:\n    $.user.id: 42\n").evaluate(&json_response());
1069        let failure = only_failure(&report);
1070        assert_eq!(failure.expectation, "`$.user.id` is not 42");
1071        assert_eq!(failure.failure.as_deref(), Some("got 42"));
1072    }
1073
1074    #[test]
1075    fn a_hard_error_under_not_still_fails_rather_than_being_negated_into_a_pass() {
1076        // A malformed path, an ambiguous match, a type mismatch — these are
1077        // facts about the file or the response, not a condition `not:` can
1078        // flip: a `greater_than` operator against the wrong type.
1079        let report = assertions("not:\n  json:\n    $.user.name: {greater_than: 5}\n")
1080            .evaluate(&json_response());
1081        assert!(
1082            !report.passed(),
1083            "a type mismatch must still fail under `not:`"
1084        );
1085        let failure = only_failure(&report);
1086        assert!(
1087            failure
1088                .failure
1089                .as_deref()
1090                .unwrap()
1091                .contains("is not a number"),
1092            "{failure:?}"
1093        );
1094    }
1095
1096    #[test]
1097    fn a_malformed_json_path_under_not_still_fails() {
1098        let report = assertions("not:\n  json:\n    '$.[': 1\n").evaluate(&json_response());
1099        assert!(!report.passed());
1100        let failure = only_failure(&report);
1101        assert!(
1102            failure
1103                .failure
1104                .as_deref()
1105                .unwrap()
1106                .contains("not a valid JSON path"),
1107            "{failure:?}"
1108        );
1109    }
1110
1111    #[test]
1112    fn not_and_the_plain_block_can_be_combined_and_both_are_reported() {
1113        let report = assertions(
1114            "\
1115status: 200
1116not:
1117  body_contains: error
1118",
1119        )
1120        .evaluate(&response(200, &[], "all good"));
1121        assert!(report.passed(), "{report:?}");
1122        assert_eq!(report.len(), 2);
1123    }
1124
1125    #[test]
1126    fn a_nested_not_inside_not_is_a_parse_error() {
1127        let err = serde_yaml::from_str::<Assertions>("not:\n  not:\n    status: 200\n")
1128            .expect_err("double negation is not part of the schema");
1129        assert!(err.to_string().contains("not"), "{err}");
1130    }
1131
1132    #[test]
1133    fn an_empty_not_block_counts_as_no_assertions() {
1134        let assertions = assertions("not: {}");
1135        assert!(assertions.is_empty());
1136    }
1137}