fix: clamp bag remaining to zero instead of rejecting brew with excess coffee

Allow creating a brew when coffee_weight exceeds the bag's remaining
amount — remaining is clamped to 0 rather than returning a 409 error.
Brews against open bags with 0g remaining also succeed, keeping
remaining at 0. Only closed bags are rejected.
This commit is contained in:
Jon Seager 2026-05-22 15:37:08 +00:00
parent bd7d4f3c5f
commit f5fcf69e33
3 changed files with 76 additions and 11 deletions

View file

@ -197,8 +197,8 @@ pub trait GearRepository: Send + Sync {
#[async_trait] #[async_trait]
pub trait BrewRepository: Send + Sync { pub trait BrewRepository: Send + Sync {
/// Insert a new brew and deduct `coffee_weight` from the bag's remaining amount. /// Insert a new brew and deduct `coffee_weight` from the bag's remaining amount,
/// This is a transactional operation. /// clamping to zero. Rejects if the bag is closed. This is a transactional operation.
async fn insert(&self, brew: NewBrew) -> Result<Brew, RepositoryError>; async fn insert(&self, brew: NewBrew) -> Result<Brew, RepositoryError>;
async fn get(&self, id: BrewId) -> Result<Brew, RepositoryError>; async fn get(&self, id: BrewId) -> Result<Brew, RepositoryError>;
async fn get_with_details(&self, id: BrewId) -> Result<BrewWithDetails, RepositoryError>; async fn get_with_details(&self, id: BrewId) -> Result<BrewWithDetails, RepositoryError>;

View file

@ -116,25 +116,22 @@ impl BrewRepository for SqlBrewRepository {
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
// Deduct coffee weight from bag's remaining amount // Deduct coffee weight from bag's remaining amount, clamping to zero
let update_bag_query = r" let update_bag_query = r"
UPDATE bags UPDATE bags
SET remaining = remaining - ?, updated_at = CURRENT_TIMESTAMP SET remaining = MAX(remaining - ?, 0), updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND remaining >= ? AND closed = FALSE WHERE id = ? AND closed = FALSE
"; ";
let result = sqlx::query(update_bag_query) let result = sqlx::query(update_bag_query)
.bind(brew.coffee_weight) .bind(brew.coffee_weight)
.bind(brew.bag_id.into_inner()) .bind(brew.bag_id.into_inner())
.bind(brew.coffee_weight)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
return Err(RepositoryError::conflict( return Err(RepositoryError::conflict("Bag is closed or not found"));
"Insufficient coffee remaining in bag or bag is closed",
));
} }
// Insert the brew // Insert the brew

View file

@ -141,7 +141,7 @@ async fn creating_a_brew_deducts_from_bag_remaining() {
} }
#[tokio::test] #[tokio::test]
async fn creating_a_brew_fails_if_insufficient_coffee_in_bag() { async fn creating_a_brew_with_excess_coffee_clamps_remaining_to_zero() {
// Arrange // Arrange
let app = spawn_app_with_auth().await; let app = spawn_app_with_auth().await;
let roaster = create_default_roaster(&app).await; let roaster = create_default_roaster(&app).await;
@ -175,7 +175,75 @@ async fn creating_a_brew_fails_if_insufficient_coffee_in_bag() {
.expect("Failed to execute request"); .expect("Failed to execute request");
// Assert // Assert
assert_eq!(response.status(), 409); // Conflict assert_eq!(response.status(), 201);
let brew: Brew = response.json().await.expect("Failed to parse response");
assert_eq!(brew.coffee_weight, 300.0);
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, 0.0);
}
#[tokio::test]
async fn creating_a_brew_against_empty_open_bag_succeeds() {
// 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 update_payload = serde_json::json!({ "remaining": 0.0 });
client
.put(app.api_url(&format!("/bags/{}", bag.id)))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&update_payload)
.send()
.await
.expect("Failed to update bag");
let new_brew = NewBrew {
bag_id: bag.id,
coffee_weight: 15.0,
grinder_id: grinder.id,
grind_setting: 24.0,
brewer_id: brewer.id,
filter_paper_id: None,
water_volume: 250,
water_temp: 92.0,
quick_notes: Vec::new(),
brew_time: None,
created_at: None,
};
// 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 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, 0.0);
} }
#[tokio::test] #[tokio::test]