diff --git a/tests/server/datastar.rs b/tests/server/datastar.rs new file mode 100644 index 0000000..e1aace5 --- /dev/null +++ b/tests/server/datastar.rs @@ -0,0 +1,513 @@ +//! 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::{ + assert_datastar_headers, assert_full_page, assert_html_fragment, create_default_bag, + create_default_roast, create_default_roaster, spawn_app_with_auth, +}; +use brewlog::domain::bags::UpdateBag; +use brewlog::domain::roasters::NewRoaster; +use brewlog::domain::roasts::NewRoast; +use reqwest::Client; + +// ============================================================================ +// Roasters +// ============================================================================ + +#[tokio::test] +async fn roasters_list_with_datastar_header_returns_fragment() { + let app = spawn_app_with_auth().await; + create_default_roaster(&app).await; + let client = Client::new(); + + let response = client + .get(format!("{}/roasters", app.address)) + .header("datastar-request", "true") + .send() + .await + .expect("failed to fetch roasters"); + + 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("id=\"roaster-list\""), + "Fragment should contain the selector element" + ); +} + +#[tokio::test] +async fn roasters_list_without_datastar_header_returns_full_page() { + let app = spawn_app_with_auth().await; + create_default_roaster(&app).await; + let client = Client::new(); + + let response = client + .get(format!("{}/roasters", app.address)) + .send() + .await + .expect("failed to fetch roasters"); + + 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); +} + +#[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, + notes: 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, + notes: 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_with_datastar_header_returns_fragment() { + 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()) + .header("datastar-request", "true") + .send() + .await + .expect("failed to delete 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); +} + +#[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 +// ============================================================================ + +#[tokio::test] +async fn roasts_list_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(format!("{}/roasts", app.address)) + .header("datastar-request", "true") + .send() + .await + .expect("failed to fetch roasts"); + + 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("id=\"roast-list\""), + "Fragment should contain the selector element" + ); +} + +#[tokio::test] +async fn roasts_list_without_datastar_header_returns_full_page() { + 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(format!("{}/roasts", app.address)) + .send() + .await + .expect("failed to fetch roasts"); + + 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); +} + +#[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(), + }; + + 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 roasts_delete_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 response = client + .delete(app.api_url(&format!("/roasts/{}", roast.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .header("datastar-request", "true") + .send() + .await + .expect("failed to delete 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); +} + +#[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 = + response.json().await.expect("failed to parse JSON"); + assert_eq!(roasts.len(), 1); +} + +// ============================================================================ +// Bags +// ============================================================================ + +#[tokio::test] +async fn bags_list_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; + create_default_bag(&app, roast.id).await; + let client = Client::new(); + + let response = client + .get(format!("{}/bags", app.address)) + .header("datastar-request", "true") + .send() + .await + .expect("failed to fetch bags"); + + 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); + assert!( + body.contains("id=\"bag-list\""), + "Fragment should contain the selector element" + ); +} + +#[tokio::test] +async fn bags_list_without_datastar_header_returns_full_page() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + create_default_bag(&app, roast.id).await; + let client = Client::new(); + + let response = client + .get(format!("{}/bags", app.address)) + .send() + .await + .expect("failed to fetch bags"); + + 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); +} + +#[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, + }; + + 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, + }; + + 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); +} + +#[tokio::test] +async fn bags_delete_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 response = client + .delete(app.api_url(&format!("/bags/{}", bag.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .header("datastar-request", "true") + .send() + .await + .expect("failed to delete 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); +} + +// ============================================================================ +// 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); +} diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 6b03b3a..0011f95 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -215,3 +215,74 @@ pub async fn create_roaster_with_name(app: &TestApp, name: &str) -> Roaster { ) .await } + +pub async fn create_default_bag( + app: &TestApp, + roast_id: brewlog::domain::ids::RoastId, +) -> brewlog::domain::bags::Bag { + let client = Client::new(); + let new_bag = brewlog::domain::bags::NewBag { + 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 { + 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 +pub fn assert_datastar_headers(response: &reqwest::Response, expected_selector: &str) { + let selector = response + .headers() + .get("datastar-selector") + .and_then(|v| v.to_str().ok()); + assert_eq!( + selector, + Some(expected_selector), + "Expected datastar-selector header to be '{}', got {:?}", + expected_selector, + selector + ); + + let mode = response + .headers() + .get("datastar-mode") + .and_then(|v| v.to_str().ok()); + assert_eq!( + mode, + Some("replace"), + "Expected datastar-mode header to be 'replace', got {:?}", + mode + ); +} + +/// Asserts that the response body is an HTML fragment (not a full page) +pub fn assert_html_fragment(body: &str) { + assert!( + !body.contains(" tag" + ); +} + +/// Asserts that the body contains full HTML page structure +pub fn assert_full_page(body: &str) { + assert!( + body.contains(" tag" + ); +} diff --git a/tests/server/main.rs b/tests/server/main.rs index 1aa4fd3..5869d92 100644 --- a/tests/server/main.rs +++ b/tests/server/main.rs @@ -1,5 +1,6 @@ pub mod auth_api; pub mod bags_api; +pub mod datastar; pub mod helpers; pub mod roasters_api; pub mod roasts_api;