use axum::http::{HeaderMap, StatusCode};
#[cfg(any(feature = "inertia", feature = "api"))]
use axum::http::header;
use axum::response::Response;
use http_body_util::BodyExt;
use serde_json::Value;
use super::preview;
const PREVIEW_LIMIT: usize = 2000;
#[derive(Debug, Clone)]
pub struct TestResponse {
status: StatusCode,
headers: HeaderMap,
body: Vec<u8>,
}
impl TestResponse {
pub(crate) async fn collect(response: Response) -> Self {
let (parts, body) = response.into_parts();
let body = body
.collect()
.await
.unwrap_or_else(|error| panic!("response body could not be read: {error}"))
.to_bytes()
.to_vec();
Self {
status: parts.status,
headers: parts.headers,
body,
}
}
#[must_use]
pub fn status(&self) -> StatusCode {
self.status
}
#[must_use]
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
#[must_use]
pub fn body(&self) -> &[u8] {
&self.body
}
#[must_use]
pub fn text(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
}
impl TestResponse {
#[must_use]
pub fn json(&self) -> Value {
serde_json::from_slice(&self.body).unwrap_or_else(|error| {
panic!(
"response body is not JSON ({error})\n status: {}\n body: {}",
self.status,
preview(&self.body, PREVIEW_LIMIT)
)
})
}
#[must_use]
pub fn json_path(&self, path: &str) -> Option<Value> {
resolve(&self.json(), path).cloned()
}
pub fn assert_status(&self, expected: StatusCode) -> &Self {
assert!(
self.status == expected,
"expected status {expected}, got {}\n body: {}",
self.status,
preview(&self.body, PREVIEW_LIMIT)
);
self
}
pub fn assert_ok(&self) -> &Self {
self.assert_status(StatusCode::OK)
}
pub fn assert_header(&self, name: &str, expected: &str) -> &Self {
let actual = self.headers.get(name).map(|value| {
value
.to_str()
.map_or_else(|_| format!("{value:?}"), str::to_owned)
});
match actual {
Some(actual) if actual == expected => self,
Some(actual) => panic!("header `{name}`: expected `{expected}`, got `{actual}`"),
None => panic!(
"header `{name}` is absent; the response carries: {}",
header_names(&self.headers)
),
}
}
}
impl TestResponse {
pub fn assert_redirect(&self, location: &str) -> &Self {
let Some((header_name, actual)) = self.redirect_target() else {
panic!(
"expected a redirect to `{location}`, got status {} with no redirect header\n body: {}",
self.status,
preview(&self.body, PREVIEW_LIMIT)
);
};
assert!(
actual == location,
"expected a redirect to `{location}`, got `{actual}` (status {}, via `{header_name}`)",
self.status
);
self
}
#[must_use]
pub fn redirect_target(&self) -> Option<(&'static str, String)> {
let candidates: [(&'static str, bool); 3] = [
("location", self.status.is_redirection()),
("x-inertia-location", self.status == StatusCode::CONFLICT),
("x-inertia-redirect", self.status == StatusCode::CONFLICT),
];
for (name, eligible) in candidates {
if !eligible {
continue;
}
if let Some(value) = self.headers.get(name).and_then(|value| value.to_str().ok()) {
return Some((name, value.to_owned()));
}
}
None
}
pub fn assert_json_path(&self, path: &str, expected: impl Into<Value>) -> &Self {
let expected = expected.into();
let root = self.json();
match resolve(&root, path) {
Some(actual) if *actual == expected => self,
Some(actual) => panic!("json path `{path}`: expected {expected}, got {actual}"),
None => panic!(
"json path `{path}` does not exist; {}",
nearest(&root, path)
),
}
}
}
#[cfg(feature = "inertia")]
impl TestResponse {
#[must_use]
pub fn inertia_page(&self) -> Value {
if let Some(page) = self.try_inertia_page() {
return page;
}
panic!(
"response carries no Inertia page object\n status: {}\n content-type: {}\n body: {}",
self.status,
self.headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("(none)"),
preview(&self.body, PREVIEW_LIMIT)
);
}
#[must_use]
pub fn try_inertia_page(&self) -> Option<Value> {
let inertia = self
.headers
.get("x-inertia")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.eq_ignore_ascii_case("true"));
if inertia {
return serde_json::from_slice(&self.body).ok();
}
let html = std::str::from_utf8(&self.body).ok()?;
let json = extract_data_page(html)?;
serde_json::from_str(&json).ok()
}
}
#[cfg(feature = "inertia")]
impl TestResponse {
pub fn assert_inertia_component(&self, expected: &str) -> &Self {
let page = self.inertia_page();
match page.get("component").and_then(Value::as_str) {
Some(actual) if actual == expected => self,
Some(actual) => {
panic!("expected Inertia component `{expected}`, got `{actual}`")
}
None => panic!(
"expected Inertia component `{expected}`, but the page object has no `component` key\n page: {page}"
),
}
}
pub fn assert_inertia_prop(&self, path: &str, expected: impl Into<Value>) -> &Self {
let expected = expected.into();
let page = self.inertia_page();
let props = page.get("props").unwrap_or(&Value::Null);
match resolve(props, path) {
Some(actual) if *actual == expected => self,
Some(actual) => panic!("Inertia prop `{path}`: expected {expected}, got {actual}"),
None => panic!(
"Inertia prop `{path}` does not exist; {}",
nearest(props, path)
),
}
}
}
impl TestResponse {
pub fn assert_validation_error(&self, field: &str) -> &Self {
let Some(errors) = self.validation_errors() else {
panic!(
"expected a validation error for `{field}`, but the response carries no error bag\n status: {}\n body: {}",
self.status,
preview(&self.body, PREVIEW_LIMIT)
);
};
assert!(
resolve(&errors, field).is_some(),
"expected a validation error for `{field}`; the bag holds {}",
keys_of(&errors)
);
self
}
#[cfg(feature = "inertia")]
fn fallback_page(&self) -> Option<Value> {
self.try_inertia_page()
}
#[cfg(not(feature = "inertia"))]
fn fallback_page(&self) -> Option<Value> {
None
}
#[must_use]
pub fn validation_errors(&self) -> Option<Value> {
let body: Value = serde_json::from_slice(&self.body)
.ok()
.or_else(|| self.fallback_page())?;
if let Some(errors) = body.get("errors") {
return Some(errors.clone());
}
body.get("props")
.and_then(|props| props.get("errors"))
.cloned()
}
}
#[cfg(feature = "api")]
impl TestResponse {
pub fn assert_problem(&self, kind: crate::api::ProblemKind) -> &Self {
self.assert_status(kind.status());
let content_type = self
.headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("(none)");
assert!(
content_type.starts_with(crate::api::PROBLEM_JSON),
"expected content type `{}`, got `{content_type}`",
crate::api::PROBLEM_JSON
);
let body = self.json();
let actual = body.get("type").and_then(Value::as_str).unwrap_or("(none)");
assert!(
actual == kind.type_uri(),
"expected problem type `{}`, got `{actual}`\n body: {body}",
kind.type_uri()
);
self
}
}
fn resolve<'value>(root: &'value Value, path: &str) -> Option<&'value Value> {
let mut current = root;
for segment in path.split('.') {
current = step(current, segment)?;
}
Some(current)
}
fn step<'value>(value: &'value Value, segment: &str) -> Option<&'value Value> {
match value {
Value::Object(map) => map.get(segment),
Value::Array(items) => segment.parse::<usize>().ok().and_then(|i| items.get(i)),
_ => None,
}
}
fn nearest(root: &Value, path: &str) -> String {
let mut current = root;
let mut reached: Vec<&str> = Vec::new();
for segment in path.split('.') {
match step(current, segment) {
Some(next) => {
reached.push(segment);
current = next;
}
None => {
let at = if reached.is_empty() {
"the root".to_owned()
} else {
format!("`{}`", reached.join("."))
};
return format!(
"`{segment}` is missing at {at}, which holds {}",
keys_of(current)
);
}
}
}
String::new()
}
fn keys_of(value: &Value) -> String {
match value {
Value::Object(map) if map.is_empty() => "an empty object".to_owned(),
Value::Object(map) => format!(
"the keys [{}]",
map.keys()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ")
),
Value::Array(items) => format!("an array of {} items", items.len()),
other => format!("{other}"),
}
}
fn header_names(headers: &HeaderMap) -> String {
let mut names: Vec<&str> = headers.keys().map(axum::http::HeaderName::as_str).collect();
names.sort_unstable();
names.dedup();
if names.is_empty() {
return "no headers".to_owned();
}
format!("[{}]", names.join(", "))
}
#[cfg(feature = "inertia")]
fn extract_data_page(html: &str) -> Option<String> {
let start = html.find("<script data-page=")?;
let open_end = html[start..].find('>')? + start + 1;
let close = html[open_end..].find("</script>")? + open_end;
Some(html[open_end..close].to_owned())
}