Skip to main content

max / quasi

18.3 KB · 528 lines History Blame Raw
1 //! Real requests through the mounted service.
2 //!
3 //! Everything here goes in as an `http::Request` and comes out as an
4 //! `http::Response`, because the things worth testing in an adapter are exactly
5 //! the ones that only exist at that boundary: decoding, status mapping, the
6 //! retarget header, and the blocking hop.
7
8 // Handlers take their parameters by value because `Handler` says so, and one
9 // that only reads them is the common case rather than an oversight.
10 #![allow(clippy::needless_pass_by_value)]
11
12 use std::sync::Arc;
13
14 use axum::body::Body;
15 use axum::http::{Request, StatusCode, header};
16 use http_body_util::BodyExt;
17 use quasi_router::{Node, RegionKind, Response, RouteError, Router, Screen, Slot};
18 use tower::ServiceExt;
19
20 /// An app with nothing in it. The router is what is under test.
21 struct App;
22
23 /// A renderer that says what it was handed, so a test can read it back.
24 struct Spy;
25
26 impl super::Serves for Spy {
27 fn screen(&self, screen: &Screen) -> String {
28 format!("screen:{}", screen.title)
29 }
30
31 fn fragment(&self, node: &Node) -> String {
32 match node {
33 Node::Text { text, .. } => format!("text:{text}"),
34 Node::Notice { tone, text, .. } => format!("notice:{tone:?}:{text}"),
35 other => format!("other:{other:?}"),
36 }
37 }
38 }
39
40 fn home(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
41 Ok(Screen::sidebar_content("Home")
42 .with(Slot::new("content", RegionKind::Pane))
43 .into())
44 }
45
46 fn echo(_app: &App, request: quasi_router::Request) -> Result<Response, RouteError> {
47 // Captures, then what was sent, then the view it was sent from: the same
48 // order the one merged bag used to hold them in.
49 let joined = request
50 .captures
51 .iter()
52 .chain(request.payload.iter())
53 .chain(request.carried.iter())
54 .map(|(k, v)| format!("{k}={v}"))
55 .collect::<Vec<_>>()
56 .join(",");
57 Ok(Response::fragment("detail", Node::text(joined)))
58 }
59
60 fn tags(_app: &App, request: quasi_router::Request) -> Result<Response, RouteError> {
61 let all = request.payload.get_all("tag").collect::<Vec<_>>().join("+");
62 Ok(Response::fragment("detail", Node::text(all)))
63 }
64
65 fn denied(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
66 Err(RouteError::denied("not yours"))
67 }
68
69 fn boom(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
70 panic!("a handler that panics");
71 }
72
73 fn service() -> axum::Router {
74 let router = Router::<App>::new()
75 .get("/", home)
76 .get("/task/{id}", echo)
77 .post("/task/{id}/edit", echo)
78 .post("/tags", tags)
79 .post("/task/{id}/delete", denied)
80 .get("/boom", boom);
81 super::Adapter::new(router, Arc::new(App), Arc::new(Spy)).into_router()
82 }
83
84 /// Send a request, read the whole response.
85 async fn send(request: Request<Body>) -> (StatusCode, String, Option<String>) {
86 let response = service().oneshot(request).await.unwrap();
87 let status = response.status();
88 let retarget = response
89 .headers()
90 .get(super::htmx::RETARGET)
91 .map(|v| v.to_str().unwrap().to_owned());
92 let body = response.into_body().collect().await.unwrap().to_bytes();
93 (status, String::from_utf8(body.to_vec()).unwrap(), retarget)
94 }
95
96 fn get(uri: &str) -> Request<Body> {
97 Request::builder().uri(uri).body(Body::empty()).unwrap()
98 }
99
100 fn post_form(uri: &str, body: &'static str) -> Request<Body> {
101 Request::builder()
102 .method("POST")
103 .uri(uri)
104 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
105 .body(Body::from(body))
106 .unwrap()
107 }
108
109 #[tokio::test]
110 async fn a_screen_is_served_whole_and_names_no_target() {
111 let (status, body, retarget) = send(get("/")).await;
112 assert_eq!(status, StatusCode::OK);
113 assert_eq!(body, "screen:Home");
114 assert_eq!(retarget, None);
115 }
116
117 #[tokio::test]
118 async fn a_fragment_carries_the_region_it_replaces() {
119 let (status, body, retarget) = send(get("/task/7")).await;
120 assert_eq!(status, StatusCode::OK);
121 assert_eq!(body, "text:id=7");
122 // A slot id becomes a CSS selector, which is what htmx wants.
123 assert_eq!(retarget.as_deref(), Some("#detail"));
124 }
125
126 #[tokio::test]
127 async fn a_query_string_is_decoded_into_params() {
128 let (_, body, _) = send(get("/task/7?note=a%20b&flag=1")).await;
129 assert!(body.contains("note=a b"), "{body}");
130 assert!(body.contains("flag=1"), "{body}");
131 }
132
133 #[tokio::test]
134 async fn a_form_body_is_decoded_into_params() {
135 let (status, body, _) = send(post_form("/task/7/edit", "title=new+title")).await;
136 assert_eq!(status, StatusCode::OK);
137 assert!(body.contains("title=new title"), "{body}");
138 }
139
140 #[tokio::test]
141 async fn a_form_field_beats_a_query_argument_of_the_same_name() {
142 // The form is the answer to the question the screen asked. The query
143 // argument is context that came along with it.
144 let (_, body, _) = send(post_form(
145 "/task/7/edit?title=from-query",
146 "title=from-form",
147 ))
148 .await;
149 assert_eq!(first_named(&body, "title"), "title=from-form", "{body}");
150 }
151
152 /// The first entry under a name, which is the one `Params::get` answers with.
153 fn first_named<'a>(body: &'a str, name: &str) -> &'a str {
154 body.trim_start_matches("text:")
155 .split(',')
156 .find(|entry| entry.starts_with(&format!("{name}=")))
157 .unwrap_or("")
158 }
159
160 #[tokio::test]
161 async fn the_path_capture_beats_both() {
162 let (_, body, _) = send(post_form("/task/7/edit?id=9", "id=8")).await;
163 assert_eq!(first_named(&body, "id"), "id=7", "{body}");
164 }
165
166 #[tokio::test]
167 async fn repeated_names_all_survive_decoding() {
168 let (_, body, _) = send(post_form("/tags", "tag=rust&tag=router&tag=axum")).await;
169 assert_eq!(body, "text:rust+router+axum");
170 }
171
172 #[tokio::test]
173 async fn a_denial_is_a_403_with_the_notice_as_its_body() {
174 let (status, body, _) = send(post_form("/task/7/delete", "")).await;
175 assert_eq!(status, StatusCode::FORBIDDEN);
176 // The class became a status code and the notice became the screen, from one
177 // return value.
178 assert_eq!(body, "notice:Warning:not yours");
179 }
180
181 #[tokio::test]
182 async fn an_unknown_path_is_a_404_that_still_renders_something() {
183 let (status, body, _) = send(get("/nowhere")).await;
184 assert_eq!(status, StatusCode::NOT_FOUND);
185 assert!(body.starts_with("notice:Warning:"), "{body}");
186 }
187
188 #[tokio::test]
189 async fn a_verb_the_description_layer_lacks_is_refused_with_allow() {
190 // PATCH is the verb the layer still does not have, since `61e1b069` gave it
191 // DELETE and PUT. Nothing in either app writes one.
192 let request = Request::builder()
193 .method("PATCH")
194 .uri("/task/7")
195 .body(Body::empty())
196 .unwrap();
197 let response = service().oneshot(request).await.unwrap();
198 assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
199 assert_eq!(
200 response.headers().get(header::ALLOW).unwrap(),
201 "GET, POST, DELETE, PUT"
202 );
203 }
204
205 #[tokio::test]
206 async fn a_panicking_handler_is_a_500_and_reads_as_ours() {
207 let (status, body, _) = send(get("/boom")).await;
208 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
209 assert!(body.starts_with("notice:Danger:"), "{body}");
210 }
211
212 #[tokio::test]
213 async fn an_oversized_form_is_refused_before_the_router_sees_it() {
214 let router = Router::<App>::new().post("/tags", tags);
215 let service = super::Adapter::new(router, Arc::new(App), Arc::new(Spy))
216 .body_limit(16)
217 .into_router();
218
219 let request = Request::builder()
220 .method("POST")
221 .uri("/tags")
222 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
223 .body(Body::from("tag=".to_owned() + &"x".repeat(1024)))
224 .unwrap();
225
226 let response = service.oneshot(request).await.unwrap();
227 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
228 }
229
230 #[tokio::test]
231 async fn a_body_that_is_not_a_form_is_ignored_rather_than_guessed_at() {
232 // Multipart is not read here on purpose. The request still routes, and the
233 // handler simply sees no parameters from the body.
234 let request = Request::builder()
235 .method("POST")
236 .uri("/task/7/edit")
237 .header(header::CONTENT_TYPE, "multipart/form-data; boundary=xyz")
238 .body(Body::from("--xyz--"))
239 .unwrap();
240 let response = service().oneshot(request).await.unwrap();
241 assert_eq!(response.status(), StatusCode::OK);
242 }
243
244 #[tokio::test]
245 async fn a_charset_on_the_form_content_type_still_reads_as_a_form() {
246 let request = Request::builder()
247 .method("POST")
248 .uri("/task/7/edit")
249 .header(
250 header::CONTENT_TYPE,
251 "application/x-www-form-urlencoded; charset=utf-8",
252 )
253 .body(Body::from("title=ok"))
254 .unwrap();
255 let response = service().oneshot(request).await.unwrap();
256 let body = response.into_body().collect().await.unwrap().to_bytes();
257 assert!(String::from_utf8_lossy(&body).contains("title=ok"));
258 }
259
260 #[tokio::test]
261 async fn the_content_type_is_the_renderers_answer() {
262 let response = service().oneshot(get("/")).await.unwrap();
263 assert_eq!(
264 response.headers().get(header::CONTENT_TYPE).unwrap(),
265 "text/html; charset=utf-8"
266 );
267 }
268
269 /// A renderer carrying something the factory decided for one request.
270 struct PerRequest {
271 greeting: String,
272 }
273
274 impl super::Serves for PerRequest {
275 fn screen(&self, screen: &Screen) -> String {
276 format!("{}:{}", self.greeting, screen.title)
277 }
278
279 fn fragment(&self, _node: &Node) -> String {
280 self.greeting.clone()
281 }
282 }
283
284 #[tokio::test]
285 async fn a_per_request_renderer_sees_the_params_and_the_answer() {
286 // The whole point of the factory: the host gets its say at the one moment
287 // it knows both what was asked and what is being answered. Two requests to
288 // one route, two different renderers.
289 let router = Router::<App>::new().get("/", home);
290 let service = super::Adapter::per_request(router, Arc::new(App), |_app, params, answer| {
291 let title = match answer.map(|a| &a.outcome) {
292 Some(quasi_router::Outcome::Screen(screen)) => screen.title.clone(),
293 _ => "none".to_owned(),
294 };
295 PerRequest {
296 greeting: format!("{}/{title}", params.get("who").unwrap_or("nobody")),
297 }
298 })
299 .into_router();
300
301 let first = service.clone().oneshot(get("/?who=ada")).await.unwrap();
302 let first = first.into_body().collect().await.unwrap().to_bytes();
303 assert_eq!(String::from_utf8_lossy(&first), "ada/Home:Home");
304
305 let second = service.oneshot(get("/?who=grace")).await.unwrap();
306 let second = second.into_body().collect().await.unwrap().to_bytes();
307 assert_eq!(String::from_utf8_lossy(&second), "grace/Home:Home");
308 }
309
310 #[tokio::test]
311 async fn a_refusal_reaches_the_factory_with_no_answer_to_read() {
312 // There is no screen to fill when the router refuses, and the factory is
313 // told so rather than handed something invented.
314 let router = Router::<App>::new().post("/task/{id}/delete", denied);
315 let service =
316 super::Adapter::per_request(router, Arc::new(App), |_app, _params, answer| PerRequest {
317 greeting: match answer {
318 Some(_) => "answered".to_owned(),
319 None => "refused".to_owned(),
320 },
321 })
322 .into_router();
323
324 let response = service
325 .oneshot(post_form("/task/7/delete", ""))
326 .await
327 .unwrap();
328 assert_eq!(response.status(), StatusCode::FORBIDDEN);
329 let body = response.into_body().collect().await.unwrap().to_bytes();
330 assert_eq!(String::from_utf8_lossy(&body), "refused");
331 }
332
333 #[tokio::test]
334 async fn a_shared_renderer_still_serves() {
335 // The change is additive. A host with nothing per-request to say keeps the
336 // constructor it had.
337 let (status, body, _) = send(get("/")).await;
338 assert_eq!(status, StatusCode::OK);
339 assert_eq!(body, "screen:Home");
340 }
341
342 /// The one screen the end-to-end test renders for real.
343 ///
344 /// Deliberately not the `Spy`. Everything above proves the adapter's own
345 /// boundary against a renderer that says what it was handed; this proves the
346 /// boundary holds when the renderer is the one an app actually ships, which is
347 /// the only place the two could disagree.
348 fn real_screen() -> Screen {
349 Screen::list_detail("Tasks", false)
350 .with(
351 Slot::new("list", RegionKind::Pane).with(Node::list([quasi_router::screen::Row::new(
352 "Write it down",
353 )
354 .activate(quasi_router::Action::get("/task/1"))])),
355 )
356 .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected")))
357 }
358
359 fn real_home(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
360 Ok(real_screen().into())
361 }
362
363 #[tokio::test]
364 async fn a_description_served_here_is_the_renderer_s_own_output() {
365 let router = Router::<App>::new().get("/", real_home);
366 let service = super::Adapter::new(
367 router,
368 Arc::new(App),
369 Arc::new(quasi_webview::Webview::under("/static")),
370 )
371 .into_router();
372
373 let response = service.oneshot(get("/")).await.unwrap();
374 assert_eq!(response.status(), StatusCode::OK);
375 assert_eq!(
376 response
377 .headers()
378 .get(header::CONTENT_TYPE)
379 .and_then(|v| v.to_str().ok()),
380 Some("text/html; charset=utf-8")
381 );
382
383 let body = response.into_body().collect().await.unwrap().to_bytes();
384 let served = String::from_utf8(body.to_vec()).unwrap();
385
386 // The adapter adds nothing to the document and removes nothing from it.
387 // Anything it wanted to add would be a second party emitting markup, which
388 // is the thing `quasi_http::render`'s existence rules out.
389 use quasi_http::Serves as _;
390 assert_eq!(
391 served,
392 quasi_webview::Webview::under("/static").screen(&real_screen())
393 );
394 assert!(served.starts_with("<!doctype html>"));
395 assert!(served.contains("hx-get=\"/task/1\""));
396 }
397
398 /// An app resolved per request rather than at startup: who is asking.
399 struct Viewer {
400 who: String,
401 }
402
403 /// A renderer that reports what the state factory resolved, so a test can tell
404 /// the two per-request channels apart.
405 struct Viewed {
406 seen: String,
407 }
408
409 impl super::Serves for Viewed {
410 fn screen(&self, screen: &Screen) -> String {
411 format!("{}|{}", self.seen, screen.title)
412 }
413
414 fn fragment(&self, node: &Node) -> String {
415 match node {
416 Node::Text { text, .. } => format!("{}|{text}", self.seen),
417 other => format!("{}|{other:?}", self.seen),
418 }
419 }
420 }
421
422 fn whoami(viewer: &Viewer, _request: quasi_router::Request) -> Result<Response, RouteError> {
423 Ok(Response::fragment("detail", Node::text(viewer.who.clone())))
424 }
425
426 /// Read the viewer out of a header, the way a server reads a session cookie.
427 fn viewer_of(parts: &http::request::Parts) -> super::StateFuture<Viewer> {
428 let who = parts
429 .headers
430 .get("x-who")
431 .and_then(|value| value.to_str().ok())
432 .unwrap_or("nobody")
433 .to_owned();
434 Box::pin(async move { Ok(Viewer { who }) })
435 }
436
437 fn as_who(uri: &str, who: &str) -> Request<Body> {
438 Request::builder()
439 .uri(uri)
440 .header("x-who", who)
441 .body(Body::empty())
442 .unwrap()
443 }
444
445 #[tokio::test]
446 async fn the_state_is_built_per_request_and_the_handler_reads_it() {
447 // The whole of Q1: identity arrives at a handler whose signature did not
448 // change. Two requests, one route, two viewers.
449 let router = Router::<Viewer>::new().get("/whoami", whoami);
450 let service =
451 super::Adapter::per_viewer(router, viewer_of, |viewer, _params, _answer| Viewed {
452 seen: viewer.who.clone(),
453 })
454 .into_router();
455
456 let first = service
457 .clone()
458 .oneshot(as_who("/whoami", "ada"))
459 .await
460 .unwrap();
461 let first = first.into_body().collect().await.unwrap().to_bytes();
462 // Left of the bar is what the renderer saw, right of it what the handler
463 // answered. Both halves are the request's own state.
464 assert_eq!(String::from_utf8_lossy(&first), "ada|ada");
465
466 let second = service.oneshot(as_who("/whoami", "grace")).await.unwrap();
467 let second = second.into_body().collect().await.unwrap().to_bytes();
468 assert_eq!(String::from_utf8_lossy(&second), "grace|grace");
469 }
470
471 #[tokio::test]
472 async fn a_state_that_cannot_be_resolved_never_reaches_the_router() {
473 // A store that will not answer is not a signed-out reader. The handler
474 // panics, so reading the factory's own status back proves it never ran.
475 fn never(_viewer: &Viewer, _request: quasi_router::Request) -> Result<Response, RouteError> {
476 panic!("the router was called without a state");
477 }
478
479 let router = Router::<Viewer>::new().get("/whoami", never);
480 let service = super::Adapter::per_viewer(
481 router,
482 |_parts| Box::pin(async move { Err(RouteError::denied("the session store said no")) }),
483 |viewer: &Viewer, _params, _answer| Viewed {
484 seen: viewer.who.clone(),
485 },
486 )
487 .into_router();
488
489 let response = service.oneshot(get("/whoami")).await.unwrap();
490 assert_eq!(response.status(), StatusCode::FORBIDDEN);
491 // No renderer could be built for it, so there is no body to build one for.
492 let body = response.into_body().collect().await.unwrap().to_bytes();
493 assert!(body.is_empty());
494 }
495
496 #[tokio::test]
497 async fn the_envelope_is_refused_before_the_state_is_resolved() {
498 // An oversized body or a verb the layer does not have should not cost a
499 // session lookup. The factory panics, so a 405 is the proof it was ordered
500 // after the refusal.
501 let router = Router::<Viewer>::new().get("/whoami", whoami);
502 let service = super::Adapter::per_viewer(
503 router,
504 |_parts| panic!("the state was resolved for a request that was refused"),
505 |viewer: &Viewer, _params, _answer| Viewed {
506 seen: viewer.who.clone(),
507 },
508 )
509 .into_router();
510
511 let request = Request::builder()
512 .method("PATCH")
513 .uri("/whoami")
514 .body(Body::empty())
515 .unwrap();
516 let response = service.oneshot(request).await.unwrap();
517 assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
518 }
519
520 #[tokio::test]
521 async fn a_shared_state_still_serves() {
522 // The change is additive on this axis too: a host with one viewer keeps
523 // the constructor it had.
524 let (status, body, _) = send(get("/")).await;
525 assert_eq!(status, StatusCode::OK);
526 assert_eq!(body, "screen:Home");
527 }
528