From 931859938aa5ffffc56d07bde9b833262ec8613f Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 10 Feb 2026 18:36:21 +0000 Subject: [PATCH] test: add integration and E2E tests for edit functionality Add form-based PUT update tests via define_form_update_tests macro for all 7 entities. Add hand-written datastar update tests for roasters, roasts, cafes, brews, and cups. Add brew/cup update API tests (CRUD + auth). Add put_form, put_form_datastar, create_default_brew, create_default_cup test helpers. --- tests/server/brews_api.rs | 146 +++++++++++++++++++++ tests/server/cups_api.rs | 144 ++++++++++++++++++++- tests/server/datastar.rs | 212 ++++++++++++++++++++++++++++++- tests/server/form_submissions.rs | 166 +++++++++++++++++++++++- tests/server/helpers.rs | 86 +++++++++++++ tests/server/test_macros.rs | 69 ++++++++++ 6 files changed, 819 insertions(+), 4 deletions(-) diff --git a/tests/server/brews_api.rs b/tests/server/brews_api.rs index 5c5d119..a808c26 100644 --- a/tests/server/brews_api.rs +++ b/tests/server/brews_api.rs @@ -497,3 +497,149 @@ async fn deleting_a_brew_returns_204() { assert_eq!(get_response.status(), 404); } + +#[tokio::test] +async fn updating_a_brew_returns_200_and_updates_data() { + 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 02").await; + let client = reqwest::Client::new(); + + let new_brew = NewBrew { + bag_id: bag.id, + coffee_weight: 15.0, + grinder_id: grinder.id, + grind_setting: 24.0, + brewer_id: brewer.id, + filter_paper_id: None, + water_volume: 250, + water_temp: 92.0, + quick_notes: Vec::new(), + brew_time: None, + created_at: None, + }; + + let create_response = client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_brew) + .send() + .await + .expect("Failed to create brew"); + + let brew: Brew = create_response + .json() + .await + .expect("Failed to parse response"); + + let update = serde_json::json!({ + "water_temp": 94.0, + "grind_setting": 22.0, + }); + + let response = client + .put(app.api_url(&format!("/brews/{}", brew.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&update) + .send() + .await + .expect("Failed to update brew"); + + assert_eq!(response.status(), 200); + let updated: Brew = response.json().await.expect("Failed to parse response"); + assert_eq!(updated.water_temp, 94.0); + assert_eq!(updated.grind_setting, 22.0); + assert_eq!(updated.coffee_weight, 15.0); // unchanged +} + +#[tokio::test] +async fn updating_a_brew_without_auth_returns_401() { + let app = spawn_app().await; + let client = reqwest::Client::new(); + + let update = serde_json::json!({ + "water_temp": 94.0, + }); + + let response = client + .put(app.api_url("/brews/1")) + .json(&update) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 401); +} + +#[tokio::test] +async fn updating_a_brew_with_no_changes_returns_400() { + 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 02").await; + let client = reqwest::Client::new(); + + let new_brew = NewBrew { + bag_id: bag.id, + coffee_weight: 15.0, + grinder_id: grinder.id, + grind_setting: 24.0, + brewer_id: brewer.id, + filter_paper_id: None, + water_volume: 250, + water_temp: 92.0, + quick_notes: Vec::new(), + brew_time: None, + created_at: None, + }; + + let create_response = client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_brew) + .send() + .await + .expect("Failed to create brew"); + + let brew: Brew = create_response + .json() + .await + .expect("Failed to parse response"); + + let update = serde_json::json!({}); + + let response = client + .put(app.api_url(&format!("/brews/{}", brew.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&update) + .send() + .await + .expect("Failed to update brew"); + + assert_eq!(response.status(), 400); +} + +#[tokio::test] +async fn updating_a_nonexistent_brew_returns_404() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + let update = serde_json::json!({ + "water_temp": 94.0, + }); + + let response = client + .put(app.api_url("/brews/99999")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&update) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 404); +} diff --git a/tests/server/cups_api.rs b/tests/server/cups_api.rs index 3e0b3e6..b25b032 100644 --- a/tests/server/cups_api.rs +++ b/tests/server/cups_api.rs @@ -1,5 +1,6 @@ use crate::helpers::{ - create_default_cafe, create_default_roast, create_default_roaster, spawn_app_with_auth, + create_cafe_with_payload, create_default_cafe, create_default_roast, create_default_roaster, + spawn_app_with_auth, }; use crate::test_macros::define_crud_tests; use brewlog::domain::cups::{Cup, CupWithDetails, NewCup}; @@ -188,3 +189,144 @@ async fn deleting_a_cup_returns_a_204_for_valid_id() { assert_eq!(get_response.status(), 404); } + +#[tokio::test] +async fn updating_a_cup_returns_a_200_for_valid_data() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + let cafe1 = create_default_cafe(&app).await; + + let new_cup = NewCup { + roast_id: roast.id, + cafe_id: cafe1.id, + created_at: None, + }; + + let create_response = client + .post(app.api_url("/cups")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_cup) + .send() + .await + .expect("Failed to create cup"); + + let cup: Cup = create_response + .json() + .await + .expect("Failed to parse response"); + + // Create a second cafe to update to + let cafe2 = create_cafe_with_payload( + &app, + brewlog::domain::cafes::NewCafe { + name: "Updated Cafe".to_string(), + city: "Tokyo".to_string(), + country: "JP".to_string(), + latitude: 35.6762, + longitude: 139.6503, + website: None, + created_at: None, + }, + ) + .await; + + let update = serde_json::json!({ + "cafe_id": cafe2.id, + }); + + let response = client + .put(app.api_url(&format!("/cups/{}", cup.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&update) + .send() + .await + .expect("Failed to update cup"); + + assert_eq!(response.status(), 200); + let updated: Cup = response.json().await.expect("Failed to parse response"); + assert_eq!(updated.cafe_id, cafe2.id); + assert_eq!(updated.roast_id, roast.id); // unchanged +} + +#[tokio::test] +async fn updating_a_cup_without_auth_returns_401() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + let update = serde_json::json!({ + "cafe_id": 2, + }); + + let response = client + .put(app.api_url("/cups/1")) + .json(&update) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 401); +} + +#[tokio::test] +async fn updating_a_cup_with_no_changes_returns_400() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + let cafe = create_default_cafe(&app).await; + + let new_cup = NewCup { + roast_id: roast.id, + cafe_id: cafe.id, + created_at: None, + }; + + let create_response = client + .post(app.api_url("/cups")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_cup) + .send() + .await + .expect("Failed to create cup"); + + let cup: Cup = create_response + .json() + .await + .expect("Failed to parse response"); + + let update = serde_json::json!({}); + + let response = client + .put(app.api_url(&format!("/cups/{}", cup.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&update) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 400); +} + +#[tokio::test] +async fn updating_a_nonexistent_cup_returns_404() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + let update = serde_json::json!({ + "cafe_id": 1, + }); + + let response = client + .put(app.api_url("/cups/99999")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&update) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 404); +} diff --git a/tests/server/datastar.rs b/tests/server/datastar.rs index d8c18f6..7637894 100644 --- a/tests/server/datastar.rs +++ b/tests/server/datastar.rs @@ -5,8 +5,9 @@ use crate::helpers::{ TestApp, assert_datastar_headers, assert_datastar_headers_with_mode, assert_full_page, - assert_html_fragment, create_default_bag, create_default_cafe, create_default_gear, - create_default_roast, create_default_roaster, spawn_app_with_auth, + assert_html_fragment, create_default_bag, create_default_brew, create_default_cafe, + create_default_cup, create_default_gear, create_default_roast, create_default_roaster, + spawn_app_with_auth, }; use crate::test_macros::define_datastar_entity_tests; use brewlog::domain::bags::UpdateBag; @@ -584,3 +585,210 @@ async fn cafes_create_with_datastar_header_returns_fragment() { "Fragment should include created cafe" ); } + +// ============================================================================ +// Roasters (update with/without datastar) +// ============================================================================ + +#[tokio::test] +async fn roasters_update_with_datastar_header_returns_redirect_script() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let client = Client::new(); + + let update = serde_json::json!({ + "name": "Updated Roaster", + }); + + let response = client + .put(app.api_url(&format!("/roasters/{}", roaster.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .header("datastar-request", "true") + .json(&update) + .send() + .await + .expect("failed to update roaster"); + + assert_eq!(response.status(), 200); + assert_datastar_headers_with_mode(&response, "body", "append"); + + let body = response.text().await.expect("failed to read body"); + assert!( + body.contains("window.location"), + "Expected redirect script in body" + ); +} + +#[tokio::test] +async fn roasters_update_without_datastar_header_returns_json() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let client = Client::new(); + + let update = serde_json::json!({ + "name": "JSON Updated Roaster", + }); + + let response = client + .put(app.api_url(&format!("/roasters/{}", roaster.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&update) + .send() + .await + .expect("failed to update roaster"); + + assert_eq!(response.status(), 200); + assert!(response.headers().get("datastar-selector").is_none()); + + let updated: brewlog::domain::roasters::Roaster = + response.json().await.expect("failed to parse JSON"); + assert_eq!(updated.name, "JSON Updated Roaster"); +} + +// ============================================================================ +// Roasts (update with/without datastar) +// ============================================================================ + +#[tokio::test] +async fn roasts_update_with_datastar_header_returns_redirect_script() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + let client = Client::new(); + + let update = serde_json::json!({ + "name": "Updated Roast", + }); + + let response = client + .put(app.api_url(&format!("/roasts/{}", roast.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .header("datastar-request", "true") + .json(&update) + .send() + .await + .expect("failed to update roast"); + + assert_eq!(response.status(), 200); + assert_datastar_headers_with_mode(&response, "body", "append"); + + let body = response.text().await.expect("failed to read body"); + assert!( + body.contains("window.location"), + "Expected redirect script in body" + ); +} + +// ============================================================================ +// Cafes (update with/without datastar) +// ============================================================================ + +#[tokio::test] +async fn cafes_update_with_datastar_header_returns_redirect_script() { + let app = spawn_app_with_auth().await; + let cafe = create_default_cafe(&app).await; + let client = Client::new(); + + let update = serde_json::json!({ + "name": "Updated Cafe", + }); + + let response = client + .put(app.api_url(&format!("/cafes/{}", cafe.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .header("datastar-request", "true") + .json(&update) + .send() + .await + .expect("failed to update cafe"); + + assert_eq!(response.status(), 200); + assert_datastar_headers_with_mode(&response, "body", "append"); + + let body = response.text().await.expect("failed to read body"); + assert!( + body.contains("window.location"), + "Expected redirect script in body" + ); +} + +// ============================================================================ +// Brews (update with/without datastar) +// ============================================================================ + +#[tokio::test] +async fn brews_update_with_datastar_header_returns_redirect_script() { + let app = spawn_app_with_auth().await; + let brew = create_default_brew(&app).await; + let client = Client::new(); + + let update = serde_json::json!({ + "water_temp": 94.0, + }); + + let response = client + .put(app.api_url(&format!("/brews/{}", brew.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .header("datastar-request", "true") + .json(&update) + .send() + .await + .expect("failed to update brew"); + + assert_eq!(response.status(), 200); + assert_datastar_headers_with_mode(&response, "body", "append"); + + let body = response.text().await.expect("failed to read body"); + assert!( + body.contains("window.location"), + "Expected redirect script in body" + ); +} + +// ============================================================================ +// Cups (update with/without datastar) +// ============================================================================ + +#[tokio::test] +async fn cups_update_with_datastar_header_returns_redirect_script() { + let app = spawn_app_with_auth().await; + let cup = create_default_cup(&app).await; + let client = Client::new(); + + // Create a new cafe to update to + let cafe2 = crate::helpers::create_cafe_with_payload( + &app, + brewlog::domain::cafes::NewCafe { + name: "Updated Test Cafe".to_string(), + city: "Tokyo".to_string(), + country: "JP".to_string(), + latitude: 35.6762, + longitude: 139.6503, + website: None, + created_at: None, + }, + ) + .await; + + let update = serde_json::json!({ + "cafe_id": cafe2.id, + }); + + let response = client + .put(app.api_url(&format!("/cups/{}", cup.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .header("datastar-request", "true") + .json(&update) + .send() + .await + .expect("failed to update cup"); + + assert_eq!(response.status(), 200); + assert_datastar_headers_with_mode(&response, "body", "append"); + + let body = response.text().await.expect("failed to read body"); + assert!( + body.contains("window.location"), + "Expected redirect script in body" + ); +} diff --git a/tests/server/form_submissions.rs b/tests/server/form_submissions.rs index fa0d8e0..3baf557 100644 --- a/tests/server/form_submissions.rs +++ b/tests/server/form_submissions.rs @@ -2,7 +2,7 @@ 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; +use crate::test_macros::{define_form_create_tests, define_form_update_tests}; // --------------------------------------------------------------------------- // Setup functions: create prerequisites + return form fields @@ -93,6 +93,117 @@ async fn checkin_form_fields(app: &TestApp) -> Vec<(String, String)> { ] } +// --------------------------------------------------------------------------- +// Update setup functions: create entity + return (id, update form fields) +// --------------------------------------------------------------------------- + +async fn roaster_update_form(app: &TestApp) -> (String, Vec<(String, String)>) { + let roaster = create_default_roaster(app).await; + ( + roaster.id.into_inner().to_string(), + vec![("name".into(), "Updated Roasters".into())], + ) +} + +async fn roast_update_form(app: &TestApp) -> (String, Vec<(String, String)>) { + let roaster = create_default_roaster(app).await; + let roast = create_default_roast(app, roaster.id).await; + ( + roast.id.into_inner().to_string(), + vec![("name".into(), "Updated Roast Name".into())], + ) +} + +async fn bag_update_form(app: &TestApp) -> (String, 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; + ( + bag.id.into_inner().to_string(), + vec![("amount".into(), "300".into())], + ) +} + +async fn brew_update_form(app: &TestApp) -> (String, 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; + let brew: brewlog::domain::brews::Brew = crate::helpers::create_entity( + app, + "/brews", + &brewlog::domain::brews::NewBrew { + bag_id: bag.id, + coffee_weight: 15.0, + grinder_id: grinder.id, + grind_setting: 24.0, + brewer_id: brewer.id, + filter_paper_id: None, + water_volume: 250, + water_temp: 92.0, + quick_notes: Vec::new(), + brew_time: None, + created_at: None, + }, + ) + .await; + ( + brew.id.into_inner().to_string(), + vec![("water_temp".into(), "94.0".into())], + ) +} + +async fn cup_update_form(app: &TestApp) -> (String, 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; + let cup: brewlog::domain::cups::Cup = crate::helpers::create_entity( + app, + "/cups", + &brewlog::domain::cups::NewCup { + roast_id: roast.id, + cafe_id: cafe.id, + created_at: None, + }, + ) + .await; + // Update the cafe to a different one + let cafe2 = crate::helpers::create_cafe_with_payload( + app, + brewlog::domain::cafes::NewCafe { + name: "Form Update Cafe".to_string(), + city: "London".to_string(), + country: "UK".to_string(), + latitude: 51.5074, + longitude: -0.1278, + website: None, + created_at: None, + }, + ) + .await; + ( + cup.id.into_inner().to_string(), + vec![("cafe_id".into(), cafe2.id.into_inner().to_string())], + ) +} + +async fn gear_update_form(app: &TestApp) -> (String, Vec<(String, String)>) { + let gear = create_default_gear(app, "grinder", "Original", "Model").await; + ( + gear.id.into_inner().to_string(), + vec![("make".into(), "Updated Make".into())], + ) +} + +async fn cafe_update_form(app: &TestApp) -> (String, Vec<(String, String)>) { + let cafe = create_default_cafe(app).await; + ( + cafe.id.into_inner().to_string(), + vec![("name".into(), "Updated Cafe".into())], + ) +} + // --------------------------------------------------------------------------- // Macro-generated tests: form create → 303 redirect + datastar variant // --------------------------------------------------------------------------- @@ -153,6 +264,59 @@ define_form_create_tests!( setup_and_form: checkin_form_fields ); +// --------------------------------------------------------------------------- +// Macro-generated tests: form update → 303 redirect + datastar variant +// --------------------------------------------------------------------------- + +define_form_update_tests!( + entity: roaster, + api_path: "/roasters", + redirect_prefix: "/roasters/", + setup_and_form: roaster_update_form +); + +define_form_update_tests!( + entity: roast, + api_path: "/roasts", + redirect_prefix: "/roasters/", + setup_and_form: roast_update_form +); + +define_form_update_tests!( + entity: bag, + api_path: "/bags", + redirect_prefix: "/bags/", + setup_and_form: bag_update_form +); + +define_form_update_tests!( + entity: brew, + api_path: "/brews", + redirect_prefix: "/brews/", + setup_and_form: brew_update_form +); + +define_form_update_tests!( + entity: cup, + api_path: "/cups", + redirect_prefix: "/cups/", + setup_and_form: cup_update_form +); + +define_form_update_tests!( + entity: gear, + api_path: "/gear", + redirect_prefix: "/gear/", + setup_and_form: gear_update_form +); + +define_form_update_tests!( + entity: cafe, + api_path: "/cafes", + redirect_prefix: "/cafes/", + setup_and_form: cafe_update_form +); + // --------------------------------------------------------------------------- // Hand-written tests: form-specific parsing edge cases // --------------------------------------------------------------------------- diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 29d192b..6fa5477 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -383,6 +383,48 @@ pub async fn create_cafe_with_payload(app: &TestApp, payload: NewCafe) -> Cafe { create_entity(app, "/cafes", &payload).await } +pub async fn create_default_brew(app: &TestApp) -> brewlog::domain::brews::Brew { + 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 02").await; + create_entity( + app, + "/brews", + &brewlog::domain::brews::NewBrew { + bag_id: bag.id, + coffee_weight: 15.0, + grinder_id: grinder.id, + grind_setting: 24.0, + brewer_id: brewer.id, + filter_paper_id: None, + water_volume: 250, + water_temp: 92.0, + quick_notes: Vec::new(), + brew_time: None, + created_at: None, + }, + ) + .await +} + +pub async fn create_default_cup(app: &TestApp) -> brewlog::domain::cups::Cup { + let roaster = create_default_roaster(app).await; + let roast = create_default_roast(app, roaster.id).await; + let cafe = create_default_cafe(app).await; + create_entity( + app, + "/cups", + &brewlog::domain::cups::NewCup { + roast_id: roast.id, + cafe_id: cafe.id, + created_at: None, + }, + ) + .await +} + /// Creates a session for the authenticated user and returns the raw session token /// to use as a `brewlog_session` cookie value. pub async fn create_session(app: &TestApp) -> String { @@ -464,6 +506,50 @@ pub async fn post_form_datastar( .expect("failed to POST form with datastar") } +/// PUT a form-encoded payload with session cookie auth. +/// Uses `redirect::Policy::none()` so tests can assert the 303 redirect itself. +pub async fn put_form( + app: &TestApp, + path: &str, + form_body: &[(impl AsRef + Serialize, impl AsRef + Serialize)], +) -> reqwest::Response { + let session_token = create_session(app).await; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + client + .put(app.api_url(path)) + .header("Cookie", format!("brewlog_session={session_token}")) + .form(form_body) + .send() + .await + .expect("failed to PUT form") +} + +/// PUT a form-encoded payload with session cookie auth and Datastar headers. +pub async fn put_form_datastar( + app: &TestApp, + path: &str, + form_body: &[(impl AsRef + Serialize, impl AsRef + Serialize)], +) -> reqwest::Response { + let session_token = create_session(app).await; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + client + .put(app.api_url(path)) + .header("Cookie", format!("brewlog_session={session_token}")) + .header("datastar-request", "true") + .form(form_body) + .send() + .await + .expect("failed to PUT 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()); diff --git a/tests/server/test_macros.rs b/tests/server/test_macros.rs index d2a0635..bbf1e4a 100644 --- a/tests/server/test_macros.rs +++ b/tests/server/test_macros.rs @@ -253,3 +253,72 @@ macro_rules! define_form_create_tests { } pub(crate) use define_form_create_tests; + +/// Generates form submission tests for updating a given entity. +/// The `setup_and_form` function creates the entity and returns +/// `(entity_id_string, Vec<(String, String)>)`. +/// +/// Generated tests: +/// - `updating_{entity}_via_form_returns_redirect` — PUT form → 303 redirect +/// - `updating_{entity}_via_form_with_datastar_returns_redirect_script` — PUT form + datastar → 200 +macro_rules! define_form_update_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 []() { + let app = crate::helpers::spawn_app_with_auth().await; + let (entity_id, form_fields) = $setup_fn(&app).await; + let path = format!("{}/{}", $api_path, entity_id); + let response = crate::helpers::put_form(&app, &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 []() { + let app = crate::helpers::spawn_app_with_auth().await; + let (entity_id, form_fields) = $setup_fn(&app).await; + let path = format!("{}/{}", $api_path, entity_id); + let response = + crate::helpers::put_form_datastar(&app, &path, &form_fields).await; + + assert_eq!( + response.status(), + 200, + "expected 200 OK for datastar form update, got {}", + response.status() + ); + crate::helpers::assert_datastar_headers_with_mode(&response, "body", "append"); + + let body = response.text().await.expect("failed to read body"); + assert!( + body.contains("window.location"), + "Expected redirect script in body" + ); + } + } + }; +} + +pub(crate) use define_form_update_tests;