diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index be080fb..0b09675 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -1,12 +1,14 @@ use std::sync::Arc; use brewlog::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository}; +use brewlog::domain::roasters::{NewRoaster, Roaster}; use brewlog::infrastructure::database::Database; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; use brewlog::server::routes::app_router; use brewlog::server::server::AppState; +use reqwest::Client; use tokio::net::TcpListener; pub struct TestApp { @@ -30,7 +32,10 @@ pub async fn spawn_app() -> TestApp { .expect("Failed to connect to in-memory database"); // Run migrations - database.migrate().await.expect("Failed to migrate database"); + database + .migrate() + .await + .expect("Failed to migrate database"); // Create repositories let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); @@ -69,3 +74,36 @@ pub async fn spawn_app() -> TestApp { timeline_repo, } } + +pub async fn create_roaster_with_payload(app: &TestApp, payload: NewRoaster) -> Roaster { + let client = Client::new(); + let response = client + .post(app.api_url("/roasters")) + .json(&payload) + .send() + .await + .expect("failed to create roaster via API"); + + response + .json() + .await + .expect("failed to deserialize roaster from response") +} + +pub async fn create_roaster_with_name(app: &TestApp, name: &str) -> Roaster { + create_roaster_with_payload( + app, + NewRoaster { + name: name.to_string(), + country: "UK".to_string(), + city: None, + homepage: None, + notes: None, + }, + ) + .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 5948345..f6816b9 100644 --- a/tests/server/main.rs +++ b/tests/server/main.rs @@ -1,3 +1,4 @@ pub mod helpers; pub mod roasters_api; pub mod roasts_api; +pub mod timeline; diff --git a/tests/server/roasts_api.rs b/tests/server/roasts_api.rs index 937bf86..151a290 100644 --- a/tests/server/roasts_api.rs +++ b/tests/server/roasts_api.rs @@ -1,44 +1,11 @@ -use crate::helpers::spawn_app; -use brewlog::domain::roasters::NewRoaster; +use crate::helpers::{create_default_roaster, create_roaster_with_name, spawn_app}; use brewlog::domain::roasts::{NewRoast, Roast, RoastWithRoaster}; -async fn create_test_roaster(app: &crate::helpers::TestApp) -> String { - create_test_roaster_with_name(app, "Test Roasters").await -} - -async fn create_test_roaster_with_name( - app: &crate::helpers::TestApp, - name: &str, -) -> String { - let new_roaster = NewRoaster { - name: name.to_string(), - country: "UK".to_string(), - city: None, - homepage: None, - notes: None, - }; - - let client = reqwest::Client::new(); - let response = client - .post(app.api_url("/roasters")) - .json(&new_roaster) - .send() - .await - .expect("Failed to create roaster"); - - let roaster: brewlog::domain::roasters::Roaster = response - .json() - .await - .expect("Failed to parse roaster"); - - roaster.id -} - #[tokio::test] async fn creating_a_roast_returns_a_201_for_valid_data() { // Arrange let app = spawn_app().await; - let roaster_id = create_test_roaster(&app).await; + let roaster_id = create_default_roaster(&app).await.id; let client = reqwest::Client::new(); let new_roast = NewRoast { @@ -80,7 +47,7 @@ async fn creating_a_roast_returns_a_201_for_valid_data() { async fn creating_a_roast_persists_the_data() { // Arrange let app = spawn_app().await; - let roaster_id = create_test_roaster(&app).await; + let roaster_id = create_default_roaster(&app).await.id; let client = reqwest::Client::new(); let new_roast = NewRoast { @@ -147,7 +114,7 @@ async fn creating_a_roast_with_nonexistent_roaster_returns_a_404() { async fn getting_a_roast_returns_a_200_for_valid_id() { // Arrange let app = spawn_app().await; - let roaster_id = create_test_roaster(&app).await; + let roaster_id = create_default_roaster(&app).await.id; let client = reqwest::Client::new(); let new_roast = NewRoast { @@ -167,7 +134,10 @@ async fn getting_a_roast_returns_a_200_for_valid_id() { .await .expect("Failed to create roast"); - let created_roast: Roast = create_response.json().await.expect("Failed to parse response"); + let created_roast: Roast = create_response + .json() + .await + .expect("Failed to parse response"); // Act let response = client @@ -225,7 +195,7 @@ async fn listing_roasts_returns_a_200_with_empty_list() { async fn listing_roasts_returns_a_200_with_multiple_roasts() { // Arrange let app = spawn_app().await; - let roaster_id = create_test_roaster(&app).await; + let roaster_id = create_default_roaster(&app).await.id; let client = reqwest::Client::new(); // Create multiple roasts @@ -281,9 +251,9 @@ async fn listing_roasts_returns_a_200_with_multiple_roasts() { async fn listing_roasts_by_roaster_returns_a_200_with_filtered_list() { // Arrange let app = spawn_app().await; - let roaster1_id = create_test_roaster(&app).await; - let roaster2_id = create_test_roaster_with_name(&app, "Second Roasters").await; - + let roaster1_id = create_default_roaster(&app).await.id; + let roaster2_id = create_roaster_with_name(&app, "Second Roasters").await.id; + let client = reqwest::Client::new(); // Create roasts for both roasters @@ -340,7 +310,7 @@ async fn listing_roasts_by_roaster_returns_a_200_with_filtered_list() { async fn deleting_a_roast_returns_a_204_for_valid_id() { // Arrange let app = spawn_app().await; - let roaster_id = create_test_roaster(&app).await; + let roaster_id = create_default_roaster(&app).await.id; let client = reqwest::Client::new(); let new_roast = NewRoast { @@ -360,7 +330,10 @@ async fn deleting_a_roast_returns_a_204_for_valid_id() { .await .expect("Failed to create roast"); - let created_roast: Roast = create_response.json().await.expect("Failed to parse response"); + let created_roast: Roast = create_response + .json() + .await + .expect("Failed to parse response"); // Act let response = client @@ -403,7 +376,7 @@ async fn deleting_a_nonexistent_roast_returns_a_404() { async fn creating_a_roast_with_empty_name_returns_a_400() { // Arrange let app = spawn_app().await; - let roaster_id = create_test_roaster(&app).await; + let roaster_id = create_default_roaster(&app).await.id; let client = reqwest::Client::new(); // Act - Create roast with empty name (after trim) @@ -434,7 +407,7 @@ async fn creating_a_roast_with_empty_name_returns_a_400() { async fn creating_a_roast_with_missing_required_fields_returns_a_400() { // Arrange let app = spawn_app().await; - let roaster_id = create_test_roaster(&app).await; + let roaster_id = create_default_roaster(&app).await.id; let client = reqwest::Client::new(); // Act - Missing 'origin' field @@ -464,7 +437,7 @@ async fn creating_a_roast_with_missing_required_fields_returns_a_400() { async fn creating_a_roast_with_empty_tasting_notes_returns_a_400() { // Arrange let app = spawn_app().await; - let roaster_id = create_test_roaster(&app).await; + let roaster_id = create_default_roaster(&app).await.id; let client = reqwest::Client::new(); // Act - Empty tasting notes diff --git a/tests/server/timeline.rs b/tests/server/timeline.rs new file mode 100644 index 0000000..bd7ac91 --- /dev/null +++ b/tests/server/timeline.rs @@ -0,0 +1,146 @@ +use crate::helpers::{create_roaster_with_payload, spawn_app}; +use brewlog::domain::roasters::NewRoaster; +use brewlog::domain::roasts::NewRoast; +use reqwest::Client; +use tokio::time::{Duration, sleep}; + +async fn create_roast(app: &crate::helpers::TestApp, roaster_id: &str, name: &str) { + let client = Client::new(); + let roast = NewRoast { + roaster_id: roaster_id.to_string(), + name: name.to_string(), + origin: "Ethiopia".to_string(), + region: "Yirgacheffe".to_string(), + producer: "Chelbesa Cooperative".to_string(), + tasting_notes: vec!["Blueberry".to_string(), "Jasmine".to_string()], + process: "Washed".to_string(), + }; + + let response = client + .post(app.api_url("/roasts")) + .json(&roast) + .send() + .await + .expect("failed to create roast"); + + assert_eq!(response.status(), 201); +} + +#[tokio::test] +async fn timeline_page_returns_a_200_with_empty_state() { + // Arrange + let app = spawn_app().await; + let client = Client::new(); + + // Act + let response = client + .get(format!("{}/timeline", app.address)) + .send() + .await + .expect("failed to fetch timeline"); + + // Assert + assert_eq!(response.status(), 200); + + let body = response.text().await.expect("failed to read response body"); + assert!( + body.contains("No events yet"), + "Expected empty timeline state message, got: {body}" + ); +} + +#[tokio::test] +async fn creating_a_roaster_surfaces_on_the_timeline() { + // Arrange + let app = spawn_app().await; + let client = Client::new(); + + let roaster_name = "Timeline Roasters"; + let roaster = create_roaster_with_payload( + &app, + NewRoaster { + name: roaster_name.to_string(), + country: "UK".to_string(), + city: Some("Bristol".to_string()), + homepage: Some("https://example.com".to_string()), + notes: None, + }, + ) + .await; + let roaster_id = roaster.id.clone(); + + // Give the database a brief moment to commit timestamps + sleep(Duration::from_millis(10)).await; + + // Act + let response = client + .get(format!("{}/timeline", app.address)) + .send() + .await + .expect("failed to fetch timeline"); + + // Assert + assert_eq!(response.status(), 200); + + let body = response.text().await.expect("failed to read response body"); + assert!( + body.contains("Roaster Added"), + "Expected 'Roaster Added' badge in timeline HTML, got: {body}" + ); + assert!( + body.contains(roaster_name), + "Expected roaster name to appear in timeline HTML, got: {body}" + ); + assert!( + body.contains(&format!("/roasters/{}", roaster_id)), + "Expected roaster detail link in timeline HTML, got: {body}" + ); +} + +#[tokio::test] +async fn creating_a_roast_surfaces_on_the_timeline() { + // Arrange + let app = spawn_app().await; + let client = Client::new(); + + let roaster_id = create_roaster_with_payload( + &app, + NewRoaster { + name: "Timeline Roast Roasters".to_string(), + country: "UK".to_string(), + city: Some("Bristol".to_string()), + homepage: Some("https://example.com".to_string()), + notes: None, + }, + ) + .await + .id; + // Ensure the roast event occurs after the roaster event to make ordering deterministic + sleep(Duration::from_millis(5)).await; + let roast_name = "Timeline Natural"; + create_roast(&app, &roaster_id, roast_name).await; + + // Act + let response = client + .get(format!("{}/timeline", app.address)) + .send() + .await + .expect("failed to fetch timeline"); + + // Assert + assert_eq!(response.status(), 200); + + let body = response.text().await.expect("failed to read response body"); + assert!( + body.contains("Roast Added"), + "Expected 'Roast Added' badge in timeline HTML, got: {body}" + ); + assert!( + body.contains(roast_name), + "Expected roast name to appear in timeline HTML, got: {body}" + ); + assert!( + body.contains("Blueberry"), + "Expected tasting notes to appear in timeline HTML, got: {body}" + ); +}