arcly-http 0.2.2

Enterprise-grade NestJS-inspired web framework on axum: zero-lock DI, declarative controllers, multi-tenant data routing, transactional outbox, ABAC, and a self-documenting OpenAPI surface
Documentation
//! First-class testing support for user-land code — the missing half of
//! the NestJS-DX promise (`@nestjs/testing` equivalent).
//!
//! Two tools:
//!
//! - [`TestRequest`]: build a real [`RequestContext`] — through the SAME
//!   boundary pipeline production uses (body cap, provenance, tenant
//!   resolution) — without booting a server. Unit-test guards,
//!   interceptors, and handler logic directly.
//! - [`TestServer`]: boot the full application on an ephemeral port for
//!   integration tests, with readiness polling built in.
//!
//! ## Lifetimes
//!
//! Both leak their DI containers to `&'static` exactly like production
//! launch does. Tests are short-lived processes; the leak is the price of
//! keeping test contexts indistinguishable from production ones.
//!
//! ```ignore
//! #[tokio::test]
//! async fn admin_guard_rejects_customers() {
//!     let ctx = TestRequest::get("/admin/users")
//!         .claims(serde_json::json!({"sub": "1", "role": "customer"}))
//!         .build()
//!         .await;
//!     assert!(RoleGuard("admin").check(&ctx).is_err());
//! }
//! ```

use std::sync::Arc;

use axum::body::Body;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method};

use crate::core::engine::DiContainerBuilder;
use crate::web::context::{Claims, RequestContext};

/// Builder for a production-shaped [`RequestContext`].
pub struct TestRequest {
    method: Method,
    path: String,
    query: String,
    headers: HeaderMap,
    body: bytes::Bytes,
    claims: Option<serde_json::Value>,
    container: DiContainerBuilder,
}

impl TestRequest {
    pub fn new(method: Method, path: impl Into<String>) -> Self {
        Self {
            method,
            path: path.into(),
            query: String::new(),
            headers: HeaderMap::new(),
            body: bytes::Bytes::new(),
            claims: None,
            container: DiContainerBuilder::new(),
        }
    }

    pub fn get(path: impl Into<String>) -> Self {
        Self::new(Method::GET, path)
    }
    pub fn post(path: impl Into<String>) -> Self {
        Self::new(Method::POST, path)
    }
    pub fn put(path: impl Into<String>) -> Self {
        Self::new(Method::PUT, path)
    }
    pub fn delete(path: impl Into<String>) -> Self {
        Self::new(Method::DELETE, path)
    }

    /// Add a request header (e.g. `x-tenant-id`, `traceparent`). Invalid
    /// names/values panic — in a test, that's the right failure mode.
    pub fn header(mut self, name: &str, value: &str) -> Self {
        let n = name.parse::<HeaderName>().expect("valid header name");
        let v = HeaderValue::from_str(value).expect("valid header value");
        self.headers.insert(n, v);
        self
    }

    /// `?key=value` query string (without the leading `?`).
    pub fn query(mut self, q: impl Into<String>) -> Self {
        self.query = q.into();
        self
    }

    /// JSON body (sets `content-type: application/json`).
    pub fn json(mut self, value: &serde_json::Value) -> Self {
        self.body = bytes::Bytes::from(value.to_string());
        self.headers
            .insert("content-type", HeaderValue::from_static("application/json"));
        self
    }

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

    /// Pretend the credential pipeline decoded these claims — the test
    /// equivalent of presenting a valid JWT. Use a JSON object, e.g.
    /// `json!({"sub": "42", "role": "admin", "perms": ["users:*"]})`.
    pub fn claims(mut self, claims: serde_json::Value) -> Self {
        self.claims = Some(claims);
        self
    }

    /// Provide a DI singleton for `ctx.inject::<T>()` inside the code under
    /// test (services, `TenantRegistry`, `Masker`, …).
    pub fn provide<T: Send + Sync + 'static>(mut self, value: T) -> Self {
        self.container.register(value);
        self
    }

    /// Assemble through the production boundary pipeline. Returns a context
    /// identical in shape and semantics to what a live request would carry.
    pub async fn build(self) -> RequestContext {
        let container = Box::leak(Box::new(self.container)).clone_freeze();

        let uri = if self.query.is_empty() {
            self.path.clone()
        } else {
            format!("{}?{}", self.path, self.query)
        };
        let mut req = axum::http::Request::builder()
            .method(self.method)
            .uri(&uri)
            .body(Body::from(self.body))
            .expect("test request builds");
        *req.headers_mut() = self.headers;

        let (parts, body) = req.into_parts();
        let ctx = crate::web::boundary::assemble_context(
            parts,
            body,
            Default::default(),
            container,
            "",
            None,
        )
        .await
        .expect("test request under the default body cap");

        match self.claims {
            Some(serde_json::Value::Object(map)) => {
                ctx.__with_claims(Some(Arc::new(map as Claims)))
            }
            Some(other) => {
                let mut map = serde_json::Map::new();
                map.insert("value".into(), other);
                ctx.__with_claims(Some(Arc::new(map)))
            }
            None => ctx,
        }
    }
}

/// Boot the full application on an ephemeral port for integration tests.
pub struct TestServer {
    /// `http://127.0.0.1:<port>` — point your HTTP client here.
    pub base_url: String,
    trigger: Arc<tokio::sync::Notify>,
    server: tokio::task::JoinHandle<()>,
}

impl TestServer {
    /// Launch `RootMod` with `plugins` + `config` on an OS-assigned port and
    /// wait until it accepts connections. The server task runs until the
    /// test process exits.
    pub async fn launch<RootMod: crate::core::engine::Module>(
        plugins: Vec<Box<dyn crate::core::plugins::ArclyPlugin>>,
        config: crate::app::LaunchConfig,
    ) -> Self {
        // Bind HERE and hand the live listener to the launch path — the
        // port is ours from this moment, so parallel test servers can never
        // steal it (the classic probe-drop-rebind race made readiness
        // checks greet a *different* test's server).
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
            .await
            .expect("bind test port");
        let addr = listener.local_addr().expect("local addr").to_string();

        let base_url = format!("http://{addr}");
        let info = crate::openapi::OpenApiInfo::new("test-server", "0");
        let trigger = Arc::new(tokio::sync::Notify::new());
        let config = config.shutdown_trigger(Arc::clone(&trigger));
        let server = tokio::spawn(async move {
            if let Err(e) =
                crate::app::App::launch_on_listener::<RootMod>(listener, info, plugins, config)
                    .await
            {
                tracing::error!(error = %e, "test server exited with error");
            }
        });

        // Readiness: any HTTP response from OUR port is OUR server.
        // Generous budget — parallel test binaries can starve a fresh task.
        for _ in 0..3000 {
            if server.is_finished() {
                panic!("test server failed during boot on {addr} — check plugin on_init/on_start errors");
            }
            if tokio::net::TcpStream::connect(&addr).await.is_ok() {
                return Self {
                    base_url,
                    trigger,
                    server,
                };
            }
            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        }
        panic!("test server did not become ready on {addr}");
    }

    /// Trigger the FULL graceful-shutdown sequence — exactly what SIGTERM
    /// produces: readiness flips, `on_draining` fires, the HTTP drain runs,
    /// then every plugin's `on_shutdown` (per-plugin budget). Resolves when
    /// the server task has fully exited; panics if it wedges past 30s
    /// (which is precisely the bug such a test exists to catch).
    pub async fn shutdown(self) {
        self.trigger.notify_one();
        tokio::time::timeout(std::time::Duration::from_secs(30), self.server)
            .await
            .expect("graceful shutdown must complete within 30s")
            .expect("server task must not panic");
    }
}

// `DiContainerBuilder::freeze` consumes by value; tests hold the builder in
// a leaked box, so expose a tiny crate-private bridge.
trait CloneFreeze {
    fn clone_freeze(&'static mut self) -> &'static crate::core::engine::FrozenDiContainer;
}
impl CloneFreeze for DiContainerBuilder {
    fn clone_freeze(&'static mut self) -> &'static crate::core::engine::FrozenDiContainer {
        std::mem::take(self).freeze()
    }
}