1use 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 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 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 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 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 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 pub fn cookies(&self) -> BTreeMap<String, String> {
85 self.jar.lock().expect("cookie jar poisoned").clone()
86 }
87
88 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
114pub 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 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 #[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 #[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}