use axum::extract::State; use axum::http::{header, HeaderMap}; use axum::response::IntoResponse; use hemx_axum::{ interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path, runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form, HandlerErrorContext, HandlerFailure, InteractionForm, InteractionFormRejection, InteractionRequest, IntoHandlerFailure, PageMode, PageRequest, PageResponse, HEMX_CONTENT_TYPE, HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE, HEMX_TITLE_HEADER, }; use hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot}; use scraper::{Html, Selector}; fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct ProjectId(u32); impl std::str::FromStr for ProjectId { type Err = std::num::ParseIntError; fn from_str(value: &str) -> Result { value.parse().map(ProjectId) } } struct OpenProject { project_id: ProjectId, } impl hemx_core::FromForm for OpenProject { fn from_form_fields(fields: &[(String, String)]) -> Result { let Some(value) = fields .iter() .find_map(|(name, value)| (name == "project_id").then_some(value.as_str())) else { return Err(hemx_core::FormError::new("missing form field `project_id`")); }; Ok(Self { project_id: value .parse() .map_err(|_| hemx_core::FormError::new("invalid form field `project_id`"))?, }) } } #[test] fn page_mode_detects_partial_header() { let mut headers = HeaderMap::new(); assert_eq!(PageMode::from_headers(&headers), PageMode::Full); headers.insert(HEMX_PARTIAL_HEADER, "true".parse().unwrap()); assert_eq!(PageMode::from_headers(&headers), PageMode::Partial); } #[test] fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() { // req: test/005 let full = PageRequest { mode: PageMode::Full, } .page("
Docs
", |content| { format!("{content}") }); assert_eq!(full.mode, PageMode::Full); let full_document = Html::parse_document(&full.html); assert_eq!( full_document .select(&selector( "body[data-shell=\"docs\"] main[data-page=\"docs\"]" )) .count(), 1 ); let partial = PageRequest { mode: PageMode::Partial, } .page("
Docs
", |content| { format!("{content}") }); assert_eq!(partial.mode, PageMode::Partial); let partial_fragment = Html::parse_fragment(&partial.html); assert_eq!( partial_fragment .select(&selector("main[data-page=\"docs\"]")) .count(), 1 ); assert_eq!( partial_fragment .select(&selector("body[data-shell=\"docs\"]")) .count(), 0 ); } #[test] fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() { // req: axum_integration/001 req: html_safety/001 req: html_safety/002 req: test/005 let full = PageRequest { mode: PageMode::Full, } .page_html( SafeHtml::trusted("
Docs
"), |content| { SafeHtml::trusted(format!( "{content}" )) }, ); assert_eq!(full.mode, PageMode::Full); let full_document = Html::parse_document(&full.html); assert_eq!( full_document .select(&selector( "body[data-shell=\"docs\"] main[data-page=\"docs\"]" )) .count(), 1 ); let partial = PageRequest { mode: PageMode::Partial, } .page_html( SafeHtml::trusted("
Docs
"), |content| { SafeHtml::trusted(format!( "{content}" )) }, ); assert_eq!(partial.mode, PageMode::Partial); let partial_fragment = Html::parse_fragment(&partial.html); assert_eq!( partial_fragment .select(&selector("main[data-page=\"docs\"]")) .count(), 1 ); assert_eq!( partial_fragment .select(&selector("body[data-shell=\"docs\"]")) .count(), 0 ); } #[test] fn partial_page_response_sets_partial_and_title_headers() { let response = PageResponse::partial("
Docs
") .title("Docs") .into_response(); assert_eq!( response.headers()[header::CONTENT_TYPE], "text/html; charset=utf-8" ); assert_eq!(response.headers()[HEMX_PARTIAL_HEADER], "true"); assert_eq!(response.headers()[HEMX_TITLE_HEADER], "Docs"); } #[test] fn effect_response_is_wire_batch_with_fingerprint_header() { let response = EffectResponse::new(push("/docs"), BuildFingerprint(99)).into_response(); assert_eq!(response.headers()[header::CONTENT_TYPE], HEMX_CONTENT_TYPE); assert_eq!(response.headers()[HEMX_FINGERPRINT_HEADER], "99"); } #[test] fn interaction_form_parses_handle_and_fields() { let form = InteractionForm::parse_urlencoded(b"__h=42&title=Hello+World&tag=a&tag=b%2Fc").unwrap(); assert_eq!(form.handle_id, 42); assert_eq!(form.value("title"), Some("Hello World")); assert_eq!(form.values("tag").collect::>(), ["a", "b/c"]); } #[test] fn interaction_form_parses_typed_values() { // req: form/004 req: dx/003 let form = InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope").expect("form should parse"); assert_eq!(form.parse::("count"), Some(7)); assert_eq!(form.parse::("bad"), None); assert_eq!(form.parse::("missing"), None); } #[test] fn interaction_form_requires_numeric_handle() { assert_eq!( InteractionForm::parse_urlencoded(b"title=Hello").unwrap_err(), InteractionFormRejection::MissingHandle ); assert_eq!( InteractionForm::parse_urlencoded(b"__h=nope").unwrap_err(), InteractionFormRejection::InvalidHandle ); } #[test] fn interaction_form_preserves_hidden_csrf_fields_for_extractors() { // req: auth/004 let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello").unwrap(); assert_eq!(form.handle_id, 42); assert_eq!(form.value("csrf_token"), Some("abc123")); assert!(form.fields().iter().any(|(name, _)| name == "csrf_token")); } #[test] fn interaction_request_dispatches_with_concise_handlers_helper() { // req: axum_integration/003 req: ceremony/001 req: dx/003 let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(7), Vec::new(), )); let response = request .dispatch( interactions(BuildFingerprint(4)) .on(Handle::<()>::new(7), |_| Slot::::new(3).text("ok")), ) .unwrap(); assert_eq!(response.batch.fingerprint, BuildFingerprint(4)); assert_eq!(response.batch.ops, vec![Slot::::new(3).text("ok")]); } #[test] fn interaction_request_dispatches_typed_form_inputs() { // req: axum_integration/003 req: form/004 req: canonical_authoring/003 let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(8), vec![("project_id".to_owned(), "42".to_owned())], )); let response = request .dispatch( interactions(BuildFingerprint(4)) .on_form(Handle::<()>::new(8), |input: OpenProject| { Slot::::new(3).text(input.project_id.0) }), ) .unwrap(); assert_eq!(response.batch.fingerprint, BuildFingerprint(4)); assert_eq!(response.batch.ops, vec![Slot::::new(3).text(42)]); } #[test] fn interaction_request_rejects_invalid_typed_form_inputs() { // req: axum_integration/003 req: form/004 req: canonical_authoring/004 let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(8), vec![("project_id".to_owned(), "nope".to_owned())], )); let rejection = request .dispatch( interactions(BuildFingerprint(4)) .on_form(Handle::<()>::new(8), |input: OpenProject| { Slot::::new(3).text(input.project_id.0) }), ) .unwrap_err(); assert!(matches!( rejection, DispatchRejection::InvalidForm { handle_id: 8, .. } )); } #[test] fn interaction_request_dispatches_typed_state_handlers() { // req: axum_integration/003 req: form/004 req: canonical_authoring/003 fn open_project(multiplier: u32, input: OpenProject) -> impl IntoEffect { Slot::::new(3).text(input.project_id.0 * multiplier) } let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(9), vec![("project_id".to_owned(), "21".to_owned())], )); let response = request .dispatch( interactions(BuildFingerprint(4)) .with_state(2_u32) .on(Handle::<()>::new(9), open_project), ) .unwrap(); assert_eq!(response.batch.ops, vec![Slot::::new(3).text(42)]); } #[derive(Debug)] struct HandlerBoom; impl std::fmt::Display for HandlerBoom { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("database unavailable") } } impl std::error::Error for HandlerBoom {} impl IntoHandlerFailure for HandlerBoom { fn into_handler_failure(self, _context: HandlerErrorContext) -> HandlerFailure { HandlerFailure::internal(self.to_string()) } } #[derive(Debug)] struct UiFailure; impl IntoHandlerFailure for UiFailure { fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { HandlerFailure::effects(Slot::::new(3).text("try again"), context) } } #[tokio::test] async fn interaction_request_dispatches_async_typed_state_extractors() { // req: axum_integration/003 req: form/004 req: canonical_authoring/003 async fn open_project( State(multiplier): State, Form(input): Form, ) -> impl IntoEffect { Slot::::new(3).text(input.project_id.0 * multiplier) } let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(10), vec![("project_id".to_owned(), "21".to_owned())], )); let response = request .dispatch_async( interactions(BuildFingerprint(4)) .with_state(2_u32) .on_async(Handle::<()>::new(10), open_project) .into_registry(), ) .await .unwrap(); assert_eq!(response.batch.ops, vec![Slot::::new(3).text(42)]); } #[tokio::test] async fn interaction_request_reports_async_result_handler_errors() { // req: axum_integration/003 req: form/004 req: failure/003 async fn open_project( State(_multiplier): State, Form(_input): Form, ) -> Result { Err::<(), _>(HandlerBoom) } let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(11), vec![("project_id".to_owned(), "21".to_owned())], )); let error = request .dispatch_async( interactions(BuildFingerprint(4)) .with_state(2_u32) .on_async_result(Handle::<()>::new(11), open_project) .into_registry(), ) .await .unwrap_err(); assert_eq!( error, DispatchRejection::HandlerError(HandlerFailure::Response { status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, message: "database unavailable".to_owned(), }) ); } #[tokio::test] async fn interaction_request_maps_async_result_errors_to_effects() { // req: axum_integration/003 req: failure/004 async fn open_project( State(_multiplier): State, Form(_input): Form, ) -> Result { Err::<(), _>(UiFailure) } let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(12), vec![("project_id".to_owned(), "21".to_owned())], )); let response = request .dispatch_async( interactions(BuildFingerprint(4)) .with_state(2_u32) .on_async_result(Handle::<()>::new(12), open_project) .into_registry(), ) .await .unwrap(); assert_eq!( response.batch.ops, vec![Slot::::new(3).text("try again")] ); } #[test] fn interaction_request_maps_sync_result_errors_to_effects() { // req: axum_integration/003 req: failure/004 fn open_project( State(_multiplier): State, Form(_input): Form, ) -> Result { Err::<(), _>(UiFailure) } let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(13), vec![("project_id".to_owned(), "21".to_owned())], )); let response = request .dispatch( interactions(BuildFingerprint(4)) .with_state(2_u32) .on_result(Handle::<()>::new(13), open_project) .into_registry(), ) .unwrap(); assert_eq!( response.batch.ops, vec![Slot::::new(3).text("try again")] ); } #[test] fn interactions_dispatch_by_checked_handle() { // req: ceremony/004 req: public_api/001 let title = Slot::::new(7); let create = Handle::<()>::new(42); let request = InteractionRequest::from(InteractionForm::for_handle( create, [("title".into(), "Hello".into())], )); let response = request .dispatch(interactions(BuildFingerprint(123)).on(create, move |form| { title.text(form.value("title").unwrap_or("")) })) .unwrap(); assert_eq!(response.batch.fingerprint, BuildFingerprint(123)); assert_eq!(response.batch.ops, vec![title.text("Hello")]); } #[test] fn interactions_reject_unknown_handle_ids() { let request = InteractionRequest::from(InteractionForm::new(9, [])); assert_eq!( request .dispatch(interactions(BuildFingerprint(123))) .unwrap_err(), DispatchRejection::UnknownHandle(9) ); } #[test] fn runtime_js_response_serves_embedded_runtime() { let response = runtime_js().into_response(); assert_eq!( response.headers()[header::CONTENT_TYPE], HEMX_RUNTIME_CONTENT_TYPE ); assert_eq!( response.headers()[header::CACHE_CONTROL], "public, max-age=31536000, immutable" ); assert_eq!( response.headers()[header::ETAG], format!("\"{}\"", runtime_js_hash()) ); assert_eq!( response.headers()[header::CONTENT_LENGTH], runtime_js_source().len().to_string() ); } // req: axum_integration/005 #[test] fn runtime_js_path_is_content_hashed() { let path = runtime_js_path(); assert!(path.starts_with("/hemx.")); assert!(path.ends_with(".js")); assert!(path.contains(runtime_js_hash())); assert_eq!(runtime_js_script_src(), path); assert_eq!(runtime_js_route_path(), path); assert_eq!(runtime_js_hash().len(), 64); }