test: add server tests for bags

This commit is contained in:
Jon Seager 2025-11-27 13:35:04 +00:00
parent 5252cacad0
commit ec8dbea7ea
No known key found for this signature in database
4 changed files with 368 additions and 4 deletions

246
tests/server/bags_api.rs Normal file
View file

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

View file

@ -10,6 +10,7 @@ use brewlog::domain::roasters::{NewRoaster, Roaster};
use brewlog::domain::users::NewUser; use brewlog::domain::users::NewUser;
use brewlog::infrastructure::auth::hash_password; use brewlog::infrastructure::auth::hash_password;
use brewlog::infrastructure::database::Database; use brewlog::infrastructure::database::Database;
use brewlog::infrastructure::repositories::bags::SqlBagRepository;
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::sessions::SqlSessionRepository; use brewlog::infrastructure::repositories::sessions::SqlSessionRepository;
@ -53,6 +54,7 @@ pub async fn spawn_app() -> TestApp {
// Create repositories // Create repositories
let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool()));
let roast_repo = Arc::new(SqlRoastRepository::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 timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
let user_repo: Arc<dyn UserRepository> = let user_repo: Arc<dyn UserRepository> =
Arc::new(SqlUserRepository::new(database.clone_pool())); Arc::new(SqlUserRepository::new(database.clone_pool()));
@ -65,6 +67,7 @@ pub async fn spawn_app() -> TestApp {
let state = AppState::new( let state = AppState::new(
roaster_repo.clone(), roaster_repo.clone(),
roast_repo.clone(), roast_repo.clone(),
bag_repo.clone(),
timeline_repo.clone(), timeline_repo.clone(),
user_repo.clone(), user_repo.clone(),
token_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") .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 { pub async fn create_roaster_with_name(app: &TestApp, name: &str) -> Roaster {
create_roaster_with_payload( create_roaster_with_payload(
app, app,
@ -167,7 +215,3 @@ pub async fn create_roaster_with_name(app: &TestApp, name: &str) -> Roaster {
) )
.await .await
} }
pub async fn create_default_roaster(app: &TestApp) -> Roaster {
create_roaster_with_name(app, "Test Roasters").await
}

View file

@ -1,4 +1,5 @@
pub mod auth_api; pub mod auth_api;
pub mod bags_api;
pub mod helpers; pub mod helpers;
pub mod roasters_api; pub mod roasters_api;
pub mod roasts_api; pub mod roasts_api;

View file

@ -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<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");
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] #[tokio::test]
async fn timeline_page_signals_more_results_when_over_page_size() { async fn timeline_page_signals_more_results_when_over_page_size() {
let app = spawn_app_with_auth().await; let app = spawn_app_with_auth().await;