Skip to main content

rullst_core/
testing.rs

1#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2use axum::{
3    Router,
4    body::{Body, Bytes, to_bytes},
5    http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode, header},
6    response::Response,
7};
8use serde::Serialize;
9use std::future::{Future, IntoFuture};
10use std::pin::Pin;
11use tower::ServiceExt;
12
13#[cfg_attr(mutants, mutants::skip)]
14const DEFAULT_MAX_BODY: usize = 10 * 1024 * 1024;
15
16/// A fluent testing wrapper around Axum's `Router` to enable declarative E2E assertions.
17#[non_exhaustive]
18pub struct TestApp {
19    router: Router,
20    max_body_bytes: usize,
21}
22
23impl TestApp {
24    /// Creates a new `TestApp` from the given Axum `Router`.
25    pub fn new(router: Router) -> Self {
26        Self {
27            router,
28            max_body_bytes: DEFAULT_MAX_BODY,
29        }
30    }
31
32    /// Creates a new `TestApp` with a custom maximum response body limit in bytes.
33    pub fn new_with_limit(router: Router, max_body_bytes: usize) -> Self {
34        Self {
35            router,
36            max_body_bytes,
37        }
38    }
39
40    /// Initiates a GET request.
41    pub fn get(&self, uri: &str) -> TestRequestBuilder {
42        TestRequestBuilder::new(self.router.clone(), Method::GET, uri, self.max_body_bytes)
43    }
44
45    /// Initiates a POST request.
46    pub fn post(&self, uri: &str) -> TestRequestBuilder {
47        TestRequestBuilder::new(self.router.clone(), Method::POST, uri, self.max_body_bytes)
48    }
49
50    /// Initiates a PUT request.
51    pub fn put(&self, uri: &str) -> TestRequestBuilder {
52        TestRequestBuilder::new(self.router.clone(), Method::PUT, uri, self.max_body_bytes)
53    }
54
55    /// Initiates a PATCH request.
56    pub fn patch(&self, uri: &str) -> TestRequestBuilder {
57        TestRequestBuilder::new(self.router.clone(), Method::PATCH, uri, self.max_body_bytes)
58    }
59
60    /// Initiates a DELETE request.
61    pub fn delete(&self, uri: &str) -> TestRequestBuilder {
62        TestRequestBuilder::new(
63            self.router.clone(),
64            Method::DELETE,
65            uri,
66            self.max_body_bytes,
67        )
68    }
69}
70
71/// A request builder for constructing HTTP requests in tests.
72/// Implements `IntoFuture` so it can be `.await`ed directly to send the request.
73#[non_exhaustive]
74pub struct TestRequestBuilder {
75    router: Router,
76    method: Method,
77    uri: String,
78    headers: HeaderMap,
79    body: Option<Body>,
80    max_body_bytes: usize,
81}
82
83impl TestRequestBuilder {
84    pub(crate) fn new(router: Router, method: Method, uri: &str, max_body_bytes: usize) -> Self {
85        Self {
86            router,
87            method,
88            uri: uri.to_string(),
89            headers: HeaderMap::new(),
90            body: None,
91            max_body_bytes,
92        }
93    }
94
95    /// Sets a custom response body limit in bytes for this specific request.
96    pub fn max_body_bytes(mut self, max_body_bytes: usize) -> Self {
97        self.max_body_bytes = max_body_bytes;
98        self
99    }
100
101    /// Adds a header to the request.
102    pub fn header<K, V>(mut self, key: K, value: V) -> Self
103    where
104        HeaderName: TryFrom<K>,
105        HeaderValue: TryFrom<V>,
106    {
107        if let (Ok(k), Ok(v)) = (HeaderName::try_from(key), HeaderValue::try_from(value)) {
108            self.headers.insert(k, v);
109        }
110        self
111    }
112
113    /// Sets the JSON payload for the request.
114    pub fn json<T: Serialize>(mut self, data: &T) -> Self {
115        self.headers.insert(
116            header::CONTENT_TYPE,
117            HeaderValue::from_static("application/json"),
118        );
119        let body_bytes = serde_json::to_vec(data).expect("Failed to serialize body as JSON");
120        self.body = Some(Body::from(body_bytes));
121        self
122    }
123
124    /// Sets the URL-encoded form payload for the request.
125    pub fn form<T: Serialize>(mut self, data: &T) -> Self {
126        self.headers.insert(
127            header::CONTENT_TYPE,
128            HeaderValue::from_static("application/x-www-form-urlencoded"),
129        );
130        let body_string = serde_urlencoded::to_string(data)
131            .expect("Failed to serialize body as form URL-encoded");
132        self.body = Some(Body::from(body_string));
133        self
134    }
135
136    /// Sets the raw body of the request.
137    pub fn body<B: Into<Body>>(mut self, body: B) -> Self {
138        self.body = Some(body.into());
139        self
140    }
141
142    /// Sends the request and returns the response.
143    pub async fn send(self) -> TestResponse {
144        let mut req_builder = Request::builder().method(self.method).uri(&self.uri);
145
146        for (k, v) in self.headers {
147            if let Some(k) = k {
148                req_builder = req_builder.header(k, v);
149            }
150        }
151
152        let body = self.body.unwrap_or_else(Body::empty);
153        let req = req_builder
154            .body(body)
155            .expect("Failed to build HTTP request");
156
157        let response = self
158            .router
159            .oneshot(req)
160            .await
161            .expect("Failed to execute request on Router");
162        TestResponse::new_with_limit(response, self.max_body_bytes).await
163    }
164}
165
166impl IntoFuture for TestRequestBuilder {
167    type Output = TestResponse;
168    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
169
170    fn into_future(self) -> Self::IntoFuture {
171        Box::pin(self.send())
172    }
173}
174
175/// A wrapper around Axum's `Response` that provides fluent assertion methods.
176#[non_exhaustive]
177pub struct TestResponse {
178    status: StatusCode,
179    headers: HeaderMap,
180    body_bytes: Bytes,
181}
182
183impl TestResponse {
184    pub(crate) async fn new_with_limit(response: Response, max_body_bytes: usize) -> Self {
185        let (parts, body) = response.into_parts();
186        let body_bytes = to_bytes(body, max_body_bytes)
187            .await
188            .unwrap_or_else(|err| {
189                panic!(
190                    "Failed to read response body bytes: response body exceeds the maximum configured limit of {} bytes (or another stream error occurred: {})",
191                    max_body_bytes, err
192                );
193            });
194
195        Self {
196            status: parts.status,
197            headers: parts.headers,
198            body_bytes,
199        }
200    }
201
202    /// Returns the HTTP status code.
203    #[cfg_attr(mutants, mutants::skip)]
204    pub fn status(&self) -> StatusCode {
205        self.status
206    }
207
208    /// Returns a reference to the response headers.
209    #[cfg_attr(mutants, mutants::skip)]
210    pub fn headers(&self) -> &HeaderMap {
211        &self.headers
212    }
213
214    /// Returns the response body parsed as a string.
215    pub fn body_string(&self) -> String {
216        String::from_utf8_lossy(&self.body_bytes).to_string()
217    }
218
219    /// Deserializes the JSON response body.
220    pub fn json<T: serde::de::DeserializeOwned>(&self) -> T {
221        serde_json::from_slice(&self.body_bytes).expect("Failed to parse response body as JSON")
222    }
223
224    /// Returns the value of a specific cookie from the `Set-Cookie` header.
225    pub fn cookie_value(&self, name: &str) -> Option<String> {
226        self.headers
227            .get_all(header::SET_COOKIE)
228            .iter()
229            .find_map(|value| {
230                let cookie_str = value.to_str().ok()?;
231                let main_part = cookie_str.split(';').next()?.trim();
232                let (cookie_name, cookie_val) = main_part.split_once('=')?;
233                if cookie_name.trim() == name {
234                    Some(cookie_val.trim().to_string())
235                } else {
236                    None
237                }
238            })
239    }
240
241    /// Asserts that the response status code matches the expected value.
242    pub fn assert_status(&self, expected: u16) -> &Self {
243        assert_eq!(
244            self.status.as_u16(),
245            expected,
246            "Expected HTTP status code {}, but received {}.\nResponse Body: {}",
247            expected,
248            self.status.as_u16(),
249            self.body_string()
250        );
251        self
252    }
253
254    /// Asserts that the response body contains the given text.
255    pub fn assert_see(&self, expected: &str) -> &Self {
256        let body_str = self.body_string();
257        assert!(
258            body_str.contains(expected),
259            "Expected response body to contain '{}', but it did not.\nResponse Body: {}",
260            expected,
261            body_str
262        );
263        self
264    }
265
266    /// Asserts that the response body does not contain the given text.
267    pub fn assert_dont_see(&self, expected: &str) -> &Self {
268        let body_str = self.body_string();
269        assert!(
270            !body_str.contains(expected),
271            "Expected response body NOT to contain '{}', but it did.\nResponse Body: {}",
272            expected,
273            body_str
274        );
275        self
276    }
277
278    /// Asserts that a response header matches the expected value.
279    pub fn assert_header(&self, name: &str, expected: &str) -> &Self {
280        let header_val = self
281            .headers
282            .get(name)
283            .unwrap_or_else(|| {
284                panic!(
285                    "Expected header '{}' to be present, but it was missing",
286                    name
287                )
288            })
289            .to_str()
290            .unwrap_or_else(|_| panic!("Failed to convert value of header '{}' to a string", name));
291
292        assert_eq!(
293            header_val, expected,
294            "Expected header '{}' to be '{}', but got '{}'",
295            name, expected, header_val
296        );
297        self
298    }
299
300    /// Asserts that the response body matches the given JSON structure.
301    pub fn assert_json<
302        T: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug + PartialEq,
303    >(
304        &self,
305        expected: &T,
306    ) -> &Self {
307        let actual: T = self.json();
308        assert_eq!(
309            actual, *expected,
310            "JSON structures do not match.\nExpected: {:?}\nActual: {:?}",
311            expected, actual
312        );
313        self
314    }
315
316    /// Asserts that a cookie with the given name is present and matches the expected value.
317    pub fn assert_cookie(&self, name: &str, expected: &str) -> &Self {
318        let actual = self.cookie_value(name).unwrap_or_else(|| {
319            panic!(
320                "Expected cookie '{}' to be present, but it was missing",
321                name
322            )
323        });
324
325        assert_eq!(
326            actual, expected,
327            "Expected cookie '{}' to be '{}', but got '{}'",
328            name, expected, actual
329        );
330        self
331    }
332
333    /// Asserts that a cookie with the given name exists in the response.
334    pub fn assert_has_cookie(&self, name: &str) -> &Self {
335        assert!(
336            self.cookie_value(name).is_some(),
337            "Expected cookie '{}' to be present, but it was missing",
338            name
339        );
340        self
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_default_max_body() {
350        assert_eq!(DEFAULT_MAX_BODY, 10 * 1024 * 1024); // Kills * replaced with + mutant
351    }
352}
353
354#[cfg(test)]
355#[path = "testing_contract_tests.rs"]
356mod contract_tests;