use axum::body::Body;
use axum::http::header::CONTENT_TYPE;
#[cfg(feature = "auth")]
use axum::http::header::COOKIE;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode};
use super::app::TestApp;
use super::response::TestResponse;
const MAX_REDIRECTS: usize = 10;
#[derive(Debug)]
pub struct TestRequest {
app: TestApp,
method: Method,
uri: String,
headers: HeaderMap,
body: Vec<u8>,
follow_redirects: bool,
#[cfg(feature = "auth")]
session_entries: Vec<(String, serde_json::Value)>,
}
impl TestRequest {
pub(crate) fn new(app: TestApp, method: Method, uri: String) -> Self {
Self {
app,
method,
uri,
headers: HeaderMap::new(),
body: Vec::new(),
follow_redirects: false,
#[cfg(feature = "auth")]
session_entries: Vec::new(),
}
}
#[must_use]
pub fn header(mut self, name: &str, value: &str) -> Self {
let name: HeaderName = name
.parse()
.unwrap_or_else(|_| panic!("`{name}` is not a valid header name"));
let value: HeaderValue = value
.parse()
.unwrap_or_else(|_| panic!("`{value}` is not a valid header value"));
self.headers.insert(name, value);
self
}
}
impl TestRequest {
#[must_use]
pub fn json<T>(mut self, value: &T) -> Self
where
T: serde::Serialize + ?Sized,
{
self.body = serde_json::to_vec(value)
.unwrap_or_else(|error| panic!("request body did not serialize to JSON: {error}"));
self.headers
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
self
}
#[must_use]
pub fn form<T>(mut self, value: &T) -> Self
where
T: serde::Serialize + ?Sized,
{
let value = serde_json::to_value(value)
.unwrap_or_else(|error| panic!("form body did not serialize: {error}"));
self.body = encode_form(&value).into_bytes();
self.headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("application/x-www-form-urlencoded"),
);
self
}
#[must_use]
pub fn body(mut self, content_type: &str, body: impl Into<Vec<u8>>) -> Self {
self.body = body.into();
let value: HeaderValue = content_type
.parse()
.unwrap_or_else(|_| panic!("`{content_type}` is not a valid content type"));
self.headers.insert(CONTENT_TYPE, value);
self
}
#[cfg(feature = "inertia")]
#[must_use]
pub fn inertia(self) -> Self {
self.header("x-inertia", "true")
}
}
impl TestRequest {
#[cfg(feature = "auth")]
#[must_use]
pub fn acting_as<U>(mut self, user: &U) -> Self
where
U: crate::auth::AuthUser,
{
let id = serde_json::to_value(user.id())
.unwrap_or_else(|error| panic!("user id did not serialize: {error}"));
self.session_entries.push((U::SESSION_KEY.to_string(), id));
self.session_entries.push((
AUTH_AT_KEY.to_string(),
serde_json::Value::from(now_unix_millis()),
));
self
}
#[cfg(feature = "auth")]
#[must_use]
pub fn with_session<T>(mut self, key: &str, value: T) -> Self
where
T: serde::Serialize,
{
let value = serde_json::to_value(value)
.unwrap_or_else(|error| panic!("session value for `{key}` did not serialize: {error}"));
self.session_entries.push((key.to_string(), value));
self
}
#[must_use]
pub fn follow_redirects(mut self) -> Self {
self.follow_redirects = true;
self
}
}
#[cfg(feature = "auth")]
const AUTH_AT_KEY: &str = "__arcature_absolute_auth_at";
#[cfg(feature = "auth")]
fn now_unix_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX))
.unwrap_or(0)
}
impl TestRequest {
pub async fn send(self) -> TestResponse {
let Self {
app,
method,
uri,
headers,
body,
follow_redirects,
#[cfg(feature = "auth")]
session_entries,
} = self;
#[cfg(feature = "auth")]
let mut headers = headers;
#[cfg(feature = "auth")]
if !session_entries.is_empty() {
let sessions = app.sessions().unwrap_or_else(|| {
panic!(
"`acting_as` / `with_session` need a session store: build the \
application with `.session(config, sessions.store())` and the \
harness with `TestApp::new(app).with_sessions(sessions)`"
)
});
let cookie = sessions
.cookie_for(&session_entries)
.await
.unwrap_or_else(|error| panic!("session could not be seeded: {error}"));
let value: HeaderValue = cookie
.parse()
.unwrap_or_else(|_| panic!("seeded cookie is not a valid header value"));
headers.insert(COOKIE, value);
}
let mut response = dispatch(&app, &method, &uri, &headers, body).await;
if follow_redirects {
let mut hops = 0;
while let Some(location) = redirect_target(&response) {
assert!(
hops < MAX_REDIRECTS,
"redirect loop: still redirecting after {MAX_REDIRECTS} hops, last to `{location}`"
);
hops += 1;
response = dispatch(&app, &Method::GET, &location, &headers, Vec::new()).await;
}
}
TestResponse::collect(response).await
}
}
fn redirect_target(response: &axum::response::Response) -> Option<String> {
let status = response.status();
if !matches!(
status,
StatusCode::MOVED_PERMANENTLY
| StatusCode::FOUND
| StatusCode::SEE_OTHER
| StatusCode::TEMPORARY_REDIRECT
| StatusCode::PERMANENT_REDIRECT
) {
return None;
}
response
.headers()
.get(axum::http::header::LOCATION)?
.to_str()
.ok()
.map(str::to_owned)
}
async fn dispatch(
app: &TestApp,
method: &Method,
uri: &str,
headers: &HeaderMap,
body: Vec<u8>,
) -> axum::response::Response {
let mut builder = Request::builder().method(method.clone()).uri(uri);
for (name, value) in headers {
builder = builder.header(name, value);
}
let request = builder
.body(Body::from(body))
.unwrap_or_else(|error| panic!("could not build a request for `{uri}`: {error}"));
app.dispatch(request).await
}
fn encode_form(value: &serde_json::Value) -> String {
let serde_json::Value::Object(fields) = value else {
panic!("a form body must be an object, got: {value}");
};
let mut out = String::new();
for (key, field) in fields {
match field {
serde_json::Value::Array(items) => {
for item in items {
push_pair(&mut out, key, &scalar(key, item));
}
}
other => push_pair(&mut out, key, &scalar(key, other)),
}
}
out
}
fn scalar(key: &str, value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text.clone(),
serde_json::Value::Number(number) => number.to_string(),
serde_json::Value::Bool(flag) => flag.to_string(),
serde_json::Value::Null => String::new(),
other => panic!(
"form field `{key}` is a nested {}, which has no single form encoding; \
flatten it in the test or use `.json(..)`",
if other.is_array() { "array" } else { "object" }
),
}
}
fn push_pair(out: &mut String, key: &str, value: &str) {
if !out.is_empty() {
out.push('&');
}
percent_encode_into(out, key);
out.push('=');
percent_encode_into(out, value);
}
fn percent_encode_into(out: &mut String, text: &str) {
for byte in text.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'*' | b'-' | b'.' | b'_' => {
out.push(char::from(*byte));
}
b' ' => out.push('+'),
other => out.push_str(&format!("%{other:02X}")),
}
}
}