brewlog/tests/server/bags_api.rs
Jon Seager 590868e51f
test: add integration tests for webauthn, passkeys, and input validation
Add webauthn API tests (register, auth, passkey add flows), passkey
endpoint auth tests, bag validation (zero/negative amount, invalid date),
brew validation (zero weight, negative grind, zero water, temp > 100),
and fix flaky date assertion in bag close test.
2026-02-13 13:06:27 +00:00

435 lines
12 KiB
Rust

use crate::helpers::{
create_default_bag, create_default_gear, create_default_roast, create_default_roaster,
spawn_app, spawn_app_with_auth,
};
use crate::test_macros::define_crud_tests;
use brewlog::domain::bags::{Bag, BagWithRoast, NewBag, UpdateBag};
use chrono::NaiveDate;
define_crud_tests!(
entity: bag,
path: "/bags",
list_type: BagWithRoast
);
#[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,
created_at: None,
};
// 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,
created_at: None,
};
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,
created_at: None,
};
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,
created_at: None,
};
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()),
..Default::default()
};
// 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,
created_at: None,
};
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);
}
#[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,
created_at: None,
};
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());
// Accept today or yesterday to avoid midnight-boundary flakiness
let today = chrono::Utc::now().date_naive();
let yesterday = today - chrono::Duration::days(1);
let finished = updated_bag.finished_at.unwrap();
assert!(
finished == today || finished == yesterday,
"expected finished_at to be today or yesterday, got {finished}"
);
}
#[tokio::test]
async fn updating_bag_amount_recomputes_remaining() {
// Arrange: create a bag and brew from it to consume some coffee
let app = spawn_app_with_auth().await;
let roaster = create_default_roaster(&app).await;
let roast = create_default_roast(&app, roaster.id).await;
let bag = create_default_bag(&app, roast.id).await;
let grinder = create_default_gear(&app, "grinder", "Comandante", "C40 MK4").await;
let brewer = create_default_gear(&app, "brewer", "Hario", "V60 02").await;
let client = reqwest::Client::new();
// Brew 15g from the 250g bag → remaining should be 235g
let new_brew = brewlog::domain::brews::NewBrew {
bag_id: bag.id,
coffee_weight: 15.0,
grinder_id: grinder.id,
grind_setting: 24.0,
brewer_id: brewer.id,
filter_paper_id: None,
water_volume: 250,
water_temp: 92.0,
quick_notes: Vec::new(),
brew_time: None,
created_at: None,
};
client
.post(app.api_url("/brews"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&new_brew)
.send()
.await
.expect("Failed to create brew");
// Act: update the bag amount from 250g to 500g
let update_payload = UpdateBag {
amount: Some(500.0),
..Default::default()
};
let response = client
.put(app.api_url(&format!("/bags/{}", bag.id)))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&update_payload)
.send()
.await
.expect("Failed to execute request");
// Assert: remaining should be 500 - 15 = 485 (recomputed from consumed)
assert_eq!(response.status(), 200);
let updated_bag: Bag = response.json().await.expect("Failed to parse response");
assert_eq!(updated_bag.amount, 500.0);
assert_eq!(updated_bag.remaining, 485.0);
}
#[tokio::test]
async fn creating_bag_with_zero_amount_returns_400() {
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 response = client
.post(app.api_url("/bags"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&serde_json::json!({
"roast_id": roast.id,
"amount": 0.0
}))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 400);
}
#[tokio::test]
async fn creating_bag_with_negative_amount_returns_400() {
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 response = client
.post(app.api_url("/bags"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&serde_json::json!({
"roast_id": roast.id,
"amount": -100.0
}))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 400);
}
#[tokio::test]
async fn creating_bag_with_invalid_date_returns_400() {
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 response = client
.post(app.api_url("/bags"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&serde_json::json!({
"roast_id": roast.id,
"roast_date": "not-a-date",
"amount": 250.0
}))
.send()
.await
.expect("Failed to execute request");
assert_eq!(response.status(), 400);
}