aro-web 1.0.0

HTTP/ADR layer for the Aro web framework
Documentation
#![cfg(feature = "test-utils")]
//! Tests for the `TestClient`, `TestRequestBuilder`, and `TestResponse` types.

use aro_web::App;
use aro_web::test::TestClient;
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};

fn app() -> Router {
    #[derive(Serialize, Deserialize, Debug, PartialEq)]
    struct Item {
        name: String,
    }

    Router::new()
        .route("/hello", get(|| async { "world" }))
        .route("/status", get(|| async { StatusCode::NO_CONTENT }))
        .route(
            "/echo",
            post(|Json(item): Json<Item>| async move { Json(item) }),
        )
}

#[tokio::test]
async fn get_returns_text_body() {
    let client = TestClient::new(App::new().routes(app()).build());
    let resp = client.get("/hello").send().await;
    resp.assert_status(200);
    assert_eq!(resp.text(), "world");
}

#[tokio::test]
async fn post_json_roundtrip() {
    #[derive(Serialize, Deserialize, Debug, PartialEq)]
    struct Item {
        name: String,
    }

    let client = TestClient::new(App::new().routes(app()).build());
    let resp = client
        .post("/echo")
        .json(&Item {
            name: "widget".into(),
        })
        .send()
        .await;

    resp.assert_status(200);
    let item: Item = resp.json();
    assert_eq!(
        item,
        Item {
            name: "widget".into()
        }
    );
}

#[tokio::test]
async fn assert_json_returns_deserialized_value() {
    #[derive(Serialize, Deserialize, Debug, PartialEq)]
    struct Item {
        name: String,
    }

    let client = TestClient::new(App::new().routes(app()).build());
    let resp = client
        .post("/echo")
        .json(&Item {
            name: "gadget".into(),
        })
        .send()
        .await;

    let item: Item = resp.assert_json();
    assert_eq!(
        item,
        Item {
            name: "gadget".into()
        }
    );
}

#[tokio::test]
async fn custom_header_is_sent() {
    let router = Router::new().route(
        "/check-header",
        get(|headers: axum::http::HeaderMap| async move {
            headers
                .get("x-custom")
                .map_or_else(|| "missing".into(), |v| v.to_str().unwrap().to_string())
        }),
    );
    let client = TestClient::new(App::new().routes(router).build());
    let resp = client
        .get("/check-header")
        .header("x-custom", "hello")
        .send()
        .await;
    resp.assert_status(200);
    assert_eq!(resp.text(), "hello");
}

#[tokio::test]
async fn status_code_non_200() {
    let client = TestClient::new(App::new().routes(app()).build());
    let resp = client.get("/status").send().await;
    resp.assert_status(204);
    assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}

#[tokio::test]
async fn not_found_fallback() {
    let client = TestClient::new(App::new().routes(app()).build());
    let resp = client.get("/nonexistent").send().await;
    resp.assert_status(404);
    let body: serde_json::Value = resp.json();
    assert_eq!(body["error"], "not found");
    assert_eq!(body["status"], 404);
}

#[tokio::test]
async fn put_delete_patch_methods() {
    let router = Router::new().route(
        "/resource",
        axum::routing::put(|| async { "updated" })
            .delete(|| async { "deleted" })
            .patch(|| async { "patched" }),
    );
    let client = TestClient::new(App::new().routes(router).build());

    let resp = client.put("/resource").send().await;
    resp.assert_status(200);
    assert_eq!(resp.text(), "updated");

    let resp = client.delete("/resource").send().await;
    resp.assert_status(200);
    assert_eq!(resp.text(), "deleted");

    let resp = client.patch("/resource").send().await;
    resp.assert_status(200);
    assert_eq!(resp.text(), "patched");
}

#[tokio::test]
async fn raw_body() {
    let router = Router::new().route("/raw", post(|body: axum::body::Bytes| async move { body }));
    let client = TestClient::new(App::new().routes(router).build());
    let resp = client
        .post("/raw")
        .body(b"raw bytes".as_slice())
        .send()
        .await;
    resp.assert_status(200);
    assert_eq!(resp.text(), "raw bytes");
}

#[tokio::test]
async fn response_headers_accessible() {
    let router = Router::new().route(
        "/with-header",
        get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/plain")], "hello") }),
    );
    let client = TestClient::new(App::new().routes(router).build());
    let resp = client.get("/with-header").send().await;
    assert_eq!(
        resp.headers()
            .get("content-type")
            .unwrap()
            .to_str()
            .unwrap(),
        "text/plain"
    );
}

#[tokio::test]
async fn from_app_runs_startup_hooks() {
    use std::sync::Arc;
    use std::sync::atomic::{AtomicU32, Ordering};

    let counter = Arc::new(AtomicU32::new(0));
    let counter_clone = counter.clone();

    let client = TestClient::from_app(
        App::new()
            .on_startup(move |_state| {
                Box::pin(async move {
                    counter_clone.fetch_add(1, Ordering::SeqCst);
                    Ok(())
                })
            })
            .routes(Router::new().route("/ping", get(|| async { "pong" }))),
    )
    .await
    .unwrap();

    // Hook was executed
    assert_eq!(counter.load(Ordering::SeqCst), 1);

    // Router works
    let resp = client.get("/ping").send().await;
    resp.assert_status(200);
    assert_eq!(resp.text(), "pong");
}

#[tokio::test]
async fn from_app_propagates_hook_error() {
    let result = TestClient::from_app(
        App::new().on_startup(|_state| Box::pin(async { Err("hook failed".into()) })),
    )
    .await;

    let err = result.err().expect("expected an error");
    assert_eq!(err.to_string(), "hook failed");
}