//! Real requests through the mounted service. //! //! Everything here goes in as an `http::Request` and comes out as an //! `http::Response`, because the things worth testing in an adapter are exactly //! the ones that only exist at that boundary: decoding, status mapping, the //! retarget header, and the blocking hop. // Handlers take their parameters by value because `Handler` says so, and one // that only reads them is the common case rather than an oversight. #![allow(clippy::needless_pass_by_value)] use std::sync::Arc; use axum::body::Body; use axum::http::{Request, StatusCode, header}; use http_body_util::BodyExt; use quasi_router::{Node, RegionKind, Response, RouteError, Router, Screen, Slot}; use tower::ServiceExt; /// An app with nothing in it. The router is what is under test. struct App; /// A renderer that says what it was handed, so a test can read it back. struct Spy; impl super::Serves for Spy { fn screen(&self, screen: &Screen) -> String { format!("screen:{}", screen.title) } fn fragment(&self, node: &Node) -> String { match node { Node::Text { text, .. } => format!("text:{text}"), Node::Notice { tone, text, .. } => format!("notice:{tone:?}:{text}"), other => format!("other:{other:?}"), } } } fn home(_app: &App, _request: quasi_router::Request) -> Result { Ok(Screen::sidebar_content("Home") .with(Slot::new("content", RegionKind::Pane)) .into()) } fn echo(_app: &App, request: quasi_router::Request) -> Result { // Captures, then what was sent, then the view it was sent from: the same // order the one merged bag used to hold them in. let joined = request .captures .iter() .chain(request.payload.iter()) .chain(request.carried.iter()) .map(|(k, v)| format!("{k}={v}")) .collect::>() .join(","); Ok(Response::fragment("detail", Node::text(joined))) } fn tags(_app: &App, request: quasi_router::Request) -> Result { let all = request.payload.get_all("tag").collect::>().join("+"); Ok(Response::fragment("detail", Node::text(all))) } fn denied(_app: &App, _request: quasi_router::Request) -> Result { Err(RouteError::denied("not yours")) } fn boom(_app: &App, _request: quasi_router::Request) -> Result { panic!("a handler that panics"); } fn service() -> axum::Router { let router = Router::::new() .get("/", home) .get("/task/{id}", echo) .post("/task/{id}/edit", echo) .post("/tags", tags) .post("/task/{id}/delete", denied) .get("/boom", boom); super::Adapter::new(router, Arc::new(App), Arc::new(Spy)).into_router() } /// Send a request, read the whole response. async fn send(request: Request) -> (StatusCode, String, Option) { let response = service().oneshot(request).await.unwrap(); let status = response.status(); let retarget = response .headers() .get(super::htmx::RETARGET) .map(|v| v.to_str().unwrap().to_owned()); let body = response.into_body().collect().await.unwrap().to_bytes(); (status, String::from_utf8(body.to_vec()).unwrap(), retarget) } fn get(uri: &str) -> Request { Request::builder().uri(uri).body(Body::empty()).unwrap() } fn post_form(uri: &str, body: &'static str) -> Request { Request::builder() .method("POST") .uri(uri) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(body)) .unwrap() } #[tokio::test] async fn a_screen_is_served_whole_and_names_no_target() { let (status, body, retarget) = send(get("/")).await; assert_eq!(status, StatusCode::OK); assert_eq!(body, "screen:Home"); assert_eq!(retarget, None); } #[tokio::test] async fn a_fragment_carries_the_region_it_replaces() { let (status, body, retarget) = send(get("/task/7")).await; assert_eq!(status, StatusCode::OK); assert_eq!(body, "text:id=7"); // A slot id becomes a CSS selector, which is what htmx wants. assert_eq!(retarget.as_deref(), Some("#detail")); } #[tokio::test] async fn a_query_string_is_decoded_into_params() { let (_, body, _) = send(get("/task/7?note=a%20b&flag=1")).await; assert!(body.contains("note=a b"), "{body}"); assert!(body.contains("flag=1"), "{body}"); } #[tokio::test] async fn a_form_body_is_decoded_into_params() { let (status, body, _) = send(post_form("/task/7/edit", "title=new+title")).await; assert_eq!(status, StatusCode::OK); assert!(body.contains("title=new title"), "{body}"); } #[tokio::test] async fn a_form_field_beats_a_query_argument_of_the_same_name() { // The form is the answer to the question the screen asked. The query // argument is context that came along with it. let (_, body, _) = send(post_form( "/task/7/edit?title=from-query", "title=from-form", )) .await; assert_eq!(first_named(&body, "title"), "title=from-form", "{body}"); } /// The first entry under a name, which is the one `Params::get` answers with. fn first_named<'a>(body: &'a str, name: &str) -> &'a str { body.trim_start_matches("text:") .split(',') .find(|entry| entry.starts_with(&format!("{name}="))) .unwrap_or("") } #[tokio::test] async fn the_path_capture_beats_both() { let (_, body, _) = send(post_form("/task/7/edit?id=9", "id=8")).await; assert_eq!(first_named(&body, "id"), "id=7", "{body}"); } #[tokio::test] async fn repeated_names_all_survive_decoding() { let (_, body, _) = send(post_form("/tags", "tag=rust&tag=router&tag=axum")).await; assert_eq!(body, "text:rust+router+axum"); } #[tokio::test] async fn a_denial_is_a_403_with_the_notice_as_its_body() { let (status, body, _) = send(post_form("/task/7/delete", "")).await; assert_eq!(status, StatusCode::FORBIDDEN); // The class became a status code and the notice became the screen, from one // return value. assert_eq!(body, "notice:Warning:not yours"); } #[tokio::test] async fn an_unknown_path_is_a_404_that_still_renders_something() { let (status, body, _) = send(get("/nowhere")).await; assert_eq!(status, StatusCode::NOT_FOUND); assert!(body.starts_with("notice:Warning:"), "{body}"); } #[tokio::test] async fn a_verb_the_description_layer_lacks_is_refused_with_allow() { // PATCH is the verb the layer still does not have, since `61e1b069` gave it // DELETE and PUT. Nothing in either app writes one. let request = Request::builder() .method("PATCH") .uri("/task/7") .body(Body::empty()) .unwrap(); let response = service().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); assert_eq!( response.headers().get(header::ALLOW).unwrap(), "GET, POST, DELETE, PUT" ); } #[tokio::test] async fn a_panicking_handler_is_a_500_and_reads_as_ours() { let (status, body, _) = send(get("/boom")).await; assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); assert!(body.starts_with("notice:Danger:"), "{body}"); } #[tokio::test] async fn an_oversized_form_is_refused_before_the_router_sees_it() { let router = Router::::new().post("/tags", tags); let service = super::Adapter::new(router, Arc::new(App), Arc::new(Spy)) .body_limit(16) .into_router(); let request = Request::builder() .method("POST") .uri("/tags") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from("tag=".to_owned() + &"x".repeat(1024))) .unwrap(); let response = service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); } #[tokio::test] async fn a_body_that_is_not_a_form_is_ignored_rather_than_guessed_at() { // Multipart is not read here on purpose. The request still routes, and the // handler simply sees no parameters from the body. let request = Request::builder() .method("POST") .uri("/task/7/edit") .header(header::CONTENT_TYPE, "multipart/form-data; boundary=xyz") .body(Body::from("--xyz--")) .unwrap(); let response = service().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn a_charset_on_the_form_content_type_still_reads_as_a_form() { let request = Request::builder() .method("POST") .uri("/task/7/edit") .header( header::CONTENT_TYPE, "application/x-www-form-urlencoded; charset=utf-8", ) .body(Body::from("title=ok")) .unwrap(); let response = service().oneshot(request).await.unwrap(); let body = response.into_body().collect().await.unwrap().to_bytes(); assert!(String::from_utf8_lossy(&body).contains("title=ok")); } #[tokio::test] async fn the_content_type_is_the_renderers_answer() { let response = service().oneshot(get("/")).await.unwrap(); assert_eq!( response.headers().get(header::CONTENT_TYPE).unwrap(), "text/html; charset=utf-8" ); } /// A renderer carrying something the factory decided for one request. struct PerRequest { greeting: String, } impl super::Serves for PerRequest { fn screen(&self, screen: &Screen) -> String { format!("{}:{}", self.greeting, screen.title) } fn fragment(&self, _node: &Node) -> String { self.greeting.clone() } } #[tokio::test] async fn a_per_request_renderer_sees_the_params_and_the_answer() { // The whole point of the factory: the host gets its say at the one moment // it knows both what was asked and what is being answered. Two requests to // one route, two different renderers. let router = Router::::new().get("/", home); let service = super::Adapter::per_request(router, Arc::new(App), |_app, params, answer| { let title = match answer.map(|a| &a.outcome) { Some(quasi_router::Outcome::Screen(screen)) => screen.title.clone(), _ => "none".to_owned(), }; PerRequest { greeting: format!("{}/{title}", params.get("who").unwrap_or("nobody")), } }) .into_router(); let first = service.clone().oneshot(get("/?who=ada")).await.unwrap(); let first = first.into_body().collect().await.unwrap().to_bytes(); assert_eq!(String::from_utf8_lossy(&first), "ada/Home:Home"); let second = service.oneshot(get("/?who=grace")).await.unwrap(); let second = second.into_body().collect().await.unwrap().to_bytes(); assert_eq!(String::from_utf8_lossy(&second), "grace/Home:Home"); } #[tokio::test] async fn a_refusal_reaches_the_factory_with_no_answer_to_read() { // There is no screen to fill when the router refuses, and the factory is // told so rather than handed something invented. let router = Router::::new().post("/task/{id}/delete", denied); let service = super::Adapter::per_request(router, Arc::new(App), |_app, _params, answer| PerRequest { greeting: match answer { Some(_) => "answered".to_owned(), None => "refused".to_owned(), }, }) .into_router(); let response = service .oneshot(post_form("/task/7/delete", "")) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); let body = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(String::from_utf8_lossy(&body), "refused"); } #[tokio::test] async fn a_shared_renderer_still_serves() { // The change is additive. A host with nothing per-request to say keeps the // constructor it had. let (status, body, _) = send(get("/")).await; assert_eq!(status, StatusCode::OK); assert_eq!(body, "screen:Home"); } /// The one screen the end-to-end test renders for real. /// /// Deliberately not the `Spy`. Everything above proves the adapter's own /// boundary against a renderer that says what it was handed; this proves the /// boundary holds when the renderer is the one an app actually ships, which is /// the only place the two could disagree. fn real_screen() -> Screen { Screen::list_detail("Tasks", false) .with( Slot::new("list", RegionKind::Pane).with(Node::list([quasi_router::screen::Row::new( "Write it down", ) .activate(quasi_router::Action::get("/task/1"))])), ) .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected"))) } fn real_home(_app: &App, _request: quasi_router::Request) -> Result { Ok(real_screen().into()) } #[tokio::test] async fn a_description_served_here_is_the_renderer_s_own_output() { let router = Router::::new().get("/", real_home); let service = super::Adapter::new( router, Arc::new(App), Arc::new(quasi_webview::Webview::under("/static")), ) .into_router(); let response = service.oneshot(get("/")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response .headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()), Some("text/html; charset=utf-8") ); let body = response.into_body().collect().await.unwrap().to_bytes(); let served = String::from_utf8(body.to_vec()).unwrap(); // The adapter adds nothing to the document and removes nothing from it. // Anything it wanted to add would be a second party emitting markup, which // is the thing `quasi_http::render`'s existence rules out. use quasi_http::Serves as _; assert_eq!( served, quasi_webview::Webview::under("/static").screen(&real_screen()) ); assert!(served.starts_with("")); assert!(served.contains("hx-get=\"/task/1\"")); } /// An app resolved per request rather than at startup: who is asking. struct Viewer { who: String, } /// A renderer that reports what the state factory resolved, so a test can tell /// the two per-request channels apart. struct Viewed { seen: String, } impl super::Serves for Viewed { fn screen(&self, screen: &Screen) -> String { format!("{}|{}", self.seen, screen.title) } fn fragment(&self, node: &Node) -> String { match node { Node::Text { text, .. } => format!("{}|{text}", self.seen), other => format!("{}|{other:?}", self.seen), } } } fn whoami(viewer: &Viewer, _request: quasi_router::Request) -> Result { Ok(Response::fragment("detail", Node::text(viewer.who.clone()))) } /// Read the viewer out of a header, the way a server reads a session cookie. fn viewer_of(parts: &http::request::Parts) -> super::StateFuture { let who = parts .headers .get("x-who") .and_then(|value| value.to_str().ok()) .unwrap_or("nobody") .to_owned(); Box::pin(async move { Ok(Viewer { who }) }) } fn as_who(uri: &str, who: &str) -> Request { Request::builder() .uri(uri) .header("x-who", who) .body(Body::empty()) .unwrap() } #[tokio::test] async fn the_state_is_built_per_request_and_the_handler_reads_it() { // The whole of Q1: identity arrives at a handler whose signature did not // change. Two requests, one route, two viewers. let router = Router::::new().get("/whoami", whoami); let service = super::Adapter::per_viewer(router, viewer_of, |viewer, _params, _answer| Viewed { seen: viewer.who.clone(), }) .into_router(); let first = service .clone() .oneshot(as_who("/whoami", "ada")) .await .unwrap(); let first = first.into_body().collect().await.unwrap().to_bytes(); // Left of the bar is what the renderer saw, right of it what the handler // answered. Both halves are the request's own state. assert_eq!(String::from_utf8_lossy(&first), "ada|ada"); let second = service.oneshot(as_who("/whoami", "grace")).await.unwrap(); let second = second.into_body().collect().await.unwrap().to_bytes(); assert_eq!(String::from_utf8_lossy(&second), "grace|grace"); } #[tokio::test] async fn a_state_that_cannot_be_resolved_never_reaches_the_router() { // A store that will not answer is not a signed-out reader. The handler // panics, so reading the factory's own status back proves it never ran. fn never(_viewer: &Viewer, _request: quasi_router::Request) -> Result { panic!("the router was called without a state"); } let router = Router::::new().get("/whoami", never); let service = super::Adapter::per_viewer( router, |_parts| Box::pin(async move { Err(RouteError::denied("the session store said no")) }), |viewer: &Viewer, _params, _answer| Viewed { seen: viewer.who.clone(), }, ) .into_router(); let response = service.oneshot(get("/whoami")).await.unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); // No renderer could be built for it, so there is no body to build one for. let body = response.into_body().collect().await.unwrap().to_bytes(); assert!(body.is_empty()); } #[tokio::test] async fn the_envelope_is_refused_before_the_state_is_resolved() { // An oversized body or a verb the layer does not have should not cost a // session lookup. The factory panics, so a 405 is the proof it was ordered // after the refusal. let router = Router::::new().get("/whoami", whoami); let service = super::Adapter::per_viewer( router, |_parts| panic!("the state was resolved for a request that was refused"), |viewer: &Viewer, _params, _answer| Viewed { seen: viewer.who.clone(), }, ) .into_router(); let request = Request::builder() .method("PATCH") .uri("/whoami") .body(Body::empty()) .unwrap(); let response = service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); } #[tokio::test] async fn a_shared_state_still_serves() { // The change is additive on this axis too: a host with one viewer keeps // the constructor it had. let (status, body, _) = send(get("/")).await; assert_eq!(status, StatusCode::OK); assert_eq!(body, "screen:Home"); }