brewlog/tests/server/backup.rs
Jon Seager 902d52b171
feat(brews): add brew time field
- Add brew_time INTEGER column to brews table (nullable, stored as seconds)
- Display as M:SS format with stepper control (+/-5s) in add form
- Replace ratio with brew time in brew list Recipe column
- Show brew time in brew cards and timeline events
- Add --brew-time CLI flag and update bootstrap script (1:45-2:45 range)
2026-02-07 11:24:07 +00:00

729 lines
24 KiB
Rust

use std::sync::Arc;
use brewlog::application::services::{CafeService, GearService, RoastService, RoasterService};
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, CafeRepository, GearRepository, RoastRepository,
RoasterRepository, TimelineEventRepository,
};
use brewlog::domain::roasters::{NewRoaster, Roaster, RoasterSortKey};
use brewlog::domain::roasts::{NewRoast, Roast, RoastSortKey};
use brewlog::domain::timeline::TimelineEvent;
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;
use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
use super::helpers::{create_default_roaster, spawn_app, spawn_app_with_auth};
struct TestDb {
roaster_repo: Arc<dyn RoasterRepository>,
roast_repo: Arc<dyn RoastRepository>,
bag_repo: Arc<dyn BagRepository>,
gear_repo: Arc<dyn GearRepository>,
brew_repo: Arc<dyn BrewRepository>,
cafe_repo: Arc<dyn CafeRepository>,
timeline_repo: Arc<dyn TimelineEventRepository>,
backup_service: BackupService,
roaster_service: RoasterService,
roast_service: RoastService,
gear_service: GearService,
cafe_service: CafeService,
}
async fn create_test_db() -> TestDb {
let database = Database::connect("sqlite::memory:")
.await
.expect("Failed to connect to in-memory database");
let pool = database.clone_pool();
let roaster_repo: Arc<dyn RoasterRepository> =
Arc::new(SqlRoasterRepository::new(pool.clone()));
let roast_repo: Arc<dyn RoastRepository> = Arc::new(SqlRoastRepository::new(pool.clone()));
let bag_repo: Arc<dyn BagRepository> = Arc::new(SqlBagRepository::new(pool.clone()));
let gear_repo: Arc<dyn GearRepository> = Arc::new(SqlGearRepository::new(pool.clone()));
let brew_repo: Arc<dyn BrewRepository> = Arc::new(SqlBrewRepository::new(pool.clone()));
let cafe_repo: Arc<dyn CafeRepository> = Arc::new(SqlCafeRepository::new(pool.clone()));
let timeline_repo: Arc<dyn TimelineEventRepository> =
Arc::new(SqlTimelineEventRepository::new(pool.clone()));
let roaster_service =
RoasterService::new(Arc::clone(&roaster_repo), Arc::clone(&timeline_repo));
let roast_service = RoastService::new(
Arc::clone(&roast_repo),
Arc::clone(&roaster_repo),
Arc::clone(&timeline_repo),
);
let gear_service = GearService::new(Arc::clone(&gear_repo), Arc::clone(&timeline_repo));
let cafe_service = CafeService::new(Arc::clone(&cafe_repo), Arc::clone(&timeline_repo));
TestDb {
roaster_repo,
roast_repo,
bag_repo,
gear_repo,
brew_repo,
cafe_repo,
timeline_repo,
backup_service: BackupService::new(pool),
roaster_service,
roast_service,
gear_service,
cafe_service,
}
}
fn list_all_request<K: brewlog::domain::listing::SortKey>() -> ListRequest<K> {
ListRequest::new(
1,
PageSize::All,
K::default(),
K::default().default_direction(),
)
}
async fn list_all_roasters(repo: &dyn RoasterRepository) -> Vec<Roaster> {
repo.list(&list_all_request::<RoasterSortKey>(), None)
.await
.expect("failed to list roasters")
.items
}
async fn list_all_roasts(repo: &dyn RoastRepository) -> Vec<Roast> {
let page = repo
.list(&list_all_request::<RoastSortKey>(), None)
.await
.expect("failed to list roasts");
page.items.into_iter().map(|rwr| rwr.roast).collect()
}
async fn list_all_bags(repo: &dyn BagRepository) -> Vec<Bag> {
let page = repo
.list(BagFilter::all(), &list_all_request::<BagSortKey>(), None)
.await
.expect("failed to list bags");
page.items.into_iter().map(|bwr| bwr.bag).collect()
}
async fn list_all_gear(repo: &dyn GearRepository) -> Vec<Gear> {
repo.list(GearFilter::all(), &list_all_request::<GearSortKey>(), None)
.await
.expect("failed to list gear")
.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> {
let page = repo
.list(BrewFilter::all(), &list_all_request::<BrewSortKey>(), None)
.await
.expect("failed to list brews");
page.items.into_iter().map(|bwd| bwd.brew).collect()
}
async fn list_all_timeline_events(repo: &dyn TimelineEventRepository) -> Vec<TimelineEvent> {
repo.list_all()
.await
.expect("failed to list timeline events")
}
/// 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, Cafe) {
// Create roaster (via service to generate timeline event)
let roaster = db
.roaster_service
.create(NewRoaster {
name: "Square Mile".to_string(),
country: "UK".to_string(),
city: Some("London".to_string()),
homepage: Some("https://shop.squaremilecoffee.com".to_string()),
created_at: None,
})
.await
.expect("failed to create roaster");
// Create roast (via service to generate timeline event)
let roast = db
.roast_service
.create(NewRoast {
roaster_id: roaster.id,
name: "Red Brick".to_string(),
origin: "Brazil".to_string(),
region: "Cerrado".to_string(),
producer: "Fazenda Passeio".to_string(),
tasting_notes: vec![
"Milk Chocolate".to_string(),
"Hazelnut".to_string(),
"Caramel".to_string(),
],
process: "Natural".to_string(),
created_at: None,
})
.await
.expect("failed to create roast");
// Create bag (250g)
let bag = db
.bag_repo
.insert(NewBag {
roast_id: roast.id,
roast_date: Some(chrono::NaiveDate::from_ymd_opt(2025, 1, 15).unwrap()),
amount: 250.0,
created_at: None,
})
.await
.expect("failed to create bag");
// Create gear (via service to generate timeline events)
let grinder = db
.gear_service
.create(NewGear {
category: GearCategory::Grinder,
make: "Comandante".to_string(),
model: "C40 MK4".to_string(),
created_at: None,
})
.await
.expect("failed to create grinder");
let brewer = db
.gear_service
.create(NewGear {
category: GearCategory::Brewer,
make: "Hario".to_string(),
model: "V60 02".to_string(),
created_at: None,
})
.await
.expect("failed to create brewer");
let filter_paper = db
.gear_service
.create(NewGear {
category: GearCategory::FilterPaper,
make: "Hario".to_string(),
model: "V60 Tabbed 02".to_string(),
created_at: None,
})
.await
.expect("failed to create filter paper");
// Create a brew (deducts 15g from bag, remaining becomes 235)
let brew = db
.brew_repo
.insert(NewBrew {
bag_id: bag.id,
coffee_weight: 15.0,
grinder_id: grinder.id,
grind_setting: 24.0,
brewer_id: brewer.id,
filter_paper_id: Some(filter_paper.id),
water_volume: 250,
water_temp: 93.5,
quick_notes: Vec::new(),
brew_time: None,
created_at: None,
})
.await
.expect("failed to create brew");
// Re-fetch bag to get updated remaining
let bag = db
.bag_repo
.get(bag.id)
.await
.expect("failed to re-fetch bag");
assert_eq!(
bag.remaining, 235.0,
"bag remaining should be 235 after brew"
);
// Create cafe (via service to generate timeline event)
let cafe = db
.cafe_service
.create(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()),
created_at: None,
})
.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, cafe) =
populate_test_data(&source).await;
// Verify timeline events were created (roaster + roast inserts create them)
let source_timeline = list_all_timeline_events(source.timeline_repo.as_ref()).await;
assert!(
source_timeline.len() >= 2,
"expected at least 2 timeline events from roaster+roast creation"
);
// 2. Export backup
let backup_data = source
.backup_service
.export()
.await
.expect("failed to export backup");
assert_eq!(backup_data.version, 2);
assert_eq!(backup_data.roasters.len(), 1);
assert_eq!(backup_data.roasts.len(), 1);
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)
let json = serde_json::to_string_pretty(&backup_data).expect("failed to serialize backup");
let restored_data: BackupData =
serde_json::from_str(&json).expect("failed to deserialize backup");
assert_eq!(restored_data.version, 2);
assert_eq!(restored_data.roasters.len(), 1);
assert_eq!(restored_data.roasts.len(), 1);
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;
target
.backup_service
.restore(restored_data)
.await
.expect("failed to restore backup");
// 5. Verify all data matches
// Roasters
let target_roasters = list_all_roasters(target.roaster_repo.as_ref()).await;
assert_eq!(target_roasters.len(), 1);
let restored_roaster = &target_roasters[0];
assert_eq!(restored_roaster.id, roaster.id);
assert_eq!(restored_roaster.name, roaster.name);
assert_eq!(restored_roaster.slug, roaster.slug);
assert_eq!(restored_roaster.country, roaster.country);
assert_eq!(restored_roaster.city, roaster.city);
assert_eq!(restored_roaster.homepage, roaster.homepage);
assert_eq!(restored_roaster.created_at, roaster.created_at);
// Roasts
let target_roasts = list_all_roasts(target.roast_repo.as_ref()).await;
assert_eq!(target_roasts.len(), 1);
let restored_roast = &target_roasts[0];
assert_eq!(restored_roast.id, roast.id);
assert_eq!(restored_roast.roaster_id, roast.roaster_id);
assert_eq!(restored_roast.name, roast.name);
assert_eq!(restored_roast.slug, roast.slug);
assert_eq!(restored_roast.origin, roast.origin);
assert_eq!(restored_roast.region, roast.region);
assert_eq!(restored_roast.producer, roast.producer);
assert_eq!(restored_roast.process, roast.process);
assert_eq!(restored_roast.tasting_notes, roast.tasting_notes);
// Bags - critically verify remaining was NOT re-deducted
let target_bags = list_all_bags(target.bag_repo.as_ref()).await;
assert_eq!(target_bags.len(), 1);
let restored_bag = &target_bags[0];
assert_eq!(restored_bag.id, bag.id);
assert_eq!(restored_bag.roast_id, bag.roast_id);
assert_eq!(restored_bag.roast_date, bag.roast_date);
assert_eq!(restored_bag.amount, 250.0);
assert_eq!(
restored_bag.remaining, 235.0,
"bag remaining should be preserved at 235, not re-deducted by brew restore"
);
assert_eq!(restored_bag.closed, bag.closed);
// Gear
let target_gear = list_all_gear(target.gear_repo.as_ref()).await;
assert_eq!(target_gear.len(), 3);
let grinder_restored = target_gear.iter().find(|g| g.id == grinder.id).unwrap();
assert_eq!(grinder_restored.category, GearCategory::Grinder);
assert_eq!(grinder_restored.make, "Comandante");
assert_eq!(grinder_restored.model, "C40 MK4");
let brewer_restored = target_gear.iter().find(|g| g.id == brewer.id).unwrap();
assert_eq!(brewer_restored.category, GearCategory::Brewer);
let fp_restored = target_gear
.iter()
.find(|g| g.id == filter_paper.id)
.unwrap();
assert_eq!(fp_restored.category, GearCategory::FilterPaper);
// Brews
let target_brews = list_all_brews(target.brew_repo.as_ref()).await;
assert_eq!(target_brews.len(), 1);
let restored_brew = &target_brews[0];
assert_eq!(restored_brew.id, brew.id);
assert_eq!(restored_brew.bag_id, brew.bag_id);
assert_eq!(restored_brew.coffee_weight, 15.0);
assert_eq!(restored_brew.grinder_id, brew.grinder_id);
assert_eq!(restored_brew.grind_setting, 24.0);
assert_eq!(restored_brew.brewer_id, brew.brewer_id);
assert_eq!(restored_brew.filter_paper_id, Some(filter_paper.id));
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);
// Timeline events
let target_timeline = list_all_timeline_events(target.timeline_repo.as_ref()).await;
assert_eq!(target_timeline.len(), source_timeline.len());
for (source_event, target_event) in source_timeline.iter().zip(target_timeline.iter()) {
assert_eq!(target_event.id, source_event.id);
assert_eq!(target_event.entity_type, source_event.entity_type);
assert_eq!(target_event.entity_id, source_event.entity_id);
assert_eq!(target_event.action, source_event.action);
assert_eq!(target_event.title, source_event.title);
assert_eq!(target_event.details.len(), source_event.details.len());
assert_eq!(target_event.tasting_notes, source_event.tasting_notes);
assert_eq!(target_event.slug, source_event.slug);
assert_eq!(target_event.roaster_slug, source_event.roaster_slug);
}
}
#[tokio::test]
async fn restore_to_non_empty_database_fails() {
let db = create_test_db().await;
// Add a roaster to make the database non-empty
db.roaster_repo
.insert(NewRoaster {
name: "Existing Roaster".to_string(),
country: "UK".to_string(),
city: None,
homepage: None,
created_at: None,
})
.await
.expect("failed to create roaster");
// Create a minimal backup
let backup_data = BackupData {
version: 1,
created_at: chrono::Utc::now(),
roasters: vec![],
gear: vec![],
roasts: vec![],
bags: vec![],
brews: vec![],
cafes: vec![],
cups: vec![],
timeline_events: vec![],
};
// Restore should fail because the database is not empty
let result = db.backup_service.restore(backup_data).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("not empty"),
"expected 'not empty' error, got: {err_msg}"
);
}
#[tokio::test]
async fn backup_empty_database() {
let db = create_test_db().await;
let backup_data = db
.backup_service
.export()
.await
.expect("failed to export empty database");
assert_eq!(backup_data.version, 2);
assert!(backup_data.roasters.is_empty());
assert!(backup_data.roasts.is_empty());
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
let json = serde_json::to_string_pretty(&backup_data).expect("failed to serialize");
let parsed: BackupData = serde_json::from_str(&json).expect("failed to deserialize");
assert_eq!(parsed.version, 2);
}
// --- API-level tests ---
#[tokio::test]
async fn backup_export_requires_auth() {
let app = spawn_app().await;
let client = reqwest::Client::new();
let response = client
.get(app.api_url("/backup"))
.send()
.await
.expect("failed to send request");
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn backup_export_returns_data() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
// Create some data first
create_default_roaster(&app).await;
let response = client
.get(app.api_url("/backup"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.send()
.await
.expect("failed to send request");
assert_eq!(response.status(), reqwest::StatusCode::OK);
let data: BackupData = response.json().await.expect("failed to parse backup data");
assert_eq!(data.version, 2);
assert_eq!(data.roasters.len(), 1);
assert_eq!(data.roasters[0].name, "Test Roasters");
}
#[tokio::test]
async fn backup_restore_requires_auth() {
let app = spawn_app().await;
let client = reqwest::Client::new();
let backup_data = BackupData {
version: 1,
created_at: chrono::Utc::now(),
roasters: vec![],
gear: vec![],
roasts: vec![],
bags: vec![],
brews: vec![],
cafes: vec![],
cups: vec![],
timeline_events: vec![],
};
let response = client
.post(app.api_url("/backup/restore"))
.json(&backup_data)
.send()
.await
.expect("failed to send request");
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn backup_restore_non_empty_db_returns_conflict() {
let app = spawn_app_with_auth().await;
let client = reqwest::Client::new();
// Create data to make the database non-empty
create_default_roaster(&app).await;
let backup_data = BackupData {
version: 1,
created_at: chrono::Utc::now(),
roasters: vec![],
gear: vec![],
roasts: vec![],
bags: vec![],
brews: vec![],
cafes: vec![],
cups: vec![],
timeline_events: vec![],
};
let response = client
.post(app.api_url("/backup/restore"))
.bearer_auth(app.auth_token.as_ref().unwrap())
.json(&backup_data)
.send()
.await
.expect("failed to send request");
assert_eq!(response.status(), reqwest::StatusCode::CONFLICT);
}
#[tokio::test]
async fn backup_round_trip_via_api() {
// 1. Create source app with data
let source = spawn_app_with_auth().await;
let client = reqwest::Client::new();
create_default_roaster(&source).await;
// 2. Export via API
let response = client
.get(source.api_url("/backup"))
.bearer_auth(source.auth_token.as_ref().unwrap())
.send()
.await
.expect("failed to export backup");
assert_eq!(response.status(), reqwest::StatusCode::OK);
let backup_data: BackupData = response.json().await.expect("failed to parse backup");
assert_eq!(backup_data.roasters.len(), 1);
// 3. Restore into a fresh app
let target = spawn_app_with_auth().await;
let response = client
.post(target.api_url("/backup/restore"))
.bearer_auth(target.auth_token.as_ref().unwrap())
.json(&backup_data)
.send()
.await
.expect("failed to restore backup");
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
// 4. Verify data was restored by listing roasters
let response = client
.get(target.api_url("/roasters"))
.send()
.await
.expect("failed to list roasters");
let roasters: Vec<Roaster> = response.json().await.expect("failed to parse roasters");
assert_eq!(roasters.len(), 1);
assert_eq!(roasters[0].name, "Test Roasters");
}
// --- Reset tests ---
#[tokio::test]
async fn reset_requires_auth() {
let app = spawn_app().await;
let client = reqwest::Client::new();
let response = client
.post(app.api_url("/backup/reset"))
.send()
.await
.expect("failed to send request");
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn reset_clears_all_data() {
let db = create_test_db().await;
populate_test_data(&db).await;
// Verify data exists before reset
assert!(!list_all_roasters(db.roaster_repo.as_ref()).await.is_empty());
assert!(!list_all_roasts(db.roast_repo.as_ref()).await.is_empty());
assert!(!list_all_bags(db.bag_repo.as_ref()).await.is_empty());
assert!(!list_all_gear(db.gear_repo.as_ref()).await.is_empty());
assert!(!list_all_brews(db.brew_repo.as_ref()).await.is_empty());
assert!(!list_all_cafes(db.cafe_repo.as_ref()).await.is_empty());
assert!(
!list_all_timeline_events(db.timeline_repo.as_ref())
.await
.is_empty()
);
// Reset
db.backup_service
.reset()
.await
.expect("failed to reset database");
// Verify all tables are empty
assert!(list_all_roasters(db.roaster_repo.as_ref()).await.is_empty());
assert!(list_all_roasts(db.roast_repo.as_ref()).await.is_empty());
assert!(list_all_bags(db.bag_repo.as_ref()).await.is_empty());
assert!(list_all_gear(db.gear_repo.as_ref()).await.is_empty());
assert!(list_all_brews(db.brew_repo.as_ref()).await.is_empty());
assert!(list_all_cafes(db.cafe_repo.as_ref()).await.is_empty());
assert!(
list_all_timeline_events(db.timeline_repo.as_ref())
.await
.is_empty()
);
}
#[tokio::test]
async fn reset_then_restore_succeeds() {
let db = create_test_db().await;
populate_test_data(&db).await;
// Export before reset
let backup = db.backup_service.export().await.expect("failed to export");
assert_eq!(backup.roasters.len(), 1);
assert_eq!(backup.gear.len(), 3);
assert_eq!(backup.brews.len(), 1);
// Reset
db.backup_service
.reset()
.await
.expect("failed to reset database");
// Restore should succeed (database is now empty)
db.backup_service
.restore(backup)
.await
.expect("failed to restore after reset");
// Verify data is back
assert_eq!(list_all_roasters(db.roaster_repo.as_ref()).await.len(), 1);
assert_eq!(list_all_roasts(db.roast_repo.as_ref()).await.len(), 1);
assert_eq!(list_all_bags(db.bag_repo.as_ref()).await.len(), 1);
assert_eq!(list_all_gear(db.gear_repo.as_ref()).await.len(), 3);
assert_eq!(list_all_brews(db.brew_repo.as_ref()).await.len(), 1);
assert_eq!(list_all_cafes(db.cafe_repo.as_ref()).await.len(), 1);
}