#![allow(dead_code)]
use axum::body::Body;
use axum::extract::FromRequest;
use axum::http::{Request, header};
use serde::{Deserialize, Serialize};
use umbral::forms::Form;
#[derive(Debug, Serialize, Deserialize, Default, umbral::forms::Form)]
#[form(normalize_strings)]
struct ContactSpec {
#[form(required, min_length = 1, max_length = 100)]
name: String,
#[form(required, email)]
email: String,
#[form(max_length = 30, optional)]
phone: String,
#[form(required, length(min = 10, max = 5000))]
message: String,
}
async fn extract(body: &str) -> Form<ContactSpec> {
let req = Request::builder()
.method("POST")
.uri("/")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body.to_owned()))
.unwrap();
<Form<ContactSpec> as FromRequest<()>>::from_request(req, &())
.await
.expect("extractor never rejects — always returns Form<T>")
}
#[tokio::test]
async fn happy_path_returns_validated_struct() {
let body = "name=Alice&email=alice%40example.com&phone=&message=Hello+there+I+have+a+question";
let form = extract(body).await;
let valid = form.into_result().expect("body is valid — Ok(T) expected");
assert_eq!(valid.name, "Alice");
assert_eq!(valid.email, "alice@example.com");
assert_eq!(valid.phone, ""); assert!(valid.message.starts_with("Hello"));
}
#[tokio::test]
async fn missing_required_fields_surface_per_field_errors() {
let form = extract("").await;
let errs = form
.into_result()
.expect_err("validation should fail on empty body");
let field_errors = errs.field_errors();
assert!(field_errors.contains_key("name"));
assert!(field_errors.contains_key("email"));
assert!(field_errors.contains_key("message"));
assert!(
!field_errors.contains_key("phone"),
"optional empty field must not produce a per-field error"
);
for (field, msgs) in &field_errors {
assert!(
!msgs.is_empty(),
"field `{field}` has an empty messages vec"
);
assert!(
msgs[0].len() > 3,
"field `{field}`'s first message is suspiciously short"
);
}
}
#[tokio::test]
async fn invalid_email_surfaces_under_email_key() {
let body = "name=Alice&email=not-an-email&message=Hello+there+I+have+a+question";
let form = extract(body).await;
let errs = form.into_result().expect_err("invalid email should fail");
let field_errors = errs.field_errors();
assert!(
field_errors.contains_key("email"),
"email-format error must land under the `email` key"
);
}
#[tokio::test]
async fn normalize_strings_trims_whitespace_before_validation() {
let body =
"name=++++Alice++&email=alice%40example.com&message=Hello+there+yes+I+have+a+question";
let form = extract(body).await;
let valid = form
.into_result()
.expect("after trim, name = `Alice` is non-empty and length-valid");
assert_eq!(
valid.name, "Alice",
"normalize_strings must strip leading/trailing whitespace"
);
}
#[tokio::test]
async fn template_ctx_renders_first_error_per_field_under_flat_key() {
let form = extract("").await;
let errs = form.into_result().expect_err("validation should fail");
let ctx = errs.as_template_ctx();
assert!(ctx.get("name").and_then(|v| v.as_str()).is_some());
assert!(ctx.get("email").and_then(|v| v.as_str()).is_some());
assert!(ctx.get("message").and_then(|v| v.as_str()).is_some());
assert!(
ctx.get("phone").is_none(),
"optional field with no input must not appear in the flat ctx"
);
}