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:
parent
bd7d4f3c5f
commit
f5fcf69e33
3 changed files with 76 additions and 11 deletions
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue