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]
|
||||
pub trait BrewRepository: Send + Sync {
|
||||
/// Insert a new brew and deduct `coffee_weight` from the bag's remaining amount.
|
||||
/// This is a transactional operation.
|
||||
/// Insert a new brew and deduct `coffee_weight` from the bag's remaining amount,
|
||||
/// 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 get(&self, id: BrewId) -> Result<Brew, RepositoryError>;
|
||||
async fn get_with_details(&self, id: BrewId) -> Result<BrewWithDetails, RepositoryError>;
|
||||
|
|
|
|||
|
|
@ -116,25 +116,22 @@ impl BrewRepository for SqlBrewRepository {
|
|||
.await
|
||||
.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"
|
||||
UPDATE bags
|
||||
SET remaining = remaining - ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND remaining >= ? AND closed = FALSE
|
||||
SET remaining = MAX(remaining - ?, 0), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND closed = FALSE
|
||||
";
|
||||
|
||||
let result = sqlx::query(update_bag_query)
|
||||
.bind(brew.coffee_weight)
|
||||
.bind(brew.bag_id.into_inner())
|
||||
.bind(brew.coffee_weight)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|err| RepositoryError::unexpected(err.to_string()))?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RepositoryError::conflict(
|
||||
"Insufficient coffee remaining in bag or bag is closed",
|
||||
));
|
||||
return Err(RepositoryError::conflict("Bag is closed or not found"));
|
||||
}
|
||||
|
||||
// Insert the brew
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ async fn creating_a_brew_deducts_from_bag_remaining() {
|
|||
}
|
||||
|
||||
#[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
|
||||
let app = spawn_app_with_auth().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");
|
||||
|
||||
// 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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue