diff --git a/tests/e2e/bag_tests.rs b/tests/e2e/bag_tests.rs new file mode 100644 index 0000000..9713731 --- /dev/null +++ b/tests/e2e/bag_tests.rs @@ -0,0 +1,52 @@ +use thirtyfour::prelude::*; + +use crate::helpers::auth::authenticate_browser; +use crate::helpers::browser::BrowserSession; +use crate::helpers::server_helpers::{ + create_default_bag, create_default_roast, create_default_roaster, spawn_app_with_auth, +}; +use crate::helpers::wait::wait_for_visible; + +#[tokio::test] +async fn close_bag_from_detail_page() { + 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 session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session.goto(&format!("/bags/{}", bag.id)).await.unwrap(); + + // Find and click the "Close Bag" button + wait_for_visible(&session.driver, "button").await.unwrap(); + let buttons = session.driver.find_all(By::Css("button")).await.unwrap(); + for button in buttons { + let text = button.text().await.unwrap_or_default(); + if text.contains("Close Bag") { + button.click().await.unwrap(); + break; + } + } + + // Accept the confirm() dialog + session.driver.accept_alert().await.unwrap(); + + // After closing, the "Close Bag" button should disappear. + // Wait for the page to update — the @put returns a redirect or refreshes. + // The button text "Close Bag" should no longer be present. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let text = body.text().await.unwrap(); + assert!( + !text.contains("Close Bag"), + "Close Bag button should be gone after closing" + ); + + // Bag should still show on the page (just marked as closed) + assert!(text.contains("Test Roast"), "Should still show roast name"); + + session.quit().await; +} diff --git a/tests/e2e/data_tests.rs b/tests/e2e/data_tests.rs new file mode 100644 index 0000000..806a2e7 --- /dev/null +++ b/tests/e2e/data_tests.rs @@ -0,0 +1,246 @@ +use std::time::Duration; + +use thirtyfour::prelude::*; + +use crate::helpers::auth::authenticate_browser; +use crate::helpers::browser::BrowserSession; +use crate::helpers::server_helpers::{ + create_default_bag, create_default_gear, create_default_roast, create_default_roaster, + create_roaster_with_name, spawn_app_with_auth, +}; +use crate::helpers::wait::{wait_for_text, wait_for_visible}; + +#[tokio::test] +async fn tab_switching_loads_correct_entity_list() { + 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", "Timemore", "C3").await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + // Default tab is brews — but we have no brews, so check empty state + session.goto("/data").await.unwrap(); + wait_for_visible(&session.driver, "#data-content") + .await + .unwrap(); + + // Click "Roasters" tab (desktop) + let tabs = session + .driver + .find_all(By::Css("nav[role='tablist'] button[role='tab']")) + .await + .unwrap(); + for tab in &tabs { + let text = tab.text().await.unwrap_or_default(); + if text.contains("Roasters") { + tab.click().await.unwrap(); + break; + } + } + + // Wait for roaster list to load + wait_for_text(&session.driver, "#data-content", "Test Roasters") + .await + .unwrap(); + + // Click "Gear" tab + let tabs = session + .driver + .find_all(By::Css("nav[role='tablist'] button[role='tab']")) + .await + .unwrap(); + for tab in &tabs { + let text = tab.text().await.unwrap_or_default(); + if text.contains("Gear") { + tab.click().await.unwrap(); + break; + } + } + + wait_for_text(&session.driver, "#data-content", "Timemore") + .await + .unwrap(); + + session.quit().await; +} + +#[tokio::test] +async fn search_filters_list() { + let app = spawn_app_with_auth().await; + create_roaster_with_name(&app, "Acme Coffee").await; + create_roaster_with_name(&app, "Beta Beans").await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session.goto("/data?type=roasters").await.unwrap(); + wait_for_visible(&session.driver, "#roaster-list") + .await + .unwrap(); + + // Both roasters should be visible initially + let body = session.driver.find(By::Css("#data-content")).await.unwrap(); + let text = body.text().await.unwrap(); + assert!(text.contains("Acme Coffee"), "Should show Acme Coffee"); + assert!(text.contains("Beta Beans"), "Should show Beta Beans"); + + // Type in search field — the data page search is on the outer page (input[type='search']) + let search = session + .driver + .find(By::Css("input[type='search']")) + .await + .unwrap(); + search.send_keys("Acme").await.unwrap(); + + // Wait for debounce (300ms) + fragment load + tokio::time::sleep(Duration::from_millis(500)).await; + + // Wait for the list to update with filtered results + wait_for_text(&session.driver, "#data-content", "Acme Coffee") + .await + .unwrap(); + + // Beta Beans should be filtered out + let text = session + .driver + .find(By::Css("#data-content")) + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + !text.contains("Beta Beans"), + "Beta Beans should be filtered out" + ); + + session.quit().await; +} + +#[tokio::test] +async fn pagination_next_and_prev() { + let app = spawn_app_with_auth().await; + + // Create 11 roasters — default page size is 10 + for i in 0..11 { + create_roaster_with_name(&app, &format!("Roaster {i:02}")).await; + } + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session.goto("/data?type=roasters").await.unwrap(); + wait_for_visible(&session.driver, ".pagination-controls") + .await + .unwrap(); + + // Should show "Page 1 of 2" + let pagination = session + .driver + .find(By::Css(".pagination-controls")) + .await + .unwrap(); + let pagination_text = pagination.text().await.unwrap(); + assert!( + pagination_text.contains("Page 1 of 2"), + "Should show page 1 of 2, got: {pagination_text}" + ); + + // Click Next + let next_btn = pagination + .find(By::XPath(".//button[contains(., 'Next')]")) + .await + .unwrap(); + next_btn.click().await.unwrap(); + + // Wait for page 2 + tokio::time::sleep(Duration::from_millis(300)).await; + let pagination = session + .driver + .find(By::Css(".pagination-controls")) + .await + .unwrap(); + wait_for_text(&session.driver, ".pagination-controls", "Page 2 of 2") + .await + .unwrap(); + + // Click Prev + let prev_btn = pagination + .find(By::XPath(".//button[contains(., 'Prev')]")) + .await + .unwrap(); + prev_btn.click().await.unwrap(); + + wait_for_text(&session.driver, ".pagination-controls", "Page 1 of 2") + .await + .unwrap(); + + session.quit().await; +} + +#[tokio::test] +async fn sort_by_column_reverses_order() { + let app = spawn_app_with_auth().await; + create_roaster_with_name(&app, "Alpha Roasters").await; + create_roaster_with_name(&app, "Zulu Coffee").await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session.goto("/data?type=roasters").await.unwrap(); + wait_for_visible(&session.driver, "#roaster-list") + .await + .unwrap(); + + // Click "Name" sort header to sort ascending + let name_header = session + .driver + .find(By::XPath("//thead//button[contains(., 'Name')]")) + .await + .unwrap(); + name_header.click().await.unwrap(); + + tokio::time::sleep(Duration::from_millis(300)).await; + + // Get row order after first click (ascending) + let rows = session + .driver + .find_all(By::Css("#roaster-list tbody tr")) + .await + .unwrap(); + let mut first_order = Vec::new(); + for row in &rows { + first_order.push(row.text().await.unwrap_or_default()); + } + + // Click again to reverse + let name_header = session + .driver + .find(By::XPath("//thead//button[contains(., 'Name')]")) + .await + .unwrap(); + name_header.click().await.unwrap(); + + tokio::time::sleep(Duration::from_millis(300)).await; + + let rows = session + .driver + .find_all(By::Css("#roaster-list tbody tr")) + .await + .unwrap(); + let mut second_order = Vec::new(); + for row in &rows { + second_order.push(row.text().await.unwrap_or_default()); + } + + // Orders should be different (reversed) + assert_ne!( + first_order, second_order, + "Clicking sort header twice should reverse order" + ); + + session.quit().await; +} diff --git a/tests/e2e/detail_tests.rs b/tests/e2e/detail_tests.rs new file mode 100644 index 0000000..97e9022 --- /dev/null +++ b/tests/e2e/detail_tests.rs @@ -0,0 +1,136 @@ +use thirtyfour::prelude::*; + +use crate::helpers::auth::authenticate_browser; +use crate::helpers::browser::BrowserSession; +use crate::helpers::server_helpers::{ + create_default_bag, create_default_cafe, create_default_gear, create_default_roast, + create_default_roaster, create_roaster_with_payload, spawn_app_with_auth, +}; +use crate::helpers::wait::wait_for_visible; + +use brewlog::domain::coffee::roasters::NewRoaster; + +#[tokio::test] +async fn roaster_detail_shows_all_fields() { + let app = spawn_app_with_auth().await; + let roaster = create_roaster_with_payload( + &app, + NewRoaster { + name: "Detail Roasters".to_string(), + country: "JP".to_string(), + city: Some("Tokyo".to_string()), + homepage: Some("https://example.com".to_string()), + created_at: None, + }, + ) + .await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session + .goto(&format!("/roasters/{}", roaster.slug)) + .await + .unwrap(); + wait_for_visible(&session.driver, "h1").await.unwrap(); + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let text = body.text().await.unwrap(); + assert!(text.contains("Detail Roasters"), "Should show roaster name"); + assert!(text.contains("JP"), "Should show country code"); + assert!(text.contains("Tokyo"), "Should show city"); + assert!(text.contains("Visit Website"), "Should show homepage link"); + + session.quit().await; +} + +#[tokio::test] +async fn roast_detail_shows_coffee_info() { + let app = spawn_app_with_auth().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(); + + session + .goto(&format!("/roasters/{}/roasts/{}", roaster.slug, roast.slug)) + .await + .unwrap(); + wait_for_visible(&session.driver, "h1").await.unwrap(); + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let text = body.text().await.unwrap(); + assert!(text.contains("Test Roast"), "Should show roast name"); + assert!(text.contains("Ethiopia"), "Should show origin"); + assert!(text.contains("Yirgacheffe"), "Should show region"); + assert!(text.contains("Washed"), "Should show process"); + assert!(text.contains("Blueberry"), "Should show tasting notes"); + + session.quit().await; +} + +#[tokio::test] +async fn bag_detail_shows_status_and_close_button() { + 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 session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session.goto(&format!("/bags/{}", bag.id)).await.unwrap(); + wait_for_visible(&session.driver, "h1").await.unwrap(); + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let text = body.text().await.unwrap(); + assert!(text.contains("Test Roast"), "Should show roast name"); + assert!(text.contains("250"), "Should show bag amount"); + + // Close Bag button should be visible (bag is open) + assert!(text.contains("Close Bag"), "Should show Close Bag button"); + + session.quit().await; +} + +#[tokio::test] +async fn gear_detail_shows_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/{}", gear.id)).await.unwrap(); + wait_for_visible(&session.driver, "h1").await.unwrap(); + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let text = body.text().await.unwrap(); + assert!(text.contains("Comandante"), "Should show make"); + assert!(text.contains("C40 MK4"), "Should show model"); + + session.quit().await; +} + +#[tokio::test] +async fn cafe_detail_shows_location() { + 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/{}", cafe.slug)) + .await + .unwrap(); + wait_for_visible(&session.driver, "h1").await.unwrap(); + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let text = body.text().await.unwrap(); + assert!(text.contains("Blue Bottle"), "Should show cafe name"); + assert!(text.contains("San Francisco"), "Should show city"); + + session.quit().await; +} diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs index 0486bad..fdf28b2 100644 --- a/tests/e2e/main.rs +++ b/tests/e2e/main.rs @@ -2,8 +2,12 @@ mod helpers; +mod bag_tests; mod brew_tests; mod checkin_tests; +mod data_tests; mod delete_tests; +mod detail_tests; +mod navigation_tests; mod roaster_tests; mod scan_tests; diff --git a/tests/e2e/navigation_tests.rs b/tests/e2e/navigation_tests.rs new file mode 100644 index 0000000..15d8840 --- /dev/null +++ b/tests/e2e/navigation_tests.rs @@ -0,0 +1,87 @@ +use thirtyfour::prelude::*; + +use crate::helpers::auth::authenticate_browser; +use crate::helpers::browser::BrowserSession; +use crate::helpers::server_helpers::{ + create_default_bag, create_default_roast, create_default_roaster, spawn_app_with_auth, +}; +use crate::helpers::wait::wait_for_visible; + +#[tokio::test] +async fn home_page_shows_quick_actions_and_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 session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session.goto("/").await.unwrap(); + wait_for_visible(&session.driver, "body").await.unwrap(); + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let text = body.text().await.unwrap(); + + // Quick action links should be present for authenticated users + assert!(text.contains("Check In"), "Should show Check In action"); + assert!(text.contains("Add"), "Should show Add action"); + + // Open bag should appear + assert!(text.contains("Test Roast"), "Should show open bag's roast"); + + session.quit().await; +} + +#[tokio::test] +async fn stats_page_renders_with_data() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let _roast = create_default_roast(&app, roaster.id).await; + + // Stats are computed by a background task with debouncing. + // Give it time to populate. + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + + let session = BrowserSession::new(&app.address).await.unwrap(); + authenticate_browser(&session, &app).await.unwrap(); + + session.goto("/stats").await.unwrap(); + wait_for_visible(&session.driver, "h1").await.unwrap(); + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let text = body.text().await.unwrap(); + + assert!(text.contains("Stats"), "Should show Stats heading"); + // With a roast created, we should see origin stats + assert!( + text.contains("Origins") || text.contains("Top Origin"), + "Should show stat cards" + ); + + session.quit().await; +} + +#[tokio::test] +async fn timeline_page_loads_events() { + 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("/timeline").await.unwrap(); + wait_for_visible(&session.driver, "#timeline-events") + .await + .unwrap(); + + let body = session.driver.find(By::Css("body")).await.unwrap(); + let text = body.text().await.unwrap(); + // Creating a roaster generates a timeline event + assert!( + text.contains("Test Roasters"), + "Should show roaster name in timeline" + ); + + session.quit().await; +}