wabot-testing 0.1.0

Test harnesses for Wabot: a scriptable LLM adapter plus chat-bot and agent harnesses that drive the real production paths.
Documentation
//! Drive REST controllers in a test. Port of
//! `wabot-ts/src/testing/restHarness.ts`.
//!
//! ## No port, same application
//!
//! TS binds an ephemeral port and makes real `fetch` calls, because
//! Express has no other way in. axum's router *is* a `tower::Service`,
//! so a request can be driven straight through it — no listener, no
//! port collisions between parallel tests, no async teardown to
//! forget.
//!
//! The risk that buys is testing a *different* application than the
//! one that ships: the framework wraps the router in trailing-slash
//! normalization and the request log context, and a bare router has
//! neither. So the harness builds its stack with
//! [`rest_app`](wabot_feature_rest_controller::rest_app) — the very
//! function `run_rest_controllers` calls.

use std::sync::Arc;

use serde::de::DeserializeOwned;
use serde::Serialize;
use tower::ServiceExt;
use wabot_feature_rest_controller::axum::body::Body;
use wabot_feature_rest_controller::axum::http::{HeaderMap, Request, StatusCode};
use wabot_feature_rest_controller::axum::Router;
use wabot_feature_rest_controller::rest_app;

/// Mounts a router and exercises the real pipeline: routing,
/// extractors, middlewares and guards, validation, and error mapping.
///
/// ```ignore
/// let harness = RestHarness::new(UserController::register_routes(&container, Router::new()));
///
/// let response = harness.get("/users/1").send().await;
/// assert_eq!(response.status, 200);
/// assert_eq!(response.json::<User>().name, "Ada");
/// ```
#[derive(Clone)]
pub struct RestHarness {
    router: Router,
    /// Headers added to every request — how [`RestHarness::with_header`]
    /// builds an authenticated client without repeating itself.
    default_headers: Arc<Vec<(String, String)>>,
}

impl RestHarness {
    pub fn new(router: Router) -> Self {
        Self {
            router,
            default_headers: Arc::new(Vec::new()),
        }
    }

    /// A client that sends `name: value` on every request — a bearer
    /// token, an API key, a tenant header.
    ///
    /// Returns a new harness rather than mutating: a test usually
    /// wants both the authenticated and the anonymous client, and
    /// comparing them is the point.
    pub fn with_header(&self, name: impl Into<String>, value: impl Into<String>) -> Self {
        let mut headers = (*self.default_headers).clone();
        headers.push((name.into(), value.into()));
        Self {
            router: self.router.clone(),
            default_headers: Arc::new(headers),
        }
    }

    /// A client authenticating with `Authorization: Bearer …`.
    pub fn with_bearer(&self, token: impl std::fmt::Display) -> Self {
        self.with_header("authorization", format!("Bearer {token}"))
    }

    /// A client sending the token in a cookie, for a guard configured
    /// to read one.
    pub fn with_cookie(&self, name: &str, value: impl std::fmt::Display) -> Self {
        self.with_header("cookie", format!("{name}={value}"))
    }

    pub fn get(&self, path: &str) -> RequestBuilder {
        self.request("GET", path)
    }
    pub fn post(&self, path: &str) -> RequestBuilder {
        self.request("POST", path)
    }
    pub fn put(&self, path: &str) -> RequestBuilder {
        self.request("PUT", path)
    }
    pub fn delete(&self, path: &str) -> RequestBuilder {
        self.request("DELETE", path)
    }

    pub fn request(&self, method: &str, path: &str) -> RequestBuilder {
        RequestBuilder {
            router: self.router.clone(),
            method: method.to_string(),
            path: path.to_string(),
            query: Vec::new(),
            headers: (*self.default_headers).clone(),
            body: None,
        }
    }
}

pub struct RequestBuilder {
    router: Router,
    method: String,
    path: String,
    query: Vec<(String, String)>,
    headers: Vec<(String, String)>,
    body: Option<String>,
}

impl RequestBuilder {
    /// A JSON body. Sets `Content-Type` unless one was already given.
    pub fn json<T: Serialize>(mut self, body: &T) -> Self {
        self.body = Some(serde_json::to_string(body).expect("a serializable body"));
        self
    }

    /// A raw body, for testing what the framework does with something
    /// malformed.
    pub fn body(mut self, body: impl Into<String>) -> Self {
        self.body = Some(body.into());
        self
    }

    pub fn query(mut self, key: &str, value: impl std::fmt::Display) -> Self {
        self.query.push((key.into(), value.to_string()));
        self
    }

    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.headers.push((name.into(), value.into()));
        self
    }

    pub fn bearer(self, token: impl std::fmt::Display) -> Self {
        self.header("authorization", format!("Bearer {token}"))
    }

    /// Run the request through the real stack.
    ///
    /// # Panics
    ///
    /// If the request could not be built or the service failed
    /// outright — neither is a condition a test can act on, and both
    /// mean the test itself is wrong.
    pub async fn send(self) -> TestResponse {
        let mut uri = self.path.clone();
        if !self.query.is_empty() {
            let encoded: Vec<String> = self
                .query
                .iter()
                .map(|(k, v)| format!("{}={}", encode(k), encode(v)))
                .collect();
            uri = format!("{uri}?{}", encoded.join("&"));
        }

        let mut request = Request::builder().method(self.method.as_str()).uri(&uri);
        let has_content_type = self
            .headers
            .iter()
            .any(|(name, _)| name.eq_ignore_ascii_case("content-type"));
        for (name, value) in &self.headers {
            request = request.header(name, value);
        }
        if self.body.is_some() && !has_content_type {
            request = request.header("content-type", "application/json");
        }

        let request = request
            .body(self.body.map(Body::from).unwrap_or_else(Body::empty))
            .expect("a valid request");

        let response = rest_app(self.router)
            .oneshot(request)
            .await
            .expect("the service should not fail outright");

        let status = response.status();
        let headers = response.headers().clone();
        let bytes =
            wabot_feature_rest_controller::axum::body::to_bytes(response.into_body(), usize::MAX)
                .await
                .expect("a readable body");

        TestResponse {
            status,
            headers,
            body: String::from_utf8_lossy(&bytes).into_owned(),
        }
    }
}

/// What came back.
#[derive(Debug, Clone)]
pub struct TestResponse {
    pub status: StatusCode,
    pub headers: HeaderMap,
    /// The raw body. Use [`TestResponse::json`] for the typed form.
    pub body: String,
}

impl TestResponse {
    /// The body as `T`.
    ///
    /// # Panics
    ///
    /// With the status and body in the message when it doesn't
    /// deserialize — the usual cause is an error response the test
    /// didn't expect, and seeing it beats a bare parse error.
    pub fn json<T: DeserializeOwned>(&self) -> T {
        serde_json::from_str(&self.body).unwrap_or_else(|error| {
            panic!(
                "expected a {} body, got HTTP {} with {:?} ({error})",
                std::any::type_name::<T>(),
                self.status,
                self.body
            )
        })
    }

    /// The body as untyped JSON, for poking at one field.
    pub fn value(&self) -> serde_json::Value {
        self.json()
    }

    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers.get(name).and_then(|v| v.to_str().ok())
    }

    pub fn is_success(&self) -> bool {
        self.status.is_success()
    }

    /// Assert the status, showing the body when it doesn't match —
    /// which is exactly when a test needs to see it.
    pub fn assert_status(&self, expected: StatusCode) -> &Self {
        assert_eq!(
            self.status, expected,
            "expected HTTP {expected}, got {} with body {:?}",
            self.status, self.body
        );
        self
    }

    pub fn assert_ok(&self) -> &Self {
        self.assert_status(StatusCode::OK)
    }
}

/// Percent-encode a query parameter. Small enough not to be worth a
/// dependency, and a test's query strings are its own.
fn encode(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for byte in value.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(byte as char)
            }
            b' ' => out.push_str("%20"),
            other => out.push_str(&format!("%{other:02X}")),
        }
    }
    out
}