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
This commit is contained in:
Jon Seager 2026-02-03 10:32:56 +00:00
parent cb1cdc5c5b
commit 2ac61345ec
No known key found for this signature in database
8 changed files with 1040 additions and 0 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ result*
brewlog.db
TODO.md
SPEC.md
backup.json

View file

@ -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<Utc>,
pub roasters: Vec<Roaster>,
pub gear: Vec<Gear>,
pub roasts: Vec<Roast>,
pub bags: Vec<Bag>,
pub brews: Vec<Brew>,
pub timeline_events: Vec<TimelineEvent>,
}
pub struct BackupService {
pool: DatabasePool,
}
impl BackupService {
pub fn new(pool: DatabasePool) -> Self {
Self { pool }
}
pub async fn export(&self) -> anyhow::Result<BackupData> {
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<Vec<Roaster>> {
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<Vec<Gear>> {
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::<anyhow::Result<Vec<_>>>()
}
async fn export_roasts(&self) -> anyhow::Result<Vec<Roast>> {
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::<anyhow::Result<Vec<_>>>()
}
async fn export_bags(&self) -> anyhow::Result<Vec<Bag>> {
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<Vec<Brew>> {
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<Vec<TimelineEvent>> {
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::<anyhow::Result<Vec<_>>>()
}
// --- 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<String>,
homepage: Option<String>,
notes: Option<String>,
created_at: DateTime<Utc>,
}
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<Utc>,
updated_at: DateTime<Utc>,
}
impl GearRecord {
fn into_domain(self) -> anyhow::Result<Gear> {
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<String>,
region: Option<String>,
producer: Option<String>,
process: Option<String>,
tasting_notes: Option<String>,
created_at: DateTime<Utc>,
}
impl RoastRecord {
fn into_domain(self) -> anyhow::Result<Roast> {
let tasting_notes = match self.tasting_notes {
Some(raw) => from_str::<Vec<String>>(&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<NaiveDate>,
amount: f64,
remaining: f64,
closed: bool,
finished_at: Option<NaiveDate>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
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<i64>,
water_volume: i32,
water_temp: f64,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
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<Utc>,
title: String,
details_json: Option<String>,
tasting_notes_json: Option<String>,
slug: Option<String>,
roaster_slug: Option<String>,
brew_data_json: Option<String>,
}
impl TimelineEventRecord {
fn into_domain(self) -> anyhow::Result<TimelineEvent> {
let details = match self.details_json {
Some(raw) if !raw.is_empty() => from_str::<Vec<TimelineEventDetail>>(&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::<Vec<String>>(&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::<TimelineBrewData>(&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,
})
}
}

View file

@ -1,4 +1,5 @@
pub mod auth;
pub mod backup;
pub mod client;
pub mod database;
pub mod repositories;

View file

@ -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(())
}
}
}

View file

@ -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,
}

View file

@ -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)]

408
tests/server/backup.rs Normal file
View file

@ -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<dyn RoasterRepository>,
roast_repo: Arc<dyn RoastRepository>,
bag_repo: Arc<dyn BagRepository>,
gear_repo: Arc<dyn GearRepository>,
brew_repo: Arc<dyn BrewRepository>,
timeline_repo: Arc<dyn TimelineEventRepository>,
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<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>())
.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>())
.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>())
.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>())
.await
.expect("failed to list gear")
.items
}
async fn list_all_brews(repo: &dyn BrewRepository) -> Vec<Brew> {
let page = repo
.list(BrewFilter::all(), &list_all_request::<BrewSortKey>())
.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) {
// 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);
}

View file

@ -1,4 +1,5 @@
pub mod auth_api;
pub mod backup;
pub mod bags_api;
pub mod brews_api;
pub mod datastar;