Skip to main content

max / quasi

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