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 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 #[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 #[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}