use axum::Router;
use axum::body::{Body, Bytes};
use axum::http::header::{CONTENT_TYPE, HeaderName, HeaderValue};
use axum::http::{HeaderMap, Method, Request, StatusCode};
use serde::Serialize;
use serde::de::DeserializeOwned;
use tower::ServiceExt;
use crate::App;
#[derive(Clone)]
pub struct TestClient {
router: Router,
}
impl TestClient {
pub fn new(router: Router) -> Self {
Self { router }
}
pub async fn from_app(app: App) -> Result<Self, Box<dyn std::error::Error>> {
let router = app.build_with_hooks().await?;
Ok(Self { router })
}
pub fn get(&self, path: &str) -> TestRequestBuilder {
self.request(Method::GET, path)
}
pub fn post(&self, path: &str) -> TestRequestBuilder {
self.request(Method::POST, path)
}
pub fn put(&self, path: &str) -> TestRequestBuilder {
self.request(Method::PUT, path)
}
pub fn delete(&self, path: &str) -> TestRequestBuilder {
self.request(Method::DELETE, path)
}
pub fn patch(&self, path: &str) -> TestRequestBuilder {
self.request(Method::PATCH, path)
}
fn request(&self, method: Method, path: &str) -> TestRequestBuilder {
TestRequestBuilder {
router: self.router.clone(),
method,
path: path.to_owned(),
headers: HeaderMap::new(),
body: Body::empty(),
}
}
}
pub struct TestRequestBuilder {
router: Router,
method: Method,
path: String,
headers: HeaderMap,
body: Body,
}
#[expect(
clippy::expect_used,
reason = "test helper — panics provide clear assertion failures"
)]
impl TestRequestBuilder {
pub fn header<K, V>(mut self, name: K, value: V) -> Self
where
K: TryInto<HeaderName>,
K::Error: std::fmt::Debug,
V: TryInto<HeaderValue>,
V::Error: std::fmt::Debug,
{
let name = name.try_into().expect("invalid header name");
let value = value.try_into().expect("invalid header value");
self.headers.insert(name, value);
self
}
pub fn json<T: Serialize>(mut self, body: &T) -> Self {
let bytes = serde_json::to_vec(body).expect("failed to serialize JSON body");
self.headers
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
self.body = Body::from(bytes);
self
}
pub fn body(mut self, bytes: impl Into<Bytes>) -> Self {
self.body = Body::from(bytes.into());
self
}
pub async fn send(self) -> TestResponse {
let mut builder = Request::builder().method(self.method).uri(&self.path);
for (name, value) in &self.headers {
builder = builder.header(name, value);
}
let req = builder.body(self.body).expect("failed to build request");
let resp = self
.router
.oneshot(req)
.await
.expect("router returned an error");
let status = resp.status();
let headers = resp.headers().clone();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("failed to read response body");
TestResponse {
status,
headers,
body,
}
}
}
pub struct TestResponse {
status: StatusCode,
headers: HeaderMap,
body: Bytes,
}
#[expect(
clippy::expect_used,
clippy::panic,
reason = "test helper — panics provide clear assertion failures"
)]
impl TestResponse {
pub fn status(&self) -> StatusCode {
self.status
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn json<T: DeserializeOwned>(&self) -> T {
serde_json::from_slice(&self.body).unwrap_or_else(|err| {
panic!(
"failed to deserialize response body as {}: {err}\nbody was: {}",
std::any::type_name::<T>(),
String::from_utf8_lossy(&self.body),
)
})
}
pub fn text(&self) -> String {
String::from_utf8(self.body.to_vec()).expect("response body is not valid UTF-8")
}
pub fn assert_status(&self, expected: u16) {
let expected_status = StatusCode::from_u16(expected)
.unwrap_or_else(|_| panic!("{expected} is not a valid HTTP status code"));
assert_eq!(
self.status, expected_status,
"expected status {expected_status} but got {}",
self.status,
);
}
pub fn assert_json<T: DeserializeOwned>(&self) -> T {
self.json()
}
}