From ec8dbea7eaafa1a3057eb8ad8347342feafad391 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Thu, 27 Nov 2025 13:35:04 +0000 Subject: [PATCH] test: add server tests for bags --- tests/server/bags_api.rs | 246 +++++++++++++++++++++++++++++++++++++++ tests/server/helpers.rs | 52 ++++++++- tests/server/main.rs | 1 + tests/server/timeline.rs | 73 ++++++++++++ 4 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 tests/server/bags_api.rs diff --git a/tests/server/bags_api.rs b/tests/server/bags_api.rs new file mode 100644 index 0000000..cfb7306 --- /dev/null +++ b/tests/server/bags_api.rs @@ -0,0 +1,246 @@ +use crate::helpers::{ + create_default_roast, create_default_roaster, spawn_app, spawn_app_with_auth, +}; +use brewlog::domain::bags::{Bag, BagWithRoast, NewBag, UpdateBag}; +use chrono::NaiveDate; + +#[tokio::test] +async fn creating_a_bag_returns_a_201_for_valid_data() { + // 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: Some(NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()), + amount: 250.0, + }; + + // Act + let response = client + .post(app.api_url("/bags")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_bag) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 201); + + let bag: Bag = response.json().await.expect("Failed to parse response"); + assert_eq!(bag.roast_id, roast.id); + assert_eq!(bag.amount, 250.0); + assert_eq!(bag.remaining, 250.0); + assert_eq!( + bag.roast_date, + Some(NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()) + ); +} + +#[tokio::test] +async fn creating_a_bag_without_auth_returns_401() { + // Arrange + let app = spawn_app().await; // No auth + let client = reqwest::Client::new(); + + // We can't easily create a roast without auth, so we'll just try to create a bag + // with a dummy ID. The auth check should happen before validation. + let new_bag = serde_json::json!({ + "roast_id": 123, + "amount": 250.0 + }); + + // Act + let response = client + .post(app.api_url("/bags")) + .json(&new_bag) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 401); +} + +#[tokio::test] +async fn listing_bags_returns_200_and_correct_data() { + // 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(); + + // Create a bag + let new_bag = NewBag { + roast_id: roast.id, + roast_date: None, + amount: 500.0, + }; + + client + .post(app.api_url("/bags")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_bag) + .send() + .await + .expect("Failed to create bag"); + + // Act + let response = client + .get(app.api_url(&format!("/bags?roast_id={}", roast.id))) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 200); + + let bags: Vec = response.json().await.expect("Failed to parse response"); + assert_eq!(bags.len(), 1); + assert_eq!(bags[0].bag.amount, 500.0); + assert_eq!(bags[0].roast_name, roast.name); +} + +#[tokio::test] +async fn getting_a_bag_returns_200_for_valid_id() { + // 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"); + + // Act + let response = client + .get(app.api_url(&format!("/bags/{}", created_bag.id))) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 200); + let bag: Bag = response.json().await.expect("Failed to parse response"); + assert_eq!(bag.id, created_bag.id); +} + +#[tokio::test] +async fn updating_a_bag_returns_200_and_updates_data() { + // 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 = UpdateBag { + remaining: Some(100.0), + closed: Some(true), + finished_at: Some(NaiveDate::from_ymd_opt(2023, 2, 1).unwrap()), + }; + + // 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_eq!(updated_bag.remaining, 100.0); + assert!(updated_bag.closed); + assert_eq!( + updated_bag.finished_at, + Some(NaiveDate::from_ymd_opt(2023, 2, 1).unwrap()) + ); +} + +#[tokio::test] +async fn deleting_a_bag_returns_204() { + // 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"); + + // Act + let response = client + .delete(app.api_url(&format!("/bags/{}", created_bag.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .send() + .await + .expect("Failed to execute request"); + + // Assert + assert_eq!(response.status(), 204); + + // Verify deletion + let get_response = client + .get(app.api_url(&format!("/bags/{}", created_bag.id))) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(get_response.status(), 404); +} diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index a933610..6b03b3a 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -10,6 +10,7 @@ use brewlog::domain::roasters::{NewRoaster, Roaster}; use brewlog::domain::users::NewUser; use brewlog::infrastructure::auth::hash_password; use brewlog::infrastructure::database::Database; +use brewlog::infrastructure::repositories::bags::SqlBagRepository; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; use brewlog::infrastructure::repositories::sessions::SqlSessionRepository; @@ -53,6 +54,7 @@ pub async fn spawn_app() -> TestApp { // Create repositories let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); + let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); let user_repo: Arc = Arc::new(SqlUserRepository::new(database.clone_pool())); @@ -65,6 +67,7 @@ pub async fn spawn_app() -> TestApp { let state = AppState::new( roaster_repo.clone(), roast_repo.clone(), + bag_repo.clone(), timeline_repo.clone(), user_repo.clone(), token_repo.clone(), @@ -154,6 +157,51 @@ pub async fn create_roaster_with_payload(app: &TestApp, payload: NewRoaster) -> .expect("failed to deserialize roaster from response") } +pub async fn create_roast_with_payload( + app: &TestApp, + payload: brewlog::domain::roasts::NewRoast, +) -> brewlog::domain::roasts::Roast { + let client = Client::new(); + let mut request = client.post(app.api_url("/roasts")).json(&payload); + + if let Some(token) = &app.auth_token { + request = request.bearer_auth(token); + } + + let response = request + .send() + .await + .expect("failed to create roast via API"); + + response + .json() + .await + .expect("failed to deserialize roast from response") +} + +pub async fn create_default_roaster(app: &TestApp) -> Roaster { + create_roaster_with_name(app, "Test Roasters").await +} + +pub async fn create_default_roast( + app: &TestApp, + roaster_id: brewlog::domain::ids::RoasterId, +) -> brewlog::domain::roasts::Roast { + create_roast_with_payload( + app, + brewlog::domain::roasts::NewRoast { + roaster_id, + name: "Test Roast".to_string(), + origin: "Ethiopia".to_string(), + region: "Yirgacheffe".to_string(), + producer: "Coop".to_string(), + tasting_notes: vec!["Blueberry".to_string()], + process: "Washed".to_string(), + }, + ) + .await +} + pub async fn create_roaster_with_name(app: &TestApp, name: &str) -> Roaster { create_roaster_with_payload( app, @@ -167,7 +215,3 @@ pub async fn create_roaster_with_name(app: &TestApp, name: &str) -> Roaster { ) .await } - -pub async fn create_default_roaster(app: &TestApp) -> Roaster { - create_roaster_with_name(app, "Test Roasters").await -} diff --git a/tests/server/main.rs b/tests/server/main.rs index bd2ba0b..1aa4fd3 100644 --- a/tests/server/main.rs +++ b/tests/server/main.rs @@ -1,4 +1,5 @@ pub mod auth_api; +pub mod bags_api; pub mod helpers; pub mod roasters_api; pub mod roasts_api; diff --git a/tests/server/timeline.rs b/tests/server/timeline.rs index 38d5841..8c0bbdc 100644 --- a/tests/server/timeline.rs +++ b/tests/server/timeline.rs @@ -168,6 +168,79 @@ async fn creating_a_roast_surfaces_on_the_timeline() { ); } +#[tokio::test] +async fn creating_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 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 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"); + + assert_eq!(roasts_response.status(), 200); + 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"); + assert_eq!(response.status(), 201); + + 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 Added"), + "Expected 'Bag Added' badge in timeline HTML, got: {body}" + ); + assert!( + body.contains(&format!("{}", roast_name)), + "Expected bag title to appear in timeline HTML, got: {body}" + ); +} + #[tokio::test] async fn timeline_page_signals_more_results_when_over_page_size() { let app = spawn_app_with_auth().await;