From 069e8612557ac66610b90da164d103b162415fbd Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 14:53:08 +0000 Subject: [PATCH] test(cafes): add API, CLI, Datastar, timeline, and backup tests Add 14 server API tests, 6 CLI tests, 4 Datastar fragment tests, 1 timeline event test, and backup round-trip verification. Wire cafe_repo into test helpers. --- tests/cli/cafes_cli.rs | 148 ++++++++++++++++ tests/cli/helpers.rs | 42 +++++ tests/cli/main.rs | 1 + tests/server/backup.rs | 63 ++++++- tests/server/cafes_api.rs | 363 ++++++++++++++++++++++++++++++++++++++ tests/server/datastar.rs | 106 ++++++++++- tests/server/helpers.rs | 41 ++++- tests/server/main.rs | 1 + tests/server/timeline.rs | 48 ++++- 9 files changed, 805 insertions(+), 8 deletions(-) create mode 100644 tests/cli/cafes_cli.rs create mode 100644 tests/server/cafes_api.rs diff --git a/tests/cli/cafes_cli.rs b/tests/cli/cafes_cli.rs new file mode 100644 index 0000000..6c1ce6c --- /dev/null +++ b/tests/cli/cafes_cli.rs @@ -0,0 +1,148 @@ +use crate::helpers::{create_cafe, create_token, run_brewlog, server_info}; +use serde_json::Value; + +#[test] +fn test_add_cafe_requires_authentication() { + let _ = server_info(); + + let output = run_brewlog( + &[ + "cafe", + "add", + "--name", + "Test Cafe", + "--city", + "London", + "--country", + "UK", + "--latitude", + "51.5074", + "--longitude", + "-0.1278", + ], + &[], + ); + + assert!( + !output.status.success(), + "cafe add without auth should fail" + ); +} + +#[test] +fn test_add_cafe_with_authentication() { + let token = create_token("test-add-cafe"); + + let output = run_brewlog( + &[ + "cafe", + "add", + "--name", + "Test Cafe", + "--city", + "London", + "--country", + "UK", + "--latitude", + "51.5074", + "--longitude", + "-0.1278", + ], + &[("BREWLOG_TOKEN", &token)], + ); + + assert!( + output.status.success(), + "cafe add with auth should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let cafe: Value = serde_json::from_str(&stdout) + .unwrap_or_else(|_| panic!("Should output valid JSON, got: {}", stdout)); + + assert_eq!(cafe["name"], "Test Cafe"); + assert_eq!(cafe["city"], "London"); + assert_eq!(cafe["country"], "UK"); + assert!(cafe["id"].is_i64(), "Should have an ID"); +} + +#[test] +fn test_list_cafes_works_without_authentication() { + let _ = server_info(); + + let output = run_brewlog(&["cafe", "list"], &[]); + + assert!( + output.status.success(), + "cafe list should work without auth" + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let cafes: Value = serde_json::from_str(&stdout).expect("Should output valid JSON array"); + + assert!(cafes.is_array(), "Should return an array"); +} + +#[test] +fn test_list_cafes_shows_added_cafe() { + let token = create_token("test-list-cafes"); + + let cafe_id = create_cafe( + "List Test Cafe", + "Bristol", + "UK", + "51.4545", + "-2.5879", + &token, + ); + + let list_output = run_brewlog(&["cafe", "list"], &[]); + + assert!(list_output.status.success()); + + let list_stdout = String::from_utf8_lossy(&list_output.stdout); + let cafes: Value = serde_json::from_str(&list_stdout).expect("Should output valid JSON array"); + + assert!(cafes.is_array(), "Should return an array"); + let cafes_array = cafes.as_array().unwrap(); + + let found = cafes_array + .iter() + .any(|c| c["id"].as_i64().unwrap().to_string() == cafe_id); + assert!(found, "Should find the added cafe in the list"); +} + +#[test] +fn test_delete_cafe_requires_authentication() { + let _ = server_info(); + + let output = run_brewlog(&["cafe", "delete", "--id", "some-id"], &[]); + + assert!( + !output.status.success(), + "cafe delete without auth should fail" + ); +} + +#[test] +fn test_update_cafe_requires_authentication() { + let _ = server_info(); + + let output = run_brewlog( + &[ + "cafe", + "update", + "--id", + "some-id", + "--name", + "Updated Name", + ], + &[], + ); + + assert!( + !output.status.success(), + "cafe update without auth should fail" + ); +} diff --git a/tests/cli/helpers.rs b/tests/cli/helpers.rs index e4e254c..7261629 100644 --- a/tests/cli/helpers.rs +++ b/tests/cli/helpers.rs @@ -224,3 +224,45 @@ pub fn create_roast(roaster_id: &str, name: &str, token: &str) -> String { .expect("roast id should be numeric") .to_string() } + +/// Helper to create a cafe and return its ID +pub fn create_cafe( + name: &str, + city: &str, + country: &str, + latitude: &str, + longitude: &str, + token: &str, +) -> String { + let output = run_brewlog( + &[ + "cafe", + "add", + "--name", + name, + "--city", + city, + "--country", + country, + "--latitude", + latitude, + "--longitude", + longitude, + ], + &[("BREWLOG_TOKEN", token)], + ); + + if !output.status.success() { + panic!( + "Failed to create cafe: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let cafe: serde_json::Value = serde_json::from_str(&stdout).expect("Should output valid JSON"); + cafe["id"] + .as_i64() + .expect("cafe id should be numeric") + .to_string() +} diff --git a/tests/cli/main.rs b/tests/cli/main.rs index 48d42cf..9b1a95a 100644 --- a/tests/cli/main.rs +++ b/tests/cli/main.rs @@ -1,5 +1,6 @@ pub mod bags_cli; pub mod brews_cli; +pub mod cafes_cli; pub mod gear_cli; pub mod helpers; pub mod roasters_cli; diff --git a/tests/server/backup.rs b/tests/server/backup.rs index b92045f..dc5173d 100644 --- a/tests/server/backup.rs +++ b/tests/server/backup.rs @@ -2,11 +2,12 @@ use std::sync::Arc; use brewlog::domain::bags::{Bag, BagFilter, BagSortKey, NewBag}; use brewlog::domain::brews::{Brew, BrewFilter, BrewSortKey, NewBrew}; +use brewlog::domain::cafes::{Cafe, CafeSortKey, NewCafe}; use brewlog::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear}; use brewlog::domain::listing::{ListRequest, PageSize}; use brewlog::domain::repositories::{ - BagRepository, BrewRepository, GearRepository, RoastRepository, RoasterRepository, - TimelineEventRepository, + BagRepository, BrewRepository, CafeRepository, GearRepository, RoastRepository, + RoasterRepository, TimelineEventRepository, }; use brewlog::domain::roasters::{NewRoaster, Roaster, RoasterSortKey}; use brewlog::domain::roasts::{NewRoast, Roast, RoastSortKey}; @@ -15,6 +16,7 @@ use brewlog::infrastructure::backup::{BackupData, BackupService}; use brewlog::infrastructure::database::Database; use brewlog::infrastructure::repositories::bags::SqlBagRepository; use brewlog::infrastructure::repositories::brews::SqlBrewRepository; +use brewlog::infrastructure::repositories::cafes::SqlCafeRepository; use brewlog::infrastructure::repositories::gear::SqlGearRepository; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; @@ -26,6 +28,7 @@ struct TestDb { bag_repo: Arc, gear_repo: Arc, brew_repo: Arc, + cafe_repo: Arc, timeline_repo: Arc, backup_service: BackupService, } @@ -43,6 +46,7 @@ async fn create_test_db() -> TestDb { bag_repo: Arc::new(SqlBagRepository::new(pool.clone())), gear_repo: Arc::new(SqlGearRepository::new(pool.clone())), brew_repo: Arc::new(SqlBrewRepository::new(pool.clone())), + cafe_repo: Arc::new(SqlCafeRepository::new(pool.clone())), timeline_repo: Arc::new(SqlTimelineEventRepository::new(pool.clone())), backup_service: BackupService::new(pool), } @@ -87,6 +91,13 @@ async fn list_all_gear(repo: &dyn GearRepository) -> Vec { .items } +async fn list_all_cafes(repo: &dyn CafeRepository) -> Vec { + repo.list(&list_all_request::(), None) + .await + .expect("failed to list cafes") + .items +} + async fn list_all_brews(repo: &dyn BrewRepository) -> Vec { let page = repo .list(BrewFilter::all(), &list_all_request::(), None) @@ -102,7 +113,7 @@ async fn list_all_timeline_events(repo: &dyn TimelineEventRepository) -> Vec (Roaster, Roast, Bag, Gear, Gear, Gear, Brew) { +async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Gear, Brew, Cafe) { // Create roaster let roaster = db .roaster_repo @@ -205,14 +216,38 @@ async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Ge "bag remaining should be 235 after brew" ); - (roaster, roast, bag, grinder, brewer, filter_paper, brew) + // Create cafe + let cafe = db + .cafe_repo + .insert(NewCafe { + name: "Prufrock".to_string(), + city: "London".to_string(), + country: "UK".to_string(), + latitude: 51.5246, + longitude: -0.1098, + website: Some("https://prufrockcoffee.com".to_string()), + notes: Some("Award-winning espresso bar".to_string()), + }) + .await + .expect("failed to create cafe"); + + ( + roaster, + roast, + bag, + grinder, + brewer, + filter_paper, + brew, + cafe, + ) } #[tokio::test] async fn backup_and_restore_round_trip() { // 1. Create source database and populate with test data let source = create_test_db().await; - let (roaster, roast, bag, grinder, brewer, filter_paper, brew) = + let (roaster, roast, bag, grinder, brewer, filter_paper, brew, cafe) = populate_test_data(&source).await; // Verify timeline events were created (roaster + roast inserts create them) @@ -235,6 +270,7 @@ async fn backup_and_restore_round_trip() { assert_eq!(backup_data.bags.len(), 1); assert_eq!(backup_data.gear.len(), 3); assert_eq!(backup_data.brews.len(), 1); + assert_eq!(backup_data.cafes.len(), 1); assert_eq!(backup_data.timeline_events.len(), source_timeline.len()); // 3. Serialize to JSON and deserialize back (verify serde round-trip) @@ -248,6 +284,7 @@ async fn backup_and_restore_round_trip() { assert_eq!(restored_data.bags.len(), 1); assert_eq!(restored_data.gear.len(), 3); assert_eq!(restored_data.brews.len(), 1); + assert_eq!(restored_data.cafes.len(), 1); // 4. Restore to a fresh database let target = create_test_db().await; @@ -329,6 +366,20 @@ async fn backup_and_restore_round_trip() { assert_eq!(restored_brew.water_volume, 250); assert_eq!(restored_brew.water_temp, 93.5); + // Cafes + let target_cafes = list_all_cafes(target.cafe_repo.as_ref()).await; + assert_eq!(target_cafes.len(), 1); + let restored_cafe = &target_cafes[0]; + assert_eq!(restored_cafe.id, cafe.id); + assert_eq!(restored_cafe.name, cafe.name); + assert_eq!(restored_cafe.slug, cafe.slug); + assert_eq!(restored_cafe.city, cafe.city); + assert_eq!(restored_cafe.country, cafe.country); + assert_eq!(restored_cafe.latitude, cafe.latitude); + assert_eq!(restored_cafe.longitude, cafe.longitude); + assert_eq!(restored_cafe.website, cafe.website); + assert_eq!(restored_cafe.notes, cafe.notes); + // Timeline events let target_timeline = list_all_timeline_events(target.timeline_repo.as_ref()).await; assert_eq!(target_timeline.len(), source_timeline.len()); @@ -370,6 +421,7 @@ async fn restore_to_non_empty_database_fails() { roasts: vec![], bags: vec![], brews: vec![], + cafes: vec![], timeline_events: vec![], }; @@ -399,6 +451,7 @@ async fn backup_empty_database() { assert!(backup_data.bags.is_empty()); assert!(backup_data.gear.is_empty()); assert!(backup_data.brews.is_empty()); + assert!(backup_data.cafes.is_empty()); assert!(backup_data.timeline_events.is_empty()); // Should serialize to valid JSON diff --git a/tests/server/cafes_api.rs b/tests/server/cafes_api.rs new file mode 100644 index 0000000..af5a6f9 --- /dev/null +++ b/tests/server/cafes_api.rs @@ -0,0 +1,363 @@ +use crate::helpers::{create_default_cafe, spawn_app_with_auth}; +use brewlog::domain::cafes::{Cafe, NewCafe, UpdateCafe}; + +#[tokio::test] +async fn creating_a_cafe_returns_a_201_for_valid_data() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + let new_cafe = NewCafe { + name: "Blue Bottle".to_string(), + city: "San Francisco".to_string(), + country: "United States".to_string(), + latitude: 37.7749, + longitude: -122.4194, + website: Some("https://bluebottlecoffee.com".to_string()), + notes: Some("Great pour-over".to_string()), + }; + + let response = client + .post(app.api_url("/cafes")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_cafe) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 201); + + let cafe: Cafe = response.json().await.expect("Failed to parse response"); + assert_eq!(cafe.name, "Blue Bottle"); + assert_eq!(cafe.city, "San Francisco"); + assert_eq!(cafe.country, "United States"); + assert_eq!(cafe.latitude, 37.7749); + assert_eq!(cafe.longitude, -122.4194); + assert_eq!( + cafe.website, + Some("https://bluebottlecoffee.com".to_string()) + ); + assert_eq!(cafe.notes, Some("Great pour-over".to_string())); +} + +#[tokio::test] +async fn creating_a_cafe_persists_the_data() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + let new_cafe = NewCafe { + name: "Persistent Cafe".to_string(), + city: "Paris".to_string(), + country: "France".to_string(), + latitude: 48.8566, + longitude: 2.3522, + website: None, + notes: None, + }; + + let response = client + .post(app.api_url("/cafes")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&new_cafe) + .send() + .await + .expect("Failed to execute request"); + + let cafe: Cafe = response.json().await.expect("Failed to parse response"); + + let fetched_cafe = app + .cafe_repo + .get(cafe.id) + .await + .expect("Failed to fetch cafe"); + + assert_eq!(fetched_cafe.name, "Persistent Cafe"); + assert_eq!(fetched_cafe.city, "Paris"); + assert_eq!(fetched_cafe.country, "France"); +} + +#[tokio::test] +async fn creating_a_cafe_requires_authentication() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + let new_cafe = NewCafe { + name: "Auth Test Cafe".to_string(), + city: "London".to_string(), + country: "UK".to_string(), + latitude: 51.5074, + longitude: -0.1278, + website: None, + notes: None, + }; + + let response = client + .post(app.api_url("/cafes")) + .json(&new_cafe) + .send() + .await + .expect("Failed to execute request"); + + 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 = response.json().await.expect("Failed to parse response"); + assert_eq!(cafes.len(), 0); +} + +#[tokio::test] +async fn listing_cafes_returns_a_200_with_multiple_cafes() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + let cafe1 = NewCafe { + name: "First Cafe".to_string(), + city: "London".to_string(), + country: "UK".to_string(), + latitude: 51.5074, + longitude: -0.1278, + website: None, + notes: None, + }; + + let cafe2 = NewCafe { + name: "Second Cafe".to_string(), + city: "Berlin".to_string(), + country: "Germany".to_string(), + latitude: 52.52, + longitude: 13.405, + website: None, + notes: None, + }; + + client + .post(app.api_url("/cafes")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&cafe1) + .send() + .await + .expect("Failed to create first cafe"); + + client + .post(app.api_url("/cafes")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&cafe2) + .send() + .await + .expect("Failed to create second cafe"); + + let response = client + .get(app.api_url("/cafes")) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 200); + + let cafes: Vec = response.json().await.expect("Failed to parse response"); + assert_eq!(cafes.len(), 2); +} + +#[tokio::test] +async fn getting_a_cafe_returns_a_200_for_valid_id() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + let cafe = create_default_cafe(&app).await; + + let response = client + .get(app.api_url(&format!("/cafes/{}", cafe.id))) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 200); + + let fetched: Cafe = response.json().await.expect("Failed to parse response"); + assert_eq!(fetched.id, cafe.id); + 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] +async fn updating_a_cafe_returns_a_200_for_valid_data() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + let cafe = create_default_cafe(&app).await; + + let update = UpdateCafe { + name: Some("Updated Cafe".to_string()), + city: None, + country: None, + latitude: None, + longitude: None, + website: Some("https://updated.com".to_string()), + notes: None, + }; + + let response = client + .put(app.api_url(&format!("/cafes/{}", cafe.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&update) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 200); + + let updated: Cafe = response.json().await.expect("Failed to parse response"); + assert_eq!(updated.name, "Updated Cafe"); + assert_eq!(updated.city, "San Francisco"); // unchanged + assert_eq!(updated.website, Some("https://updated.com".to_string())); +} + +#[tokio::test] +async fn updating_a_cafe_with_no_changes_returns_a_400() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + let cafe = create_default_cafe(&app).await; + + let update = UpdateCafe { + name: None, + city: None, + country: None, + latitude: None, + longitude: None, + website: None, + notes: None, + }; + + let response = client + .put(app.api_url(&format!("/cafes/{}", cafe.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&update) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 400); +} + +#[tokio::test] +async fn updating_a_nonexistent_cafe_returns_a_404() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + + let update = UpdateCafe { + name: Some("New Name".to_string()), + city: None, + country: None, + latitude: None, + longitude: None, + website: None, + notes: None, + }; + + let response = client + .put(app.api_url("/cafes/999999")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&update) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 404); +} + +#[tokio::test] +async fn deleting_a_cafe_returns_a_204_for_valid_id() { + let app = spawn_app_with_auth().await; + let client = reqwest::Client::new(); + let cafe = create_default_cafe(&app).await; + + let response = client + .delete(app.api_url(&format!("/cafes/{}", cafe.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 204); + + // Verify cafe was deleted + let get_response = client + .get(app.api_url(&format!("/cafes/{}", cafe.id))) + .send() + .await + .expect("Failed to execute request"); + + 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); +} diff --git a/tests/server/datastar.rs b/tests/server/datastar.rs index 824cc57..bf0129b 100644 --- a/tests/server/datastar.rs +++ b/tests/server/datastar.rs @@ -5,9 +5,10 @@ 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, + create_default_cafe, create_default_roast, create_default_roaster, spawn_app_with_auth, }; use brewlog::domain::bags::UpdateBag; +use brewlog::domain::cafes::NewCafe; use brewlog::domain::roasters::NewRoaster; use brewlog::domain::roasts::NewRoast; use reqwest::Client; @@ -753,3 +754,106 @@ async fn gear_delete_with_datastar_header_returns_fragment() { let body = response.text().await.expect("failed to read body"); assert_html_fragment(&body); } + +// ============================================================================ +// Cafes +// ============================================================================ + +#[tokio::test] +async fn cafes_list_with_datastar_header_returns_fragment() { + let app = spawn_app_with_auth().await; + create_default_cafe(&app).await; + let client = Client::new(); + + let response = client + .get(format!("{}/cafes", app.address)) + .header("datastar-request", "true") + .send() + .await + .expect("failed to fetch cafes"); + + 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("id=\"cafe-list\""), + "Fragment should contain the selector element" + ); +} + +#[tokio::test] +async fn cafes_list_without_datastar_header_returns_full_page() { + let app = spawn_app_with_auth().await; + create_default_cafe(&app).await; + let client = Client::new(); + + let response = client + .get(format!("{}/cafes", app.address)) + .send() + .await + .expect("failed to fetch cafes"); + + 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 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, + notes: 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" + ); +} + +#[tokio::test] +async fn cafes_delete_with_datastar_header_returns_fragment() { + let app = spawn_app_with_auth().await; + let cafe = create_default_cafe(&app).await; + let client = Client::new(); + + let response = client + .delete(app.api_url(&format!("/cafes/{}", cafe.id))) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .header("datastar-request", "true") + .send() + .await + .expect("failed to delete 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); +} diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 32ed3ba..0668757 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -2,8 +2,9 @@ use std::sync::Arc; use brewlog::application::routes::app_router; use brewlog::application::server::AppState; +use brewlog::domain::cafes::{Cafe, NewCafe}; use brewlog::domain::repositories::{ - RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, + CafeRepository, RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, }; use brewlog::domain::roasters::{NewRoaster, Roaster}; @@ -12,6 +13,7 @@ use brewlog::infrastructure::auth::hash_password; use brewlog::infrastructure::database::Database; use brewlog::infrastructure::repositories::bags::SqlBagRepository; use brewlog::infrastructure::repositories::brews::SqlBrewRepository; +use brewlog::infrastructure::repositories::cafes::SqlCafeRepository; use brewlog::infrastructure::repositories::gear::SqlGearRepository; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; @@ -27,6 +29,8 @@ pub struct TestApp { pub roaster_repo: Arc, pub roast_repo: Arc, #[allow(dead_code)] + pub cafe_repo: Arc, + #[allow(dead_code)] pub timeline_repo: Arc, #[allow(dead_code)] pub user_repo: Option>, @@ -59,6 +63,7 @@ pub async fn spawn_app() -> TestApp { let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); let gear_repo = Arc::new(SqlGearRepository::new(database.clone_pool())); let brew_repo = Arc::new(SqlBrewRepository::new(database.clone_pool())); + let cafe_repo = Arc::new(SqlCafeRepository::new(database.clone_pool())); let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); let user_repo: Arc = Arc::new(SqlUserRepository::new(database.clone_pool())); @@ -74,6 +79,7 @@ pub async fn spawn_app() -> TestApp { bag_repo.clone(), gear_repo.clone(), brew_repo.clone(), + cafe_repo.clone(), timeline_repo.clone(), user_repo.clone(), token_repo.clone(), @@ -102,6 +108,7 @@ pub async fn spawn_app() -> TestApp { address, roaster_repo, roast_repo, + cafe_repo, timeline_repo, user_repo: Some(user_repo), token_repo: Some(token_repo), @@ -325,3 +332,35 @@ pub async fn create_default_gear( .await .expect("failed to deserialize gear from response") } + +pub async fn create_default_cafe(app: &TestApp) -> Cafe { + create_cafe_with_payload( + app, + NewCafe { + name: "Blue Bottle".to_string(), + city: "San Francisco".to_string(), + country: "US".to_string(), + latitude: 37.7749, + longitude: -122.4194, + website: Some("https://bluebottlecoffee.com".to_string()), + notes: Some("Great pour-over".to_string()), + }, + ) + .await +} + +pub async fn create_cafe_with_payload(app: &TestApp, payload: NewCafe) -> Cafe { + let client = Client::new(); + 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") +} diff --git a/tests/server/main.rs b/tests/server/main.rs index e6d96fa..7ec9d25 100644 --- a/tests/server/main.rs +++ b/tests/server/main.rs @@ -2,6 +2,7 @@ pub mod auth_api; pub mod backup; pub mod bags_api; pub mod brews_api; +pub mod cafes_api; pub mod datastar; pub mod gear_api; pub mod helpers; diff --git a/tests/server/timeline.rs b/tests/server/timeline.rs index 1ca10d7..5a6c1b1 100644 --- a/tests/server/timeline.rs +++ b/tests/server/timeline.rs @@ -1,4 +1,5 @@ -use crate::helpers::{create_roaster_with_payload, spawn_app_with_auth}; +use crate::helpers::{create_cafe_with_payload, create_roaster_with_payload, spawn_app_with_auth}; +use brewlog::domain::cafes::NewCafe; use brewlog::domain::ids::RoasterId; use brewlog::domain::roasters::NewRoaster; use brewlog::domain::roasts::NewRoast; @@ -459,3 +460,48 @@ async fn creating_gear_surfaces_on_the_timeline() { "Expected gear category to appear in timeline HTML, got: {body}" ); } + +#[tokio::test] +async fn creating_a_cafe_surfaces_on_the_timeline() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + let cafe_name = "Timeline Test Cafe"; + let cafe = create_cafe_with_payload( + &app, + NewCafe { + name: cafe_name.to_string(), + city: "Bristol".to_string(), + country: "UK".to_string(), + latitude: 51.4545, + longitude: -2.5879, + website: Some("https://example.com".to_string()), + notes: None, + }, + ) + .await; + + sleep(Duration::from_millis(10)).await; + + let response = client + .get(format!("{}/timeline", app.address)) + .send() + .await + .expect("failed to fetch timeline"); + + assert_eq!(response.status(), 200); + + let body = response.text().await.expect("failed to read response body"); + assert!( + body.contains("Cafe Added"), + "Expected 'Cafe Added' badge in timeline HTML, got: {body}" + ); + assert!( + body.contains(cafe_name), + "Expected cafe name to appear in timeline HTML, got: {body}" + ); + assert!( + body.contains(&format!("/cafes/{}", cafe.slug)), + "Expected cafe detail link in timeline HTML, got: {body}" + ); +}