From b3d697978c9f6f570b025e646135b42e314d09a8 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Mon, 2 Feb 2026 19:32:35 +0000 Subject: [PATCH] test(brews): add server and CLI integration tests - Test create, list, get, delete operations - Test bag deduction and conflict on insufficient coffee - Test CLI commands with defaults - Add create_default_gear helper --- tests/cli/brews_cli.rs | 286 +++++++++++++++++++++++++++++ tests/cli/main.rs | 1 + tests/server/brews_api.rs | 368 ++++++++++++++++++++++++++++++++++++++ tests/server/helpers.rs | 35 ++++ tests/server/main.rs | 1 + 5 files changed, 691 insertions(+) create mode 100644 tests/cli/brews_cli.rs create mode 100644 tests/server/brews_api.rs diff --git a/tests/cli/brews_cli.rs b/tests/cli/brews_cli.rs new file mode 100644 index 0000000..5678a29 --- /dev/null +++ b/tests/cli/brews_cli.rs @@ -0,0 +1,286 @@ +use crate::helpers::{create_roast, create_roaster, create_token, run_brewlog}; + +fn create_bag(roast_id: &str, token: &str) -> String { + let output = run_brewlog( + &["bag", "add", "--roast-id", roast_id, "--amount", "250"], + &[("BREWLOG_TOKEN", token)], + ); + + if !output.status.success() { + panic!( + "Failed to create bag: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let bag: serde_json::Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); + bag["id"] + .as_i64() + .expect("bag id should be numeric") + .to_string() +} + +fn create_gear(category: &str, make: &str, model: &str, token: &str) -> String { + let output = run_brewlog( + &[ + "gear", + "add", + "--category", + category, + "--make", + make, + "--model", + model, + ], + &[("BREWLOG_TOKEN", token)], + ); + + if !output.status.success() { + panic!( + "Failed to create gear: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let gear: serde_json::Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); + gear["id"] + .as_i64() + .expect("gear id should be numeric") + .to_string() +} + +#[test] +fn brew_add_creates_brew_via_api() { + let token = create_token("brew-add-test"); + let roaster_id = create_roaster("Brew Test Roaster", &token); + let roast_id = create_roast(&roaster_id, "Brew Test Roast", &token); + let bag_id = create_bag(&roast_id, &token); + let grinder_id = create_gear("grinder", "Comandante", "C40", &token); + let brewer_id = create_gear("brewer", "Hario", "V60", &token); + + let output = run_brewlog( + &[ + "brew", + "add", + "--bag-id", + &bag_id, + "--coffee-weight", + "15.0", + "--grinder-id", + &grinder_id, + "--grind-setting", + "24.0", + "--brewer-id", + &brewer_id, + "--water-volume", + "250", + "--water-temp", + "92.0", + ], + &[("BREWLOG_TOKEN", &token)], + ); + + assert!( + output.status.success(), + "brew add should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let brew: serde_json::Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); + + assert_eq!(brew["coffee_weight"], 15.0); + assert_eq!(brew["grind_setting"], 24.0); + assert_eq!(brew["water_volume"], 250); + assert_eq!(brew["water_temp"], 92.0); +} + +#[test] +fn brew_add_with_defaults_uses_default_values() { + let token = create_token("brew-defaults-test"); + let roaster_id = create_roaster("Brew Defaults Roaster", &token); + let roast_id = create_roast(&roaster_id, "Brew Defaults Roast", &token); + let bag_id = create_bag(&roast_id, &token); + let grinder_id = create_gear("grinder", "1Zpresso", "JMax", &token); + let brewer_id = create_gear("brewer", "AeroPress", "Original", &token); + + // Only provide required args, let defaults apply + let output = run_brewlog( + &[ + "brew", + "add", + "--bag-id", + &bag_id, + "--grinder-id", + &grinder_id, + "--brewer-id", + &brewer_id, + ], + &[("BREWLOG_TOKEN", &token)], + ); + + assert!( + output.status.success(), + "brew add should succeed with defaults: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let brew: serde_json::Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); + + // Check defaults: 15.0g coffee, 6.0 grind, 250ml water, 91.0°C + assert_eq!(brew["coffee_weight"], 15.0); + assert_eq!(brew["grind_setting"], 6.0); + assert_eq!(brew["water_volume"], 250); + assert_eq!(brew["water_temp"], 91.0); +} + +#[test] +fn brew_list_returns_json_array() { + let token = create_token("brew-list-test"); + let roaster_id = create_roaster("Brew List Roaster", &token); + let roast_id = create_roast(&roaster_id, "Brew List Roast", &token); + let bag_id = create_bag(&roast_id, &token); + let grinder_id = create_gear("grinder", "Fellow", "Ode", &token); + let brewer_id = create_gear("brewer", "Chemex", "Classic", &token); + + // Create a brew first + run_brewlog( + &[ + "brew", + "add", + "--bag-id", + &bag_id, + "--grinder-id", + &grinder_id, + "--brewer-id", + &brewer_id, + ], + &[("BREWLOG_TOKEN", &token)], + ); + + // List brews + let output = run_brewlog(&["brew", "list"], &[("BREWLOG_TOKEN", &token)]); + + assert!( + output.status.success(), + "brew list should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let brews: serde_json::Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); + + assert!(brews.is_array(), "brew list should return an array"); + assert!( + !brews.as_array().unwrap().is_empty(), + "should have at least one brew" + ); +} + +#[test] +fn brew_get_returns_brew_details() { + let token = create_token("brew-get-test"); + let roaster_id = create_roaster("Brew Get Roaster", &token); + let roast_id = create_roast(&roaster_id, "Brew Get Roast", &token); + let bag_id = create_bag(&roast_id, &token); + let grinder_id = create_gear("grinder", "Baratza", "Encore", &token); + let brewer_id = create_gear("brewer", "Kalita", "Wave", &token); + + // Create a brew + let create_output = run_brewlog( + &[ + "brew", + "add", + "--bag-id", + &bag_id, + "--grinder-id", + &grinder_id, + "--brewer-id", + &brewer_id, + "--coffee-weight", + "18.5", + ], + &[("BREWLOG_TOKEN", &token)], + ); + + let create_stdout = String::from_utf8_lossy(&create_output.stdout); + let created_brew: serde_json::Value = + serde_json::from_str(&create_stdout).expect("Should output valid JSON"); + let brew_id = created_brew["id"].as_i64().unwrap().to_string(); + + // Get the brew + let output = run_brewlog( + &["brew", "get", "--id", &brew_id], + &[("BREWLOG_TOKEN", &token)], + ); + + assert!( + output.status.success(), + "brew get should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let brew: serde_json::Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); + + assert_eq!(brew["coffee_weight"], 18.5); + assert!( + brew["roast_name"].is_string(), + "should include enriched roast_name" + ); +} + +#[test] +fn brew_delete_removes_brew() { + let token = create_token("brew-delete-test"); + let roaster_id = create_roaster("Brew Delete Roaster", &token); + let roast_id = create_roast(&roaster_id, "Brew Delete Roast", &token); + let bag_id = create_bag(&roast_id, &token); + let grinder_id = create_gear("grinder", "Timemore", "C2", &token); + let brewer_id = create_gear("brewer", "Melitta", "Pour Over", &token); + + // Create a brew + let create_output = run_brewlog( + &[ + "brew", + "add", + "--bag-id", + &bag_id, + "--grinder-id", + &grinder_id, + "--brewer-id", + &brewer_id, + ], + &[("BREWLOG_TOKEN", &token)], + ); + + let create_stdout = String::from_utf8_lossy(&create_output.stdout); + let created_brew: serde_json::Value = + serde_json::from_str(&create_stdout).expect("Should output valid JSON"); + let brew_id = created_brew["id"].as_i64().unwrap().to_string(); + + // Delete the brew + let output = run_brewlog( + &["brew", "delete", "--id", &brew_id], + &[("BREWLOG_TOKEN", &token)], + ); + + assert!( + output.status.success(), + "brew delete should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Verify it's gone + let get_output = run_brewlog( + &["brew", "get", "--id", &brew_id], + &[("BREWLOG_TOKEN", &token)], + ); + assert!( + !get_output.status.success(), + "brew get should fail after deletion" + ); +} diff --git a/tests/cli/main.rs b/tests/cli/main.rs index 282e876..48d42cf 100644 --- a/tests/cli/main.rs +++ b/tests/cli/main.rs @@ -1,4 +1,5 @@ pub mod bags_cli; +pub mod brews_cli; pub mod gear_cli; pub mod helpers; pub mod roasters_cli; diff --git a/tests/server/brews_api.rs b/tests/server/brews_api.rs new file mode 100644 index 0000000..5362c3f --- /dev/null +++ b/tests/server/brews_api.rs @@ -0,0 +1,368 @@ +use crate::helpers::{ + create_default_bag, create_default_gear, create_default_roast, create_default_roaster, + spawn_app, spawn_app_with_auth, +}; +use brewlog::domain::bags::Bag; +use brewlog::domain::brews::{Brew, BrewWithDetails, NewBrew}; + +#[tokio::test] +async fn creating_a_brew_returns_201_for_valid_data() { + // Arrange + 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, + water_volume: 250, + water_temp: 92.0, + }; + + // Act + let response = client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_brew) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 201); + + let brew: Brew = response.json().await.expect("Failed to parse response"); + assert_eq!(brew.bag_id, bag.id); + assert_eq!(brew.coffee_weight, 15.0); + assert_eq!(brew.grinder_id, grinder.id); + assert_eq!(brew.grind_setting, 24.0); + assert_eq!(brew.brewer_id, brewer.id); + assert_eq!(brew.water_volume, 250); + assert_eq!(brew.water_temp, 92.0); +} + +#[tokio::test] +async fn creating_a_brew_deducts_from_bag_remaining() { + // Arrange + 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; // 250g + 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, + water_volume: 250, + water_temp: 92.0, + }; + + // Act + client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_brew) + .send() + .await + .expect("Failed to execute request"); + + // Assert - check bag remaining was deducted + let bag_response = client + .get(app.api_url(&format!("/bags/{}", bag.id))) + .send() + .await + .expect("Failed to get bag"); + + let updated_bag: Bag = bag_response.json().await.expect("Failed to parse bag"); + assert_eq!(updated_bag.remaining, 235.0); // 250 - 15 = 235 +} + +#[tokio::test] +async fn creating_a_brew_fails_if_insufficient_coffee_in_bag() { + // Arrange + 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; // 250g + 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: 300.0, // More than the 250g in the bag + grinder_id: grinder.id, + grind_setting: 24.0, + brewer_id: brewer.id, + water_volume: 250, + water_temp: 92.0, + }; + + // Act + let response = client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_brew) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 409); // Conflict +} + +#[tokio::test] +async fn creating_a_brew_without_auth_returns_401() { + // Arrange + let app = spawn_app().await; // No auth + let client = reqwest::Client::new(); + + let new_brew = serde_json::json!({ + "bag_id": 1, + "coffee_weight": 15.0, + "grinder_id": 1, + "grind_setting": 24.0, + "brewer_id": 2, + "water_volume": 250, + "water_temp": 92.0 + }); + + // Act + let response = client + .post(app.api_url("/brews")) + .json(&new_brew) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 401); +} + +#[tokio::test] +async fn listing_brews_returns_200_and_enriched_data() { + // Arrange + 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(); + + // Create a brew + let new_brew = NewBrew { + bag_id: bag.id, + coffee_weight: 15.0, + grinder_id: grinder.id, + grind_setting: 24.0, + brewer_id: brewer.id, + water_volume: 250, + water_temp: 92.0, + }; + + client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_brew) + .send() + .await + .expect("Failed to create brew"); + + // Act + let response = client + .get(app.api_url("/brews")) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 200); + + let brews: Vec = response.json().await.expect("Failed to parse response"); + assert_eq!(brews.len(), 1); + assert_eq!(brews[0].roast_name, roast.name); + assert_eq!(brews[0].roaster_name, roaster.name); + assert_eq!(brews[0].grinder_name, "Comandante C40 MK4"); + assert_eq!(brews[0].brewer_name, "Hario V60 02"); +} + +#[tokio::test] +async fn listing_brews_with_bag_filter_returns_filtered_results() { + // Arrange + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + let bag1 = create_default_bag(&app, roast.id).await; + let bag2 = 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(); + + // Create brew for bag1 + let new_brew1 = NewBrew { + bag_id: bag1.id, + coffee_weight: 15.0, + grinder_id: grinder.id, + grind_setting: 24.0, + brewer_id: brewer.id, + water_volume: 250, + water_temp: 92.0, + }; + + client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_brew1) + .send() + .await + .expect("Failed to create brew 1"); + + // Create brew for bag2 + let new_brew2 = NewBrew { + bag_id: bag2.id, + coffee_weight: 17.0, + grinder_id: grinder.id, + grind_setting: 22.0, + brewer_id: brewer.id, + water_volume: 255, + water_temp: 88.0, + }; + + client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_brew2) + .send() + .await + .expect("Failed to create brew 2"); + + // Act - filter by bag1 + let response = client + .get(app.api_url(&format!("/brews?bag_id={}", bag1.id))) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 200); + + let brews: Vec = response.json().await.expect("Failed to parse response"); + assert_eq!(brews.len(), 1); + assert_eq!(brews[0].brew.coffee_weight, 15.0); +} + +#[tokio::test] +async fn getting_a_brew_returns_200_for_valid_id() { + // Arrange + 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, + water_volume: 250, + water_temp: 92.0, + }; + + 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 created_brew: Brew = create_response + .json() + .await + .expect("Failed to parse response"); + + // Act + let response = client + .get(app.api_url(&format!("/brews/{}", created_brew.id))) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 200); + let brew: BrewWithDetails = response.json().await.expect("Failed to parse response"); + assert_eq!(brew.brew.id, created_brew.id); + assert_eq!(brew.roast_name, roast.name); +} + +#[tokio::test] +async fn deleting_a_brew_returns_204() { + // Arrange + 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, + water_volume: 250, + water_temp: 92.0, + }; + + 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 created_brew: Brew = create_response + .json() + .await + .expect("Failed to parse response"); + + // Act + let response = client + .delete(app.api_url(&format!("/brews/{}", created_brew.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 204); + + // Verify deletion + let get_response = client + .get(app.api_url(&format!("/brews/{}", created_brew.id))) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(get_response.status(), 404); +} diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 4ab4bb0..7eb157b 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -11,6 +11,7 @@ use brewlog::domain::users::NewUser; use brewlog::infrastructure::auth::hash_password; use brewlog::infrastructure::database::Database; use brewlog::infrastructure::repositories::bags::SqlBagRepository; +use brewlog::infrastructure::repositories::brews::SqlBrewRepository; use brewlog::infrastructure::repositories::gear::SqlGearRepository; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; @@ -57,6 +58,7 @@ pub async fn spawn_app() -> TestApp { let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool())); + let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool())); let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); let user_repo: Arc = Arc::new(SqlUserRepository::new(database.clone_pool())); @@ -71,6 +73,7 @@ pub async fn spawn_app() -> TestApp { roast_repo.clone(), bag_repo.clone(), gear_repo.clone(), + brew_repo.clone(), timeline_repo.clone(), user_repo.clone(), token_repo.clone(), @@ -289,3 +292,35 @@ pub fn assert_full_page(body: &str) { "Expected full HTML page with DOCTYPE or tag" ); } + +pub async fn create_default_gear( + app: &TestApp, + category: &str, + make: &str, + model: &str, +) -> brewlog::domain::gear::Gear { + let client = Client::new(); + let gear_category = match category { + "grinder" => brewlog::domain::gear::GearCategory::Grinder, + "brewer" => brewlog::domain::gear::GearCategory::Brewer, + _ => panic!("Unknown gear category: {}", category), + }; + let new_gear = brewlog::domain::gear::NewGear { + category: gear_category, + make: make.to_string(), + model: model.to_string(), + }; + + let mut request = client.post(app.api_url("/gear")).json(&new_gear); + + if let Some(token) = &app.auth_token { + request = request.bearer_auth(token); + } + + let response = request.send().await.expect("failed to create gear via API"); + + response + .json() + .await + .expect("failed to deserialize gear from response") +} diff --git a/tests/server/main.rs b/tests/server/main.rs index 73533a0..11b69ee 100644 --- a/tests/server/main.rs +++ b/tests/server/main.rs @@ -1,5 +1,6 @@ pub mod auth_api; pub mod bags_api; +pub mod brews_api; pub mod datastar; pub mod gear_api; pub mod helpers;