From 144257c2c8259c896d4fb6e78cf767caed4ae4fd Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Thu, 27 Nov 2025 14:42:38 +0000 Subject: [PATCH] fix: ensure bags are closed properly and closures are on the timeline --- src/application/routes/bags.rs | 38 +++++++++------ tests/server/bags_api.rs | 51 +++++++++++++++++++ tests/server/timeline.rs | 89 ++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 16 deletions(-) diff --git a/src/application/routes/bags.rs b/src/application/routes/bags.rs index 06c2251..8b42a18 100644 --- a/src/application/routes/bags.rs +++ b/src/application/routes/bags.rs @@ -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; + } } } diff --git a/tests/server/bags_api.rs b/tests/server/bags_api.rs index cfb7306..73b676c 100644 --- a/tests/server/bags_api.rs +++ b/tests/server/bags_api.rs @@ -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() + ); +} diff --git a/tests/server/timeline.rs b/tests/server/timeline.rs index 8c0bbdc..46f362c 100644 --- a/tests/server/timeline.rs +++ b/tests/server/timeline.rs @@ -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 = 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}" + ); +}