aro-web 1.0.0

HTTP/ADR layer for the Aro web framework
Documentation
//! Integration tests for the App builder.

use aro_web::App;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::routing::get;
use tower::ServiceExt;

#[expect(
    clippy::unwrap_used,
    reason = "test helper — constructing requests from literals cannot fail"
)]
async fn response(router: Router, method: &str, uri: &str) -> (StatusCode, Vec<u8>) {
    let req = Request::builder()
        .method(method)
        .uri(uri)
        .body(Body::empty())
        .unwrap();
    let resp = router.oneshot(req).await.unwrap();
    let status = resp.status();
    let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
        .await
        .unwrap()
        .to_vec();
    (status, body)
}

// --- Tests migrated to TestClient ---

#[cfg(feature = "test-utils")]
mod with_test_client {
    use aro_web::App;
    use aro_web::test::TestClient;
    use axum::Router;
    use axum::routing::get;

    #[tokio::test]
    async fn build_produces_working_service() {
        let router = Router::new().route("/hello", get(|| async { "world" }));
        let client = TestClient::new(App::new().routes(router).build());

        let resp = client.get("/hello").send().await;
        resp.assert_status(200);
        assert_eq!(resp.text(), "world");
    }

    #[tokio::test]
    async fn default_404_returns_json() {
        let client = TestClient::new(App::new().build());

        let resp = client.get("/nonexistent").send().await;
        resp.assert_status(404);
        let json: serde_json::Value = resp.json();
        assert_eq!(json["error"], "not found");
        assert_eq!(json["status"], 404);
    }

    #[tokio::test]
    async fn multiple_routes_calls_merge() {
        let router_a = Router::new().route("/a", get(|| async { "a" }));
        let router_b = Router::new().route("/b", get(|| async { "b" }));
        let client = TestClient::new(App::new().routes(router_a).routes(router_b).build());

        let resp = client.get("/a").send().await;
        resp.assert_status(200);
        assert_eq!(resp.text(), "a");

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

// --- Tests using the legacy response() helper ---

#[tokio::test]
async fn without_defaults_disables_trace_layer() {
    let router = Router::new().route("/ok", get(|| async { "ok" }));
    let app = App::new().routes(router).without_defaults().build();

    let (status, body) = response(app, "GET", "/ok").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, b"ok");
}

#[tokio::test]
async fn without_defaults_still_has_fallback() {
    let app = App::new().without_defaults().build();

    let (status, body) = response(app, "GET", "/nonexistent").await;
    assert_eq!(status, StatusCode::NOT_FOUND);
    let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(json["error"], "not found");
    assert_eq!(json["status"], 404);
}

#[tokio::test]
async fn empty_app_builds_without_routes() {
    let app = App::new().build();
    let (status, _) = response(app, "GET", "/anything").await;
    assert_eq!(status, StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn state_with_dep_extractor() {
    use aro_web::dep::Dep;
    use aro_web::state::AroState;
    use std::sync::Arc;

    trait Greeter: Send + Sync {
        fn greet(&self) -> &'static str;
    }
    struct HelloGreeter;
    impl Greeter for HelloGreeter {
        fn greet(&self) -> &'static str {
            "hi from dep"
        }
    }

    let state = AroState::builder()
        .register::<dyn Greeter>(Arc::new(HelloGreeter))
        .build();

    let stateful_routes: Router<AroState> = Router::new().route(
        "/greet",
        get(|dep: Dep<dyn Greeter>| async move { dep.greet().to_string() }),
    );

    let app = App::new()
        .routes_with_state(stateful_routes)
        .state(state)
        .build();

    let (status, body) = response(app, "GET", "/greet").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, b"hi from dep");
}

#[tokio::test]
async fn register_provides_deps_without_external_state() {
    use aro_web::dep::Dep;
    use aro_web::state::AroState;
    use std::sync::Arc;

    trait Greeter: Send + Sync {
        fn greet(&self) -> &'static str;
    }
    struct HelloGreeter;
    impl Greeter for HelloGreeter {
        fn greet(&self) -> &'static str {
            "hi from register"
        }
    }

    let stateful_routes: Router<AroState> = Router::new().route(
        "/greet",
        get(|dep: Dep<dyn Greeter>| async move { dep.greet().to_string() }),
    );

    let app = App::new()
        .register::<dyn Greeter>(Arc::new(HelloGreeter))
        .routes_with_state(stateful_routes)
        .build();

    let (status, body) = response(app, "GET", "/greet").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, b"hi from register");
}

#[tokio::test]
async fn register_merges_with_external_state() {
    use aro_web::dep::Dep;
    use aro_web::state::AroState;
    use std::sync::Arc;

    trait Greeter: Send + Sync {
        fn greet(&self) -> &'static str;
    }
    struct HelloGreeter;
    impl Greeter for HelloGreeter {
        fn greet(&self) -> &'static str {
            "from external"
        }
    }

    trait Counter: Send + Sync {
        fn count(&self) -> u32;
    }
    struct FixedCounter;
    impl Counter for FixedCounter {
        fn count(&self) -> u32 {
            99
        }
    }

    // Counter comes from external state, Greeter from .register()
    let external = AroState::builder()
        .register::<dyn Counter>(Arc::new(FixedCounter))
        .build();

    let stateful_routes: Router<AroState> = Router::new()
        .route(
            "/greet",
            get(|dep: Dep<dyn Greeter>| async move { dep.greet().to_string() }),
        )
        .route(
            "/count",
            get(|dep: Dep<dyn Counter>| async move { dep.count().to_string() }),
        );

    let app = App::new()
        .state(external)
        .register::<dyn Greeter>(Arc::new(HelloGreeter))
        .routes_with_state(stateful_routes)
        .build();

    let (status, body) = response(app.clone(), "GET", "/greet").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, b"from external");

    let (status, body) = response(app, "GET", "/count").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, b"99");
}

#[tokio::test]
async fn register_multiple_deps() {
    use aro_web::dep::Dep;
    use aro_web::state::AroState;
    use std::sync::Arc;

    trait Greeter: Send + Sync {
        fn greet(&self) -> &'static str;
    }
    struct HelloGreeter;
    impl Greeter for HelloGreeter {
        fn greet(&self) -> &'static str {
            "hello"
        }
    }

    trait Counter: Send + Sync {
        fn count(&self) -> u32;
    }
    struct FixedCounter;
    impl Counter for FixedCounter {
        fn count(&self) -> u32 {
            7
        }
    }

    let stateful_routes: Router<AroState> = Router::new()
        .route(
            "/greet",
            get(|dep: Dep<dyn Greeter>| async move { dep.greet().to_string() }),
        )
        .route(
            "/count",
            get(|dep: Dep<dyn Counter>| async move { dep.count().to_string() }),
        );

    let app = App::new()
        .register::<dyn Greeter>(Arc::new(HelloGreeter))
        .register::<dyn Counter>(Arc::new(FixedCounter))
        .routes_with_state(stateful_routes)
        .build();

    let (status, body) = response(app.clone(), "GET", "/greet").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, b"hello");

    let (status, body) = response(app, "GET", "/count").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, b"7");
}

#[tokio::test]
async fn mixed_stateful_and_stateless_routes() {
    use aro_web::dep::Dep;
    use aro_web::state::AroState;
    use std::sync::Arc;

    trait Counter: Send + Sync {
        fn count(&self) -> u32;
    }
    struct FixedCounter;
    impl Counter for FixedCounter {
        fn count(&self) -> u32 {
            42
        }
    }

    let state = AroState::builder()
        .register::<dyn Counter>(Arc::new(FixedCounter))
        .build();

    // Stateless route
    let plain_routes = Router::new().route("/ping", get(|| async { "pong" }));

    // Stateful route
    let stateful_routes: Router<AroState> = Router::new().route(
        "/count",
        get(|dep: Dep<dyn Counter>| async move { dep.count().to_string() }),
    );

    let app = App::new()
        .routes(plain_routes)
        .routes_with_state(stateful_routes)
        .state(state)
        .build();

    let (status, body) = response(app.clone(), "GET", "/ping").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, b"pong");

    let (status, body) = response(app, "GET", "/count").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(body, b"42");
}

#[tokio::test]
async fn layer_applies_to_stateful_routes() {
    use aro_web::dep::Dep;
    use aro_web::middleware::cache_control;
    use aro_web::state::AroState;
    use std::sync::Arc;

    trait Marker: Send + Sync {
        fn value(&self) -> &'static str;
    }
    struct TestMarker;
    impl Marker for TestMarker {
        fn value(&self) -> &'static str {
            "marked"
        }
    }

    let stateful_routes: Router<AroState> = Router::new().route(
        "/marked",
        get(|dep: Dep<dyn Marker>| async move { dep.value().to_string() }),
    );

    let app = App::new()
        .register::<dyn Marker>(Arc::new(TestMarker))
        .routes_with_state(stateful_routes)
        .layer(cache_control("max-age=60"))
        .build();

    let req = Request::builder()
        .method("GET")
        .uri("/marked")
        .body(Body::empty())
        .unwrap();
    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    assert_eq!(resp.headers().get("cache-control").unwrap(), "max-age=60");
}

#[tokio::test]
async fn method_not_allowed_returns_json_405() {
    let router = Router::new().route("/only-get", get(|| async { "ok" }));
    let app = App::new().routes(router).build();

    let (status, body) = response(app, "POST", "/only-get").await;
    assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
    let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
    assert_eq!(json["error"], "method not allowed");
    assert_eq!(json["status"], 405);
}