af-web 0.11.3

Reusable Agent Factory host infrastructure for HTTP and gRPC boundaries.
Documentation
//! Unit coverage for the error envelope + health + router helpers.
//! (The middleware is also exercised by the starter-product integration tests.)

use std::time::Duration;

use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use tower::ServiceExt;

use af_web::{
    base_router, health, join_tasks, metrics, operations_router, with_edge_middleware,
    with_standard_middleware, ApiError, EdgeConfig, Readiness,
};

#[test]
fn api_error_constructors_and_status() {
    assert_eq!(ApiError::bad_request("x").status, StatusCode::BAD_REQUEST);
    assert_eq!(ApiError::not_found("x").status, StatusCode::NOT_FOUND);
    assert_eq!(
        ApiError::internal("x").status,
        StatusCode::INTERNAL_SERVER_ERROR
    );

    let e = ApiError::new(StatusCode::CONFLICT, "dup").with_request_id("req_1".parse().unwrap());
    assert_eq!(e.status, StatusCode::CONFLICT);
    assert_eq!(e.request_id.as_deref(), Some("req_1"));

    // renders to a response with the right status
    let resp = e.into_response();
    assert_eq!(resp.status(), StatusCode::CONFLICT);
}

#[tokio::test]
async fn health_payload() {
    let axum::Json(v) = health().await;
    assert_eq!(v["status"], "ok");
}

#[tokio::test]
async fn metrics_payload() {
    let response = metrics().await;
    assert_eq!(response.1, "agent_factory_up 1\n");
}

#[test]
fn router_helpers_compose() {
    // base_router + middleware wrapper type-check and build.
    let _r: axum::Router = with_standard_middleware(base_router::<()>());
    let readiness = Readiness::default();
    readiness.set(true);
    assert!(readiness.is_ready());
    let _r = with_edge_middleware(operations_router(readiness), EdgeConfig::default());
}

#[tokio::test]
async fn edge_stack_sets_request_id_denies_cors_and_limits_rate() {
    let config = EdgeConfig {
        requests_per_window: 1,
        rate_window: Duration::from_secs(60),
        ..EdgeConfig::default()
    };
    let app = with_edge_middleware(
        operations_router(Readiness::default()).route("/work", get(|| async { "ok" })),
        config,
    );
    let first = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/work")
                .header("origin", "https://not-allowed.invalid")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(first.status(), StatusCode::OK);
    assert!(first.headers().contains_key("x-request-id"));
    assert!(!first.headers().contains_key("access-control-allow-origin"));
    let second = app
        .clone()
        .oneshot(Request::builder().uri("/work").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
    let health = app
        .oneshot(Request::builder().uri("/live").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(health.status(), StatusCode::OK);
}

#[tokio::test]
async fn edge_stack_times_out_slow_requests() {
    let app = with_edge_middleware(
        axum::Router::new().route(
            "/slow",
            get(|| async {
                tokio::time::sleep(Duration::from_millis(50)).await;
                "late"
            }),
        ),
        EdgeConfig {
            request_timeout: Duration::from_millis(1),
            ..EdgeConfig::default()
        },
    );
    let response = app
        .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT);
}

#[tokio::test]
async fn edge_stack_rejects_oversized_bodies() {
    let app = with_edge_middleware(
        axum::Router::new().route("/body", post(|_body: String| async { "ok" })),
        EdgeConfig {
            max_body_bytes: 8,
            ..EdgeConfig::default()
        },
    );
    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/body")
                .body(Body::from("more than eight bytes"))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}

#[tokio::test]
async fn cancellation_drains_tasks_within_grace_period() {
    let cancel = tokio_util::sync::CancellationToken::new();
    let child = cancel.clone();
    let task = tokio::spawn(async move { child.cancelled().await });
    cancel.cancel();
    assert!(join_tasks(vec![task], Duration::from_secs(1)).await);
}

#[tokio::test]
async fn task_panics_fail_shutdown_join() {
    let task = tokio::spawn(async { panic!("task failed") });
    assert!(!join_tasks(vec![task], Duration::from_secs(1)).await);
}

#[tokio::test]
async fn supervisor_failure_revokes_readiness_and_cancels_siblings() {
    for panics in [false, true] {
        let readiness = Readiness::default();
        readiness.set(true);
        let cancel = tokio_util::sync::CancellationToken::new();
        let child = cancel.clone();
        let sibling = tokio::spawn(async move { child.cancelled().await });
        let failure = tokio::spawn(async move { assert!(!panics, "worker failed") });
        assert!(
            !af_web::supervise_tasks(
                vec![sibling, failure],
                readiness.clone(),
                cancel.clone(),
                Duration::from_secs(1)
            )
            .await
        );
        assert!(!readiness.is_ready());
        assert!(cancel.is_cancelled());
    }
}

#[tokio::test]
async fn requested_shutdown_drains_and_timeout_aborts_workers() {
    let cancel = tokio_util::sync::CancellationToken::new();
    cancel.cancel();
    assert!(
        af_web::supervise_tasks(vec![], Readiness::default(), cancel, Duration::from_secs(1)).await
    );
    let task = tokio::spawn(std::future::pending::<()>());
    let abort = task.abort_handle();
    assert!(!join_tasks(vec![task], Duration::ZERO).await);
    tokio::task::yield_now().await;
    assert!(abort.is_finished());
}