test: add simple tests for timeline view

This commit is contained in:
Jon Seager 2025-11-24 19:15:45 +00:00
parent 9ca4b4930d
commit f60e759935
No known key found for this signature in database
4 changed files with 206 additions and 48 deletions

View file

@ -1,12 +1,14 @@
use std::sync::Arc; use std::sync::Arc;
use brewlog::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository}; use brewlog::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository};
use brewlog::domain::roasters::{NewRoaster, Roaster};
use brewlog::infrastructure::database::Database; use brewlog::infrastructure::database::Database;
use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository;
use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository;
use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
use brewlog::server::routes::app_router; use brewlog::server::routes::app_router;
use brewlog::server::server::AppState; use brewlog::server::server::AppState;
use reqwest::Client;
use tokio::net::TcpListener; use tokio::net::TcpListener;
pub struct TestApp { pub struct TestApp {
@ -30,7 +32,10 @@ pub async fn spawn_app() -> TestApp {
.expect("Failed to connect to in-memory database"); .expect("Failed to connect to in-memory database");
// Run migrations // Run migrations
database.migrate().await.expect("Failed to migrate database"); database
.migrate()
.await
.expect("Failed to migrate database");
// Create repositories // Create repositories
let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
@ -69,3 +74,36 @@ pub async fn spawn_app() -> TestApp {
timeline_repo, 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
}

View file

@ -1,3 +1,4 @@
pub mod helpers; pub mod helpers;
pub mod roasters_api; pub mod roasters_api;
pub mod roasts_api; pub mod roasts_api;
pub mod timeline;

View file

@ -1,44 +1,11 @@
use crate::helpers::spawn_app; use crate::helpers::{create_default_roaster, create_roaster_with_name, spawn_app};
use brewlog::domain::roasters::NewRoaster;
use brewlog::domain::roasts::{NewRoast, Roast, RoastWithRoaster}; 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] #[tokio::test]
async fn creating_a_roast_returns_a_201_for_valid_data() { async fn creating_a_roast_returns_a_201_for_valid_data() {
// Arrange // Arrange
let app = spawn_app().await; 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 client = reqwest::Client::new();
let new_roast = NewRoast { 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() { async fn creating_a_roast_persists_the_data() {
// Arrange // Arrange
let app = spawn_app().await; 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 client = reqwest::Client::new();
let new_roast = NewRoast { 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() { async fn getting_a_roast_returns_a_200_for_valid_id() {
// Arrange // Arrange
let app = spawn_app().await; 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 client = reqwest::Client::new();
let new_roast = NewRoast { let new_roast = NewRoast {
@ -167,7 +134,10 @@ async fn getting_a_roast_returns_a_200_for_valid_id() {
.await .await
.expect("Failed to create roast"); .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 // Act
let response = client 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() { async fn listing_roasts_returns_a_200_with_multiple_roasts() {
// Arrange // Arrange
let app = spawn_app().await; 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 client = reqwest::Client::new();
// Create multiple roasts // 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() { async fn listing_roasts_by_roaster_returns_a_200_with_filtered_list() {
// Arrange // Arrange
let app = spawn_app().await; let app = spawn_app().await;
let roaster1_id = create_test_roaster(&app).await; let roaster1_id = create_default_roaster(&app).await.id;
let roaster2_id = create_test_roaster_with_name(&app, "Second Roasters").await; let roaster2_id = create_roaster_with_name(&app, "Second Roasters").await.id;
let client = reqwest::Client::new(); let client = reqwest::Client::new();
// Create roasts for both roasters // 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() { async fn deleting_a_roast_returns_a_204_for_valid_id() {
// Arrange // Arrange
let app = spawn_app().await; 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 client = reqwest::Client::new();
let new_roast = NewRoast { let new_roast = NewRoast {
@ -360,7 +330,10 @@ async fn deleting_a_roast_returns_a_204_for_valid_id() {
.await .await
.expect("Failed to create roast"); .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 // Act
let response = client 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() { async fn creating_a_roast_with_empty_name_returns_a_400() {
// Arrange // Arrange
let app = spawn_app().await; 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 client = reqwest::Client::new();
// Act - Create roast with empty name (after trim) // 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() { async fn creating_a_roast_with_missing_required_fields_returns_a_400() {
// Arrange // Arrange
let app = spawn_app().await; 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 client = reqwest::Client::new();
// Act - Missing 'origin' field // 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() { async fn creating_a_roast_with_empty_tasting_notes_returns_a_400() {
// Arrange // Arrange
let app = spawn_app().await; 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 client = reqwest::Client::new();
// Act - Empty tasting notes // Act - Empty tasting notes

146
tests/server/timeline.rs Normal file
View file

@ -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}"
);
}