test: add form submission tests for all entity create handlers
The PayloadSource::Form branch (redirect response) and form-specific Submission type parsing were previously untested. Add 19 tests covering form-encoded POST for all 8 entities, including Datastar+form combo and edge cases for custom deserializers (TastingNotesInput, optional gear ID, comma-separated quick notes, date string parsing).
This commit is contained in:
parent
dfb89fab66
commit
092bdce164
4 changed files with 338 additions and 0 deletions
225
tests/server/form_submissions.rs
Normal file
225
tests/server/form_submissions.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
use crate::helpers::{
|
||||
TestApp, create_default_bag, create_default_cafe, create_default_gear, create_default_roast,
|
||||
create_default_roaster, post_form, spawn_app_with_auth,
|
||||
};
|
||||
use crate::test_macros::define_form_create_tests;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup functions: create prerequisites + return form fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn roaster_form_fields(_app: &TestApp) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("name".into(), "Form Roasters".into()),
|
||||
("country".into(), "UK".into()),
|
||||
]
|
||||
}
|
||||
|
||||
async fn roast_form_fields(app: &TestApp) -> Vec<(String, String)> {
|
||||
let roaster = create_default_roaster(app).await;
|
||||
vec![
|
||||
("roaster_id".into(), roaster.id.into_inner().to_string()),
|
||||
("name".into(), "Form Roast".into()),
|
||||
("origin".into(), "Ethiopia".into()),
|
||||
("region".into(), "Yirgacheffe".into()),
|
||||
("producer".into(), "Coop".into()),
|
||||
("tasting_notes".into(), "Blueberry, Jasmine".into()),
|
||||
("process".into(), "Washed".into()),
|
||||
]
|
||||
}
|
||||
|
||||
async fn bag_form_fields(app: &TestApp) -> Vec<(String, String)> {
|
||||
let roaster = create_default_roaster(app).await;
|
||||
let roast = create_default_roast(app, roaster.id).await;
|
||||
vec![
|
||||
("roast_id".into(), roast.id.into_inner().to_string()),
|
||||
("roast_date".into(), "2023-06-15".into()),
|
||||
("amount".into(), "250".into()),
|
||||
]
|
||||
}
|
||||
|
||||
async fn brew_form_fields(app: &TestApp) -> Vec<(String, String)> {
|
||||
let roaster = create_default_roaster(app).await;
|
||||
let roast = create_default_roast(app, roaster.id).await;
|
||||
let bag = create_default_bag(app, roast.id).await;
|
||||
let grinder = create_default_gear(app, "grinder", "Comandante", "C40 MK4").await;
|
||||
let brewer = create_default_gear(app, "brewer", "Hario", "V60").await;
|
||||
vec![
|
||||
("bag_id".into(), bag.id.into_inner().to_string()),
|
||||
("coffee_weight".into(), "15.0".into()),
|
||||
("grinder_id".into(), grinder.id.into_inner().to_string()),
|
||||
("grind_setting".into(), "24.0".into()),
|
||||
("brewer_id".into(), brewer.id.into_inner().to_string()),
|
||||
("water_volume".into(), "250".into()),
|
||||
("water_temp".into(), "96.0".into()),
|
||||
]
|
||||
}
|
||||
|
||||
async fn cup_form_fields(app: &TestApp) -> Vec<(String, String)> {
|
||||
let roaster = create_default_roaster(app).await;
|
||||
let roast = create_default_roast(app, roaster.id).await;
|
||||
let cafe = create_default_cafe(app).await;
|
||||
vec![
|
||||
("roast_id".into(), roast.id.into_inner().to_string()),
|
||||
("cafe_id".into(), cafe.id.into_inner().to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
async fn gear_form_fields(_app: &TestApp) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("category".into(), "grinder".into()),
|
||||
("make".into(), "Comandante".into()),
|
||||
("model".into(), "C40 MK4".into()),
|
||||
]
|
||||
}
|
||||
|
||||
async fn cafe_form_fields(_app: &TestApp) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("name".into(), "Form Cafe".into()),
|
||||
("city".into(), "London".into()),
|
||||
("country".into(), "UK".into()),
|
||||
("latitude".into(), "51.5074".into()),
|
||||
("longitude".into(), "-0.1278".into()),
|
||||
]
|
||||
}
|
||||
|
||||
async fn checkin_form_fields(app: &TestApp) -> Vec<(String, String)> {
|
||||
let roaster = create_default_roaster(app).await;
|
||||
let roast = create_default_roast(app, roaster.id).await;
|
||||
let cafe = create_default_cafe(app).await;
|
||||
vec![
|
||||
("cafe_id".into(), cafe.id.into_inner().to_string()),
|
||||
("roast_id".into(), roast.id.into_inner().to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Macro-generated tests: form create → 303 redirect + datastar variant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
define_form_create_tests!(
|
||||
entity: roaster,
|
||||
api_path: "/roasters",
|
||||
redirect_prefix: "/roasters/",
|
||||
setup_and_form: roaster_form_fields
|
||||
);
|
||||
|
||||
define_form_create_tests!(
|
||||
entity: roast,
|
||||
api_path: "/roasts",
|
||||
redirect_prefix: "/roasters/",
|
||||
setup_and_form: roast_form_fields
|
||||
);
|
||||
|
||||
define_form_create_tests!(
|
||||
entity: bag,
|
||||
api_path: "/bags",
|
||||
redirect_prefix: "/bags/",
|
||||
setup_and_form: bag_form_fields
|
||||
);
|
||||
|
||||
define_form_create_tests!(
|
||||
entity: brew,
|
||||
api_path: "/brews",
|
||||
redirect_prefix: "/brews/",
|
||||
setup_and_form: brew_form_fields
|
||||
);
|
||||
|
||||
define_form_create_tests!(
|
||||
entity: cup,
|
||||
api_path: "/cups",
|
||||
redirect_prefix: "/cups/",
|
||||
setup_and_form: cup_form_fields
|
||||
);
|
||||
|
||||
define_form_create_tests!(
|
||||
entity: gear,
|
||||
api_path: "/gear",
|
||||
redirect_prefix: "/gear/",
|
||||
setup_and_form: gear_form_fields
|
||||
);
|
||||
|
||||
define_form_create_tests!(
|
||||
entity: cafe,
|
||||
api_path: "/cafes",
|
||||
redirect_prefix: "/cafes/",
|
||||
setup_and_form: cafe_form_fields
|
||||
);
|
||||
|
||||
define_form_create_tests!(
|
||||
entity: checkin,
|
||||
api_path: "/check-in",
|
||||
redirect_prefix: "/cups/",
|
||||
setup_and_form: checkin_form_fields
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hand-written tests: form-specific parsing edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn brew_form_with_empty_filter_paper_and_quick_notes() {
|
||||
let app = spawn_app_with_auth().await;
|
||||
let roaster = create_default_roaster(&app).await;
|
||||
let roast = create_default_roast(&app, roaster.id).await;
|
||||
let bag = create_default_bag(&app, roast.id).await;
|
||||
let grinder = create_default_gear(&app, "grinder", "Comandante", "C40 MK4").await;
|
||||
let brewer = create_default_gear(&app, "brewer", "Hario", "V60").await;
|
||||
|
||||
let form_fields = vec![
|
||||
("bag_id", bag.id.into_inner().to_string()),
|
||||
("coffee_weight", "15.0".into()),
|
||||
("grinder_id", grinder.id.into_inner().to_string()),
|
||||
("grind_setting", "24.0".into()),
|
||||
("brewer_id", brewer.id.into_inner().to_string()),
|
||||
("water_volume", "250".into()),
|
||||
("water_temp", "96.0".into()),
|
||||
("filter_paper_id", String::new()), // empty string → None
|
||||
("quick_notes", "good,too-fast".into()), // comma-separated → vec
|
||||
];
|
||||
|
||||
let response = post_form(&app, "/brews", &form_fields).await;
|
||||
assert_eq!(response.status(), 303);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn roast_form_with_comma_separated_tasting_notes() {
|
||||
let app = spawn_app_with_auth().await;
|
||||
let roaster = create_default_roaster(&app).await;
|
||||
|
||||
let form_fields = vec![
|
||||
("roaster_id", roaster.id.into_inner().to_string()),
|
||||
("name", "Tasting Test".into()),
|
||||
("origin", "Kenya".into()),
|
||||
("region", "Nyeri".into()),
|
||||
("producer", "Smallholder".into()),
|
||||
("tasting_notes", "Blackcurrant, Tomato, Brown Sugar".into()),
|
||||
("process", "Natural".into()),
|
||||
];
|
||||
|
||||
let response = post_form(&app, "/roasts", &form_fields).await;
|
||||
assert_eq!(response.status(), 303);
|
||||
|
||||
let location = response
|
||||
.headers()
|
||||
.get("location")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.expect("missing Location header");
|
||||
assert!(location.starts_with("/roasters/"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bag_form_with_empty_roast_date() {
|
||||
let app = spawn_app_with_auth().await;
|
||||
let roaster = create_default_roaster(&app).await;
|
||||
let roast = create_default_roast(&app, roaster.id).await;
|
||||
|
||||
let form_fields = vec![
|
||||
("roast_id", roast.id.into_inner().to_string()),
|
||||
("roast_date", String::new()), // empty string → None
|
||||
("amount", "250".into()),
|
||||
];
|
||||
|
||||
let response = post_form(&app, "/bags", &form_fields).await;
|
||||
assert_eq!(response.status(), 303);
|
||||
}
|
||||
|
|
@ -420,6 +420,50 @@ pub async fn create_session(app: &TestApp) -> String {
|
|||
session_token
|
||||
}
|
||||
|
||||
/// POST a form-encoded payload with session cookie auth.
|
||||
/// Uses `redirect::Policy::none()` so tests can assert the 303 redirect itself.
|
||||
pub async fn post_form(
|
||||
app: &TestApp,
|
||||
path: &str,
|
||||
form_body: &[(impl AsRef<str> + Serialize, impl AsRef<str> + Serialize)],
|
||||
) -> reqwest::Response {
|
||||
let session_token = create_session(app).await;
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
.post(app.api_url(path))
|
||||
.header("Cookie", format!("brewlog_session={session_token}"))
|
||||
.form(form_body)
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to POST form")
|
||||
}
|
||||
|
||||
/// POST a form-encoded payload with session cookie auth and Datastar headers.
|
||||
pub async fn post_form_datastar(
|
||||
app: &TestApp,
|
||||
path: &str,
|
||||
form_body: &[(impl AsRef<str> + Serialize, impl AsRef<str> + Serialize)],
|
||||
) -> reqwest::Response {
|
||||
let session_token = create_session(app).await;
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
.post(app.api_url(path))
|
||||
.header("Cookie", format!("brewlog_session={session_token}"))
|
||||
.header("datastar-request", "true")
|
||||
.form(form_body)
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to POST form with datastar")
|
||||
}
|
||||
|
||||
pub async fn spawn_app_with_openrouter_mock() -> TestApp {
|
||||
let mock_server = wiremock::MockServer::start().await;
|
||||
let openrouter_url = format!("{}/api/v1/chat/completions", mock_server.uri());
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pub mod checkin_api;
|
|||
pub mod cups_api;
|
||||
pub mod datastar;
|
||||
pub mod extraction_api;
|
||||
pub mod form_submissions;
|
||||
pub mod gear_api;
|
||||
pub mod helpers;
|
||||
pub mod nearby_api;
|
||||
|
|
|
|||
|
|
@ -185,3 +185,71 @@ macro_rules! define_datastar_entity_tests {
|
|||
}
|
||||
|
||||
pub(crate) use define_datastar_entity_tests;
|
||||
|
||||
/// Generates form submission tests for a given entity.
|
||||
/// The `setup_and_form` function creates any prerequisite entities and returns
|
||||
/// the form fields as `Vec<(String, String)>`.
|
||||
///
|
||||
/// Generated tests:
|
||||
/// - `creating_{entity}_via_form_returns_redirect` — POST form → 303 redirect
|
||||
/// - `creating_{entity}_via_form_with_datastar_returns_fragment` — POST form + datastar → 200
|
||||
macro_rules! define_form_create_tests {
|
||||
(
|
||||
entity: $entity:ident,
|
||||
api_path: $api_path:expr,
|
||||
redirect_prefix: $redirect_prefix:expr,
|
||||
setup_and_form: $setup_fn:expr
|
||||
) => {
|
||||
paste::paste! {
|
||||
#[tokio::test]
|
||||
async fn [<creating_ $entity _via_form_returns_redirect>]() {
|
||||
let app = crate::helpers::spawn_app_with_auth().await;
|
||||
let form_fields = $setup_fn(&app).await;
|
||||
let response = crate::helpers::post_form(&app, $api_path, &form_fields).await;
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
303,
|
||||
"expected 303 See Other, got {}",
|
||||
response.status()
|
||||
);
|
||||
let location = response
|
||||
.headers()
|
||||
.get("location")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.expect("missing Location header");
|
||||
assert!(
|
||||
location.starts_with($redirect_prefix),
|
||||
"expected redirect to start with '{}', got '{}'",
|
||||
$redirect_prefix,
|
||||
location
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn [<creating_ $entity _via_form_with_datastar_returns_fragment>]() {
|
||||
let app = crate::helpers::spawn_app_with_auth().await;
|
||||
let form_fields = $setup_fn(&app).await;
|
||||
let response =
|
||||
crate::helpers::post_form_datastar(&app, $api_path, &form_fields).await;
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
200,
|
||||
"expected 200 OK for datastar form, got {}",
|
||||
response.status()
|
||||
);
|
||||
assert!(
|
||||
response.headers().get("datastar-selector").is_some(),
|
||||
"expected datastar-selector header"
|
||||
);
|
||||
assert!(
|
||||
response.headers().get("datastar-mode").is_some(),
|
||||
"expected datastar-mode header"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use define_form_create_tests;
|
||||
|
|
|
|||
Loading…
Reference in a new issue