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    /// The raw body, for a response that is not text — a compressed one, say.
129    pub fn body_bytes(&self) -> &[u8] {
130        &self.response.body
131    }
132
133    pub fn json(&self) -> Json {
134        Json::parse(&self.body()).unwrap_or_else(|e| {
135            panic!("response body is not valid JSON ({e}); body was:\n{}", self.body())
136        })
137    }
138
139    pub fn header(&self, name: &str) -> Option<&str> {
140        self.response.headers.get(name)
141    }
142
143    #[track_caller]
144    pub fn assert_status(self, expected: u16) -> Self {
145        assert_eq!(
146            self.status(),
147            expected,
148            "expected status {expected}, got {}. Body:\n{}",
149            self.status(),
150            self.body()
151        );
152        self
153    }
154
155    #[track_caller]
156    pub fn assert_ok(self) -> Self {
157        assert!(
158            self.response.status.is_success(),
159            "expected a 2xx status, got {}. Body:\n{}",
160            self.status(),
161            self.body()
162        );
163        self
164    }
165
166    #[track_caller]
167    pub fn assert_not_found(self) -> Self {
168        self.assert_status(404)
169    }
170
171    /// Assert the body contains this text.
172    #[track_caller]
173    pub fn assert_see(self, needle: &str) -> Self {
174        assert!(
175            self.body().contains(needle),
176            "expected the body to contain {needle:?}. Body:\n{}",
177            self.body()
178        );
179        self
180    }
181
182    #[track_caller]
183    pub fn assert_dont_see(self, needle: &str) -> Self {
184        assert!(
185            !self.body().contains(needle),
186            "expected the body not to contain {needle:?}. Body:\n{}",
187            self.body()
188        );
189        self
190    }
191
192    #[track_caller]
193    pub fn assert_header(self, name: &str, expected: &str) -> Self {
194        assert_eq!(self.header(name), Some(expected), "header `{name}` did not match");
195        self
196    }
197
198    #[track_caller]
199    pub fn assert_redirect(self, location: &str) -> Self {
200        assert!(
201            self.response.status.is_redirect(),
202            "expected a redirect, got {}",
203            self.status()
204        );
205        self.assert_header("location", location)
206    }
207
208    /// Assert a dotted path in the JSON body equals a value.
209    #[track_caller]
210    pub fn assert_json(self, path: &str, expected: impl Into<Json>) -> Self {
211        let body = self.json();
212        let found = body.get(path);
213        let expected = expected.into();
214        assert_eq!(
215            found,
216            Some(&expected),
217            "at JSON path `{path}` expected {}, found {}",
218            expected,
219            found.map_or("nothing".to_string(), Json::to_string)
220        );
221        self
222    }
223
224    #[track_caller]
225    pub fn assert_json_missing(self, path: &str) -> Self {
226        assert!(self.json().get(path).is_none(), "expected no value at JSON path `{path}`");
227        self
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn router() -> Router {
236        let mut router = Router::new();
237        router.get("/", |_req: Request| async { "Hello, Ada" });
238        router.get("/api", |_req: Request| async {
239            Json::object([("name", "Rustlavel".into()), ("stars", 42.into())])
240        });
241        router.post("/users", |mut req: Request| async move {
242            let name = req.input("name").unwrap_or_default();
243            (201, Json::object([("created", Json::from(name))]))
244        });
245        router.get("/old", |_req: Request| async { Response::redirect("/new") });
246        router
247    }
248
249    #[tokio::test]
250    async fn asserts_on_html_responses() {
251        TestClient::new(router()).get("/").await.assert_ok().assert_see("Ada").assert_dont_see("Bob");
252    }
253
254    #[tokio::test]
255    async fn asserts_on_json_paths() {
256        TestClient::new(router())
257            .get("/api")
258            .await
259            .assert_ok()
260            .assert_json("name", "Rustlavel")
261            .assert_json("stars", 42)
262            .assert_json_missing("missing");
263    }
264
265    #[tokio::test]
266    async fn posts_forms_and_json() {
267        let client = TestClient::new(router());
268
269        client.post("/users", &[("name", "ada")]).await.assert_status(201).assert_json("created", "ada");
270        client
271            .post_json("/users", Json::object([("name", "grace".into())]))
272            .await
273            .assert_json("created", "grace");
274    }
275
276    #[tokio::test]
277    async fn cookies_persist_between_requests_like_a_browser() {
278        let mut router = Router::new();
279        router.post("/login", |_req: Request| async {
280            Response::text("in").with_cookie(crate::Cookie::new("session", "abc123"))
281        });
282        router.get("/me", |req: Request| async move {
283            req.cookie("session").unwrap_or_else(|| "anonymous".to_string())
284        });
285        router.post("/logout", |_req: Request| async {
286            Response::text("out").without_cookie("session")
287        });
288
289        let client = TestClient::new(router);
290
291        client.get("/me").await.assert_see("anonymous");
292
293        client.post("/login", &[]).await.assert_ok();
294        assert_eq!(client.cookies().get("session").map(String::as_str), Some("abc123"));
295        client.get("/me").await.assert_see("abc123");
296
297        client.post("/logout", &[]).await.assert_ok();
298        client.get("/me").await.assert_see("anonymous");
299    }
300
301    #[tokio::test]
302    async fn an_explicit_cookie_header_overrides_the_jar() {
303        let mut router = Router::new();
304        router.get("/me", |req: Request| async move {
305            req.cookie("session").unwrap_or_default()
306        });
307        let client = TestClient::new(router);
308
309        client
310            .send(Request::new(Method::Get, "/me").with_header("cookie", "session=explicit"))
311            .await
312            .assert_see("explicit");
313    }
314
315    #[tokio::test]
316    async fn asserts_redirects_and_missing_routes() {
317        let client = TestClient::new(router());
318
319        client.get("/old").await.assert_redirect("/new");
320        client.get("/nowhere").await.assert_not_found();
321    }
322}