aro-web 1.0.0

HTTP/ADR layer for the Aro web framework
Documentation
//! Test utilities for the Aro web framework.
//!
//! Provides a [`TestClient`] that sends requests through a [`Router`] without
//! starting a real HTTP server, using `tower::ServiceExt::oneshot`.
//!
//! # Example
//!
//! ```
//! # #[tokio::main]
//! # async fn main() {
//! use aro_web::test::TestClient;
//! use aro_web::App;
//! use axum::routing::get;
//! use axum::Router;
//!
//! let router = Router::new().route("/hello", get(|| async { "world" }));
//! let app = App::new().routes(router).build();
//! let client = TestClient::new(app);
//!
//! let resp = client.get("/hello").send().await;
//! resp.assert_status(200);
//! assert_eq!(resp.text(), "world");
//! # }
//! ```

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;

/// A test client that sends requests through a [`Router`] in-process.
///
/// Each request clones the router and dispatches via `oneshot`, so the client
/// can be reused across multiple requests without `&mut self`.
#[derive(Clone)]
pub struct TestClient {
    router: Router,
}

impl TestClient {
    /// Wrap a [`Router`] (typically from [`App::build()`](crate::App::build)).
    pub fn new(router: Router) -> Self {
        Self { router }
    }

    /// Build a test client from an [`App`], running all startup hooks.
    ///
    /// This is the recommended way to create a `TestClient` for apps that
    /// use [`on_startup`](App::on_startup) hooks (e.g., database migrations).
    ///
    /// # Errors
    ///
    /// Returns an error if any startup hook fails.
    ///
    /// # Example
    ///
    /// ```
    /// # #[tokio::main]
    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// use aro_web::test::TestClient;
    /// use aro_web::App;
    /// use axum::Router;
    /// use axum::routing::get;
    ///
    /// let client = TestClient::from_app(
    ///     App::new()
    ///         .on_startup(|_| Box::pin(async { Ok(()) }))
    ///         .routes(Router::new().route("/hello", get(|| async { "world" }))),
    /// )
    /// .await?;
    ///
    /// let resp = client.get("/hello").send().await;
    /// resp.assert_status(200);
    /// # Ok(())
    /// # }
    /// ```
    pub async fn from_app(app: App) -> Result<Self, Box<dyn std::error::Error>> {
        let router = app.build_with_hooks().await?;
        Ok(Self { router })
    }

    /// Start building a GET request.
    pub fn get(&self, path: &str) -> TestRequestBuilder {
        self.request(Method::GET, path)
    }

    /// Start building a POST request.
    pub fn post(&self, path: &str) -> TestRequestBuilder {
        self.request(Method::POST, path)
    }

    /// Start building a PUT request.
    pub fn put(&self, path: &str) -> TestRequestBuilder {
        self.request(Method::PUT, path)
    }

    /// Start building a DELETE request.
    pub fn delete(&self, path: &str) -> TestRequestBuilder {
        self.request(Method::DELETE, path)
    }

    /// Start building a PATCH request.
    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(),
        }
    }
}

/// Builder for a test request. Call [`.send()`](Self::send) to execute it.
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 {
    /// Add a header to the request.
    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
    }

    /// Set a JSON body and the `Content-Type: application/json` header.
    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
    }

    /// Set a raw body.
    pub fn body(mut self, bytes: impl Into<Bytes>) -> Self {
        self.body = Body::from(bytes.into());
        self
    }

    /// Send the request through the router and return a [`TestResponse`].
    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,
        }
    }
}

/// An eagerly-collected HTTP response from a test request.
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 {
    /// The HTTP status code.
    pub fn status(&self) -> StatusCode {
        self.status
    }

    /// The response headers.
    pub fn headers(&self) -> &HeaderMap {
        &self.headers
    }

    /// Deserialize the response body as JSON.
    ///
    /// # Panics
    ///
    /// Panics with a helpful message if deserialization fails.
    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),
            )
        })
    }

    /// The response body as a UTF-8 string.
    ///
    /// # Panics
    ///
    /// Panics if the body is not valid UTF-8.
    pub fn text(&self) -> String {
        String::from_utf8(self.body.to_vec()).expect("response body is not valid UTF-8")
    }

    /// Assert that the status code matches the expected value.
    ///
    /// # Panics
    ///
    /// Panics with a diff-style message if the status does not match.
    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,
        );
    }

    /// Deserialize the response body as JSON, panicking on failure.
    ///
    /// This is identical to [`json()`](Self::json) but reads more naturally
    /// in assertion-heavy test code.
    pub fn assert_json<T: DeserializeOwned>(&self) -> T {
        self.json()
    }
}