diff --git a/tests/e2e/edit_tests.rs b/tests/e2e/edit_tests.rs new file mode 100644 index 0000000..3877993 --- /dev/null +++ b/tests/e2e/edit_tests.rs @@ -0,0 +1,753 @@ +use std::time::Duration; + +use thirtyfour::prelude::*; + +use crate::helpers::auth::authenticate_browser; +use crate::helpers::browser::BrowserSession; +use crate::helpers::forms::{fill_input, submit_visible_form}; +use crate::helpers::server_helpers::{ + create_default_brew, create_default_cafe, create_default_cup, create_default_gear, + create_default_roaster, spawn_app_with_auth, +}; +use crate::helpers::wait::{wait_for_url_contains, wait_for_url_not_contains, wait_for_visible}; + +// ── Roaster: text fields ────────────────────────────────────────────── + +#[tokio::test] +async fn edit_roaster_updates_text_fields() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + // Navigate to edit page + session + .goto(&format!("/roasters/{}/edit", roaster.id)) + .await + .unwrap(); + wait_for_visible(&session.driver, "input[name='name']") + .await + .unwrap(); + + // Verify pre-populated value + let name_input = session + .driver + .find(By::Css("input[name='name']")) + .await + .unwrap(); + let current_name = name_input.prop("value").await.unwrap().unwrap_or_default(); + assert_eq!(current_name, "Test Roasters"); + + // Clear and update the name + fill_input(&session.driver, "name", "Updated Roasters") + .await + .unwrap(); + + submit_visible_form(&session.driver).await.unwrap(); + + // Should redirect to the roaster detail page (edit URL also contains /roasters/) + wait_for_url_not_contains(&session.driver, "/edit") + .await + .unwrap(); + + // Verify the detail page reflects the change + let heading = session.driver.find(By::Css("h1")).await.unwrap(); + let heading_text = heading.text().await.unwrap(); + assert_eq!(heading_text, "Updated Roasters"); + + session.quit().await; +} + +// ── Gear: text fields ───────────────────────────────────────────────── + +#[tokio::test] +async fn edit_gear_updates_text_fields() { + let app = spawn_app_with_auth().await; + let gear = create_default_gear(&app, "grinder", "Comandante", "C40 MK4").await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session + .goto(&format!("/gear/{}/edit", gear.id)) + .await + .unwrap(); + wait_for_visible(&session.driver, "input[name='model']") + .await + .unwrap(); + + // Verify pre-populated values + let make_input = session + .driver + .find(By::Css("input[name='make']")) + .await + .unwrap(); + assert_eq!( + make_input.prop("value").await.unwrap().unwrap_or_default(), + "Comandante" + ); + + let model_input = session + .driver + .find(By::Css("input[name='model']")) + .await + .unwrap(); + assert_eq!( + model_input.prop("value").await.unwrap().unwrap_or_default(), + "C40 MK4" + ); + + // Update model + fill_input(&session.driver, "model", "C40 MK5") + .await + .unwrap(); + + submit_visible_form(&session.driver).await.unwrap(); + + // Edit page URL contains /gear/ too, so wait for /edit to disappear + wait_for_url_not_contains(&session.driver, "/edit") + .await + .unwrap(); + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let body_text = body.text().await.unwrap(); + assert!( + body_text.contains("C40 MK5"), + "Detail page should show updated model" + ); + + session.quit().await; +} + +// ── Cafe: text + numeric fields ─────────────────────────────────────── + +#[tokio::test] +async fn edit_cafe_updates_text_and_numeric_fields() { + let app = spawn_app_with_auth().await; + let cafe = create_default_cafe(&app).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session + .goto(&format!("/cafes/{}/edit", cafe.id)) + .await + .unwrap(); + wait_for_visible(&session.driver, "input[name='name']") + .await + .unwrap(); + + // Verify pre-populated values + let name_input = session + .driver + .find(By::Css("input[name='name']")) + .await + .unwrap(); + assert_eq!( + name_input.prop("value").await.unwrap().unwrap_or_default(), + "Blue Bottle" + ); + + let lat_input = session + .driver + .find(By::Css("input[name='latitude']")) + .await + .unwrap(); + let lat_value = lat_input.prop("value").await.unwrap().unwrap_or_default(); + assert!( + lat_value.starts_with("37.77"), + "Latitude should be pre-populated, got: {lat_value}" + ); + + // Update name and city + fill_input(&session.driver, "name", "Verve Coffee") + .await + .unwrap(); + fill_input(&session.driver, "city", "Santa Cruz") + .await + .unwrap(); + fill_input(&session.driver, "latitude", "36.9741") + .await + .unwrap(); + + submit_visible_form(&session.driver).await.unwrap(); + + // Edit page URL contains /cafes/ too, so wait for /edit to disappear + wait_for_url_not_contains(&session.driver, "/edit") + .await + .unwrap(); + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let body_text = body.text().await.unwrap(); + assert!( + body_text.contains("Verve Coffee"), + "Detail page should show updated name" + ); + assert!( + body_text.contains("Santa Cruz"), + "Detail page should show updated city" + ); + + session.quit().await; +} + +// ── Brew: numeric fields with +/- adjusters ─────────────────────────── + +#[tokio::test] +async fn edit_brew_updates_numeric_fields() { + let app = spawn_app_with_auth().await; + let brew = create_default_brew(&app).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session + .goto(&format!("/brews/{}/edit", brew.id)) + .await + .unwrap(); + wait_for_visible(&session.driver, "input[name='coffee_weight']") + .await + .unwrap(); + + // Verify pre-populated coffee weight + let weight_input = session + .driver + .find(By::Css("input[name='coffee_weight']")) + .await + .unwrap(); + let current_weight = weight_input + .prop("value") + .await + .unwrap() + .unwrap_or_default(); + assert_eq!( + current_weight, "15", + "Coffee weight should be pre-populated" + ); + + // Update coffee weight via direct input + fill_input(&session.driver, "coffee_weight", "18") + .await + .unwrap(); + + // Update water volume via direct input + fill_input(&session.driver, "water_volume", "300") + .await + .unwrap(); + + // Update grind setting + fill_input(&session.driver, "grind_setting", "28") + .await + .unwrap(); + + submit_visible_form(&session.driver).await.unwrap(); + + // Edit page URL contains /brews/ too, so wait for /edit to disappear + wait_for_url_not_contains(&session.driver, "/edit") + .await + .unwrap(); + + // Verify the brew detail page shows updated values + let body = session.driver.find(By::Css("body")).await.unwrap(); + let body_text = body.text().await.unwrap(); + assert!( + body_text.contains("18"), + "Should show updated coffee weight" + ); + assert!( + body_text.contains("300"), + "Should show updated water volume" + ); + + session.quit().await; +} + +// ── Brew: +/- adjuster buttons ──────────────────────────────────────── + +#[tokio::test] +async fn edit_brew_adjuster_buttons_change_values() { + let app = spawn_app_with_auth().await; + let brew = create_default_brew(&app).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session + .goto(&format!("/brews/{}/edit", brew.id)) + .await + .unwrap(); + wait_for_visible(&session.driver, "input[name='coffee_weight']") + .await + .unwrap(); + + // Find the + button next to coffee weight (it's the sibling after the input) + let weight_plus = session + .driver + .execute( + r#" + const input = document.querySelector("input[name='coffee_weight']"); + return input.parentElement.querySelector("button:last-of-type"); + "#, + vec![], + ) + .await + .unwrap(); + let weight_plus_el = weight_plus.element().unwrap(); + + // Click + three times to add 1.5g (0.5 per click) + weight_plus_el.click().await.unwrap(); + weight_plus_el.click().await.unwrap(); + weight_plus_el.click().await.unwrap(); + + // Wait for Datastar signal to update the input + tokio::time::sleep(Duration::from_millis(200)).await; + + let weight_input = session + .driver + .find(By::Css("input[name='coffee_weight']")) + .await + .unwrap(); + let updated_weight = weight_input + .prop("value") + .await + .unwrap() + .unwrap_or_default(); + assert_eq!(updated_weight, "16.5", "Weight should be 15 + 3*0.5 = 16.5"); + + session.quit().await; +} + +// ── Cup: edit with searchable-select ────────────────────────────────── + +#[tokio::test] +async fn edit_cup_updates_searchable_selects() { + let app = spawn_app_with_auth().await; + let cup = create_default_cup(&app).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session + .goto(&format!("/cups/{}/edit", cup.id)) + .await + .unwrap(); + wait_for_visible(&session.driver, "searchable-select[name='roast_id']") + .await + .unwrap(); + + // The searchable-select should show the current roast name in its display span + // (when pre-selected, the component hides the search input and shows a span) + let roast_select = session + .driver + .find(By::Css("searchable-select[name='roast_id']")) + .await + .unwrap(); + let display_span = roast_select + .find(By::Css("span.input-field")) + .await + .unwrap(); + let display_text = display_span.text().await.unwrap(); + assert!( + !display_text.is_empty(), + "Searchable select should show the pre-populated roast name" + ); + + // Submit without changes — should still redirect successfully + submit_visible_form(&session.driver).await.unwrap(); + + // Edit page URL contains /cups/ too, so wait for /edit to disappear + wait_for_url_not_contains(&session.driver, "/edit") + .await + .unwrap(); + + session.quit().await; +} + +// ── Edit form with image upload ─────────────────────────────────────── + +#[tokio::test] +async fn edit_roaster_shows_existing_image_and_can_replace() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + // First, upload an image via the API + session.goto("/login").await.unwrap(); + let upload_script = format!( + r#" + const callback = arguments[arguments.length - 1]; + const canvas = document.createElement('canvas'); + canvas.width = 4; + canvas.height = 4; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'blue'; + ctx.fillRect(0, 0, 4, 4); + const dataUrl = canvas.toDataURL('image/png'); + fetch('/api/v1/roaster/{}/image', {{ + method: 'PUT', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify({{ image: dataUrl }}) + }}).then(r => callback(r.status.toString())).catch(e => callback('error: ' + e)); + "#, + roaster.id + ); + let result = session + .driver + .execute_async(&upload_script, vec![]) + .await + .unwrap(); + let status = result.json().as_str().unwrap_or("").to_string(); + assert_eq!(status, "204", "Image upload should return 204"); + + // Navigate to the edit page + session + .goto(&format!("/roasters/{}/edit", roaster.id)) + .await + .unwrap(); + wait_for_visible(&session.driver, "input[name='name']") + .await + .unwrap(); + + // Verify the existing image preview is shown + let existing_preview = session + .driver + .find(By::Css("[id$='-existing']")) + .await + .expect("Existing image preview should be present"); + assert!( + existing_preview.is_displayed().await.unwrap_or(false), + "Existing image preview should be visible" + ); + + // Verify Replace and Remove buttons are present + let replace_btn = existing_preview + .find(By::Css("image-upload[mode='deferred']")) + .await + .expect("Replace button should be present"); + assert!(replace_btn.is_displayed().await.unwrap_or(false)); + + let remove_btn = existing_preview + .find(By::Css("button")) + .await + .expect("Remove button should be present"); + assert!(remove_btn.is_displayed().await.unwrap_or(false)); + + // Simulate uploading a replacement image via JavaScript + // (we can't trigger the file picker in headless mode, but we can set the hidden input) + let replace_script = r#" + const callback = arguments[arguments.length - 1]; + const canvas = document.createElement('canvas'); + canvas.width = 4; + canvas.height = 4; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'green'; + ctx.fillRect(0, 0, 4, 4); + const dataUrl = canvas.toDataURL('image/jpeg'); + const hiddenInput = document.getElementById('edit-roaster-image'); + hiddenInput.value = dataUrl; + // Remove existing preview to simulate what image-upload.js does + const existing = document.getElementById('edit-roaster-image-existing'); + if (existing) existing.remove(); + callback('done'); + "#; + session + .driver + .execute_async(replace_script, vec![]) + .await + .unwrap(); + + // Existing preview should be removed + let result = session + .driver + .find(By::Css("#edit-roaster-image-existing")) + .await; + assert!( + result.is_err(), + "Existing image preview should be removed after replacement" + ); + + // Hidden input should have the new data URL + let hidden_input = session + .driver + .find(By::Css("#edit-roaster-image")) + .await + .unwrap(); + let value = hidden_input + .prop("value") + .await + .unwrap() + .unwrap_or_default(); + assert!( + value.starts_with("data:image/jpeg"), + "Hidden input should contain the replacement image data URL" + ); + + session.quit().await; +} + +#[tokio::test] +async fn edit_form_without_image_shows_upload_area() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session + .goto(&format!("/roasters/{}/edit", roaster.id)) + .await + .unwrap(); + wait_for_visible(&session.driver, "input[name='name']") + .await + .unwrap(); + + // No existing image — should show the dashed upload area + let result = session.driver.find(By::Css("[id$='-existing']")).await; + assert!( + result.is_err(), + "Existing image preview should not be present when there is no image" + ); + + // The deferred upload component should be visible + let upload = session + .driver + .find(By::Css("image-upload[mode='deferred']")) + .await + .expect("Deferred upload area should be present"); + assert!( + upload.is_displayed().await.unwrap_or(false), + "Deferred upload area should be visible" + ); + + session.quit().await; +} + +// ── Edit form image removal ─────────────────────────────────────────── + +#[tokio::test] +async fn edit_form_remove_image_calls_delete_api() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + // Upload an image first + session.goto("/login").await.unwrap(); + let upload_script = format!( + r#" + const callback = arguments[arguments.length - 1]; + const canvas = document.createElement('canvas'); + canvas.width = 4; + canvas.height = 4; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'red'; + ctx.fillRect(0, 0, 4, 4); + const dataUrl = canvas.toDataURL('image/png'); + fetch('/api/v1/roaster/{}/image', {{ + method: 'PUT', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify({{ image: dataUrl }}) + }}).then(r => callback(r.status.toString())).catch(e => callback('error: ' + e)); + "#, + roaster.id + ); + let result = session + .driver + .execute_async(&upload_script, vec![]) + .await + .unwrap(); + assert_eq!( + result.json().as_str().unwrap_or(""), + "204", + "Image upload should succeed" + ); + + // Navigate to edit page + session + .goto(&format!("/roasters/{}/edit", roaster.id)) + .await + .unwrap(); + wait_for_visible(&session.driver, "[id$='-existing']") + .await + .unwrap(); + + // Click the Remove button (need to accept the confirm dialog) + let remove_script = format!( + r#" + const callback = arguments[arguments.length - 1]; + fetch('/api/v1/roaster/{}/image', {{ method: 'DELETE' }}) + .then(r => {{ + document.querySelector("[id$='-existing']").remove(); + callback(r.status.toString()); + }}) + .catch(e => callback('error: ' + e)); + "#, + roaster.id + ); + let result = session + .driver + .execute_async(&remove_script, vec![]) + .await + .unwrap(); + assert_eq!( + result.json().as_str().unwrap_or(""), + "204", + "Image delete should return 204" + ); + + // Preview should be gone + let result = session.driver.find(By::Css("[id$='-existing']")).await; + assert!(result.is_err(), "Image preview should be removed from DOM"); + + // Verify image is actually deleted from server + let check_script = format!( + r#" + const callback = arguments[arguments.length - 1]; + fetch('/api/v1/roaster/{}/image') + .then(r => callback(r.status.toString())) + .catch(e => callback('error: ' + e)); + "#, + roaster.id + ); + let result = session + .driver + .execute_async(&check_script, vec![]) + .await + .unwrap(); + assert_eq!( + result.json().as_str().unwrap_or(""), + "404", + "Image should be deleted from server" + ); + + session.quit().await; +} + +// ── Cancel button ───────────────────────────────────────────────────── + +#[tokio::test] +async fn edit_form_cancel_navigates_back() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + // First visit the detail page (so there's a history entry to go back to) + session + .goto(&format!("/roasters/{}", roaster.slug)) + .await + .unwrap(); + wait_for_visible(&session.driver, "h1").await.unwrap(); + + // Click the Edit link to go to the edit page + let edit_link = session + .driver + .find(By::Css("a[href*='/edit']")) + .await + .unwrap(); + edit_link.click().await.unwrap(); + + wait_for_url_contains(&session.driver, "/edit") + .await + .unwrap(); + wait_for_visible(&session.driver, "input[name='name']") + .await + .unwrap(); + + // Click Cancel + let cancel_btn = session + .driver + .find(By::XPath("//button[contains(text(), 'Cancel')]")) + .await + .unwrap(); + cancel_btn.click().await.unwrap(); + + // Should go back to the detail page + tokio::time::sleep(Duration::from_millis(500)).await; + let url = session.driver.current_url().await.unwrap(); + assert!( + !url.as_str().contains("/edit"), + "Should navigate away from edit page, got: {url}" + ); + + session.quit().await; +} + +// ── Edit with deferred image save ───────────────────────────────────── + +#[tokio::test] +async fn edit_roaster_with_new_image_saves_image() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session + .goto(&format!("/roasters/{}/edit", roaster.id)) + .await + .unwrap(); + wait_for_visible(&session.driver, "input[name='name']") + .await + .unwrap(); + + // Set the hidden image input to a data URL (simulating deferred upload) + let set_image_script = r#" + const canvas = document.createElement('canvas'); + canvas.width = 4; + canvas.height = 4; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'purple'; + ctx.fillRect(0, 0, 4, 4); + document.getElementById('edit-roaster-image').value = canvas.toDataURL('image/jpeg'); + "#; + session + .driver + .execute(set_image_script, vec![]) + .await + .unwrap(); + + // Update name too + fill_input(&session.driver, "name", "Roaster With Image") + .await + .unwrap(); + + submit_visible_form(&session.driver).await.unwrap(); + + // Edit page URL contains /roasters/ too, so wait for /edit to disappear + wait_for_url_not_contains(&session.driver, "/edit") + .await + .unwrap(); + + // Verify the name was updated + let heading = session.driver.find(By::Css("h1")).await.unwrap(); + assert_eq!(heading.text().await.unwrap(), "Roaster With Image"); + + // Verify the image was saved by checking the API + let check_script = format!( + r#" + const callback = arguments[arguments.length - 1]; + fetch('/api/v1/roaster/{}/image') + .then(r => callback(r.status.toString())) + .catch(e => callback('error: ' + e)); + "#, + roaster.id + ); + let result = session + .driver + .execute_async(&check_script, vec![]) + .await + .unwrap(); + assert_eq!( + result.json().as_str().unwrap_or(""), + "200", + "Image should be saved to server after edit with deferred upload" + ); + + session.quit().await; +} diff --git a/tests/e2e/helpers/wait.rs b/tests/e2e/helpers/wait.rs index 0fdbaed..adc1ba7 100644 --- a/tests/e2e/helpers/wait.rs +++ b/tests/e2e/helpers/wait.rs @@ -87,3 +87,18 @@ pub async fn wait_for_url_contains(driver: &WebDriver, substring: &str) -> WebDr "URL did not contain '{substring}' within {DEFAULT_TIMEOUT:?}", ))) } + +/// Wait until the current URL no longer contains a specific substring. +pub async fn wait_for_url_not_contains(driver: &WebDriver, substring: &str) -> WebDriverResult<()> { + let start = Instant::now(); + while start.elapsed() < DEFAULT_TIMEOUT { + let url = driver.current_url().await?; + if !url.as_str().contains(substring) { + return Ok(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + Err(WebDriverError::Timeout(format!( + "URL still contains '{substring}' after {DEFAULT_TIMEOUT:?}", + ))) +} diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs index ae6f7fb..538f0f1 100644 --- a/tests/e2e/main.rs +++ b/tests/e2e/main.rs @@ -8,6 +8,7 @@ mod checkin_tests; mod data_tests; mod delete_tests; mod detail_tests; +mod edit_tests; mod image_tests; mod navigation_tests; mod roaster_tests;