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.
This commit is contained in:
Jon Seager 2026-02-03 14:53:08 +00:00
parent 3f83d74e20
commit 069e861255
No known key found for this signature in database
9 changed files with 805 additions and 8 deletions

148
tests/cli/cafes_cli.rs Normal file
View file

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

View file

@ -224,3 +224,45 @@ pub fn create_roast(roaster_id: &str, name: &str, token: &str) -> String {
.expect("roast id should be numeric") .expect("roast id should be numeric")
.to_string() .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()
}

View file

@ -1,5 +1,6 @@
pub mod bags_cli; pub mod bags_cli;
pub mod brews_cli; pub mod brews_cli;
pub mod cafes_cli;
pub mod gear_cli; pub mod gear_cli;
pub mod helpers; pub mod helpers;
pub mod roasters_cli; pub mod roasters_cli;

View file

@ -2,11 +2,12 @@ use std::sync::Arc;
use brewlog::domain::bags::{Bag, BagFilter, BagSortKey, NewBag}; use brewlog::domain::bags::{Bag, BagFilter, BagSortKey, NewBag};
use brewlog::domain::brews::{Brew, BrewFilter, BrewSortKey, NewBrew}; 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::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear};
use brewlog::domain::listing::{ListRequest, PageSize}; use brewlog::domain::listing::{ListRequest, PageSize};
use brewlog::domain::repositories::{ use brewlog::domain::repositories::{
BagRepository, BrewRepository, GearRepository, RoastRepository, RoasterRepository, BagRepository, BrewRepository, CafeRepository, GearRepository, RoastRepository,
TimelineEventRepository, RoasterRepository, TimelineEventRepository,
}; };
use brewlog::domain::roasters::{NewRoaster, Roaster, RoasterSortKey}; use brewlog::domain::roasters::{NewRoaster, Roaster, RoasterSortKey};
use brewlog::domain::roasts::{NewRoast, Roast, RoastSortKey}; 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::database::Database;
use brewlog::infrastructure::repositories::bags::SqlBagRepository; use brewlog::infrastructure::repositories::bags::SqlBagRepository;
use brewlog::infrastructure::repositories::brews::SqlBrewRepository; use brewlog::infrastructure::repositories::brews::SqlBrewRepository;
use brewlog::infrastructure::repositories::cafes::SqlCafeRepository;
use brewlog::infrastructure::repositories::gear::SqlGearRepository; use brewlog::infrastructure::repositories::gear::SqlGearRepository;
use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository;
use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository;
@ -26,6 +28,7 @@ struct TestDb {
bag_repo: Arc<dyn BagRepository>, bag_repo: Arc<dyn BagRepository>,
gear_repo: Arc<dyn GearRepository>, gear_repo: Arc<dyn GearRepository>,
brew_repo: Arc<dyn BrewRepository>, brew_repo: Arc<dyn BrewRepository>,
cafe_repo: Arc<dyn CafeRepository>,
timeline_repo: Arc<dyn TimelineEventRepository>, timeline_repo: Arc<dyn TimelineEventRepository>,
backup_service: BackupService, backup_service: BackupService,
} }
@ -43,6 +46,7 @@ async fn create_test_db() -> TestDb {
bag_repo: Arc::new(SqlBagRepository::new(pool.clone())), bag_repo: Arc::new(SqlBagRepository::new(pool.clone())),
gear_repo: Arc::new(SqlGearRepository::new(pool.clone())), gear_repo: Arc::new(SqlGearRepository::new(pool.clone())),
brew_repo: Arc::new(SqlBrewRepository::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())), timeline_repo: Arc::new(SqlTimelineEventRepository::new(pool.clone())),
backup_service: BackupService::new(pool), backup_service: BackupService::new(pool),
} }
@ -87,6 +91,13 @@ async fn list_all_gear(repo: &dyn GearRepository) -> Vec<Gear> {
.items .items
} }
async fn list_all_cafes(repo: &dyn CafeRepository) -> Vec<Cafe> {
repo.list(&list_all_request::<CafeSortKey>(), None)
.await
.expect("failed to list cafes")
.items
}
async fn list_all_brews(repo: &dyn BrewRepository) -> Vec<Brew> { async fn list_all_brews(repo: &dyn BrewRepository) -> Vec<Brew> {
let page = repo let page = repo
.list(BrewFilter::all(), &list_all_request::<BrewSortKey>(), None) .list(BrewFilter::all(), &list_all_request::<BrewSortKey>(), None)
@ -102,7 +113,7 @@ async fn list_all_timeline_events(repo: &dyn TimelineEventRepository) -> Vec<Tim
} }
/// Populate a database with representative test data and return the key entities. /// Populate a database with representative test data and return the key entities.
async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Gear, Brew) { async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Gear, Brew, Cafe) {
// Create roaster // Create roaster
let roaster = db let roaster = db
.roaster_repo .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" "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] #[tokio::test]
async fn backup_and_restore_round_trip() { async fn backup_and_restore_round_trip() {
// 1. Create source database and populate with test data // 1. Create source database and populate with test data
let source = create_test_db().await; 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; populate_test_data(&source).await;
// Verify timeline events were created (roaster + roast inserts create them) // 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.bags.len(), 1);
assert_eq!(backup_data.gear.len(), 3); assert_eq!(backup_data.gear.len(), 3);
assert_eq!(backup_data.brews.len(), 1); assert_eq!(backup_data.brews.len(), 1);
assert_eq!(backup_data.cafes.len(), 1);
assert_eq!(backup_data.timeline_events.len(), source_timeline.len()); assert_eq!(backup_data.timeline_events.len(), source_timeline.len());
// 3. Serialize to JSON and deserialize back (verify serde round-trip) // 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.bags.len(), 1);
assert_eq!(restored_data.gear.len(), 3); assert_eq!(restored_data.gear.len(), 3);
assert_eq!(restored_data.brews.len(), 1); assert_eq!(restored_data.brews.len(), 1);
assert_eq!(restored_data.cafes.len(), 1);
// 4. Restore to a fresh database // 4. Restore to a fresh database
let target = create_test_db().await; 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_volume, 250);
assert_eq!(restored_brew.water_temp, 93.5); 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 // Timeline events
let target_timeline = list_all_timeline_events(target.timeline_repo.as_ref()).await; let target_timeline = list_all_timeline_events(target.timeline_repo.as_ref()).await;
assert_eq!(target_timeline.len(), source_timeline.len()); assert_eq!(target_timeline.len(), source_timeline.len());
@ -370,6 +421,7 @@ async fn restore_to_non_empty_database_fails() {
roasts: vec![], roasts: vec![],
bags: vec![], bags: vec![],
brews: vec![], brews: vec![],
cafes: vec![],
timeline_events: vec![], timeline_events: vec![],
}; };
@ -399,6 +451,7 @@ async fn backup_empty_database() {
assert!(backup_data.bags.is_empty()); assert!(backup_data.bags.is_empty());
assert!(backup_data.gear.is_empty()); assert!(backup_data.gear.is_empty());
assert!(backup_data.brews.is_empty()); assert!(backup_data.brews.is_empty());
assert!(backup_data.cafes.is_empty());
assert!(backup_data.timeline_events.is_empty()); assert!(backup_data.timeline_events.is_empty());
// Should serialize to valid JSON // Should serialize to valid JSON

363
tests/server/cafes_api.rs Normal file
View file

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

View file

@ -5,9 +5,10 @@
use crate::helpers::{ use crate::helpers::{
assert_datastar_headers, assert_full_page, assert_html_fragment, create_default_bag, 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::bags::UpdateBag;
use brewlog::domain::cafes::NewCafe;
use brewlog::domain::roasters::NewRoaster; use brewlog::domain::roasters::NewRoaster;
use brewlog::domain::roasts::NewRoast; use brewlog::domain::roasts::NewRoast;
use reqwest::Client; 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"); let body = response.text().await.expect("failed to read body");
assert_html_fragment(&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);
}

View file

@ -2,8 +2,9 @@ use std::sync::Arc;
use brewlog::application::routes::app_router; use brewlog::application::routes::app_router;
use brewlog::application::server::AppState; use brewlog::application::server::AppState;
use brewlog::domain::cafes::{Cafe, NewCafe};
use brewlog::domain::repositories::{ use brewlog::domain::repositories::{
RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, CafeRepository, RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository,
TokenRepository, UserRepository, TokenRepository, UserRepository,
}; };
use brewlog::domain::roasters::{NewRoaster, Roaster}; use brewlog::domain::roasters::{NewRoaster, Roaster};
@ -12,6 +13,7 @@ use brewlog::infrastructure::auth::hash_password;
use brewlog::infrastructure::database::Database; use brewlog::infrastructure::database::Database;
use brewlog::infrastructure::repositories::bags::SqlBagRepository; use brewlog::infrastructure::repositories::bags::SqlBagRepository;
use brewlog::infrastructure::repositories::brews::SqlBrewRepository; use brewlog::infrastructure::repositories::brews::SqlBrewRepository;
use brewlog::infrastructure::repositories::cafes::SqlCafeRepository;
use brewlog::infrastructure::repositories::gear::SqlGearRepository; use brewlog::infrastructure::repositories::gear::SqlGearRepository;
use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository;
use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository;
@ -27,6 +29,8 @@ pub struct TestApp {
pub roaster_repo: Arc<dyn RoasterRepository>, pub roaster_repo: Arc<dyn RoasterRepository>,
pub roast_repo: Arc<dyn RoastRepository>, pub roast_repo: Arc<dyn RoastRepository>,
#[allow(dead_code)] #[allow(dead_code)]
pub cafe_repo: Arc<dyn CafeRepository>,
#[allow(dead_code)]
pub timeline_repo: Arc<dyn TimelineEventRepository>, pub timeline_repo: Arc<dyn TimelineEventRepository>,
#[allow(dead_code)] #[allow(dead_code)]
pub user_repo: Option<Arc<dyn UserRepository>>, pub user_repo: Option<Arc<dyn UserRepository>>,
@ -59,6 +63,7 @@ pub async fn spawn_app() -> TestApp {
let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool())); let bag_repo = Arc::new(SqlBagRepository::new(database.clone_pool()));
let gear_repo = Arc::new(SqlGearRepository::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 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 timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool()));
let user_repo: Arc<dyn UserRepository> = let user_repo: Arc<dyn UserRepository> =
Arc::new(SqlUserRepository::new(database.clone_pool())); Arc::new(SqlUserRepository::new(database.clone_pool()));
@ -74,6 +79,7 @@ pub async fn spawn_app() -> TestApp {
bag_repo.clone(), bag_repo.clone(),
gear_repo.clone(), gear_repo.clone(),
brew_repo.clone(), brew_repo.clone(),
cafe_repo.clone(),
timeline_repo.clone(), timeline_repo.clone(),
user_repo.clone(), user_repo.clone(),
token_repo.clone(), token_repo.clone(),
@ -102,6 +108,7 @@ pub async fn spawn_app() -> TestApp {
address, address,
roaster_repo, roaster_repo,
roast_repo, roast_repo,
cafe_repo,
timeline_repo, timeline_repo,
user_repo: Some(user_repo), user_repo: Some(user_repo),
token_repo: Some(token_repo), token_repo: Some(token_repo),
@ -325,3 +332,35 @@ pub async fn create_default_gear(
.await .await
.expect("failed to deserialize gear from response") .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")
}

View file

@ -2,6 +2,7 @@ pub mod auth_api;
pub mod backup; pub mod backup;
pub mod bags_api; pub mod bags_api;
pub mod brews_api; pub mod brews_api;
pub mod cafes_api;
pub mod datastar; pub mod datastar;
pub mod gear_api; pub mod gear_api;
pub mod helpers; pub mod helpers;

View file

@ -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::ids::RoasterId;
use brewlog::domain::roasters::NewRoaster; use brewlog::domain::roasters::NewRoaster;
use brewlog::domain::roasts::NewRoast; 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}" "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}"
);
}