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 share their DI container via `Arc<FrozenDiContainer>` exactly like
16//! production launch does — the container drops when the test (or server)
17//! finishes, so building many contexts or launching many servers in one
18//! process no longer accumulates leaked containers.
19//!
20//! ```ignore
21//! #[tokio::test]
22//! async fn admin_guard_rejects_customers() {
23//!     let ctx = TestRequest::get("/admin/users")
24//!         .claims(serde_json::json!({"sub": "1", "role": "customer"}))
25//!         .build()
26//!         .await;
27//!     assert!(RoleGuard("admin").check(&ctx).is_err());
28//! }
29//! ```
30
31use std::sync::Arc;
32
33use axum::body::Body;
34use axum::http::{HeaderMap, HeaderName, HeaderValue, Method};
35
36use arcly_http_core::core::engine::DiContainerBuilder;
37use arcly_http_core::web::context::{Claims, RequestContext};
38
39/// Builder for a production-shaped [`RequestContext`].
40pub struct TestRequest {
41    method: Method,
42    path: String,
43    query: String,
44    headers: HeaderMap,
45    body: bytes::Bytes,
46    claims: Option<serde_json::Value>,
47    container: DiContainerBuilder,
48}
49
50impl TestRequest {
51    pub fn new(method: Method, path: impl Into<String>) -> Self {
52        Self {
53            method,
54            path: path.into(),
55            query: String::new(),
56            headers: HeaderMap::new(),
57            body: bytes::Bytes::new(),
58            claims: None,
59            container: DiContainerBuilder::new(),
60        }
61    }
62
63    pub fn get(path: impl Into<String>) -> Self {
64        Self::new(Method::GET, path)
65    }
66    pub fn post(path: impl Into<String>) -> Self {
67        Self::new(Method::POST, path)
68    }
69    pub fn put(path: impl Into<String>) -> Self {
70        Self::new(Method::PUT, path)
71    }
72    pub fn delete(path: impl Into<String>) -> Self {
73        Self::new(Method::DELETE, path)
74    }
75
76    /// Add a request header (e.g. `x-tenant-id`, `traceparent`). Invalid
77    /// names/values panic — in a test, that's the right failure mode.
78    pub fn header(mut self, name: &str, value: &str) -> Self {
79        let n = name.parse::<HeaderName>().expect("valid header name");
80        let v = HeaderValue::from_str(value).expect("valid header value");
81        self.headers.insert(n, v);
82        self
83    }
84
85    /// `?key=value` query string (without the leading `?`).
86    pub fn query(mut self, q: impl Into<String>) -> Self {
87        self.query = q.into();
88        self
89    }
90
91    /// JSON body (sets `content-type: application/json`).
92    pub fn json(mut self, value: &serde_json::Value) -> Self {
93        self.body = bytes::Bytes::from(value.to_string());
94        self.headers
95            .insert("content-type", HeaderValue::from_static("application/json"));
96        self
97    }
98
99    /// Raw body bytes.
100    pub fn body(mut self, bytes: impl Into<bytes::Bytes>) -> Self {
101        self.body = bytes.into();
102        self
103    }
104
105    /// Pretend the credential pipeline decoded these claims — the test
106    /// equivalent of presenting a valid JWT. Use a JSON object, e.g.
107    /// `json!({"sub": "42", "role": "admin", "perms": ["users:*"]})`.
108    pub fn claims(mut self, claims: serde_json::Value) -> Self {
109        self.claims = Some(claims);
110        self
111    }
112
113    /// Provide a DI singleton for `ctx.inject::<T>()` inside the code under
114    /// test (services, `TenantRegistry`, `Masker`, …).
115    pub fn provide<T: Send + Sync + 'static>(mut self, value: T) -> Self {
116        self.container.register(value);
117        self
118    }
119
120    /// Assemble through the production boundary pipeline. Returns a context
121    /// identical in shape and semantics to what a live request would carry.
122    pub async fn build(self) -> RequestContext {
123        let container = self.container.freeze();
124
125        let uri = if self.query.is_empty() {
126            self.path.clone()
127        } else {
128            format!("{}?{}", self.path, self.query)
129        };
130        let mut req = axum::http::Request::builder()
131            .method(self.method)
132            .uri(&uri)
133            .body(Body::from(self.body))
134            .expect("test request builds");
135        *req.headers_mut() = self.headers;
136
137        let (parts, body) = req.into_parts();
138        let ctx = arcly_http_core::web::boundary::assemble_context(
139            parts,
140            body,
141            Default::default(),
142            container,
143            "",
144            None,
145        )
146        .await
147        .expect("test request under the default body cap");
148
149        match self.claims {
150            Some(serde_json::Value::Object(map)) => {
151                ctx.__with_claims(Some(Arc::new(map as Claims)))
152            }
153            Some(other) => {
154                let mut map = serde_json::Map::new();
155                map.insert("value".into(), other);
156                ctx.__with_claims(Some(Arc::new(map)))
157            }
158            None => ctx,
159        }
160    }
161}
162
163/// Boot the full application on an ephemeral port for integration tests.
164pub struct TestServer {
165    /// `http://127.0.0.1:<port>` — point your HTTP client here.
166    pub base_url: String,
167    trigger: Arc<tokio::sync::Notify>,
168    server: tokio::task::JoinHandle<()>,
169}
170
171impl TestServer {
172    /// Launch `RootMod` with `plugins` + `config` on an OS-assigned port and
173    /// wait until it accepts connections. The server task runs until the
174    /// test process exits.
175    pub async fn launch<RootMod: arcly_http_core::core::engine::Module>(
176        plugins: Vec<Box<dyn arcly_http_core::core::plugins::ArclyPlugin>>,
177        config: crate::app::LaunchConfig,
178    ) -> Self {
179        // Bind HERE and hand the live listener to the launch path — the
180        // port is ours from this moment, so parallel test servers can never
181        // steal it (the classic probe-drop-rebind race made readiness
182        // checks greet a *different* test's server).
183        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
184            .await
185            .expect("bind test port");
186        let addr = listener.local_addr().expect("local addr").to_string();
187
188        let base_url = format!("http://{addr}");
189        let info = arcly_http_core::openapi::OpenApiInfo::new("test-server", "0");
190        let trigger = Arc::new(tokio::sync::Notify::new());
191        let config = config.shutdown_trigger(Arc::clone(&trigger));
192        let server = tokio::spawn(async move {
193            if let Err(e) =
194                crate::app::App::launch_on_listener::<RootMod>(listener, info, plugins, config)
195                    .await
196            {
197                tracing::error!(error = %e, "test server exited with error");
198            }
199        });
200
201        // Readiness: any HTTP response from OUR port is OUR server.
202        // Generous budget — parallel test binaries can starve a fresh task.
203        for _ in 0..3000 {
204            if server.is_finished() {
205                panic!("test server failed during boot on {addr} — check plugin on_init/on_start errors");
206            }
207            if tokio::net::TcpStream::connect(&addr).await.is_ok() {
208                return Self {
209                    base_url,
210                    trigger,
211                    server,
212                };
213            }
214            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
215        }
216        panic!("test server did not become ready on {addr}");
217    }
218
219    /// Trigger the FULL graceful-shutdown sequence — exactly what SIGTERM
220    /// produces: readiness flips, `on_draining` fires, the HTTP drain runs,
221    /// then every plugin's `on_shutdown` (per-plugin budget). Resolves when
222    /// the server task has fully exited; panics if it wedges past 30s
223    /// (which is precisely the bug such a test exists to catch).
224    pub async fn shutdown(self) {
225        self.trigger.notify_one();
226        tokio::time::timeout(std::time::Duration::from_secs(30), self.server)
227            .await
228            .expect("graceful shutdown must complete within 30s")
229            .expect("server task must not panic");
230    }
231}