diff --git a/tests/e2e/checkin_tests.rs b/tests/e2e/checkin_tests.rs index af3c5c2..17f4638 100644 --- a/tests/e2e/checkin_tests.rs +++ b/tests/e2e/checkin_tests.rs @@ -192,28 +192,36 @@ async fn checkin_with_new_cafe_and_scanned_roast() { .await .unwrap(); - // Click the first result — shows the review form - let result_btn = session + // Click the first result — use JS to avoid StaleElementReference from Datastar updates + session .driver - .find(By::Css("#nearby-results button")) + .execute( + "document.querySelector('#nearby-results button').click()", + vec![], + ) .await .unwrap(); - result_btn.click().await.unwrap(); // Wait for the review form, then click "Next" to advance to step 2 wait_for_text(&session.driver, "body", "Confirm cafe details") .await .unwrap(); - let buttons = session.driver.find_all(By::Css("button")).await.unwrap(); - for button in buttons { - if button.is_displayed().await.unwrap_or(false) { - let text = button.text().await.unwrap_or_default(); - if text.contains("Next") { - button.click().await.unwrap(); - break; + // Use JS to find+click atomically — avoids StaleElementReference from DOM updates + session + .driver + .execute( + r#" + for (const btn of document.querySelectorAll('button')) { + if (btn.offsetParent !== null && btn.textContent.includes('Next')) { + btn.click(); + return; + } } - } - } + "#, + vec![], + ) + .await + .unwrap(); // Step 2: Scan a coffee bag via text prompt let prompt_input = wait_for_visible(&session.driver, "#checkin-scan-form input[name='prompt']") diff --git a/tests/e2e/scan_tests.rs b/tests/e2e/scan_tests.rs index 3e38b2b..eb7c309 100644 --- a/tests/e2e/scan_tests.rs +++ b/tests/e2e/scan_tests.rs @@ -4,8 +4,10 @@ use wiremock::{Mock, ResponseTemplate}; use crate::helpers::auth::authenticate_browser; use crate::helpers::browser::BrowserSession; -use crate::helpers::server_helpers::spawn_app_with_openrouter_mock; -use crate::helpers::wait::{wait_for_url_contains, wait_for_visible}; +use crate::helpers::server_helpers::{ + create_default_roast, create_default_roaster, spawn_app_with_openrouter_mock, +}; +use crate::helpers::wait::{wait_for_text, wait_for_url_contains, wait_for_visible}; fn mock_openrouter_response(json_content: &str) -> ResponseTemplate { let body = serde_json::json!({ @@ -109,3 +111,228 @@ async fn extract_roaster_via_text_prompt_populates_form() { session.quit().await; } + +fn mock_bag_scan_response(json_content: &str) -> ResponseTemplate { + mock_openrouter_response(json_content) +} + +/// Trigger the homepage scan by setting the hidden image input and submitting the form. +/// The `brew-photo-capture` web component is image-only; we bypass it via JS. +async fn trigger_homepage_scan(driver: &WebDriver) { + driver + .execute( + r#" + document.getElementById('scan-image').value = 'data:image/png;base64,iVBOR'; + document.getElementById('scan-extract-form').requestSubmit(); + "#, + vec![], + ) + .await + .unwrap(); +} + +/// Find and click a visible button whose text contains the given substring. +async fn click_button_with_text(driver: &WebDriver, text: &str) { + let buttons = driver.find_all(By::Css("button")).await.unwrap(); + for button in buttons { + if button.is_displayed().await.unwrap_or(false) { + let btn_text = button.text().await.unwrap_or_default(); + if btn_text.contains(text) { + button.click().await.unwrap(); + return; + } + } + } + panic!("No visible button containing '{text}' found"); +} + +#[tokio::test] +async fn homepage_scan_new_roaster_and_new_roast() { + let app = spawn_app_with_openrouter_mock().await; + let mock_server = app.mock_server.as_ref().unwrap(); + + Mock::given(method("POST")) + .and(path("/api/v1/chat/completions")) + .respond_with(mock_bag_scan_response( + r#"{"roaster": {"name": "Koppi", "country": "SE", "city": "Helsingborg"}, "roast": {"name": "Finca Vista", "origin": "Colombia", "region": "Huila", "producer": "Luis Anibal", "process": "Washed", "tasting_notes": ["Caramel", "Red Apple"]}}"#, + )) + .mount(mock_server) + .await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session.goto("/").await.unwrap(); + wait_for_visible(&session.driver, "brew-photo-capture") + .await + .unwrap(); + + trigger_homepage_scan(&session.driver).await; + + // Wait for extraction to complete — the result form appears + // Neither roaster nor roast matched → editable form with "Save Roaster & Roast" button + wait_for_text(&session.driver, "body", "Save Roaster") + .await + .unwrap(); + + // Verify extracted fields are populated in form inputs (not visible body text) + let roaster_input = session + .driver + .find(By::Css("[data-bind\\:_roaster-name]")) + .await + .unwrap(); + let roaster_val = roaster_input.value().await.unwrap().unwrap_or_default(); + assert_eq!(roaster_val, "Koppi", "Roaster name input should be filled"); + + let roast_input = session + .driver + .find(By::Css("[data-bind\\:_roast-name]")) + .await + .unwrap(); + let roast_val = roast_input.value().await.unwrap().unwrap_or_default(); + assert_eq!( + roast_val, "Finca Vista", + "Roast name input should be filled" + ); + + // Submit — creates roaster + roast + bag, then reloads + click_button_with_text(&session.driver, "Save Roaster").await; + + // After reload, the new bag should appear in Open Bags + wait_for_text(&session.driver, "#open-bags-section", "Finca Vista") + .await + .unwrap(); + + session.quit().await; +} + +#[tokio::test] +async fn homepage_scan_existing_roaster_new_roast() { + let app = spawn_app_with_openrouter_mock().await; + let mock_server = app.mock_server.as_ref().unwrap(); + + // Pre-create the roaster so the extraction matches it + let _roaster = create_default_roaster(&app).await; + + // AI returns data matching the existing roaster name but a new roast + Mock::given(method("POST")) + .and(path("/api/v1/chat/completions")) + .respond_with(mock_bag_scan_response( + r#"{"roaster": {"name": "Test Roasters", "country": "UK"}, "roast": {"name": "Gesha Village", "origin": "Ethiopia", "region": "Bench Maji", "producer": "Gesha Village Estate", "process": "Natural", "tasting_notes": ["Jasmine", "Peach"]}}"#, + )) + .mount(mock_server) + .await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session.goto("/").await.unwrap(); + wait_for_visible(&session.driver, "brew-photo-capture") + .await + .unwrap(); + + trigger_homepage_scan(&session.driver).await; + + // Wait for extraction — roaster matched, roast not → "Save Roast" button + wait_for_text(&session.driver, "body", "Save Roast") + .await + .unwrap(); + + // Roaster should show as a card (matched) — visible in body text + let body_text = session + .driver + .find(By::Css("body")) + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + body_text.contains("Test Roasters"), + "Should show matched roaster name in card" + ); + + // Roast name is in an editable input (not matched) + let roast_input = session + .driver + .find(By::Css("[data-bind\\:_roast-name]")) + .await + .unwrap(); + let roast_val = roast_input.value().await.unwrap().unwrap_or_default(); + assert_eq!( + roast_val, "Gesha Village", + "Roast name input should be filled" + ); + + // Submit — creates roast + bag under existing roaster, then reloads + click_button_with_text(&session.driver, "Save Roast").await; + + // After reload, the new bag should appear in Open Bags + wait_for_text(&session.driver, "#open-bags-section", "Gesha Village") + .await + .unwrap(); + + session.quit().await; +} + +#[tokio::test] +async fn homepage_scan_existing_roaster_and_existing_roast() { + let app = spawn_app_with_openrouter_mock().await; + let mock_server = app.mock_server.as_ref().unwrap(); + + // Pre-create both roaster and roast so the extraction matches both + let roaster = create_default_roaster(&app).await; + let _roast = create_default_roast(&app, roaster.id).await; + + // AI returns data matching both existing entities + Mock::given(method("POST")) + .and(path("/api/v1/chat/completions")) + .respond_with(mock_bag_scan_response( + r#"{"roaster": {"name": "Test Roasters", "country": "UK"}, "roast": {"name": "Test Roast", "origin": "Ethiopia", "region": "Yirgacheffe", "producer": "Coop", "process": "Washed", "tasting_notes": ["Blueberry"]}}"#, + )) + .mount(mock_server) + .await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session.goto("/").await.unwrap(); + wait_for_visible(&session.driver, "brew-photo-capture") + .await + .unwrap(); + + trigger_homepage_scan(&session.driver).await; + + // Wait for extraction — both matched → "Open Bag" button (open_bag defaults to true) + wait_for_text(&session.driver, "body", "Open Bag") + .await + .unwrap(); + + // Both roaster and roast should show as cards (matched) + let body_text = session + .driver + .find(By::Css("body")) + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + body_text.contains("Test Roasters"), + "Should show matched roaster" + ); + assert!( + body_text.contains("Test Roast"), + "Should show matched roast" + ); + + // Submit — creates bag only for existing roast, then reloads + click_button_with_text(&session.driver, "Open Bag").await; + + // After reload, the bag should appear in Open Bags + wait_for_text(&session.driver, "#open-bags-section", "Test Roast") + .await + .unwrap(); + + session.quit().await; +}