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    trigger: Arc<tokio::sync::Notify>,
167    server: tokio::task::JoinHandle<()>,
168}
169
170impl TestServer {
171    /// Launch `RootMod` with `plugins` + `config` on an OS-assigned port and
172    /// wait until it accepts connections. The server task runs until the
173    /// test process exits.
174    pub async fn launch<RootMod: crate::core::engine::Module>(
175        plugins: Vec<Box<dyn crate::core::plugins::ArclyPlugin>>,
176        config: crate::app::LaunchConfig,
177    ) -> Self {
178        // Bind HERE and hand the live listener to the launch path — the
179        // port is ours from this moment, so parallel test servers can never
180        // steal it (the classic probe-drop-rebind race made readiness
181        // checks greet a *different* test's server).
182        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
183            .await
184            .expect("bind test port");
185        let addr = listener.local_addr().expect("local addr").to_string();
186
187        let base_url = format!("http://{addr}");
188        let info = crate::openapi::OpenApiInfo::new("test-server", "0");
189        let trigger = Arc::new(tokio::sync::Notify::new());
190        let config = config.shutdown_trigger(Arc::clone(&trigger));
191        let server = tokio::spawn(async move {
192            if let Err(e) =
193                crate::app::App::launch_on_listener::<RootMod>(listener, info, plugins, config)
194                    .await
195            {
196                tracing::error!(error = %e, "test server exited with error");
197            }
198        });
199
200        // Readiness: any HTTP response from OUR port is OUR server.
201        // Generous budget — parallel test binaries can starve a fresh task.
202        for _ in 0..3000 {
203            if server.is_finished() {
204                panic!("test server failed during boot on {addr} — check plugin on_init/on_start errors");
205            }
206            if tokio::net::TcpStream::connect(&addr).await.is_ok() {
207                return Self {
208                    base_url,
209                    trigger,
210                    server,
211                };
212            }
213            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
214        }
215        panic!("test server did not become ready on {addr}");
216    }
217
218    /// Trigger the FULL graceful-shutdown sequence — exactly what SIGTERM
219    /// produces: readiness flips, `on_draining` fires, the HTTP drain runs,
220    /// then every plugin's `on_shutdown` (per-plugin budget). Resolves when
221    /// the server task has fully exited; panics if it wedges past 30s
222    /// (which is precisely the bug such a test exists to catch).
223    pub async fn shutdown(self) {
224        self.trigger.notify_one();
225        tokio::time::timeout(std::time::Duration::from_secs(30), self.server)
226            .await
227            .expect("graceful shutdown must complete within 30s")
228            .expect("server task must not panic");
229    }
230}
231
232// `DiContainerBuilder::freeze` consumes by value; tests hold the builder in
233// a leaked box, so expose a tiny crate-private bridge.
234trait CloneFreeze {
235    fn clone_freeze(&'static mut self) -> &'static crate::core::engine::FrozenDiContainer;
236}
237impl CloneFreeze for DiContainerBuilder {
238    fn clone_freeze(&'static mut self) -> &'static crate::core::engine::FrozenDiContainer {
239        std::mem::take(self).freeze()
240    }
241}