brewlog/tests/server/datastar.rs
Jon Seager 3fccfa2cd0
test: update test struct initializers for created_at field
Add `created_at: None` to all New*/Update* struct constructions in
integration tests so they compile with the new optional field.
2026-02-07 09:48:26 +00:00

583 lines
18 KiB
Rust

//! Integration tests for Datastar partial rendering.
//!
//! These tests verify that endpoints return HTML fragments with correct
//! Datastar headers when the `datastar-request: true` header is present.
use crate::helpers::{
TestApp, assert_datastar_headers, assert_full_page, assert_html_fragment, create_default_bag,
create_default_cafe, create_default_gear, create_default_roast, create_default_roaster,
spawn_app_with_auth,
};
use crate::test_macros::define_datastar_entity_tests;
use brewlog::domain::bags::UpdateBag;
use brewlog::domain::cafes::NewCafe;
use brewlog::domain::roasters::NewRoaster;
use brewlog::domain::roasts::NewRoast;
use reqwest::Client;
// ============================================================================
// Setup functions (return entity ID as String for delete tests)
// ============================================================================
async fn create_roaster_entity(app: &TestApp) -> String {
create_default_roaster(app).await.id.to_string()
}
async fn create_roast_entity(app: &TestApp) -> String {
let roaster = create_default_roaster(app).await;
create_default_roast(app, roaster.id).await.id.to_string()
}
async fn create_bag_entity(app: &TestApp) -> String {
let roaster = create_default_roaster(app).await;
let roast = create_default_roast(app, roaster.id).await;
create_default_bag(app, roast.id).await.id.to_string()
}
async fn create_gear_entity(app: &TestApp) -> String {
create_default_gear(app, "grinder", "Baratza", "Encore")
.await
.id
.to_string()
}
async fn create_cafe_entity(app: &TestApp) -> String {
create_default_cafe(app).await.id.to_string()
}
// ============================================================================
// Macro-generated tests: list with/without + delete with datastar header
// ============================================================================
define_datastar_entity_tests!(
entity: roasters,
type_param: "roasters",
api_path: "/roasters",
list_element: r#"id="roaster-list""#,
selector: "#roaster-list",
setup: create_roaster_entity
);
define_datastar_entity_tests!(
entity: roasts,
type_param: "roasts",
api_path: "/roasts",
list_element: r#"id="roast-list""#,
selector: "#roast-list",
setup: create_roast_entity
);
define_datastar_entity_tests!(
entity: bags,
type_param: "bags",
api_path: "/bags",
list_element: r#"id="bag-list""#,
selector: "#bag-list",
setup: create_bag_entity
);
define_datastar_entity_tests!(
entity: gear,
type_param: "gear",
api_path: "/gear",
list_element: r#"id="gear-list""#,
selector: "#gear-list",
setup: create_gear_entity
);
define_datastar_entity_tests!(
entity: cafes,
type_param: "cafes",
api_path: "/cafes",
list_element: r#"id="cafe-list""#,
selector: "#cafe-list",
setup: create_cafe_entity
);
// ============================================================================
// Roasters (hand-written: create with/without, delete without)
// ============================================================================
#[tokio::test]
async fn roasters_create_with_datastar_header_returns_fragment() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let new_roaster = NewRoaster {
name: "Datastar Test Roasters".to_string(),
country: "UK".to_string(),
city: None,
homepage: None,
created_at: None,
};
let response = client
.post(app.api_url("/roasters"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true")
.json(&new_roaster)
.send()
.await
.expect("failed to create roaster");
assert_eq!(response.status(), 200);
assert_datastar_headers(&response, "#roaster-list");
let body = response.text().await.expect("failed to read body");
assert_html_fragment(&body);
assert!(
body.contains("Datastar Test Roasters"),
"Fragment should include created roaster"
);
}
#[tokio::test]
async fn roasters_create_without_datastar_header_returns_json() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let new_roaster = NewRoaster {
name: "JSON Test Roasters".to_string(),
country: "UK".to_string(),
city: None,
homepage: None,
created_at: None,
};
let response = client
.post(app.api_url("/roasters"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&new_roaster)
.send()
.await
.expect("failed to create roaster");
assert_eq!(response.status(), 201);
assert!(response.headers().get("datastar-selector").is_none());
let roaster: brewlog::domain::roasters::Roaster =
response.json().await.expect("failed to parse JSON");
assert_eq!(roaster.name, "JSON Test Roasters");
}
#[tokio::test]
async fn roasters_delete_without_datastar_header_returns_204() {
let app = spawn_app_with_auth().await;
let roaster = create_default_roaster(&app).await;
let client = Client::new();
let response = client
.delete(app.api_url(&format!("/roasters/{}", roaster.id)))
.bearer_auth(app.auth_token.as_ref().unwrap())
.send()
.await
.expect("failed to delete roaster");
assert_eq!(response.status(), 204);
assert!(response.headers().get("datastar-selector").is_none());
}
// ============================================================================
// Roasts (hand-written: create with, roast_options with/without)
// ============================================================================
#[tokio::test]
async fn roasts_create_with_datastar_header_returns_fragment() {
let app = spawn_app_with_auth().await;
let roaster = create_default_roaster(&app).await;
let client = Client::new();
let new_roast = NewRoast {
roaster_id: roaster.id,
name: "Datastar Roast".to_string(),
origin: "Ethiopia".to_string(),
region: "Yirgacheffe".to_string(),
producer: "Test Farm".to_string(),
tasting_notes: vec!["Blueberry".to_string()],
process: "Washed".to_string(),
created_at: None,
};
let response = client
.post(app.api_url("/roasts"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true")
.json(&new_roast)
.send()
.await
.expect("failed to create roast");
assert_eq!(response.status(), 200);
assert_datastar_headers(&response, "#roast-list");
let body = response.text().await.expect("failed to read body");
assert_html_fragment(&body);
assert!(
body.contains("Datastar Roast"),
"Fragment should include created roast"
);
}
#[tokio::test]
async fn roast_options_with_datastar_header_returns_fragment() {
let app = spawn_app_with_auth().await;
let roaster = create_default_roaster(&app).await;
create_default_roast(&app, roaster.id).await;
let client = Client::new();
let response = client
.get(app.api_url(&format!("/roasts?roaster_id={}", roaster.id)))
.header("datastar-request", "true")
.send()
.await
.expect("failed to fetch roast options");
assert_eq!(response.status(), 200);
assert_datastar_headers(&response, "#roast-select-options");
let body = response.text().await.expect("failed to read body");
assert_html_fragment(&body);
}
#[tokio::test]
async fn roast_options_without_datastar_header_returns_json() {
let app = spawn_app_with_auth().await;
let roaster = create_default_roaster(&app).await;
create_default_roast(&app, roaster.id).await;
let client = Client::new();
let response = client
.get(app.api_url(&format!("/roasts?roaster_id={}", roaster.id)))
.send()
.await
.expect("failed to fetch roast options");
assert_eq!(response.status(), 200);
assert!(response.headers().get("datastar-selector").is_none());
let roasts: Vec<brewlog::domain::roasts::RoastWithRoaster> =
response.json().await.expect("failed to parse JSON");
assert_eq!(roasts.len(), 1);
}
// ============================================================================
// Bags (hand-written: create with, update with/without)
// ============================================================================
#[tokio::test]
async fn bags_create_with_datastar_header_returns_fragment() {
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 = Client::new();
let new_bag = serde_json::json!({
"roast_id": roast.id,
"roast_date": "2023-06-15",
"amount": 350.0
});
let response = client
.post(app.api_url("/bags"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true")
.json(&new_bag)
.send()
.await
.expect("failed to create bag");
assert_eq!(response.status(), 200);
assert_datastar_headers(&response, "#bag-list");
let body = response.text().await.expect("failed to read body");
assert_html_fragment(&body);
}
#[tokio::test]
async fn bags_update_with_datastar_header_returns_fragment() {
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 client = Client::new();
let update = UpdateBag {
remaining: Some(100.0),
closed: None,
finished_at: None,
created_at: None,
};
let response = client
.put(app.api_url(&format!("/bags/{}", bag.id)))
.bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true")
.json(&update)
.send()
.await
.expect("failed to update bag");
assert_eq!(response.status(), 200);
assert_datastar_headers(&response, "#bag-list");
let body = response.text().await.expect("failed to read body");
assert_html_fragment(&body);
}
#[tokio::test]
async fn bags_update_without_datastar_header_returns_json() {
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 client = Client::new();
let update = UpdateBag {
remaining: Some(100.0),
closed: None,
finished_at: None,
created_at: None,
};
let response = client
.put(app.api_url(&format!("/bags/{}", bag.id)))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&update)
.send()
.await
.expect("failed to update bag");
assert_eq!(response.status(), 200);
assert!(response.headers().get("datastar-selector").is_none());
let updated_bag: brewlog::domain::bags::Bag =
response.json().await.expect("failed to parse JSON");
assert_eq!(updated_bag.remaining, 100.0);
}
// ============================================================================
// Timeline
// ============================================================================
#[tokio::test]
async fn timeline_with_datastar_header_returns_fragment() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let response = client
.get(format!("{}/timeline", app.address))
.header("datastar-request", "true")
.send()
.await
.expect("failed to fetch timeline");
assert_eq!(response.status(), 200);
assert_datastar_headers(&response, "#timeline-loader");
let body = response.text().await.expect("failed to read body");
assert_html_fragment(&body);
}
#[tokio::test]
async fn timeline_without_datastar_header_returns_full_page() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let response = client
.get(format!("{}/timeline", app.address))
.send()
.await
.expect("failed to fetch timeline");
assert_eq!(response.status(), 200);
assert!(response.headers().get("datastar-selector").is_none());
let body = response.text().await.expect("failed to read body");
assert_full_page(&body);
}
// ============================================================================
// Gear (hand-written: create with/without, update with/without)
// ============================================================================
#[tokio::test]
async fn gear_create_with_datastar_header_returns_fragment() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let new_gear = serde_json::json!({
"category": "grinder",
"make": "Datastar Grinder",
"model": "Test Model"
});
let response = client
.post(app.api_url("/gear"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true")
.json(&new_gear)
.send()
.await
.expect("failed to create gear");
assert_eq!(response.status(), 200);
assert_datastar_headers(&response, "#gear-list");
let body = response.text().await.expect("failed to read body");
assert_html_fragment(&body);
assert!(
body.contains("Datastar Grinder"),
"Fragment should include created gear"
);
}
#[tokio::test]
async fn gear_create_without_datastar_header_returns_json() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let new_gear = serde_json::json!({
"category": "brewer",
"make": "JSON Test Brewer",
"model": "V60"
});
let response = client
.post(app.api_url("/gear"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&new_gear)
.send()
.await
.expect("failed to create gear");
assert_eq!(response.status(), 201);
assert!(response.headers().get("datastar-selector").is_none());
let gear: brewlog::domain::gear::Gear = response.json().await.expect("failed to parse JSON");
assert_eq!(gear.make, "JSON Test Brewer");
}
#[tokio::test]
async fn gear_update_with_datastar_header_returns_fragment() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let new_gear = serde_json::json!({
"category": "grinder",
"make": "Original Make",
"model": "Original Model"
});
let create_response = client
.post(app.api_url("/gear"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&new_gear)
.send()
.await
.expect("failed to create gear");
let gear: brewlog::domain::gear::Gear = create_response.json().await.unwrap();
let update = brewlog::domain::gear::UpdateGear {
make: Some("Updated Make".to_string()),
model: None,
created_at: None,
};
let response = client
.put(app.api_url(&format!("/gear/{}", gear.id)))
.bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true")
.json(&update)
.send()
.await
.expect("failed to update gear");
assert_eq!(response.status(), 200);
assert_datastar_headers(&response, "#gear-list");
let body = response.text().await.expect("failed to read body");
assert_html_fragment(&body);
}
#[tokio::test]
async fn gear_update_without_datastar_header_returns_json() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let new_gear = serde_json::json!({
"category": "grinder",
"make": "Original Make",
"model": "Original Model"
});
let create_response = client
.post(app.api_url("/gear"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&new_gear)
.send()
.await
.expect("failed to create gear");
let gear: brewlog::domain::gear::Gear = create_response.json().await.unwrap();
let update = brewlog::domain::gear::UpdateGear {
make: Some("JSON Updated".to_string()),
model: None,
created_at: None,
};
let response = client
.put(app.api_url(&format!("/gear/{}", gear.id)))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&update)
.send()
.await
.expect("failed to update gear");
assert_eq!(response.status(), 200);
assert!(response.headers().get("datastar-selector").is_none());
let updated_gear: brewlog::domain::gear::Gear =
response.json().await.expect("failed to parse JSON");
assert_eq!(updated_gear.make, "JSON Updated");
}
// ============================================================================
// Cafes (hand-written: create with)
// ============================================================================
#[tokio::test]
async fn cafes_create_with_datastar_header_returns_fragment() {
let app = spawn_app_with_auth().await;
let client = Client::new();
let new_cafe = NewCafe {
name: "Datastar Test Cafe".to_string(),
city: "London".to_string(),
country: "UK".to_string(),
latitude: 51.5074,
longitude: -0.1278,
website: None,
created_at: None,
};
let response = client
.post(app.api_url("/cafes"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.header("datastar-request", "true")
.json(&new_cafe)
.send()
.await
.expect("failed to create cafe");
assert_eq!(response.status(), 200);
assert_datastar_headers(&response, "#cafe-list");
let body = response.text().await.expect("failed to read body");
assert_html_fragment(&body);
assert!(
body.contains("Datastar Test Cafe"),
"Fragment should include created cafe"
);
}