From 2ac61345ecd7281a70a4de0d6313b2cd4c89e27e Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 10:32:56 +0000 Subject: [PATCH] feat(backup): add CLI backup and restore commands - Add BackupData struct and BackupService with raw SQL export/import - Restore uses raw inserts to bypass brew deductions and timeline creation - Restore requires an empty database, inserts in FK dependency order - Add comprehensive e2e test verifying full round-trip fidelity --- .gitignore | 1 + src/infrastructure/backup.rs | 575 +++++++++++++++++++++++++++++++++ src/infrastructure/mod.rs | 1 + src/main.rs | 19 ++ src/presentation/cli/backup.rs | 27 ++ src/presentation/cli/mod.rs | 8 + tests/server/backup.rs | 408 +++++++++++++++++++++++ tests/server/main.rs | 1 + 8 files changed, 1040 insertions(+) create mode 100644 src/infrastructure/backup.rs create mode 100644 src/presentation/cli/backup.rs create mode 100644 tests/server/backup.rs diff --git a/.gitignore b/.gitignore index 308f938..7c728ef 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ result* brewlog.db TODO.md SPEC.md +backup.json \ No newline at end of file diff --git a/src/infrastructure/backup.rs b/src/infrastructure/backup.rs new file mode 100644 index 0000000..97e3b26 --- /dev/null +++ b/src/infrastructure/backup.rs @@ -0,0 +1,575 @@ +use std::str::FromStr; + +use anyhow::{Context, bail}; +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{from_str, to_string}; + +use crate::domain::bags::Bag; +use crate::domain::brews::Brew; +use crate::domain::gear::{Gear, GearCategory}; +use crate::domain::ids::{BagId, BrewId, GearId, RoastId, RoasterId, TimelineEventId}; +use crate::domain::roasters::Roaster; +use crate::domain::roasts::Roast; +use crate::domain::timeline::{TimelineBrewData, TimelineEvent, TimelineEventDetail}; +use crate::infrastructure::database::{DatabasePool, DatabaseTransaction}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct BackupData { + pub version: u32, + pub created_at: DateTime, + pub roasters: Vec, + pub gear: Vec, + pub roasts: Vec, + pub bags: Vec, + pub brews: Vec, + pub timeline_events: Vec, +} + +pub struct BackupService { + pool: DatabasePool, +} + +impl BackupService { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } + + pub async fn export(&self) -> anyhow::Result { + let roasters = self.export_roasters().await?; + let gear = self.export_gear().await?; + let roasts = self.export_roasts().await?; + let bags = self.export_bags().await?; + let brews = self.export_brews().await?; + let timeline_events = self.export_timeline_events().await?; + + Ok(BackupData { + version: 1, + created_at: Utc::now(), + roasters, + gear, + roasts, + bags, + brews, + timeline_events, + }) + } + + pub async fn restore(&self, data: BackupData) -> anyhow::Result<()> { + self.verify_empty_database().await?; + + let mut tx = self + .pool + .begin() + .await + .context("failed to begin transaction")?; + + self.restore_roasters(&mut tx, &data.roasters).await?; + self.restore_gear(&mut tx, &data.gear).await?; + self.restore_roasts(&mut tx, &data.roasts).await?; + self.restore_bags(&mut tx, &data.bags).await?; + self.restore_brews(&mut tx, &data.brews).await?; + self.restore_timeline_events(&mut tx, &data.timeline_events) + .await?; + + tx.commit().await.context("failed to commit transaction")?; + + Ok(()) + } + + // --- Export methods --- + + async fn export_roasters(&self) -> anyhow::Result> { + let records = sqlx::query_as::<_, RoasterRecord>( + "SELECT id, name, slug, country, city, homepage, notes, created_at FROM roasters ORDER BY id", + ) + .fetch_all(&self.pool) + .await + .context("failed to export roasters")?; + + Ok(records + .into_iter() + .map(RoasterRecord::into_domain) + .collect()) + } + + async fn export_gear(&self) -> anyhow::Result> { + let records = sqlx::query_as::<_, GearRecord>( + "SELECT id, category, make, model, created_at, updated_at FROM gear ORDER BY id", + ) + .fetch_all(&self.pool) + .await + .context("failed to export gear")?; + + records + .into_iter() + .map(GearRecord::into_domain) + .collect::>>() + } + + async fn export_roasts(&self) -> anyhow::Result> { + let records = sqlx::query_as::<_, RoastRecord>( + "SELECT id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at FROM roasts ORDER BY id", + ) + .fetch_all(&self.pool) + .await + .context("failed to export roasts")?; + + records + .into_iter() + .map(RoastRecord::into_domain) + .collect::>>() + } + + async fn export_bags(&self) -> anyhow::Result> { + let records = sqlx::query_as::<_, BagRecord>( + "SELECT id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at FROM bags ORDER BY id", + ) + .fetch_all(&self.pool) + .await + .context("failed to export bags")?; + + Ok(records.into_iter().map(BagRecord::into_domain).collect()) + } + + async fn export_brews(&self) -> anyhow::Result> { + let records = sqlx::query_as::<_, BrewRecord>( + "SELECT id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, created_at, updated_at FROM brews ORDER BY id", + ) + .fetch_all(&self.pool) + .await + .context("failed to export brews")?; + + Ok(records.into_iter().map(BrewRecord::into_domain).collect()) + } + + async fn export_timeline_events(&self) -> anyhow::Result> { + let records = sqlx::query_as::<_, TimelineEventRecord>( + "SELECT id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json FROM timeline_events ORDER BY id", + ) + .fetch_all(&self.pool) + .await + .context("failed to export timeline events")?; + + records + .into_iter() + .map(TimelineEventRecord::into_domain) + .collect::>>() + } + + // --- Restore methods --- + + async fn verify_empty_database(&self) -> anyhow::Result<()> { + let tables = [ + "roasters", + "roasts", + "bags", + "gear", + "brews", + "timeline_events", + ]; + + for table in tables { + let query = format!("SELECT COUNT(*) as count FROM {table}"); + let row: (i64,) = sqlx::query_as(&query) + .fetch_one(&self.pool) + .await + .with_context(|| format!("failed to check table {table}"))?; + + if row.0 > 0 { + bail!( + "Cannot restore: table '{table}' is not empty ({} rows). Restore requires an empty database.", + row.0 + ); + } + } + + Ok(()) + } + + async fn restore_roasters( + &self, + tx: &mut DatabaseTransaction<'_>, + roasters: &[Roaster], + ) -> anyhow::Result<()> { + for roaster in roasters { + sqlx::query( + "INSERT INTO roasters (id, name, slug, country, city, homepage, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(i64::from(roaster.id)) + .bind(&roaster.name) + .bind(&roaster.slug) + .bind(&roaster.country) + .bind(roaster.city.as_deref()) + .bind(roaster.homepage.as_deref()) + .bind(roaster.notes.as_deref()) + .bind(roaster.created_at) + .execute(&mut **tx) + .await + .context("failed to restore roaster")?; + } + + Ok(()) + } + + async fn restore_gear( + &self, + tx: &mut DatabaseTransaction<'_>, + gear: &[Gear], + ) -> anyhow::Result<()> { + for item in gear { + sqlx::query( + "INSERT INTO gear (id, category, make, model, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(i64::from(item.id)) + .bind(item.category.as_str()) + .bind(&item.make) + .bind(&item.model) + .bind(item.created_at) + .bind(item.updated_at) + .execute(&mut **tx) + .await + .context("failed to restore gear")?; + } + + Ok(()) + } + + async fn restore_roasts( + &self, + tx: &mut DatabaseTransaction<'_>, + roasts: &[Roast], + ) -> anyhow::Result<()> { + for roast in roasts { + let tasting_notes_json = if roast.tasting_notes.is_empty() { + None + } else { + Some( + to_string(&roast.tasting_notes) + .context("failed to encode tasting notes for restore")?, + ) + }; + + sqlx::query( + "INSERT INTO roasts (id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(i64::from(roast.id)) + .bind(i64::from(roast.roaster_id)) + .bind(&roast.name) + .bind(&roast.slug) + .bind(roast.origin.as_deref()) + .bind(roast.region.as_deref()) + .bind(roast.producer.as_deref()) + .bind(roast.process.as_deref()) + .bind(tasting_notes_json.as_deref()) + .bind(roast.created_at) + .execute(&mut **tx) + .await + .context("failed to restore roast")?; + } + + Ok(()) + } + + async fn restore_bags( + &self, + tx: &mut DatabaseTransaction<'_>, + bags: &[Bag], + ) -> anyhow::Result<()> { + for bag in bags { + sqlx::query( + "INSERT INTO bags (id, roast_id, roast_date, amount, remaining, closed, finished_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(i64::from(bag.id)) + .bind(i64::from(bag.roast_id)) + .bind(bag.roast_date) + .bind(bag.amount) + .bind(bag.remaining) + .bind(bag.closed) + .bind(bag.finished_at) + .bind(bag.created_at) + .bind(bag.updated_at) + .execute(&mut **tx) + .await + .context("failed to restore bag")?; + } + + Ok(()) + } + + async fn restore_brews( + &self, + tx: &mut DatabaseTransaction<'_>, + brews: &[Brew], + ) -> anyhow::Result<()> { + for brew in brews { + sqlx::query( + "INSERT INTO brews (id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(i64::from(brew.id)) + .bind(i64::from(brew.bag_id)) + .bind(brew.coffee_weight) + .bind(i64::from(brew.grinder_id)) + .bind(brew.grind_setting) + .bind(i64::from(brew.brewer_id)) + .bind(brew.filter_paper_id.map(i64::from)) + .bind(brew.water_volume) + .bind(brew.water_temp) + .bind(brew.created_at) + .bind(brew.updated_at) + .execute(&mut **tx) + .await + .context("failed to restore brew")?; + } + + Ok(()) + } + + async fn restore_timeline_events( + &self, + tx: &mut DatabaseTransaction<'_>, + events: &[TimelineEvent], + ) -> anyhow::Result<()> { + for event in events { + let details_json = to_string(&event.details) + .context("failed to encode timeline event details for restore")?; + + let tasting_notes_json = to_string(&event.tasting_notes) + .context("failed to encode timeline event tasting notes for restore")?; + + let brew_data_json = event + .brew_data + .as_ref() + .map(to_string) + .transpose() + .context("failed to encode timeline brew data for restore")?; + + sqlx::query( + "INSERT INTO timeline_events (id, entity_type, entity_id, action, occurred_at, title, details_json, tasting_notes_json, slug, roaster_slug, brew_data_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(i64::from(event.id)) + .bind(&event.entity_type) + .bind(event.entity_id) + .bind(&event.action) + .bind(event.occurred_at) + .bind(&event.title) + .bind(&details_json) + .bind(&tasting_notes_json) + .bind(event.slug.as_deref()) + .bind(event.roaster_slug.as_deref()) + .bind(brew_data_json.as_deref()) + .execute(&mut **tx) + .await + .context("failed to restore timeline event")?; + } + + Ok(()) + } +} + +// --- Record types for export queries --- + +#[derive(sqlx::FromRow)] +struct RoasterRecord { + id: i64, + name: String, + slug: String, + country: String, + city: Option, + homepage: Option, + notes: Option, + created_at: DateTime, +} + +impl RoasterRecord { + fn into_domain(self) -> Roaster { + Roaster { + id: RoasterId::from(self.id), + name: self.name, + slug: self.slug, + country: self.country, + city: self.city, + homepage: self.homepage, + notes: self.notes, + created_at: self.created_at, + } + } +} + +#[derive(sqlx::FromRow)] +struct GearRecord { + id: i64, + category: String, + make: String, + model: String, + created_at: DateTime, + updated_at: DateTime, +} + +impl GearRecord { + fn into_domain(self) -> anyhow::Result { + let category = GearCategory::from_str(&self.category) + .map_err(|()| anyhow::anyhow!("invalid gear category: {}", self.category))?; + + Ok(Gear { + id: GearId::new(self.id), + category, + make: self.make, + model: self.model, + created_at: self.created_at, + updated_at: self.updated_at, + }) + } +} + +#[derive(sqlx::FromRow)] +struct RoastRecord { + id: i64, + roaster_id: i64, + name: String, + slug: String, + origin: Option, + region: Option, + producer: Option, + process: Option, + tasting_notes: Option, + created_at: DateTime, +} + +impl RoastRecord { + fn into_domain(self) -> anyhow::Result { + let tasting_notes = match self.tasting_notes { + Some(raw) => from_str::>(&raw) + .with_context(|| format!("failed to decode tasting notes: {raw}"))?, + None => Vec::new(), + }; + + Ok(Roast { + id: RoastId::from(self.id), + roaster_id: RoasterId::from(self.roaster_id), + name: self.name, + slug: self.slug, + origin: self.origin, + region: self.region, + producer: self.producer, + process: self.process, + tasting_notes, + created_at: self.created_at, + }) + } +} + +#[derive(sqlx::FromRow)] +struct BagRecord { + id: i64, + roast_id: i64, + roast_date: Option, + amount: f64, + remaining: f64, + closed: bool, + finished_at: Option, + created_at: DateTime, + updated_at: DateTime, +} + +impl BagRecord { + fn into_domain(self) -> Bag { + Bag { + id: BagId::new(self.id), + roast_id: RoastId::new(self.roast_id), + roast_date: self.roast_date, + amount: self.amount, + remaining: self.remaining, + closed: self.closed, + finished_at: self.finished_at, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + +#[derive(sqlx::FromRow)] +struct BrewRecord { + id: i64, + bag_id: i64, + coffee_weight: f64, + grinder_id: i64, + grind_setting: f64, + brewer_id: i64, + filter_paper_id: Option, + water_volume: i32, + water_temp: f64, + created_at: DateTime, + updated_at: DateTime, +} + +impl BrewRecord { + fn into_domain(self) -> Brew { + Brew { + id: BrewId::new(self.id), + bag_id: BagId::new(self.bag_id), + coffee_weight: self.coffee_weight, + grinder_id: GearId::new(self.grinder_id), + grind_setting: self.grind_setting, + brewer_id: GearId::new(self.brewer_id), + filter_paper_id: self.filter_paper_id.map(GearId::new), + water_volume: self.water_volume, + water_temp: self.water_temp, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + +#[derive(sqlx::FromRow)] +struct TimelineEventRecord { + id: i64, + entity_type: String, + entity_id: i64, + action: String, + occurred_at: DateTime, + title: String, + details_json: Option, + tasting_notes_json: Option, + slug: Option, + roaster_slug: Option, + brew_data_json: Option, +} + +impl TimelineEventRecord { + fn into_domain(self) -> anyhow::Result { + let details = match self.details_json { + Some(raw) if !raw.is_empty() => from_str::>(&raw) + .with_context(|| format!("failed to decode timeline event details: {raw}"))?, + _ => Vec::new(), + }; + + let tasting_notes = match self.tasting_notes_json { + Some(raw) if !raw.is_empty() => from_str::>(&raw) + .with_context(|| format!("failed to decode timeline tasting notes: {raw}"))?, + _ => Vec::new(), + }; + + let brew_data = match self.brew_data_json { + Some(raw) if !raw.is_empty() => Some( + from_str::(&raw) + .with_context(|| format!("failed to decode timeline brew data: {raw}"))?, + ), + _ => None, + }; + + Ok(TimelineEvent { + id: TimelineEventId::from(self.id), + entity_type: self.entity_type, + entity_id: self.entity_id, + action: self.action, + occurred_at: self.occurred_at, + title: self.title, + details, + tasting_notes, + slug: self.slug, + roaster_slug: self.roaster_slug, + brew_data, + }) + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 77bcde9..6dfba9b 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod backup; pub mod client; pub mod database; pub mod repositories; diff --git a/src/main.rs b/src/main.rs index e0abe0e..9ad9399 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ use anyhow::Result; use brewlog::application::{ServerConfig, serve}; +use brewlog::infrastructure::backup::{BackupData, BackupService}; use brewlog::infrastructure::client::BrewlogClient; +use brewlog::infrastructure::database::Database; use brewlog::presentation::cli::{ Cli, Commands, ServeCommand, bags, brews, gear, roasters, roasts, tokens, }; @@ -45,6 +47,23 @@ async fn main() -> Result<()> { let client = BrewlogClient::from_base_url(&cli.api_url)?; tokens::run(&client, command).await } + Commands::Backup(cmd) => { + let database = Database::connect(&cmd.database_url).await?; + let service = BackupService::new(database.clone_pool()); + let data = service.export().await?; + let json = serde_json::to_string_pretty(&data)?; + println!("{json}"); + Ok(()) + } + Commands::Restore(cmd) => { + let contents = std::fs::read_to_string(&cmd.file)?; + let data: BackupData = serde_json::from_str(&contents)?; + let database = Database::connect(&cmd.database_url).await?; + let service = BackupService::new(database.clone_pool()); + service.restore(data).await?; + eprintln!("Restore complete."); + Ok(()) + } } } diff --git a/src/presentation/cli/backup.rs b/src/presentation/cli/backup.rs new file mode 100644 index 0000000..c28dc3f --- /dev/null +++ b/src/presentation/cli/backup.rs @@ -0,0 +1,27 @@ +use clap::Args; + +#[derive(Debug, Args)] +pub struct BackupCommand { + /// Database URL to back up from + #[arg( + long, + env = "BREWLOG_DATABASE_URL", + default_value = "sqlite://brewlog.db" + )] + pub database_url: String, +} + +#[derive(Debug, Args)] +pub struct RestoreCommand { + /// Database URL to restore into (must be an empty database) + #[arg( + long, + env = "BREWLOG_DATABASE_URL", + default_value = "sqlite://brewlog.db" + )] + pub database_url: String, + + /// Path to the backup JSON file + #[arg(long)] + pub file: String, +} diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index 48e3e7d..c50c473 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -1,3 +1,4 @@ +pub mod backup; pub mod bags; pub mod brews; pub mod gear; @@ -8,6 +9,7 @@ pub mod tokens; use std::net::SocketAddr; +use backup::{BackupCommand, RestoreCommand}; use bags::BagCommands; use brews::BrewCommands; use clap::{Args, Parser, Subcommand}; @@ -71,6 +73,12 @@ pub enum Commands { #[command(subcommand)] command: TokenCommands, }, + + /// Back up all coffee data to JSON (stdout) + Backup(BackupCommand), + + /// Restore coffee data from a JSON backup file + Restore(RestoreCommand), } #[derive(Debug, Args)] diff --git a/tests/server/backup.rs b/tests/server/backup.rs new file mode 100644 index 0000000..b446e6c --- /dev/null +++ b/tests/server/backup.rs @@ -0,0 +1,408 @@ +use std::sync::Arc; + +use brewlog::domain::bags::{Bag, BagFilter, BagSortKey, NewBag}; +use brewlog::domain::brews::{Brew, BrewFilter, BrewSortKey, NewBrew}; +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, +}; +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::gear::SqlGearRepository; +use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; +use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; +use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; + +struct TestDb { + roaster_repo: Arc, + roast_repo: Arc, + bag_repo: Arc, + gear_repo: Arc, + brew_repo: Arc, + timeline_repo: Arc, + backup_service: BackupService, +} + +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(); + + TestDb { + roaster_repo: Arc::new(SqlRoasterRepository::new(pool.clone())), + roast_repo: Arc::new(SqlRoastRepository::new(pool.clone())), + bag_repo: Arc::new(SqlBagRepository::new(pool.clone())), + gear_repo: Arc::new(SqlGearRepository::new(pool.clone())), + brew_repo: Arc::new(SqlBrewRepository::new(pool.clone())), + timeline_repo: Arc::new(SqlTimelineEventRepository::new(pool.clone())), + backup_service: BackupService::new(pool), + } +} + +fn list_all_request() -> ListRequest { + ListRequest::new( + 1, + PageSize::All, + K::default(), + K::default().default_direction(), + ) +} + +async fn list_all_roasters(repo: &dyn RoasterRepository) -> Vec { + repo.list(&list_all_request::()) + .await + .expect("failed to list roasters") + .items +} + +async fn list_all_roasts(repo: &dyn RoastRepository) -> Vec { + let page = repo + .list(&list_all_request::()) + .await + .expect("failed to list roasts"); + page.items.into_iter().map(|rwr| rwr.roast).collect() +} + +async fn list_all_bags(repo: &dyn BagRepository) -> Vec { + let page = repo + .list(BagFilter::all(), &list_all_request::()) + .await + .expect("failed to list bags"); + page.items.into_iter().map(|bwr| bwr.bag).collect() +} + +async fn list_all_gear(repo: &dyn GearRepository) -> Vec { + repo.list(GearFilter::all(), &list_all_request::()) + .await + .expect("failed to list gear") + .items +} + +async fn list_all_brews(repo: &dyn BrewRepository) -> Vec { + let page = repo + .list(BrewFilter::all(), &list_all_request::()) + .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 { + 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) { + // Create roaster + let roaster = db + .roaster_repo + .insert(NewRoaster { + name: "Square Mile".to_string(), + country: "UK".to_string(), + city: Some("London".to_string()), + homepage: Some("https://shop.squaremilecoffee.com".to_string()), + notes: Some("Great seasonal espresso".to_string()), + }) + .await + .expect("failed to create roaster"); + + // Create roast + let roast = db + .roast_repo + .insert(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(), + }) + .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, + }) + .await + .expect("failed to create bag"); + + // Create gear + let grinder = db + .gear_repo + .insert(NewGear { + category: GearCategory::Grinder, + make: "Comandante".to_string(), + model: "C40 MK4".to_string(), + }) + .await + .expect("failed to create grinder"); + + let brewer = db + .gear_repo + .insert(NewGear { + category: GearCategory::Brewer, + make: "Hario".to_string(), + model: "V60 02".to_string(), + }) + .await + .expect("failed to create brewer"); + + let filter_paper = db + .gear_repo + .insert(NewGear { + category: GearCategory::FilterPaper, + make: "Hario".to_string(), + model: "V60 Tabbed 02".to_string(), + }) + .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, + }) + .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" + ); + + (roaster, roast, bag, grinder, brewer, filter_paper, brew) +} + +#[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) = + 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, 1); + 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.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, 1); + 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); + + // 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.notes, roaster.notes); + 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); + + // 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, + notes: 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![], + 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, 1); + 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.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, 1); +} diff --git a/tests/server/main.rs b/tests/server/main.rs index 11b69ee..e6d96fa 100644 --- a/tests/server/main.rs +++ b/tests/server/main.rs @@ -1,4 +1,5 @@ pub mod auth_api; +pub mod backup; pub mod bags_api; pub mod brews_api; pub mod datastar;