//! Real requests through the protocol handler, with no tauri around it. //! //! [`serve`] takes an `http::Request` and answers an `http::Response`, which is //! the whole of what the handler does once tauri has handed it the bytes. So //! everything worth testing here is testable without a window, an event loop or //! a display, and the part that is not, which is the registration itself, is //! three lines of tauri API in [`Protocol::into_handler`]. //! //! Decoding, status mapping and the retarget header are `quasi-http`'s and are //! tested there. What is here is what this host adds: URL forms, the //! passthrough, and the panic that would otherwise leave a webview hanging. // 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 quasi_router::{Node, RegionKind, Response, RouteError, Router, Screen, Slot}; use super::{Context, Protocol, Served, Serves, serve}; /// 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 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 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 router() -> Router { Router::::new() .get("/", home) .get("/task/{id}", echo) .post("/task/{id}/edit", echo) .post("/task/{id}/delete", denied) .get("/boom", boom) } /// The protocol, taken apart into the context a request is answered against. fn context(protocol: Protocol) -> Context { Context { router: protocol.router, state: protocol.state, render: protocol.render, body_limit: protocol.body_limit, passthrough: protocol.passthrough, } } fn plain() -> Context { context(Protocol::new( "quasi", router(), Arc::new(App), Arc::new(Spy), )) } /// A GET at a full custom-protocol URL, which is the only form a handler sees. fn get(uri: &str) -> http::Request> { http::Request::builder().uri(uri).body(Vec::new()).unwrap() } fn post_form(uri: &str, body: &str) -> http::Request> { http::Request::builder() .method("POST") .uri(uri) .header( http::header::CONTENT_TYPE, "application/x-www-form-urlencoded", ) .body(body.as_bytes().to_vec()) .unwrap() } fn text(response: &http::Response>) -> String { String::from_utf8(response.body().clone()).unwrap() } #[test] fn the_url_a_window_is_pointed_at_is_the_scheme_over_localhost() { let protocol = Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy)); assert_eq!(protocol.url().as_str(), "quasi://localhost/"); } #[test] #[should_panic(expected = "is not a legal URL scheme")] fn a_malformed_scheme_is_caught_at_construction() { // Rather than at first paint, as a window that loads nothing and logs // nothing about why. let _ = Protocol::new("2fast", router(), Arc::new(App), Arc::new(Spy)); } #[test] fn a_request_at_the_scheme_url_routes_by_path_alone() { let response = serve(&plain(), &get("quasi://localhost/")); assert_eq!(response.status(), 200); assert_eq!(text(&response), "screen:Home"); } #[test] fn the_windows_workaround_form_would_route_the_same_way() { // wry reverts `http://quasi.localhost/...` to `quasi://localhost/...` // before the handler is called, so this form should never actually arrive. // Asserted anyway: the adapter reads the path and nothing else, so if wry // ever stopped reverting, this is the line that says we still work. let response = serve(&plain(), &get("http://quasi.localhost/task/7")); assert_eq!(text(&response), "text:id=7"); } #[test] fn a_form_post_reaches_the_handler_with_its_body() { // The finding the whole spike turned on: an action is a POST, and a webview // that dropped the body would have taken decision 2 out. let response = serve( &plain(), &post_form("quasi://localhost/task/7/edit", "title=new+title"), ); assert_eq!(response.status(), 200); assert!(text(&response).contains("title=new title")); } #[test] fn a_fragment_names_the_region_it_replaces() { let response = serve(&plain(), &get("quasi://localhost/task/7")); assert_eq!( response.headers().get(super::htmx::RETARGET).unwrap(), "#detail" ); } #[test] fn a_denial_is_a_403_the_webview_can_still_render() { let response = serve(&plain(), &post_form("quasi://localhost/task/7/delete", "")); assert_eq!(response.status(), 403); assert_eq!(text(&response), "notice:Warning:not yours"); } #[test] fn an_unknown_path_is_a_404_that_still_renders_something() { let response = serve(&plain(), &get("quasi://localhost/nowhere")); assert_eq!(response.status(), 404); assert!(text(&response).starts_with("notice:Warning:")); } #[test] fn a_verb_the_description_layer_lacks_is_refused_with_allow() { // PATCH, since `61e1b069` gave the layer DELETE and PUT. let request = http::Request::builder() .method("PATCH") .uri("quasi://localhost/task/7") .body(Vec::new()) .unwrap(); let response = serve(&plain(), &request); assert_eq!(response.status(), 405); assert_eq!( response.headers().get(http::header::ALLOW).unwrap(), "GET, POST, DELETE, PUT" ); } #[test] fn a_panicking_handler_answers_rather_than_hanging_the_webview() { // The difference that matters is not the 500. It is that there is a // response at all: an unanswered responder is a webview waiting forever on // a request nobody will finish. let response = serve(&plain(), &get("quasi://localhost/boom")); assert_eq!(response.status(), 500); assert!(text(&response).starts_with("notice:Danger:")); } #[test] fn an_oversized_form_is_refused_before_the_router_sees_it() { let protocol = Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy)).body_limit(16); let body = "title=".to_owned() + &"x".repeat(1024); let response = serve( &context(protocol), &post_form("quasi://localhost/task/7/edit", &body), ); assert_eq!(response.status(), 413); } #[test] fn a_passthrough_answers_before_the_router() { let protocol = Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy)).passthrough(|path| { path.strip_prefix("/static/") .map(|name| Served::new("text/css", format!("/* {name} */"))) }); let context = context(protocol); let served = serve(&context, &get("quasi://localhost/static/styles.css")); assert_eq!(served.status(), 200); assert_eq!( served.headers().get(http::header::CONTENT_TYPE).unwrap(), "text/css" ); assert_eq!(text(&served), "/* styles.css */"); // And a path it declines still routes. let routed = serve(&context, &get("quasi://localhost/")); assert_eq!(text(&routed), "screen:Home"); } #[test] fn a_passthrough_that_declines_everything_changes_nothing() { let protocol = Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy)).passthrough(|_| None); let response = serve(&context(protocol), &get("quasi://localhost/task/7")); assert_eq!(text(&response), "text:id=7"); } #[test] fn the_content_type_is_the_renderers_answer() { let response = serve(&plain(), &get("quasi://localhost/")); assert_eq!( response.headers().get(http::header::CONTENT_TYPE).unwrap(), "text/html; charset=utf-8" ); } /// The one screen the end-to-end test renders for real. /// /// The same description the axum adapter's own end-to-end test serves. Two /// hosts, one description, and the only difference between the documents is /// where the assets live — which is the stack's whole claim, and the reason /// `Shell` takes an asset prefix rather than hard-coding one. 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()) } #[test] fn a_description_served_here_is_the_renderer_s_own_output() { use quasi_http::Serves as _; let render = quasi_webview::Webview::under("quasi://localhost/static"); let protocol = Protocol::new( "quasi", Router::::new().get("/", real_home), Arc::new(App), Arc::new(render), ); let context = Context { router: protocol.router, state: protocol.state, render: protocol.render, body_limit: protocol.body_limit, passthrough: protocol.passthrough, }; let response = serve(&context, &get("quasi://localhost/")); assert_eq!(response.status(), 200); let served = text(&response); assert_eq!( served, quasi_webview::Webview::under("quasi://localhost/static").screen(&real_screen()) ); // The asset paths are the custom scheme's, which is the one thing that // differs from the served case. assert!(served.contains("src=\"quasi://localhost/static/htmx.min.js\"")); // Everything the description said is identical to what axum emits. assert!(served.contains("hx-get=\"/task/1\"")); assert!(served.contains("id=\"detail\"")); }