fix: ensure bags are closed properly and closures are on the timeline

This commit is contained in:
Jon Seager 2025-11-27 14:42:38 +00:00
parent 02c6f6d675
commit 144257c2c8
No known key found for this signature in database
3 changed files with 162 additions and 16 deletions

View file

@ -210,12 +210,18 @@ pub(crate) async fn update_bag(
finished_at: None,
});
let update = UpdateBag {
let mut update = UpdateBag {
remaining: body_update.remaining.or(update_params.remaining),
closed: body_update.closed.or(update_params.closed),
finished_at: body_update.finished_at.or(update_params.finished_at),
};
if let Some(true) = update.closed {
if update.finished_at.is_none() {
update.finished_at = Some(chrono::Utc::now().date_naive());
}
}
let bag = state
.bag_repo
.update(id, update.clone())
@ -224,21 +230,21 @@ pub(crate) async fn update_bag(
if let Some(true) = update.closed {
// Fetch roast and roaster for timeline event
if let Ok(roast) = state.roast_repo.get(bag.roast_id).await
&& let Ok(roaster) = state.roaster_repo.get(roast.roaster_id).await
{
let event = NewTimelineEvent {
entity_type: "bag".to_string(),
entity_id: bag.id.into_inner(),
occurred_at: chrono::Utc::now(),
title: roast.name.to_string(),
details: vec![TimelineEventDetail {
label: "Roaster".to_string(),
value: roaster.name,
}],
tasting_notes: vec![],
};
let _ = state.timeline_repo.insert(event).await;
if let Ok(roast) = state.roast_repo.get(bag.roast_id).await {
if let Ok(roaster) = state.roaster_repo.get(roast.roaster_id).await {
let event = NewTimelineEvent {
entity_type: "bag".to_string(),
entity_id: bag.id.into_inner(),
occurred_at: chrono::Utc::now(),
title: format!("{}", roast.name),
details: vec![TimelineEventDetail {
label: "Roaster".to_string(),
value: roaster.name,
}],
tasting_notes: vec![],
};
let _ = state.timeline_repo.insert(event).await;
}
}
}

View file

@ -244,3 +244,54 @@ async fn deleting_a_bag_returns_204() {
assert_eq!(get_response.status(), 404);
}
#[tokio::test]
async fn closing_a_bag_automatically_sets_finished_at() {
// 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 client = reqwest::Client::new();
let new_bag = NewBag {
roast_id: roast.id,
roast_date: None,
amount: 250.0,
};
let create_response = client
.post(app.api_url("/bags"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&new_bag)
.send()
.await
.expect("Failed to create bag");
let created_bag: Bag = create_response
.json()
.await
.expect("Failed to parse response");
let update_payload = serde_json::json!({
"closed": true
});
// Act
let response = client
.put(app.api_url(&format!("/bags/{}", created_bag.id)))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&update_payload)
.send()
.await
.expect("Failed to execute request");
// Assert
assert_eq!(response.status(), 200);
let updated_bag: Bag = response.json().await.expect("Failed to parse response");
assert!(updated_bag.closed);
assert!(updated_bag.finished_at.is_some());
assert_eq!(
updated_bag.finished_at.unwrap(),
chrono::Utc::now().date_naive()
);
}

View file

@ -326,3 +326,92 @@ async fn timeline_chunk_endpoint_serves_remaining_events() {
"Expected chunk to clear next URL once exhausted"
);
}
#[tokio::test]
async fn closing_a_bag_surfaces_on_the_timeline() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let roaster_id = create_roaster_with_payload(
&app,
NewRoaster {
name: "Bag Finish Timeline Roasters".to_string(),
country: "UK".to_string(),
city: Some("Bristol".to_string()),
homepage: Some("https://example.com".to_string()),
notes: None,
},
)
.await
.id;
sleep(Duration::from_millis(5)).await;
let roast_name = "Bag Finish Timeline Roast";
create_roast(&app, roaster_id, roast_name).await;
// Fetch the roast to get its ID
let roasts_response = client
.get(app.api_url("/roasts"))
.send()
.await
.expect("failed to fetch roasts");
let roasts: Vec<brewlog::domain::roasts::RoastWithRoaster> = roasts_response
.json()
.await
.expect("failed to parse roasts");
let roast_id = roasts.first().unwrap().roast.id;
// Create a bag
let bag_submission = serde_json::json!({
"roast_id": roast_id,
"roast_date": "2023-01-01",
"amount": 250.0
});
let response = client
.post(app.api_url("/bags"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&bag_submission)
.send()
.await
.expect("failed to create bag");
let bag: brewlog::domain::bags::Bag = response.json().await.expect("failed to parse bag");
sleep(Duration::from_millis(10)).await;
// Close the bag
let update_submission = serde_json::json!({
"closed": true
});
let response = client
.put(app.api_url(&format!("/bags/{}", bag.id)))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&update_submission)
.send()
.await
.expect("failed to update bag");
assert_eq!(response.status(), 200);
sleep(Duration::from_millis(10)).await;
let response = client
.get(format!("{}/timeline", app.address))
.send()
.await
.expect("failed to fetch timeline");
assert_eq!(response.status(), 200);
let body = response.text().await.expect("failed to read response body");
assert!(
body.contains("Bag Finished"),
"Expected 'Bag Finished' badge in timeline HTML, got: {body}"
);
assert!(
body.contains(&format!("Finished: {}", roast_name)),
"Expected bag finished title to appear in timeline HTML, got: {body}"
);
}