refactor(tests): add generic server helpers and CRUD test macros
- Add `paste` dev-dependency for macro identifier concatenation - Add `create_entity<P, R>()` generic helper, convert per-entity creation helpers to thin wrappers - Add `define_crud_tests!` macro generating nonexistent-GET/DELETE-404, empty-list-200, malformed-JSON-400, and missing-fields-400 tests - Apply macro to roasters, cafes, cups, and roasts API tests
This commit is contained in:
parent
f31f87d4e1
commit
ad4ee2617a
9 changed files with 259 additions and 368 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -404,6 +404,7 @@ dependencies = [
|
||||||
"isocountry",
|
"isocountry",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"open",
|
"open",
|
||||||
|
"paste",
|
||||||
"portpicker",
|
"portpicker",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ tempfile = "3.8"
|
||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] }
|
webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] }
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
paste = "1.0.15"
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
name = "cli"
|
name = "cli"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
use crate::helpers::{create_default_cafe, spawn_app_with_auth};
|
use crate::helpers::{create_default_cafe, spawn_app_with_auth};
|
||||||
|
use crate::test_macros::define_crud_tests;
|
||||||
use brewlog::domain::cafes::{Cafe, NewCafe, UpdateCafe};
|
use brewlog::domain::cafes::{Cafe, NewCafe, UpdateCafe};
|
||||||
|
|
||||||
|
define_crud_tests!(
|
||||||
|
entity: cafe,
|
||||||
|
path: "/cafes",
|
||||||
|
list_type: Cafe,
|
||||||
|
malformed_json: r#"{"name": "Test", "city": }"#,
|
||||||
|
missing_fields: r#"{"name": "Test Cafe"}"#
|
||||||
|
);
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn creating_a_cafe_returns_a_201_for_valid_data() {
|
async fn creating_a_cafe_returns_a_201_for_valid_data() {
|
||||||
let app = spawn_app_with_auth().await;
|
let app = spawn_app_with_auth().await;
|
||||||
|
|
@ -96,57 +105,6 @@ async fn creating_a_cafe_requires_authentication() {
|
||||||
assert_eq!(response.status(), 401);
|
assert_eq!(response.status(), 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn creating_a_cafe_with_missing_required_fields_returns_a_400() {
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.post(app.api_url("/cafes"))
|
|
||||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(r#"{"name": "Test Cafe"}"#)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn creating_a_cafe_with_malformed_json_returns_a_400() {
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.post(app.api_url("/cafes"))
|
|
||||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(r#"{"name": "Test", "city": }"#)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn listing_cafes_returns_a_200_with_empty_list() {
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(app.api_url("/cafes"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), 200);
|
|
||||||
|
|
||||||
let cafes: Vec<Cafe> = response.json().await.expect("Failed to parse response");
|
|
||||||
assert_eq!(cafes.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn listing_cafes_returns_a_200_with_multiple_cafes() {
|
async fn listing_cafes_returns_a_200_with_multiple_cafes() {
|
||||||
let app = spawn_app_with_auth().await;
|
let app = spawn_app_with_auth().await;
|
||||||
|
|
@ -217,20 +175,6 @@ async fn getting_a_cafe_returns_a_200_for_valid_id() {
|
||||||
assert_eq!(fetched.name, "Blue Bottle");
|
assert_eq!(fetched.name, "Blue Bottle");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn getting_a_nonexistent_cafe_returns_a_404() {
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(app.api_url("/cafes/999999"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn updating_a_cafe_returns_a_200_for_valid_data() {
|
async fn updating_a_cafe_returns_a_200_for_valid_data() {
|
||||||
let app = spawn_app_with_auth().await;
|
let app = spawn_app_with_auth().await;
|
||||||
|
|
@ -337,18 +281,3 @@ async fn deleting_a_cafe_returns_a_204_for_valid_id() {
|
||||||
|
|
||||||
assert_eq!(get_response.status(), 404);
|
assert_eq!(get_response.status(), 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn deleting_a_nonexistent_cafe_returns_a_404() {
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.delete(app.api_url("/cafes/999999"))
|
|
||||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), 404);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
use crate::helpers::{
|
use crate::helpers::{
|
||||||
create_default_cafe, create_default_roast, create_default_roaster, spawn_app_with_auth,
|
create_default_cafe, create_default_roast, create_default_roaster, spawn_app_with_auth,
|
||||||
};
|
};
|
||||||
|
use crate::test_macros::define_crud_tests;
|
||||||
use brewlog::domain::cups::{Cup, CupWithDetails, NewCup};
|
use brewlog::domain::cups::{Cup, CupWithDetails, NewCup};
|
||||||
use brewlog::domain::ids::{CafeId, RoastId};
|
use brewlog::domain::ids::{CafeId, RoastId};
|
||||||
|
|
||||||
|
define_crud_tests!(
|
||||||
|
entity: cup,
|
||||||
|
path: "/cups",
|
||||||
|
list_type: CupWithDetails
|
||||||
|
);
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn creating_a_cup_returns_a_201_for_valid_data() {
|
async fn creating_a_cup_returns_a_201_for_valid_data() {
|
||||||
let app = spawn_app_with_auth().await;
|
let app = spawn_app_with_auth().await;
|
||||||
|
|
@ -53,23 +60,6 @@ async fn creating_a_cup_requires_authentication() {
|
||||||
assert_eq!(response.status(), 401);
|
assert_eq!(response.status(), 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn listing_cups_returns_a_200_with_empty_list() {
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(app.api_url("/cups"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), 200);
|
|
||||||
|
|
||||||
let cups: Vec<CupWithDetails> = response.json().await.expect("Failed to parse response");
|
|
||||||
assert_eq!(cups.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn listing_cups_returns_a_200_with_enriched_data() {
|
async fn listing_cups_returns_a_200_with_enriched_data() {
|
||||||
let app = spawn_app_with_auth().await;
|
let app = spawn_app_with_auth().await;
|
||||||
|
|
@ -148,20 +138,6 @@ async fn getting_a_cup_returns_a_200_with_details() {
|
||||||
assert_eq!(fetched.cafe_name, "Blue Bottle");
|
assert_eq!(fetched.cafe_name, "Blue Bottle");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn getting_a_nonexistent_cup_returns_a_404() {
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(app.api_url("/cups/999999"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn deleting_a_cup_returns_a_204_for_valid_id() {
|
async fn deleting_a_cup_returns_a_204_for_valid_id() {
|
||||||
let app = spawn_app_with_auth().await;
|
let app = spawn_app_with_auth().await;
|
||||||
|
|
@ -207,18 +183,3 @@ async fn deleting_a_cup_returns_a_204_for_valid_id() {
|
||||||
|
|
||||||
assert_eq!(get_response.status(), 404);
|
assert_eq!(get_response.status(), 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn deleting_a_nonexistent_cup_returns_a_404() {
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.delete(app.api_url("/cups/999999"))
|
|
||||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), 404);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use brewlog::domain::roasters::{NewRoaster, Roaster};
|
||||||
use brewlog::domain::users::NewUser;
|
use brewlog::domain::users::NewUser;
|
||||||
use brewlog::infrastructure::database::Database;
|
use brewlog::infrastructure::database::Database;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
use serde::{Serialize, de::DeserializeOwned};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::task::AbortHandle;
|
use tokio::task::AbortHandle;
|
||||||
use webauthn_rs::prelude::*;
|
use webauthn_rs::prelude::*;
|
||||||
|
|
@ -188,11 +189,16 @@ async fn add_auth_to_app(mut app: TestApp) -> TestApp {
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_roaster_with_payload(app: &TestApp, payload: NewRoaster) -> Roaster {
|
/// Generic helper: POST a JSON payload and deserialize the response.
|
||||||
|
/// Automatically attaches the auth token if the test app has one.
|
||||||
|
pub async fn create_entity<P: Serialize, R: DeserializeOwned>(
|
||||||
|
app: &TestApp,
|
||||||
|
path: &str,
|
||||||
|
payload: &P,
|
||||||
|
) -> R {
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
let mut request = client.post(app.api_url("/roasters")).json(&payload);
|
let mut request = client.post(app.api_url(path)).json(payload);
|
||||||
|
|
||||||
// Add auth token if available
|
|
||||||
if let Some(token) = &app.auth_token {
|
if let Some(token) = &app.auth_token {
|
||||||
request = request.bearer_auth(token);
|
request = request.bearer_auth(token);
|
||||||
}
|
}
|
||||||
|
|
@ -200,34 +206,23 @@ pub async fn create_roaster_with_payload(app: &TestApp, payload: NewRoaster) ->
|
||||||
let response = request
|
let response = request
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("failed to create roaster via API");
|
.unwrap_or_else(|e| panic!("failed to create entity at {path}: {e}"));
|
||||||
|
|
||||||
response
|
response
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.expect("failed to deserialize roaster from response")
|
.unwrap_or_else(|e| panic!("failed to deserialize entity from {path}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_roaster_with_payload(app: &TestApp, payload: NewRoaster) -> Roaster {
|
||||||
|
create_entity(app, "/roasters", &payload).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_roast_with_payload(
|
pub async fn create_roast_with_payload(
|
||||||
app: &TestApp,
|
app: &TestApp,
|
||||||
payload: brewlog::domain::roasts::NewRoast,
|
payload: brewlog::domain::roasts::NewRoast,
|
||||||
) -> brewlog::domain::roasts::Roast {
|
) -> brewlog::domain::roasts::Roast {
|
||||||
let client = Client::new();
|
create_entity(app, "/roasts", &payload).await
|
||||||
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 {
|
pub async fn create_default_roaster(app: &TestApp) -> Roaster {
|
||||||
|
|
@ -270,25 +265,16 @@ pub async fn create_default_bag(
|
||||||
app: &TestApp,
|
app: &TestApp,
|
||||||
roast_id: brewlog::domain::ids::RoastId,
|
roast_id: brewlog::domain::ids::RoastId,
|
||||||
) -> brewlog::domain::bags::Bag {
|
) -> brewlog::domain::bags::Bag {
|
||||||
let client = Client::new();
|
create_entity(
|
||||||
let new_bag = brewlog::domain::bags::NewBag {
|
app,
|
||||||
roast_id,
|
"/bags",
|
||||||
roast_date: Some(chrono::NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()),
|
&brewlog::domain::bags::NewBag {
|
||||||
amount: 250.0,
|
roast_id,
|
||||||
};
|
roast_date: Some(chrono::NaiveDate::from_ymd_opt(2023, 1, 1).unwrap()),
|
||||||
|
amount: 250.0,
|
||||||
let mut request = client.post(app.api_url("/bags")).json(&new_bag);
|
},
|
||||||
|
)
|
||||||
if let Some(token) = &app.auth_token {
|
.await
|
||||||
request = request.bearer_auth(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = request.send().await.expect("failed to create bag via API");
|
|
||||||
|
|
||||||
response
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.expect("failed to deserialize bag from response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asserts that the response has valid Datastar fragment headers
|
/// Asserts that the response has valid Datastar fragment headers
|
||||||
|
|
@ -352,31 +338,22 @@ pub async fn create_default_gear(
|
||||||
make: &str,
|
make: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
) -> brewlog::domain::gear::Gear {
|
) -> brewlog::domain::gear::Gear {
|
||||||
let client = Client::new();
|
|
||||||
let gear_category = match category {
|
let gear_category = match category {
|
||||||
"grinder" => brewlog::domain::gear::GearCategory::Grinder,
|
"grinder" => brewlog::domain::gear::GearCategory::Grinder,
|
||||||
"brewer" => brewlog::domain::gear::GearCategory::Brewer,
|
"brewer" => brewlog::domain::gear::GearCategory::Brewer,
|
||||||
"filter_paper" => brewlog::domain::gear::GearCategory::FilterPaper,
|
"filter_paper" => brewlog::domain::gear::GearCategory::FilterPaper,
|
||||||
_ => panic!("Unknown gear category: {}", category),
|
_ => panic!("Unknown gear category: {}", category),
|
||||||
};
|
};
|
||||||
let new_gear = brewlog::domain::gear::NewGear {
|
create_entity(
|
||||||
category: gear_category,
|
app,
|
||||||
make: make.to_string(),
|
"/gear",
|
||||||
model: model.to_string(),
|
&brewlog::domain::gear::NewGear {
|
||||||
};
|
category: gear_category,
|
||||||
|
make: make.to_string(),
|
||||||
let mut request = client.post(app.api_url("/gear")).json(&new_gear);
|
model: model.to_string(),
|
||||||
|
},
|
||||||
if let Some(token) = &app.auth_token {
|
)
|
||||||
request = request.bearer_auth(token);
|
.await
|
||||||
}
|
|
||||||
|
|
||||||
let response = request.send().await.expect("failed to create gear via API");
|
|
||||||
|
|
||||||
response
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.expect("failed to deserialize gear from response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_default_cafe(app: &TestApp) -> Cafe {
|
pub async fn create_default_cafe(app: &TestApp) -> Cafe {
|
||||||
|
|
@ -395,19 +372,7 @@ pub async fn create_default_cafe(app: &TestApp) -> Cafe {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_cafe_with_payload(app: &TestApp, payload: NewCafe) -> Cafe {
|
pub async fn create_cafe_with_payload(app: &TestApp, payload: NewCafe) -> Cafe {
|
||||||
let client = Client::new();
|
create_entity(app, "/cafes", &payload).await
|
||||||
let mut request = client.post(app.api_url("/cafes")).json(&payload);
|
|
||||||
|
|
||||||
if let Some(token) = &app.auth_token {
|
|
||||||
request = request.bearer_auth(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = request.send().await.expect("failed to create cafe via API");
|
|
||||||
|
|
||||||
response
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.expect("failed to deserialize cafe from response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a session for the authenticated user and returns the raw session token
|
/// Creates a session for the authenticated user and returns the raw session token
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,5 @@ pub mod pages;
|
||||||
pub mod roasters_api;
|
pub mod roasters_api;
|
||||||
pub mod roasts_api;
|
pub mod roasts_api;
|
||||||
pub mod scan_api;
|
pub mod scan_api;
|
||||||
|
pub mod test_macros;
|
||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
use crate::helpers::spawn_app_with_auth;
|
use crate::helpers::spawn_app_with_auth;
|
||||||
|
use crate::test_macros::define_crud_tests;
|
||||||
use brewlog::domain::roasters::{NewRoaster, Roaster, UpdateRoaster};
|
use brewlog::domain::roasters::{NewRoaster, Roaster, UpdateRoaster};
|
||||||
|
|
||||||
|
define_crud_tests!(
|
||||||
|
entity: roaster,
|
||||||
|
path: "/roasters",
|
||||||
|
list_type: Roaster,
|
||||||
|
malformed_json: r#"{"name": "Test", "country": }"#,
|
||||||
|
missing_fields: r#"{"name": "Test Roasters"}"#
|
||||||
|
);
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn creating_a_roaster_returns_a_201_for_valid_data() {
|
async fn creating_a_roaster_returns_a_201_for_valid_data() {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
@ -110,43 +119,6 @@ async fn getting_a_roaster_returns_a_200_for_valid_id() {
|
||||||
assert_eq!(roaster.name, "Fetchable Roasters");
|
assert_eq!(roaster.name, "Fetchable Roasters");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn getting_a_nonexistent_roaster_returns_a_404() {
|
|
||||||
// Arrange
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
let response = client
|
|
||||||
.get(app.api_url("/roasters/999999"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn listing_roasters_returns_a_200_with_empty_list() {
|
|
||||||
// Arrange
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
let response = client
|
|
||||||
.get(app.api_url("/roasters"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 200);
|
|
||||||
|
|
||||||
let roasters: Vec<Roaster> = response.json().await.expect("Failed to parse response");
|
|
||||||
assert_eq!(roasters.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn listing_roasters_returns_a_200_with_multiple_roasters() {
|
async fn listing_roasters_returns_a_200_with_multiple_roasters() {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
@ -372,24 +344,6 @@ async fn deleting_a_roaster_returns_a_204_for_valid_id() {
|
||||||
assert_eq!(get_response.status(), 404);
|
assert_eq!(get_response.status(), 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn deleting_a_nonexistent_roaster_returns_a_404() {
|
|
||||||
// Arrange
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
let response = client
|
|
||||||
.delete(app.api_url("/roasters/999999"))
|
|
||||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn creating_a_roaster_with_empty_name_returns_a_201_after_normalization() {
|
async fn creating_a_roaster_with_empty_name_returns_a_201_after_normalization() {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
@ -416,43 +370,3 @@ async fn creating_a_roaster_with_empty_name_returns_a_201_after_normalization()
|
||||||
// Assert - The API accepts this but normalizes to empty string
|
// Assert - The API accepts this but normalizes to empty string
|
||||||
assert_eq!(response.status(), 201);
|
assert_eq!(response.status(), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn creating_a_roaster_with_malformed_json_returns_a_400() {
|
|
||||||
// Arrange
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
let response = client
|
|
||||||
.post(app.api_url("/roasters"))
|
|
||||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(r#"{"name": "Test", "country": }"#) // Invalid JSON
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn creating_a_roaster_with_missing_required_fields_returns_a_400() {
|
|
||||||
// Arrange
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Act - Missing 'country' field
|
|
||||||
let response = client
|
|
||||||
.post(app.api_url("/roasters"))
|
|
||||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(r#"{"name": "Test Roasters"}"#)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 400);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
use crate::helpers::{create_default_roaster, create_roaster_with_name, spawn_app_with_auth};
|
use crate::helpers::{create_default_roaster, create_roaster_with_name, spawn_app_with_auth};
|
||||||
|
use crate::test_macros::define_crud_tests;
|
||||||
use brewlog::domain::ids::RoasterId;
|
use brewlog::domain::ids::RoasterId;
|
||||||
use brewlog::domain::roasts::{NewRoast, Roast, RoastWithRoaster};
|
use brewlog::domain::roasts::{NewRoast, Roast, RoastWithRoaster};
|
||||||
|
|
||||||
|
define_crud_tests!(
|
||||||
|
entity: roast,
|
||||||
|
path: "/roasts",
|
||||||
|
list_type: RoastWithRoaster,
|
||||||
|
malformed_json: r#"{"name": "Test", "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
|
||||||
|
|
@ -159,43 +167,6 @@ async fn getting_a_roast_returns_a_200_for_valid_id() {
|
||||||
assert_eq!(roast.name, "Kenyan AA");
|
assert_eq!(roast.name, "Kenyan AA");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn getting_a_nonexistent_roast_returns_a_404() {
|
|
||||||
// Arrange
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
let response = client
|
|
||||||
.get(app.api_url("/roasts/999999"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn listing_roasts_returns_a_200_with_empty_list() {
|
|
||||||
// Arrange
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
let response = client
|
|
||||||
.get(app.api_url("/roasts"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 200);
|
|
||||||
|
|
||||||
let roasts: Vec<RoastWithRoaster> = response.json().await.expect("Failed to parse response");
|
|
||||||
assert_eq!(roasts.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn listing_roasts_returns_a_200_with_multiple_roasts() {
|
async fn listing_roasts_returns_a_200_with_multiple_roasts() {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
@ -366,24 +337,6 @@ async fn deleting_a_roast_returns_a_204_for_valid_id() {
|
||||||
assert_eq!(get_response.status(), 404);
|
assert_eq!(get_response.status(), 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn deleting_a_nonexistent_roast_returns_a_404() {
|
|
||||||
// Arrange
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
let response = client
|
|
||||||
.delete(app.api_url("/roasts/999999"))
|
|
||||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn creating_a_roast_with_empty_name_returns_a_400() {
|
async fn creating_a_roast_with_empty_name_returns_a_400() {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
@ -478,23 +431,3 @@ async fn creating_a_roast_with_empty_tasting_notes_returns_a_400() {
|
||||||
// Assert
|
// Assert
|
||||||
assert_eq!(response.status(), 400);
|
assert_eq!(response.status(), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn creating_a_roast_with_malformed_json_returns_a_400() {
|
|
||||||
// Arrange
|
|
||||||
let app = spawn_app_with_auth().await;
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
let response = client
|
|
||||||
.post(app.api_url("/roasts"))
|
|
||||||
.bearer_auth(app.auth_token.as_ref().unwrap())
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(r#"{"name": "Test", "roaster_id": }"#) // Invalid JSON
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.expect("Failed to execute request");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
assert_eq!(response.status(), 400);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
186
tests/server/test_macros.rs
Normal file
186
tests/server/test_macros.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
/// Generates mechanical CRUD tests that are identical across entities.
|
||||||
|
/// Entity-specific tests remain hand-written in each file.
|
||||||
|
macro_rules! define_crud_tests {
|
||||||
|
(
|
||||||
|
entity: $entity:ident,
|
||||||
|
path: $path:expr,
|
||||||
|
list_type: $list_type:ty
|
||||||
|
$(, malformed_json: $malformed:expr)?
|
||||||
|
$(, missing_fields: $missing:expr)?
|
||||||
|
) => {
|
||||||
|
paste::paste! {
|
||||||
|
#[tokio::test]
|
||||||
|
async fn [<getting_a_nonexistent_ $entity _returns_a_404>]() {
|
||||||
|
let app = crate::helpers::spawn_app_with_auth().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(app.api_url(&format!("{}/999999", $path)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn [<deleting_a_nonexistent_ $entity _returns_a_404>]() {
|
||||||
|
let app = crate::helpers::spawn_app_with_auth().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.delete(app.api_url(&format!("{}/999999", $path)))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn [<listing_ $entity s_returns_a_200_with_empty_list>]() {
|
||||||
|
let app = crate::helpers::spawn_app_with_auth().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(app.api_url($path))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
|
||||||
|
let items: Vec<$list_type> =
|
||||||
|
response.json().await.expect("Failed to parse response");
|
||||||
|
assert_eq!(items.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$(
|
||||||
|
#[tokio::test]
|
||||||
|
async fn [<creating_a_ $entity _with_malformed_json_returns_a_400>]() {
|
||||||
|
let app = crate::helpers::spawn_app_with_auth().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(app.api_url($path))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body($malformed)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 400);
|
||||||
|
}
|
||||||
|
)?
|
||||||
|
|
||||||
|
$(
|
||||||
|
#[tokio::test]
|
||||||
|
async fn [<creating_a_ $entity _with_missing_required_fields_returns_a_400>]() {
|
||||||
|
let app = crate::helpers::spawn_app_with_auth().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(app.api_url($path))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body($missing)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 400);
|
||||||
|
}
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use define_crud_tests;
|
||||||
|
|
||||||
|
/// Generates list (with/without datastar header) and delete (with datastar header)
|
||||||
|
/// tests for a given entity. The setup function creates an entity and returns its
|
||||||
|
/// ID as a String (used for the delete test; ignored for list tests).
|
||||||
|
macro_rules! define_datastar_entity_tests {
|
||||||
|
(
|
||||||
|
entity: $entity:ident,
|
||||||
|
type_param: $type_param:expr,
|
||||||
|
api_path: $api_path:expr,
|
||||||
|
list_element: $list_element:expr,
|
||||||
|
selector: $selector:expr,
|
||||||
|
setup: $setup:expr
|
||||||
|
) => {
|
||||||
|
paste::paste! {
|
||||||
|
#[tokio::test]
|
||||||
|
async fn [<$entity _list_with_datastar_header_returns_fragment>]() {
|
||||||
|
let app = crate::helpers::spawn_app_with_auth().await;
|
||||||
|
$setup(&app).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(format!("{}/data?type={}", app.address, $type_param))
|
||||||
|
.header("datastar-request", "true")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect(concat!("failed to fetch ", stringify!($entity)));
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
crate::helpers::assert_datastar_headers_with_mode(
|
||||||
|
&response,
|
||||||
|
"#data-content",
|
||||||
|
"inner",
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = response.text().await.expect("failed to read body");
|
||||||
|
crate::helpers::assert_html_fragment(&body);
|
||||||
|
assert!(
|
||||||
|
body.contains($list_element),
|
||||||
|
"Fragment should contain the selector element"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn [<$entity _list_without_datastar_header_returns_full_page>]() {
|
||||||
|
let app = crate::helpers::spawn_app_with_auth().await;
|
||||||
|
$setup(&app).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(format!("{}/data?type={}", app.address, $type_param))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect(concat!("failed to fetch ", stringify!($entity)));
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
assert!(response.headers().get("datastar-selector").is_none());
|
||||||
|
|
||||||
|
let body = response.text().await.expect("failed to read body");
|
||||||
|
crate::helpers::assert_full_page(&body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn [<$entity _delete_with_datastar_header_returns_fragment>]() {
|
||||||
|
let app = crate::helpers::spawn_app_with_auth().await;
|
||||||
|
let entity_id = $setup(&app).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.delete(app.api_url(&format!("{}/{}", $api_path, entity_id)))
|
||||||
|
.bearer_auth(app.auth_token.as_ref().unwrap())
|
||||||
|
.header("datastar-request", "true")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect(concat!("failed to delete ", stringify!($entity)));
|
||||||
|
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
crate::helpers::assert_datastar_headers(&response, $selector);
|
||||||
|
|
||||||
|
let body = response.text().await.expect("failed to read body");
|
||||||
|
crate::helpers::assert_html_fragment(&body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use define_datastar_entity_tests;
|
||||||
Loading…
Reference in a new issue