Skip to main content

rustlavel_http/
testing.rs

1//! The test client.
2//!
3//! Requests go straight through the router — no socket, no port, no waiting —
4//! so an HTTP test costs about as much as calling a function:
5//!
6//! ```ignore
7//! let client = TestClient::new(router);
8//! client.get("/users").await.assert_ok().assert_see("Ada");
9//! ```
10
11use crate::method::Method;
12use crate::request::Request;
13use crate::response::Response;
14use crate::router::Router;
15use rustlavel_core::{Context, Json};
16use std::collections::BTreeMap;
17use std::sync::Arc;
18
19pub struct TestClient {
20    router: Arc<Router>,
21    context: Context,
22    /// Cookies the "browser" is holding, so a session survives between
23    /// requests the way it does for a real client. Without this, every test of
24    /// anything stateful has to shuttle `set-cookie` by hand.
25    jar: Arc<std::sync::Mutex<BTreeMap<String, String>>>,
26}
27
28impl TestClient {
29    pub fn new(mut router: Router) -> Self {
30        router.finalize();
31        TestClient {
32            router: Arc::new(router),
33            context: Context::default(),
34            jar: Arc::new(std::sync::Mutex::new(BTreeMap::new())),
35        }
36    }
37
38    /// Use an application context, so handlers can resolve real services.
39    pub fn with_context(mut self, context: Context) -> Self {
40        self.context = context;
41        self
42    }
43
44    pub async fn send(&self, mut request: Request) -> TestResponse {
45        // Send what the jar holds, unless the caller set its own header.
46        if !request.headers().contains("cookie") {
47            let jar = self.jar.lock().expect("cookie jar poisoned");
48            if !jar.is_empty() {
49                let header = jar
50                    .iter()
51                    .map(|(name, value)| format!("{name}={value}"))
52                    .collect::<Vec<_>>()
53                    .join("; ");
54                request.headers_mut().set("cookie", header);
55            }
56        }
57
58        let request = request.with_context(self.context.clone());
59        let response = self.router.dispatch(request).await;
60        self.remember(&response);
61        TestResponse { response }
62    }
63
64    /// Record any `Set-Cookie` the response carried.
65    fn remember(&self, response: &Response) {
66        let mut jar = self.jar.lock().expect("cookie jar poisoned");
67
68        for header in response.headers.get_all("set-cookie") {
69            let Some(pair) = header.split(';').next() else { continue };
70            let Some((name, value)) = pair.split_once('=') else { continue };
71            let name = name.trim().to_string();
72
73            // Max-Age=0 is how a cookie is expired; drop it rather than keeping
74            // an empty value that would look like a live session.
75            if header.contains("Max-Age=0") {
76                jar.remove(&name);
77            } else {
78                jar.insert(name, crate::url::decode(value.trim()));
79            }
80        }
81    }
82
83    /// The cookies this client is currently holding.
84    pub fn cookies(&self) -> BTreeMap<String, String> {
85        self.jar.lock().expect("cookie jar poisoned").clone()
86    }
87
88    /// Forget every cookie — the equivalent of a fresh browser.
89    pub fn clear_cookies(&self) {
90        self.jar.lock().expect("cookie jar poisoned").clear();
91    }
92
93    pub async fn get(&self, path: &str) -> TestResponse {
94        self.send(Request::new(Method::Get, path)).await
95    }
96
97    pub async fn delete(&self, path: &str) -> TestResponse {
98        self.send(Request::new(Method::Delete, path)).await
99    }
100
101    pub async fn post(&self, path: &str, form: &[(&str, &str)]) -> TestResponse {
102        self.send(Request::new(Method::Post, path).with_form(form)).await
103    }
104
105    pub async fn post_json(&self, path: &str, body: Json) -> TestResponse {
106        self.send(Request::new(Method::Post, path).with_json(body)).await
107    }
108
109    pub async fn put_json(&self, path: &str, body: Json) -> TestResponse {
110        self.send(Request::new(Method::Put, path).with_json(body)).await
111    }
112}
113
114/// A response with assertions attached, each returning `self` so they chain.
115pub struct TestResponse {
116    pub response: Response,
117}
118
119impl TestResponse {
120    pub fn status(&self) -> u16 {
121        self.response.status.code()
122    }
123
124    pub fn body(&self) -> String {
125        self.response.body_string()
126    }
127
128    pub fn json(&self) -> Json {
129        Json::parse(&self.body()).unwrap_or_else(|e| {
130            panic!("response body is not valid JSON ({e}); body was:\n{}", self.body())
131        })
132    }
133
134    pub fn header(&self, name: &str) -> Option<&str> {
135        self.response.headers.get(name)
136    }
137
138    #[track_caller]
139    pub fn assert_status(self, expected: u16) -> Self {
140        assert_eq!(
141            self.status(),
142            expected,
143            "expected status {expected}, got {}. Body:\n{}",
144            self.status(),
145            self.body()
146        );
147        self
148    }
149
150    #[track_caller]
151    pub fn assert_ok(self) -> Self {
152        assert!(
153            self.response.status.is_success(),
154            "expected a 2xx status, got {}. Body:\n{}",
155            self.status(),
156            self.body()
157        );
158        self
159    }
160
161    #[track_caller]
162    pub fn assert_not_found(self) -> Self {
163        self.assert_status(404)
164    }
165
166    /// Assert the body contains this text.
167    #[track_caller]
168    pub fn assert_see(self, needle: &str) -> Self {
169        assert!(
170            self.body().contains(needle),
171            "expected the body to contain {needle:?}. Body:\n{}",
172            self.body()
173        );
174        self
175    }
176
177    #[track_caller]
178    pub fn assert_dont_see(self, needle: &str) -> Self {
179        assert!(
180            !self.body().contains(needle),
181            "expected the body not to contain {needle:?}. Body:\n{}",
182            self.body()
183        );
184        self
185    }
186
187    #[track_caller]
188    pub fn assert_header(self, name: &str, expected: &str) -> Self {
189        assert_eq!(self.header(name), Some(expected), "header `{name}` did not match");
190        self
191    }
192
193    #[track_caller]
194    pub fn assert_redirect(self, location: &str) -> Self {
195        assert!(
196            self.response.status.is_redirect(),
197            "expected a redirect, got {}",
198            self.status()
199        );
200        self.assert_header("location", location)
201    }
202
203    /// Assert a dotted path in the JSON body equals a value.
204    #[track_caller]
205    pub fn assert_json(self, path: &str, expected: impl Into<Json>) -> Self {
206        let body = self.json();
207        let found = body.get(path);
208        let expected = expected.into();
209        assert_eq!(
210            found,
211            Some(&expected),
212            "at JSON path `{path}` expected {}, found {}",
213            expected,
214            found.map_or("nothing".to_string(), Json::to_string)
215        );
216        self
217    }
218
219    #[track_caller]
220    pub fn assert_json_missing(self, path: &str) -> Self {
221        assert!(self.json().get(path).is_none(), "expected no value at JSON path `{path}`");
222        self
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn router() -> Router {
231        let mut router = Router::new();
232        router.get("/", |_req: Request| async { "Hello, Ada" });
233        router.get("/api", |_req: Request| async {
234            Json::object([("name", "Rustlavel".into()), ("stars", 42.into())])
235        });
236        router.post("/users", |mut req: Request| async move {
237            let name = req.input("name").unwrap_or_default();
238            (201, Json::object([("created", Json::from(name))]))
239        });
240        router.get("/old", |_req: Request| async { Response::redirect("/new") });
241        router
242    }
243
244    #[tokio::test]
245    async fn asserts_on_html_responses() {
246        TestClient::new(router()).get("/").await.assert_ok().assert_see("Ada").assert_dont_see("Bob");
247    }
248
249    #[tokio::test]
250    async fn asserts_on_json_paths() {
251        TestClient::new(router())
252            .get("/api")
253            .await
254            .assert_ok()
255            .assert_json("name", "Rustlavel")
256            .assert_json("stars", 42)
257            .assert_json_missing("missing");
258    }
259
260    #[tokio::test]
261    async fn posts_forms_and_json() {
262        let client = TestClient::new(router());
263
264        client.post("/users", &[("name", "ada")]).await.assert_status(201).assert_json("created", "ada");
265        client
266            .post_json("/users", Json::object([("name", "grace".into())]))
267            .await
268            .assert_json("created", "grace");
269    }
270
271    #[tokio::test]
272    async fn cookies_persist_between_requests_like_a_browser() {
273        let mut router = Router::new();
274        router.post("/login", |_req: Request| async {
275            Response::text("in").with_cookie(crate::Cookie::new("session", "abc123"))
276        });
277        router.get("/me", |req: Request| async move {
278            req.cookie("session").unwrap_or_else(|| "anonymous".to_string())
279        });
280        router.post("/logout", |_req: Request| async {
281            Response::text("out").without_cookie("session")
282        });
283
284        let client = TestClient::new(router);
285
286        client.get("/me").await.assert_see("anonymous");
287
288        client.post("/login", &[]).await.assert_ok();
289        assert_eq!(client.cookies().get("session").map(String::as_str), Some("abc123"));
290        client.get("/me").await.assert_see("abc123");
291
292        client.post("/logout", &[]).await.assert_ok();
293        client.get("/me").await.assert_see("anonymous");
294    }
295
296    #[tokio::test]
297    async fn an_explicit_cookie_header_overrides_the_jar() {
298        let mut router = Router::new();
299        router.get("/me", |req: Request| async move {
300            req.cookie("session").unwrap_or_default()
301        });
302        let client = TestClient::new(router);
303
304        client
305            .send(Request::new(Method::Get, "/me").with_header("cookie", "session=explicit"))
306            .await
307            .assert_see("explicit");
308    }
309
310    #[tokio::test]
311    async fn asserts_redirects_and_missing_routes() {
312        let client = TestClient::new(router());
313
314        client.get("/old").await.assert_redirect("/new");
315        client.get("/nowhere").await.assert_not_found();
316    }
317}