diff --git a/tests/e2e/brew_tests.rs b/tests/e2e/brew_tests.rs new file mode 100644 index 0000000..47dc075 --- /dev/null +++ b/tests/e2e/brew_tests.rs @@ -0,0 +1,182 @@ +use thirtyfour::prelude::*; + +use crate::helpers::auth::authenticate_browser; +use crate::helpers::browser::BrowserSession; +use crate::helpers::forms::{ + fill_input, fill_textarea, select_option, select_searchable, submit_visible_form, +}; +use crate::helpers::server_helpers::{ + create_default_bag, create_default_gear, create_default_roast, create_default_roaster, + spawn_app_with_auth, +}; +use crate::helpers::wait::{wait_for_url_contains, wait_for_visible}; + +#[tokio::test] +async fn create_brew_with_api_prerequisites() { + let app = spawn_app_with_auth().await; + + // Create prerequisites via API (fast, no browser needed) + 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 session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + // Navigate to add page with brew tab + session.goto("/add?type=brew").await.unwrap(); + + // Wait for the brew form's bag select to appear + wait_for_visible(&session.driver, "searchable-select[name='bag_id']") + .await + .unwrap(); + + // Select the bag (displays the roast name "Test Roast") + select_searchable(&session.driver, "bag_id", "Test Roast") + .await + .unwrap(); + + // The grinder and brewer are pre-selected (only one option each). + // Default values for weight, temp, volume, time are pre-filled via signals. + // Submit the form. + submit_visible_form(&session.driver).await.unwrap(); + + // Should redirect to the brew detail page + wait_for_url_contains(&session.driver, "/brews/") + .await + .unwrap(); + + // Verify the brew detail page loaded + let body = session.driver.find(By::Css("body")).await.unwrap(); + let body_text = body.text().await.unwrap(); + assert!( + body_text.contains("Test Roast"), + "Brew detail should show the roast name" + ); + + session.quit().await; +} + +#[tokio::test] +async fn create_full_chain_via_browser() { + let app = spawn_app_with_auth().await; + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + // Step 1: Create roaster + session.goto("/add?type=roaster").await.unwrap(); + wait_for_visible(&session.driver, "input[name='name']") + .await + .unwrap(); + fill_input(&session.driver, "name", "Chain Roasters") + .await + .unwrap(); + fill_input(&session.driver, "country", "Ethiopia") + .await + .unwrap(); + submit_visible_form(&session.driver).await.unwrap(); + wait_for_url_contains(&session.driver, "/roasters/") + .await + .unwrap(); + + // Step 2: Create grinder + session.goto("/add?type=gear").await.unwrap(); + wait_for_visible(&session.driver, "select[name='category']") + .await + .unwrap(); + select_option(&session.driver, "category", "grinder") + .await + .unwrap(); + fill_input(&session.driver, "make", "Comandante") + .await + .unwrap(); + fill_input(&session.driver, "model", "C40").await.unwrap(); + submit_visible_form(&session.driver).await.unwrap(); + wait_for_url_contains(&session.driver, "/gear/") + .await + .unwrap(); + + // Step 3: Create brewer + session.goto("/add?type=gear").await.unwrap(); + wait_for_visible(&session.driver, "select[name='category']") + .await + .unwrap(); + select_option(&session.driver, "category", "brewer") + .await + .unwrap(); + fill_input(&session.driver, "make", "Hario").await.unwrap(); + fill_input(&session.driver, "model", "V60").await.unwrap(); + submit_visible_form(&session.driver).await.unwrap(); + wait_for_url_contains(&session.driver, "/gear/") + .await + .unwrap(); + + // Step 4: Create roast + session.goto("/add?type=roast").await.unwrap(); + wait_for_visible(&session.driver, "searchable-select[name='roaster_id']") + .await + .unwrap(); + select_searchable(&session.driver, "roaster_id", "Chain") + .await + .unwrap(); + fill_input(&session.driver, "name", "Chain Blend") + .await + .unwrap(); + fill_input(&session.driver, "origin", "Ethiopia") + .await + .unwrap(); + fill_input(&session.driver, "region", "Guji").await.unwrap(); + fill_input(&session.driver, "producer", "Coop") + .await + .unwrap(); + fill_input(&session.driver, "process", "Washed") + .await + .unwrap(); + fill_textarea(&session.driver, "tasting_notes", "Blueberry, Jasmine") + .await + .unwrap(); + submit_visible_form(&session.driver).await.unwrap(); + // Roast creation redirects to the roaster detail page + wait_for_url_contains(&session.driver, "/roasters/") + .await + .unwrap(); + + // Step 5: Create bag + session.goto("/add?type=bag").await.unwrap(); + wait_for_visible(&session.driver, "searchable-select[name='roast_id']") + .await + .unwrap(); + select_searchable(&session.driver, "roast_id", "Chain Blend") + .await + .unwrap(); + fill_input(&session.driver, "amount", "250").await.unwrap(); + submit_visible_form(&session.driver).await.unwrap(); + wait_for_url_contains(&session.driver, "/bags/") + .await + .unwrap(); + + // Step 6: Create brew + session.goto("/add?type=brew").await.unwrap(); + wait_for_visible(&session.driver, "searchable-select[name='bag_id']") + .await + .unwrap(); + select_searchable(&session.driver, "bag_id", "Chain Blend") + .await + .unwrap(); + submit_visible_form(&session.driver).await.unwrap(); + wait_for_url_contains(&session.driver, "/brews/") + .await + .unwrap(); + + // Verify the brew detail page shows the roast name + let body = session.driver.find(By::Css("body")).await.unwrap(); + let body_text = body.text().await.unwrap(); + assert!( + body_text.contains("Chain Blend"), + "Brew detail should show the roast name" + ); + + session.quit().await; +} diff --git a/tests/e2e/checkin_tests.rs b/tests/e2e/checkin_tests.rs new file mode 100644 index 0000000..af8e7e5 --- /dev/null +++ b/tests/e2e/checkin_tests.rs @@ -0,0 +1,89 @@ +use thirtyfour::prelude::*; + +use crate::helpers::auth::authenticate_browser; +use crate::helpers::browser::BrowserSession; +use crate::helpers::forms::select_searchable; +use crate::helpers::server_helpers::{ + create_default_cafe, create_default_roast, create_default_roaster, spawn_app_with_auth, +}; +use crate::helpers::wait::{wait_for_url_contains, wait_for_visible}; + +#[tokio::test] +async fn checkin_with_saved_cafe_and_existing_roast() { + let app = spawn_app_with_auth().await; + + // Pre-create entities via API + let _cafe = create_default_cafe(&app).await; + let roaster = create_default_roaster(&app).await; + let _roast = create_default_roast(&app, roaster.id).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + // Navigate to check-in page + session.goto("/check-in").await.unwrap(); + + // Step 1: Select a saved cafe + // Wait for the saved cafes searchable-select to be visible + wait_for_visible(&session.driver, "searchable-select[name='saved_cafe_id']") + .await + .unwrap(); + + // Select "Blue Bottle" from saved cafes + // This triggers data-on:change which sets $_step = 2 + select_searchable(&session.driver, "saved_cafe_id", "Blue Bottle") + .await + .unwrap(); + + // Step 2: Select an existing roast + // Wait for step 2 content to become visible + wait_for_visible(&session.driver, "searchable-select[name='roast_id']") + .await + .unwrap(); + + // Select "Test Roast" from existing roasts + // This triggers data-on:change which sets $_step = 3 + select_searchable(&session.driver, "roast_id", "Test Roast") + .await + .unwrap(); + + // Step 3: Submit the check-in + // Wait for the submit button to be visible + let submit_btn = wait_for_visible(&session.driver, "button[type='submit']") + .await + .unwrap(); + + // Verify the review section shows the selected cafe and coffee + let body = session.driver.find(By::Css("body")).await.unwrap(); + let body_text = body.text().await.unwrap(); + assert!( + body_text.contains("Blue Bottle"), + "Review should show the selected cafe" + ); + assert!( + body_text.contains("Test Roast"), + "Review should show the selected roast" + ); + + // Click the Check In button — Datastar @post → redirect script → /cups/... + submit_btn.click().await.unwrap(); + + // Should redirect to the cup detail page + wait_for_url_contains(&session.driver, "/cups/") + .await + .unwrap(); + + // Verify the cup detail page shows the cafe and roast + let detail_body = session.driver.find(By::Css("body")).await.unwrap(); + let detail_text = detail_body.text().await.unwrap(); + assert!( + detail_text.contains("Blue Bottle"), + "Cup detail should show the cafe name" + ); + assert!( + detail_text.contains("Test Roast"), + "Cup detail should show the roast name" + ); + + session.quit().await; +} diff --git a/tests/e2e/delete_tests.rs b/tests/e2e/delete_tests.rs new file mode 100644 index 0000000..3e3d7e1 --- /dev/null +++ b/tests/e2e/delete_tests.rs @@ -0,0 +1,49 @@ +use thirtyfour::prelude::*; + +use crate::helpers::auth::authenticate_browser; +use crate::helpers::browser::BrowserSession; +use crate::helpers::server_helpers::{create_default_roaster, spawn_app_with_auth}; +use crate::helpers::wait::{wait_for_url_contains, wait_for_visible}; + +#[tokio::test] +async fn delete_roaster_from_detail_page() { + 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 the roaster detail page + session + .goto(&format!("/roasters/{}", roaster.slug)) + .await + .unwrap(); + + // Verify we're on the right page + let heading = session.driver.find(By::Css("h1")).await.unwrap(); + assert_eq!(heading.text().await.unwrap(), "Test Roasters"); + + // Click the delete button + let delete_btn = wait_for_visible(&session.driver, "button.text-error") + .await + .unwrap(); + delete_btn.click().await.unwrap(); + + // Accept the confirm() dialog + session.driver.accept_alert().await.unwrap(); + + // After @delete + redirect script, the browser navigates to /data + wait_for_url_contains(&session.driver, "/data") + .await + .unwrap(); + + // Verify the roaster is no longer in the list + let body = session.driver.find(By::Css("body")).await.unwrap(); + let body_text = body.text().await.unwrap(); + assert!( + !body_text.contains("Test Roasters"), + "Deleted roaster should not appear in the list" + ); + + session.quit().await; +} diff --git a/tests/e2e/roaster_tests.rs b/tests/e2e/roaster_tests.rs new file mode 100644 index 0000000..844e369 --- /dev/null +++ b/tests/e2e/roaster_tests.rs @@ -0,0 +1,96 @@ +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::spawn_app_with_auth; +use crate::helpers::wait::{wait_for_url_contains, wait_for_visible}; + +#[tokio::test] +async fn create_roaster_via_add_page() { + let app = spawn_app_with_auth().await; + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + // Navigate to /add — roaster tab is the default + session.goto("/add").await.unwrap(); + + // Wait for Datastar to initialize and show the roaster form + wait_for_visible(&session.driver, "input[name='name']") + .await + .unwrap(); + + // Fill the roaster form + fill_input(&session.driver, "name", "E2E Test Roasters") + .await + .unwrap(); + fill_input(&session.driver, "country", "United Kingdom") + .await + .unwrap(); + + // Submit — standard method=post, redirects to detail page + submit_visible_form(&session.driver).await.unwrap(); + + // Should redirect to the roaster detail page + wait_for_url_contains(&session.driver, "/roasters/") + .await + .unwrap(); + + // Verify the detail page shows the roaster name + let heading = session.driver.find(By::Css("h1")).await.unwrap(); + let heading_text = heading.text().await.unwrap(); + assert_eq!(heading_text, "E2E Test Roasters"); + + // Navigate to the data page and verify the roaster appears in the list + session.goto("/data?type=roasters").await.unwrap(); + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let body_text = body.text().await.unwrap(); + assert!( + body_text.contains("E2E Test Roasters"), + "Roaster should appear in the data list" + ); + + session.quit().await; +} + +#[tokio::test] +async fn create_roaster_with_all_fields() { + let app = spawn_app_with_auth().await; + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session.goto("/add").await.unwrap(); + + wait_for_visible(&session.driver, "input[name='name']") + .await + .unwrap(); + + fill_input(&session.driver, "name", "Full Detail Roasters") + .await + .unwrap(); + fill_input(&session.driver, "country", "Ethiopia") + .await + .unwrap(); + fill_input(&session.driver, "city", "Addis Ababa") + .await + .unwrap(); + fill_input(&session.driver, "homepage", "https://example.coffee") + .await + .unwrap(); + + submit_visible_form(&session.driver).await.unwrap(); + + wait_for_url_contains(&session.driver, "/roasters/") + .await + .unwrap(); + + // Verify detail page shows country and city + let body = session.driver.find(By::Css("body")).await.unwrap(); + let body_text = body.text().await.unwrap(); + assert!(body_text.contains("Full Detail Roasters")); + assert!(body_text.contains("Ethiopia")); + assert!(body_text.contains("Addis Ababa")); + + session.quit().await; +} diff --git a/tests/e2e/scan_tests.rs b/tests/e2e/scan_tests.rs new file mode 100644 index 0000000..3e38b2b --- /dev/null +++ b/tests/e2e/scan_tests.rs @@ -0,0 +1,111 @@ +use thirtyfour::prelude::*; +use wiremock::matchers::{method, path}; +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}; + +fn mock_openrouter_response(json_content: &str) -> ResponseTemplate { + let body = serde_json::json!({ + "id": "gen-test", + "model": "test-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": json_content + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "cost": 0.001 + } + }); + ResponseTemplate::new(200).set_body_json(body) +} + +#[tokio::test] +async fn extract_roaster_via_text_prompt_populates_form() { + let app = spawn_app_with_openrouter_mock().await; + let mock_server = app.mock_server.as_ref().unwrap(); + + // Mount mock that returns extracted roaster data + Mock::given(method("POST")) + .and(path("/api/v1/chat/completions")) + .respond_with(mock_openrouter_response( + r#"{"name": "Square Mile", "country": "United Kingdom", "city": "London", "homepage": "https://squaremilecoffee.com"}"#, + )) + .mount(mock_server) + .await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + // Navigate to /add (roaster tab is default) + session.goto("/add").await.unwrap(); + + // Wait for the extraction prompt to be visible + let prompt_input = wait_for_visible( + &session.driver, + "input[name='prompt'][form='roaster-extract-form']", + ) + .await + .unwrap(); + + // Type a description and press Enter to submit the extraction form + prompt_input.send_keys("Square Mile Coffee").await.unwrap(); + prompt_input.send_keys(Key::Enter).await.unwrap(); + + // Wait for extraction to complete — the name field should be populated + // Datastar patches signals which update form fields via data-bind + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // Verify the main form fields were populated by the signal patch + let name_input = session + .driver + .find(By::Css( + "form[action='/api/v1/roasters'] input[name='name']", + )) + .await + .unwrap(); + let name_value = name_input.value().await.unwrap().unwrap_or_default(); + assert_eq!(name_value, "Square Mile", "Name field should be populated"); + + let country_input = session + .driver + .find(By::Css( + "form[action='/api/v1/roasters'] input[name='country']", + )) + .await + .unwrap(); + let country_value = country_input.value().await.unwrap().unwrap_or_default(); + assert_eq!( + country_value, "United Kingdom", + "Country field should be populated" + ); + + // Submit the main form to save the roaster + let submit_button = session + .driver + .find(By::Css( + "form[action='/api/v1/roasters'] button[type='submit']", + )) + .await + .unwrap(); + submit_button.click().await.unwrap(); + + // Should redirect to the roaster detail page + wait_for_url_contains(&session.driver, "/roasters/") + .await + .unwrap(); + + let heading = session.driver.find(By::Css("h1")).await.unwrap(); + assert_eq!(heading.text().await.unwrap(), "Square Mile"); + + session.quit().await; +}