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
This commit is contained in:
Jon Seager 2026-02-02 19:32:35 +00:00
parent 0eedf09c08
commit b3d697978c
No known key found for this signature in database
5 changed files with 691 additions and 0 deletions

286
tests/cli/brews_cli.rs Normal file
View file

@ -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"
);
}

View file

@ -1,4 +1,5 @@
pub mod bags_cli;
pub mod brews_cli;
pub mod gear_cli;
pub mod helpers;
pub mod roasters_cli;

368
tests/server/brews_api.rs Normal file
View file

@ -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<BrewWithDetails> = 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<BrewWithDetails> = 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);
}

View file

@ -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<dyn UserRepository> =
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 <html> 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")
}

View file

@ -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;