Skip to main content

arcly_http/
testing.rs

1//! First-class testing support for user-land code — the missing half of
2//! the NestJS-DX promise (`@nestjs/testing` equivalent).
3//!
4//! Two tools:
5//!
6//! - [`TestRequest`]: build a real [`RequestContext`] — through the SAME
7//!   boundary pipeline production uses (body cap, provenance, tenant
8//!   resolution) — without booting a server. Unit-test guards,
9//!   interceptors, and handler logic directly.
10//! - [`TestServer`]: boot the full application on an ephemeral port for
11//!   integration tests, with readiness polling built in.
12//!
13//! ## Lifetimes
14//!
15//! Both leak their DI containers to `&'static` exactly like production
16//! launch does. Tests are short-lived processes; the leak is the price of
17//! keeping test contexts indistinguishable from production ones.
18//!
19//! ```ignore
20//! #[tokio::test]
21//! async fn admin_guard_rejects_customers() {
22//!     let ctx = TestRequest::get("/admin/users")
23//!         .claims(serde_json::json!({"sub": "1", "role": "customer"}))
24//!         .build()
25//!         .await;
26//!     assert!(RoleGuard("admin").check(&ctx).is_err());
27//! }
28//! ```
29
30use std::sync::Arc;
31
32use axum::body::Body;
33use axum::http::{HeaderMap, HeaderName, HeaderValue, Method};
34
35use crate::core::engine::DiContainerBuilder;
36use crate::web::context::{Claims, RequestContext};
37
38/// Builder for a production-shaped [`RequestContext`].
39pub struct TestRequest {
40    method: Method,
41    path: String,
42    query: String,
43    headers: HeaderMap,
44    body: bytes::Bytes,
45    claims: Option<serde_json::Value>,
46    container: DiContainerBuilder,
47}
48
49impl TestRequest {
50    pub fn new(method: Method, path: impl Into<String>) -> Self {
51        Self {
52            method,
53            path: path.into(),
54            query: String::new(),
55            headers: HeaderMap::new(),
56            body: bytes::Bytes::new(),
57            claims: None,
58            container: DiContainerBuilder::new(),
59        }
60    }
61
62    pub fn get(path: impl Into<String>) -> Self {
63        Self::new(Method::GET, path)
64    }
65    pub fn post(path: impl Into<String>) -> Self {
66        Self::new(Method::POST, path)
67    }
68    pub fn put(path: impl Into<String>) -> Self {
69        Self::new(Method::PUT, path)
70    }
71    pub fn delete(path: impl Into<String>) -> Self {
72        Self::new(Method::DELETE, path)
73    }
74
75    /// Add a request header (e.g. `x-tenant-id`, `traceparent`). Invalid
76    /// names/values panic — in a test, that's the right failure mode.
77    pub fn header(mut self, name: &str, value: &str) -> Self {
78        let n = name.parse::<HeaderName>().expect("valid header name");
79        let v = HeaderValue::from_str(value).expect("valid header value");
80        self.headers.insert(n, v);
81        self
82    }
83
84    /// `?key=value` query string (without the leading `?`).
85    pub fn query(mut self, q: impl Into<String>) -> Self {
86        self.query = q.into();
87        self
88    }
89
90    /// JSON body (sets `content-type: application/json`).
91    pub fn json(mut self, value: &serde_json::Value) -> Self {
92        self.body = bytes::Bytes::from(value.to_string());
93        self.headers
94            .insert("content-type", HeaderValue::from_static("application/json"));
95        self
96    }
97
98    /// Raw body bytes.
99    pub fn body(mut self, bytes: impl Into<bytes::Bytes>) -> Self {
100        self.body = bytes.into();
101        self
102    }
103
104    /// Pretend the credential pipeline decoded these claims — the test
105    /// equivalent of presenting a valid JWT. Use a JSON object, e.g.
106    /// `json!({"sub": "42", "role": "admin", "perms": ["users:*"]})`.
107    pub fn claims(mut self, claims: serde_json::Value) -> Self {
108        self.claims = Some(claims);
109        self
110    }
111
112    /// Provide a DI singleton for `ctx.inject::<T>()` inside the code under
113    /// test (services, `TenantRegistry`, `Masker`, …).
114    pub fn provide<T: Send + Sync + 'static>(mut self, value: T) -> Self {
115        self.container.register(value);
116        self
117    }
118
119    /// Assemble through the production boundary pipeline. Returns a context
120    /// identical in shape and semantics to what a live request would carry.
121    pub async fn build(self) -> RequestContext {
122        let container = Box::leak(Box::new(self.container)).clone_freeze();
123
124        let uri = if self.query.is_empty() {
125            self.path.clone()
126        } else {
127            format!("{}?{}", self.path, self.query)
128        };
129        let mut req = axum::http::Request::builder()
130            .method(self.method)
131            .uri(&uri)
132            .body(Body::from(self.body))
133            .expect("test request builds");
134        *req.headers_mut() = self.headers;
135
136        let (parts, body) = req.into_parts();
137        let ctx = crate::web::boundary::assemble_context(
138            parts,
139            body,
140            Default::default(),
141            container,
142            "",
143            None,
144        )
145        .await
146        .expect("test request under the default body cap");
147
148        match self.claims {
149            Some(serde_json::Value::Object(map)) => {
150                ctx.__with_claims(Some(Arc::new(map as Claims)))
151            }
152            Some(other) => {
153                let mut map = serde_json::Map::new();
154                map.insert("value".into(), other);
155                ctx.__with_claims(Some(Arc::new(map)))
156            }
157            None => ctx,
158        }
159    }
160}
161
162/// Boot the full application on an ephemeral port for integration tests.
163pub struct TestServer {
164    /// `http://127.0.0.1:<port>` — point your HTTP client here.
165    pub base_url: String,
166}
167
168impl TestServer {
169    /// Launch `RootMod` with `plugins` + `config` on an OS-assigned port and
170    /// wait until it accepts connections. The server task runs until the
171    /// test process exits.
172    pub async fn launch<RootMod: crate::core::engine::Module>(
173        plugins: Vec<Box<dyn crate::core::plugins::ArclyPlugin>>,
174        config: crate::app::LaunchConfig,
175    ) -> Self {
176        let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("port probe");
177        let addr = probe.local_addr().expect("probe addr").to_string();
178        drop(probe);
179
180        let base_url = format!("http://{addr}");
181        let info = crate::openapi::OpenApiInfo::new("test-server", "0");
182        let launch_addr = addr.clone();
183        tokio::spawn(async move {
184            let _ =
185                crate::app::App::launch_configured::<RootMod>(&launch_addr, info, plugins, config)
186                    .await;
187        });
188
189        // Readiness: a TCP connect succeeding means the listener is bound
190        // (works even with `expose_docs: false`).
191        for _ in 0..200 {
192            if tokio::net::TcpStream::connect(&addr).await.is_ok() {
193                return Self { base_url };
194            }
195            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
196        }
197        panic!("test server did not become ready on {addr}");
198    }
199}
200
201// `DiContainerBuilder::freeze` consumes by value; tests hold the builder in
202// a leaked box, so expose a tiny crate-private bridge.
203trait CloneFreeze {
204    fn clone_freeze(&'static mut self) -> &'static crate::core::engine::FrozenDiContainer;
205}
206impl CloneFreeze for DiContainerBuilder {
207    fn clone_freeze(&'static mut self) -> &'static crate::core::engine::FrozenDiContainer {
208        std::mem::take(self).freeze()
209    }
210}