rustapi_testing/client.rs
1//! TestClient for integration testing without network binding
2//!
3//! This module provides a test client that allows sending simulated HTTP requests
4//! through the full middleware and handler pipeline without starting a real server.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use rustapi_core::{RustApi, get};
10//! use rustapi_testing::TestClient;
11//!
12//! async fn hello() -> &'static str {
13//! "Hello, World!"
14//! }
15//!
16//! #[tokio::test]
17//! async fn test_hello() {
18//! let app = RustApi::new().route("/", get(hello));
19//! let client = TestClient::new(app);
20//!
21//! let response = client.get("/").await;
22//! response.assert_status(200);
23//! assert_eq!(response.text(), "Hello, World!");
24//! }
25//! ```
26
27use bytes::Bytes;
28use http::{header, HeaderMap, HeaderValue, Method, StatusCode};
29use http_body_util::BodyExt;
30use rustapi_core::middleware::{BodyLimitLayer, BoxedNext, LayerStack, DEFAULT_BODY_LIMIT};
31use rustapi_core::{ApiError, BodyVariant, IntoResponse, Request, Response, RouteMatch, Router};
32use serde::{de::DeserializeOwned, Serialize};
33use std::future::Future;
34use std::pin::Pin;
35use std::sync::Arc;
36
37/// Test client for integration testing without network binding
38///
39/// TestClient wraps a RustApi instance and allows sending simulated HTTP requests
40/// through the full middleware and handler pipeline.
41pub struct TestClient {
42 router: Arc<Router>,
43 layers: Arc<LayerStack>,
44}
45
46impl TestClient {
47 /// Create a new test client from a RustApi instance
48 ///
49 /// # Example
50 ///
51 /// ```rust,ignore
52 /// let app = RustApi::new().route("/", get(handler));
53 /// let client = TestClient::new(app);
54 /// ```
55 pub fn new(app: rustapi_core::RustApi) -> Self {
56 // Get the router and layers from the app
57 let layers = app.layers().clone();
58 let router = app.into_router();
59
60 // Apply body limit layer if not already present
61 let mut layers = layers;
62 layers.prepend(Box::new(BodyLimitLayer::new(DEFAULT_BODY_LIMIT)));
63
64 Self {
65 router: Arc::new(router),
66 layers: Arc::new(layers),
67 }
68 }
69
70 /// Create a new test client with custom body limit
71 pub fn with_body_limit(app: rustapi_core::RustApi, limit: usize) -> Self {
72 let layers = app.layers().clone();
73 let router = app.into_router();
74
75 let mut layers = layers;
76 layers.prepend(Box::new(BodyLimitLayer::new(limit)));
77
78 Self {
79 router: Arc::new(router),
80 layers: Arc::new(layers),
81 }
82 }
83
84 /// Send a GET request
85 ///
86 /// # Example
87 ///
88 /// ```rust,ignore
89 /// let response = client.get("/users").await;
90 /// ```
91 pub async fn get(&self, path: &str) -> TestResponse {
92 self.request(TestRequest::get(path)).await
93 }
94
95 /// Send a POST request with JSON body
96 ///
97 /// # Example
98 ///
99 /// ```rust,ignore
100 /// let response = client.post_json("/users", &CreateUser { name: "Alice" }).await;
101 /// ```
102 pub async fn post_json<T: Serialize>(&self, path: &str, body: &T) -> TestResponse {
103 self.request(TestRequest::post(path).json(body)).await
104 }
105
106 /// Send a request with full control
107 ///
108 /// # Example
109 ///
110 /// ```rust,ignore
111 /// let response = client.request(
112 /// TestRequest::put("/users/1")
113 /// .header("Authorization", "Bearer token")
114 /// .json(&UpdateUser { name: "Bob" })
115 /// ).await;
116 /// ```
117 pub async fn request(&self, req: TestRequest) -> TestResponse {
118 let method = req.method.clone();
119 let path = req.path.clone();
120
121 // Match the route to get path params
122 let (handler, params) = match self.router.match_route(&path, &method) {
123 RouteMatch::Found { handler, params } => (handler.clone(), params),
124 RouteMatch::NotFound => {
125 let response =
126 ApiError::not_found(format!("No route found for {} {}", method, path))
127 .into_response();
128 return TestResponse::from_response(response).await;
129 }
130 RouteMatch::MethodNotAllowed { allowed } => {
131 let allowed_str: Vec<&str> = allowed.iter().map(|m| m.as_str()).collect();
132 let mut response = ApiError::new(
133 StatusCode::METHOD_NOT_ALLOWED,
134 "method_not_allowed",
135 format!("Method {} not allowed for {}", method, path),
136 )
137 .into_response();
138
139 response
140 .headers_mut()
141 .insert(header::ALLOW, allowed_str.join(", ").parse().unwrap());
142 return TestResponse::from_response(response).await;
143 }
144 };
145
146 // Build the internal Request
147 let uri: http::Uri = path.parse().unwrap_or_else(|_| "/".parse().unwrap());
148 let mut builder = http::Request::builder().method(method).uri(uri);
149
150 // Add headers
151 for (key, value) in req.headers.iter() {
152 builder = builder.header(key, value);
153 }
154
155 let http_req = builder.body(()).unwrap();
156 let (parts, _) = http_req.into_parts();
157
158 let body_bytes = req.body.unwrap_or_default();
159
160 let request = Request::new(
161 parts,
162 BodyVariant::Buffered(body_bytes),
163 self.router.state_ref(),
164 params,
165 );
166
167 // Create the final handler as a BoxedNext
168 let final_handler: BoxedNext = Arc::new(move |req: Request| {
169 let handler = handler.clone();
170 Box::pin(async move { handler(req).await })
171 as Pin<Box<dyn Future<Output = Response> + Send + 'static>>
172 });
173
174 // Execute through middleware stack
175 let response = self.layers.execute(request, final_handler).await;
176
177 TestResponse::from_response(response).await
178 }
179}
180
181/// Test request builder
182///
183/// Provides a fluent API for building test requests with custom methods,
184/// headers, and body content.
185#[derive(Debug, Clone)]
186pub struct TestRequest {
187 method: Method,
188 path: String,
189 headers: HeaderMap,
190 body: Option<Bytes>,
191}
192
193impl TestRequest {
194 /// Create a new request with the given method and path
195 fn new(method: Method, path: &str) -> Self {
196 Self {
197 method,
198 path: path.to_string(),
199 headers: HeaderMap::new(),
200 body: None,
201 }
202 }
203
204 /// Create a GET request
205 pub fn get(path: &str) -> Self {
206 Self::new(Method::GET, path)
207 }
208
209 /// Create a POST request
210 pub fn post(path: &str) -> Self {
211 Self::new(Method::POST, path)
212 }
213
214 /// Create a PUT request
215 pub fn put(path: &str) -> Self {
216 Self::new(Method::PUT, path)
217 }
218
219 /// Create a PATCH request
220 pub fn patch(path: &str) -> Self {
221 Self::new(Method::PATCH, path)
222 }
223
224 /// Create a DELETE request
225 pub fn delete(path: &str) -> Self {
226 Self::new(Method::DELETE, path)
227 }
228
229 /// Add a header to the request
230 ///
231 /// # Example
232 ///
233 /// ```rust,ignore
234 /// let req = TestRequest::get("/")
235 /// .header("Authorization", "Bearer token")
236 /// .header("Accept", "application/json");
237 /// ```
238 pub fn header(mut self, key: &str, value: &str) -> Self {
239 if let (Ok(name), Ok(val)) = (
240 key.parse::<http::header::HeaderName>(),
241 HeaderValue::from_str(value),
242 ) {
243 self.headers.insert(name, val);
244 }
245 self
246 }
247
248 /// Set the request body as JSON
249 ///
250 /// This automatically sets the Content-Type header to `application/json`.
251 ///
252 /// # Example
253 ///
254 /// ```rust,ignore
255 /// let req = TestRequest::post("/users")
256 /// .json(&CreateUser { name: "Alice" });
257 /// ```
258 pub fn json<T: Serialize>(mut self, body: &T) -> Self {
259 match serde_json::to_vec(body) {
260 Ok(bytes) => {
261 self.body = Some(Bytes::from(bytes));
262 self.headers.insert(
263 header::CONTENT_TYPE,
264 HeaderValue::from_static("application/json"),
265 );
266 }
267 Err(_) => {
268 // If serialization fails, leave body empty
269 }
270 }
271 self
272 }
273
274 /// Set the request body as raw bytes
275 ///
276 /// # Example
277 ///
278 /// ```rust,ignore
279 /// let req = TestRequest::post("/upload")
280 /// .body("raw content");
281 /// ```
282 pub fn body(mut self, body: impl Into<Bytes>) -> Self {
283 self.body = Some(body.into());
284 self
285 }
286
287 /// Set the Content-Type header
288 pub fn content_type(self, content_type: &str) -> Self {
289 self.header("content-type", content_type)
290 }
291}
292
293/// Test response with assertion helpers
294///
295/// Provides methods to inspect and assert on the response status, headers, and body.
296#[derive(Debug)]
297pub struct TestResponse {
298 status: StatusCode,
299 headers: HeaderMap,
300 body: Bytes,
301}
302
303impl TestResponse {
304 /// Create a TestResponse from an HTTP response
305 async fn from_response(response: Response) -> Self {
306 let (parts, body) = response.into_parts();
307 let body_bytes = body
308 .collect()
309 .await
310 .map(|b| b.to_bytes())
311 .unwrap_or_default();
312
313 Self {
314 status: parts.status,
315 headers: parts.headers,
316 body: body_bytes,
317 }
318 }
319
320 /// Get the response status code
321 pub fn status(&self) -> StatusCode {
322 self.status
323 }
324
325 /// Get the response headers
326 pub fn headers(&self) -> &HeaderMap {
327 &self.headers
328 }
329
330 /// Get the response body as bytes
331 pub fn body(&self) -> &Bytes {
332 &self.body
333 }
334
335 /// Get the response body as a string
336 ///
337 /// Returns an empty string if the body is not valid UTF-8.
338 pub fn text(&self) -> String {
339 String::from_utf8_lossy(&self.body).to_string()
340 }
341
342 /// Parse the response body as JSON
343 ///
344 /// # Example
345 ///
346 /// ```rust,ignore
347 /// let user: User = response.json().unwrap();
348 /// ```
349 pub fn json<T: DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
350 serde_json::from_slice(&self.body)
351 }
352
353 /// Assert that the response has the expected status code
354 ///
355 /// # Panics
356 ///
357 /// Panics if the status code doesn't match.
358 ///
359 /// # Example
360 ///
361 /// ```rust,ignore
362 /// response.assert_status(StatusCode::OK);
363 /// response.assert_status(200);
364 /// ```
365 pub fn assert_status<S: Into<StatusCode>>(&self, expected: S) -> &Self {
366 let expected = expected.into();
367 assert_eq!(
368 self.status,
369 expected,
370 "Expected status {}, got {}. Body: {}",
371 expected,
372 self.status,
373 self.text()
374 );
375 self
376 }
377
378 /// Assert that the response has the expected header value
379 ///
380 /// # Panics
381 ///
382 /// Panics if the header doesn't exist or doesn't match.
383 ///
384 /// # Example
385 ///
386 /// ```rust,ignore
387 /// response.assert_header("content-type", "application/json");
388 /// ```
389 pub fn assert_header(&self, key: &str, expected: &str) -> &Self {
390 let actual = self
391 .headers
392 .get(key)
393 .and_then(|v| v.to_str().ok())
394 .unwrap_or("");
395
396 assert_eq!(
397 actual, expected,
398 "Expected header '{}' to be '{}', got '{}'",
399 key, expected, actual
400 );
401 self
402 }
403
404 /// Assert that the response body matches the expected JSON value
405 ///
406 /// # Panics
407 ///
408 /// Panics if the body can't be parsed as JSON or doesn't match.
409 ///
410 /// # Example
411 ///
412 /// ```rust,ignore
413 /// response.assert_json(&User { id: 1, name: "Alice".to_string() });
414 /// ```
415 pub fn assert_json<T: DeserializeOwned + PartialEq + std::fmt::Debug>(
416 &self,
417 expected: &T,
418 ) -> &Self {
419 let actual: T = self.json().expect("Failed to parse response body as JSON");
420 assert_eq!(&actual, expected, "JSON body mismatch");
421 self
422 }
423
424 /// Assert that the response body contains the expected string
425 ///
426 /// # Panics
427 ///
428 /// Panics if the body doesn't contain the expected string.
429 pub fn assert_body_contains(&self, expected: &str) -> &Self {
430 let body = self.text();
431 assert!(
432 body.contains(expected),
433 "Expected body to contain '{}', got '{}'",
434 expected,
435 body
436 );
437 self
438 }
439}