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:
Jon Seager 2026-02-06 14:26:54 +00:00
parent f31f87d4e1
commit ad4ee2617a
No known key found for this signature in database
9 changed files with 259 additions and 368 deletions

1
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

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

View file

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

View file

@ -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

View file

@ -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;

View file

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

View file

@ -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
View 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;