use std::collections::BTreeMap;
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::Response;
mod json;
#[cfg(test)]
mod test_support;
use json::check_json_path;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct Assertions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_in: Option<Vec<u16>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_contains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_matches: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub elapsed_ms_under: Option<u64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub json: BTreeMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub not: Option<NotAssertions>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct NotAssertions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_in: Option<Vec<u16>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_contains: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_matches: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub elapsed_ms_under: Option<u64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub json: BTreeMap<String, serde_json::Value>,
}
impl NotAssertions {
fn is_empty(&self) -> bool {
self.status.is_none()
&& self.status_in.is_none()
&& self.headers.is_empty()
&& self.body_contains.is_none()
&& self.body_matches.is_none()
&& self.elapsed_ms_under.is_none()
&& self.json.is_empty()
}
fn fields(&self) -> Fields<'_> {
Fields {
status: self.status,
status_in: self.status_in.as_deref(),
headers: &self.headers,
body_contains: self.body_contains.as_deref(),
body_matches: self.body_matches.as_deref(),
elapsed_ms_under: self.elapsed_ms_under,
json: &self.json,
}
}
}
struct Fields<'a> {
status: Option<u16>,
status_in: Option<&'a [u16]>,
headers: &'a BTreeMap<String, Option<String>>,
body_contains: Option<&'a str>,
body_matches: Option<&'a str>,
elapsed_ms_under: Option<u64>,
json: &'a BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssertionKind {
Status,
StatusIn,
Header,
BodyContains,
BodyMatches,
ElapsedMsUnder,
JsonPath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssertionResult {
pub kind: AssertionKind,
pub expectation: String,
pub failure: Option<String>,
}
impl AssertionResult {
pub fn passed(&self) -> bool {
self.failure.is_none()
}
fn pass(kind: AssertionKind, expectation: String) -> Self {
Self {
kind,
expectation,
failure: None,
}
}
fn fail(kind: AssertionKind, expectation: String, failure: String) -> Self {
Self {
kind,
expectation,
failure: Some(failure),
}
}
}
fn expectation_text(positive: String, negative: String, negate: bool) -> String {
if negate {
negative
} else {
positive
}
}
fn finish(
kind: AssertionKind,
holds: bool,
expectation: String,
negate: bool,
detail_if_false: String,
detail_if_true: String,
) -> AssertionResult {
let failed = if negate { holds } else { !holds };
if !failed {
AssertionResult::pass(kind, expectation)
} else {
let detail = if negate {
detail_if_true
} else {
detail_if_false
};
AssertionResult::fail(kind, expectation, detail)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct AssertionReport {
results: Vec<AssertionResult>,
}
impl AssertionReport {
pub fn results(&self) -> &[AssertionResult] {
&self.results
}
pub fn is_empty(&self) -> bool {
self.results.is_empty()
}
pub fn len(&self) -> usize {
self.results.len()
}
pub fn passed(&self) -> bool {
self.results.iter().all(AssertionResult::passed)
}
pub fn passed_count(&self) -> usize {
self.results.iter().filter(|result| result.passed()).count()
}
pub fn failed_count(&self) -> usize {
self.results.len() - self.passed_count()
}
pub fn failures(&self) -> impl Iterator<Item = &AssertionResult> {
self.results.iter().filter(|result| !result.passed())
}
}
impl Assertions {
pub fn is_empty(&self) -> bool {
self.status.is_none()
&& self.status_in.is_none()
&& self.headers.is_empty()
&& self.body_contains.is_none()
&& self.body_matches.is_none()
&& self.elapsed_ms_under.is_none()
&& self.json.is_empty()
&& self.not.as_ref().is_none_or(NotAssertions::is_empty)
}
fn fields(&self) -> Fields<'_> {
Fields {
status: self.status,
status_in: self.status_in.as_deref(),
headers: &self.headers,
body_contains: self.body_contains.as_deref(),
body_matches: self.body_matches.as_deref(),
elapsed_ms_under: self.elapsed_ms_under,
json: &self.json,
}
}
pub fn evaluate(&self, response: &Response) -> AssertionReport {
let mut results = Vec::new();
push_checks(&mut results, self.fields(), false, response);
if let Some(not) = &self.not {
push_checks(&mut results, not.fields(), true, response);
}
AssertionReport { results }
}
}
fn push_checks(
results: &mut Vec<AssertionResult>,
fields: Fields<'_>,
negate: bool,
response: &Response,
) {
if let Some(expected) = fields.status {
results.push(check_status(expected, response, negate));
}
if let Some(allowed) = fields.status_in {
results.push(check_status_in(allowed, response, negate));
}
for (name, expected) in fields.headers {
results.push(check_header(name, expected.as_deref(), response, negate));
}
if let Some(needle) = fields.body_contains {
results.push(check_body_contains(needle, response, negate));
}
if let Some(pattern) = fields.body_matches {
results.push(check_body_matches(pattern, response, negate));
}
if let Some(threshold_ms) = fields.elapsed_ms_under {
results.push(check_elapsed_ms_under(threshold_ms, response, negate));
}
if !fields.json.is_empty() {
let body = serde_json::from_str::<serde_json::Value>(&response.body);
for (path, expected) in fields.json {
results.push(check_json_path(
path,
expected,
body.as_ref(),
response,
negate,
));
}
}
}
fn check_status(expected: u16, response: &Response, negate: bool) -> AssertionResult {
let holds = response.status == expected;
let expectation = expectation_text(
format!("status is {expected}"),
format!("status is not {expected}"),
negate,
);
let detail = format!("got {}", response.status);
finish(
AssertionKind::Status,
holds,
expectation,
negate,
detail.clone(),
detail,
)
}
fn check_status_in(allowed: &[u16], response: &Response, negate: bool) -> AssertionResult {
let holds = allowed.contains(&response.status);
let list = allowed
.iter()
.map(u16::to_string)
.collect::<Vec<_>>()
.join(", ");
let expectation = expectation_text(
format!("status is one of [{list}]"),
format!("status is not one of [{list}]"),
negate,
);
let detail = format!("got {}", response.status);
finish(
AssertionKind::StatusIn,
holds,
expectation,
negate,
detail.clone(),
detail,
)
}
fn check_header(
name: &str,
expected: Option<&str>,
response: &Response,
negate: bool,
) -> AssertionResult {
let expectation = expectation_text(
match expected {
Some(value) => format!("header `{name}` is `{value}`"),
None => format!("header `{name}` is present"),
},
match expected {
Some(value) => format!("header `{name}` is not `{value}`"),
None => format!("header `{name}` is not present"),
},
negate,
);
let seen: Vec<&str> = response
.headers
.iter()
.filter(|(header, _)| header.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
.collect();
let holds = match expected {
None => !seen.is_empty(),
Some(expected) => seen.contains(&expected),
};
let detail_if_false = if seen.is_empty() {
let present = response
.headers
.iter()
.map(|(header, _)| header.as_str())
.collect::<Vec<_>>()
.join(", ");
if present.is_empty() {
"the response carries no headers at all".to_string()
} else {
format!("not present (the response has: {present})")
}
} else {
format!(
"got {}",
seen.iter()
.map(|value| format!("`{value}`"))
.collect::<Vec<_>>()
.join(", ")
)
};
let detail_if_true = format!(
"got {}",
seen.iter()
.map(|value| format!("`{value}`"))
.collect::<Vec<_>>()
.join(", ")
);
finish(
AssertionKind::Header,
holds,
expectation,
negate,
detail_if_false,
detail_if_true,
)
}
fn check_body_contains(needle: &str, response: &Response, negate: bool) -> AssertionResult {
let holds = response.body.contains(needle);
let expectation = expectation_text(
format!("body contains `{needle}`"),
format!("body does not contain `{needle}`"),
negate,
);
finish(
AssertionKind::BodyContains,
holds,
expectation,
negate,
format!("not found in the {}-byte body", response.body.len()),
format!("found in the {}-byte body", response.body.len()),
)
}
fn check_body_matches(pattern: &str, response: &Response, negate: bool) -> AssertionResult {
let expectation = expectation_text(
format!("body matches `{pattern}`"),
format!("body does not match `{pattern}`"),
negate,
);
let regex = match Regex::new(pattern) {
Ok(regex) => regex,
Err(err) => {
return AssertionResult::fail(
AssertionKind::BodyMatches,
expectation,
format!("not a valid regular expression: {err}"),
);
}
};
let holds = regex.is_match(&response.body);
finish(
AssertionKind::BodyMatches,
holds,
expectation,
negate,
format!("no match in the {}-byte body", response.body.len()),
format!("matched in the {}-byte body", response.body.len()),
)
}
fn check_elapsed_ms_under(threshold_ms: u64, response: &Response, negate: bool) -> AssertionResult {
let elapsed_ms = response.elapsed.as_millis();
let holds = elapsed_ms < u128::from(threshold_ms);
let expectation = expectation_text(
format!("elapsed time is under {threshold_ms}ms"),
format!("elapsed time is not under {threshold_ms}ms"),
negate,
);
let detail = format!("took {elapsed_ms}ms");
finish(
AssertionKind::ElapsedMsUnder,
holds,
expectation,
negate,
detail.clone(),
detail,
)
}
#[cfg(test)]
mod tests {
use super::*;
use test_support::{assertions, json_response, only_failure, response};
#[test]
fn a_matching_status_passes() {
let report = assertions("status: 200").evaluate(&json_response());
assert!(report.passed(), "{report:?}");
assert_eq!(report.len(), 1);
assert_eq!(report.results()[0].expectation, "status is 200");
}
#[test]
fn a_different_status_fails_and_says_what_it_got() {
let report = assertions("status: 200").evaluate(&response(404, &[], ""));
assert!(!report.passed());
let failure = only_failure(&report);
assert_eq!(failure.kind, AssertionKind::Status);
assert_eq!(failure.expectation, "status is 200");
assert_eq!(failure.failure.as_deref(), Some("got 404"));
}
#[test]
fn a_header_value_match_passes_regardless_of_name_casing() {
let report =
assertions("headers:\n Content-Type: application/json\n").evaluate(&json_response());
assert!(report.passed(), "{report:?}");
}
#[test]
fn a_header_with_a_null_value_asserts_only_presence() {
let report = assertions("headers:\n content-type:\n").evaluate(&json_response());
assert!(report.passed(), "{report:?}");
assert_eq!(
report.results()[0].expectation,
"header `content-type` is present"
);
}
#[test]
fn a_missing_header_fails_and_lists_the_ones_that_are_there() {
let report = assertions("headers:\n x-request-id:\n").evaluate(&json_response());
let failure = only_failure(&report);
assert_eq!(failure.kind, AssertionKind::Header);
let detail = failure.failure.as_deref().unwrap();
assert!(detail.contains("not present"), "got {detail}");
assert!(detail.contains("content-type"), "got {detail}");
}
#[test]
fn a_header_with_the_wrong_value_fails_and_shows_the_value_it_found() {
let report = assertions("headers:\n content-type: text/html\n").evaluate(&json_response());
let failure = only_failure(&report);
assert_eq!(
failure.failure.as_deref(),
Some("got `application/json`"),
"the value seen is the whole point of the message"
);
}
#[test]
fn a_header_value_is_matched_exactly_not_by_prefix() {
let decorated = response(
200,
&[("content-type", "application/json; charset=utf-8")],
"",
);
let report =
assertions("headers:\n content-type: application/json\n").evaluate(&decorated);
assert!(!report.passed(), "a prefix must not count as a match");
}
#[test]
fn a_repeated_header_passes_if_any_value_matches() {
let repeated = response(200, &[("set-cookie", "a=1"), ("set-cookie", "b=2")], "");
let report = assertions("headers:\n set-cookie: b=2\n").evaluate(&repeated);
assert!(report.passed(), "{report:?}");
let report = assertions("headers:\n set-cookie: c=3\n").evaluate(&repeated);
let detail = only_failure(&report).failure.clone().unwrap();
assert_eq!(detail, "got `a=1`, `b=2`", "both values should be shown");
}
#[test]
fn body_contains_passes_on_a_substring_and_fails_otherwise() {
let response = response(200, &[], "the operation was a success");
let report = assertions("body_contains: success").evaluate(&response);
assert!(report.passed(), "{report:?}");
let report = assertions("body_contains: failure").evaluate(&response);
let failure = only_failure(&report);
assert_eq!(failure.kind, AssertionKind::BodyContains);
assert_eq!(failure.expectation, "body contains `failure`");
assert!(
failure.failure.as_deref().unwrap().contains("27-byte body"),
"got {failure:?}"
);
}
#[test]
fn body_contains_is_case_sensitive() {
let report = assertions("body_contains: SUCCESS").evaluate(&response(200, &[], "success"));
assert!(!report.passed(), "matching is on the bytes as they arrived");
}
#[test]
fn an_unknown_assertion_key_is_a_parse_error() {
let err = serde_yaml::from_str::<Assertions>("body_contain: success\n")
.expect_err("a typo must not be silently ignored");
assert!(err.to_string().contains("body_contain"), "got {err}");
}
#[test]
fn an_expected_json_value_with_no_json_equivalent_is_a_parse_error() {
let err = serde_yaml::from_str::<Assertions>("json:\n $.a:\n ? [x, y]\n : one\n")
.expect_err("a sequence key has no JSON equivalent");
assert!(!err.to_string().is_empty());
}
#[test]
fn a_scalar_key_in_an_expected_value_is_read_as_the_string_json_would_use() {
let assertions = assertions("json:\n $.a:\n 1: one\n");
assert_eq!(
assertions.json["$.a"],
serde_json::json!({"1": "one"}),
"a scalar key becomes its string form"
);
}
#[test]
fn status_in_passes_when_the_status_is_one_of_the_list() {
let report = assertions("status_in: [200, 201, 204]").evaluate(&response(201, &[], ""));
assert!(report.passed(), "{report:?}");
assert_eq!(
report.results()[0].expectation,
"status is one of [200, 201, 204]"
);
}
#[test]
fn status_in_fails_and_says_what_it_got_when_the_status_is_not_listed() {
let report = assertions("status_in: [200, 201, 204]").evaluate(&response(404, &[], ""));
let failure = only_failure(&report);
assert_eq!(failure.kind, AssertionKind::StatusIn);
assert_eq!(failure.failure.as_deref(), Some("got 404"));
}
#[test]
fn body_matches_passes_on_a_regex_match_and_fails_otherwise() {
let body = response(200, &[], "request id: 4471");
let report = assertions(r"body_matches: 'id:\s*\d+'").evaluate(&body);
assert!(report.passed(), "{report:?}");
let report = assertions(r"body_matches: 'id:\s*[a-z]+'").evaluate(&body);
let failure = only_failure(&report);
assert_eq!(failure.kind, AssertionKind::BodyMatches);
assert!(
failure.failure.as_deref().unwrap().contains("16-byte body"),
"{failure:?}"
);
}
#[test]
fn an_invalid_regex_is_a_failed_assertion_not_a_panic() {
let report = assertions("body_matches: '['").evaluate(&response(200, &[], "anything"));
let failure = only_failure(&report);
assert!(
failure
.failure
.as_deref()
.unwrap()
.contains("not a valid regular expression"),
"{failure:?}"
);
}
#[test]
fn elapsed_ms_under_passes_when_faster_than_the_threshold() {
let mut fast = response(200, &[], "");
fast.elapsed = std::time::Duration::from_millis(10);
let report = assertions("elapsed_ms_under: 1000").evaluate(&fast);
assert!(report.passed(), "{report:?}");
assert_eq!(
report.results()[0].expectation,
"elapsed time is under 1000ms"
);
}
#[test]
fn elapsed_ms_under_fails_when_slower_than_the_threshold() {
let mut slow = response(200, &[], "");
slow.elapsed = std::time::Duration::from_millis(1500);
let report = assertions("elapsed_ms_under: 1000").evaluate(&slow);
let failure = only_failure(&report);
assert_eq!(failure.kind, AssertionKind::ElapsedMsUnder);
assert_eq!(failure.failure.as_deref(), Some("took 1500ms"));
}
#[test]
fn every_assertion_is_reported_not_just_the_first_failure() {
let report = assertions(
"\
status: 201
headers:
content-type: application/json
x-missing: whatever
body_contains: ada
json:
$.user.id: 42
$.user.name: grace
",
)
.evaluate(&json_response());
assert_eq!(report.len(), 6);
assert_eq!(report.passed_count(), 3);
assert_eq!(report.failed_count(), 3);
assert!(!report.passed());
let expectations: Vec<&str> = report
.results()
.iter()
.map(|result| result.expectation.as_str())
.collect();
assert_eq!(
expectations,
vec![
"status is 201",
"header `content-type` is `application/json`",
"header `x-missing` is `whatever`",
"body contains `ada`",
"`$.user.id` is 42",
"`$.user.name` is \"grace\"",
]
);
let failed: Vec<&str> = report
.failures()
.map(|result| result.expectation.as_str())
.collect();
assert_eq!(
failed,
vec![
"status is 201",
"header `x-missing` is `whatever`",
"`$.user.name` is \"grace\"",
],
"the passing assertions must not hide the failing ones, or vice versa"
);
}
#[test]
fn an_empty_report_is_vacuously_passing_and_knows_it_is_empty() {
let report = Assertions::default().evaluate(&json_response());
assert!(report.is_empty(), "nothing was asserted");
assert!(report.passed(), "and so nothing failed");
assert_eq!(report.failed_count(), 0);
}
#[test]
fn not_status_passes_when_the_status_differs_and_fails_when_it_matches() {
let report = assertions("not:\n status: 404\n").evaluate(&response(200, &[], ""));
assert!(report.passed(), "{report:?}");
assert_eq!(report.results()[0].expectation, "status is not 404");
let report = assertions("not:\n status: 404\n").evaluate(&response(404, &[], ""));
let failure = only_failure(&report);
assert_eq!(failure.kind, AssertionKind::Status);
assert_eq!(failure.expectation, "status is not 404");
assert_eq!(failure.failure.as_deref(), Some("got 404"));
}
#[test]
fn not_body_contains_passes_when_absent_and_fails_when_present() {
let ok = response(200, &[], "all good");
let report = assertions("not:\n body_contains: error\n").evaluate(&ok);
assert!(report.passed(), "{report:?}");
assert_eq!(
report.results()[0].expectation,
"body does not contain `error`"
);
let bad = response(200, &[], "an error occurred");
let report = assertions("not:\n body_contains: error\n").evaluate(&bad);
let failure = only_failure(&report);
assert_eq!(failure.expectation, "body does not contain `error`");
assert!(
failure.failure.as_deref().unwrap().contains("found in the"),
"{failure:?}"
);
}
#[test]
fn not_json_path_negates_equality() {
let report = assertions("not:\n json:\n $.user.id: 7\n").evaluate(&json_response());
assert!(report.passed(), "{report:?}");
assert_eq!(report.results()[0].expectation, "`$.user.id` is not 7");
let report = assertions("not:\n json:\n $.user.id: 42\n").evaluate(&json_response());
let failure = only_failure(&report);
assert_eq!(failure.expectation, "`$.user.id` is not 42");
assert_eq!(failure.failure.as_deref(), Some("got 42"));
}
#[test]
fn a_hard_error_under_not_still_fails_rather_than_being_negated_into_a_pass() {
let report = assertions("not:\n json:\n $.user.name: {greater_than: 5}\n")
.evaluate(&json_response());
assert!(
!report.passed(),
"a type mismatch must still fail under `not:`"
);
let failure = only_failure(&report);
assert!(
failure
.failure
.as_deref()
.unwrap()
.contains("is not a number"),
"{failure:?}"
);
}
#[test]
fn a_malformed_json_path_under_not_still_fails() {
let report = assertions("not:\n json:\n '$.[': 1\n").evaluate(&json_response());
assert!(!report.passed());
let failure = only_failure(&report);
assert!(
failure
.failure
.as_deref()
.unwrap()
.contains("not a valid JSON path"),
"{failure:?}"
);
}
#[test]
fn not_and_the_plain_block_can_be_combined_and_both_are_reported() {
let report = assertions(
"\
status: 200
not:
body_contains: error
",
)
.evaluate(&response(200, &[], "all good"));
assert!(report.passed(), "{report:?}");
assert_eq!(report.len(), 2);
}
#[test]
fn a_nested_not_inside_not_is_a_parse_error() {
let err = serde_yaml::from_str::<Assertions>("not:\n not:\n status: 200\n")
.expect_err("double negation is not part of the schema");
assert!(err.to_string().contains("not"), "{err}");
}
#[test]
fn an_empty_not_block_counts_as_no_assertions() {
let assertions = assertions("not: {}");
assert!(assertions.is_empty());
}
}