fix(backup): include brew quick_notes and cups in backup/restore
- Add missing quick_notes column to restore_brews INSERT - Add cups export/restore with CupRecord and BackupData field - Bump backup version to 2; old v1 backups still restore via serde default
This commit is contained in:
parent
4ba21269ed
commit
60dc9513f4
3 changed files with 84 additions and 9 deletions
|
|
@ -8,8 +8,11 @@ use serde_json::{from_str, to_string};
|
|||
use crate::domain::bags::Bag;
|
||||
use crate::domain::brews::{Brew, QuickNote};
|
||||
use crate::domain::cafes::Cafe;
|
||||
use crate::domain::cups::Cup;
|
||||
use crate::domain::gear::{Gear, GearCategory};
|
||||
use crate::domain::ids::{BagId, BrewId, CafeId, GearId, RoastId, RoasterId, TimelineEventId};
|
||||
use crate::domain::ids::{
|
||||
BagId, BrewId, CafeId, CupId, GearId, RoastId, RoasterId, TimelineEventId,
|
||||
};
|
||||
use crate::domain::roasters::Roaster;
|
||||
use crate::domain::roasts::Roast;
|
||||
use crate::domain::timeline::TimelineEvent;
|
||||
|
|
@ -50,6 +53,8 @@ pub struct BackupData {
|
|||
pub brews: Vec<Brew>,
|
||||
#[serde(default)]
|
||||
pub cafes: Vec<Cafe>,
|
||||
#[serde(default)]
|
||||
pub cups: Vec<Cup>,
|
||||
pub timeline_events: Vec<TimelineEvent>,
|
||||
}
|
||||
|
||||
|
|
@ -69,10 +74,11 @@ impl BackupService {
|
|||
let bags = self.export_bags().await?;
|
||||
let brews = self.export_brews().await?;
|
||||
let cafes = self.export_cafes().await?;
|
||||
let cups = self.export_cups().await?;
|
||||
let timeline_events = self.export_timeline_events().await?;
|
||||
|
||||
Ok(BackupData {
|
||||
version: 1,
|
||||
version: 2,
|
||||
created_at: Utc::now(),
|
||||
roasters,
|
||||
gear,
|
||||
|
|
@ -80,6 +86,7 @@ impl BackupService {
|
|||
bags,
|
||||
brews,
|
||||
cafes,
|
||||
cups,
|
||||
timeline_events,
|
||||
})
|
||||
}
|
||||
|
|
@ -99,6 +106,7 @@ impl BackupService {
|
|||
self.restore_bags(&mut tx, &data.bags).await?;
|
||||
self.restore_brews(&mut tx, &data.brews).await?;
|
||||
self.restore_cafes(&mut tx, &data.cafes).await?;
|
||||
self.restore_cups(&mut tx, &data.cups).await?;
|
||||
self.restore_timeline_events(&mut tx, &data.timeline_events)
|
||||
.await?;
|
||||
|
||||
|
|
@ -184,6 +192,17 @@ impl BackupService {
|
|||
Ok(records.into_iter().map(CafeRecord::into_domain).collect())
|
||||
}
|
||||
|
||||
async fn export_cups(&self) -> anyhow::Result<Vec<Cup>> {
|
||||
let records = sqlx::query_as::<_, CupRecord>(
|
||||
"SELECT id, roast_id, cafe_id, created_at, updated_at FROM cups ORDER BY id",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.context("failed to export cups")?;
|
||||
|
||||
Ok(records.into_iter().map(CupRecord::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",
|
||||
|
|
@ -208,6 +227,7 @@ impl BackupService {
|
|||
"gear",
|
||||
"brews",
|
||||
"cafes",
|
||||
"cups",
|
||||
"timeline_events",
|
||||
];
|
||||
|
||||
|
|
@ -344,8 +364,15 @@ impl BackupService {
|
|||
brews: &[Brew],
|
||||
) -> anyhow::Result<()> {
|
||||
for brew in brews {
|
||||
let quick_notes_json = if brew.quick_notes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let values: Vec<&str> = brew.quick_notes.iter().map(|n| n.form_value()).collect();
|
||||
Some(to_string(&values).context("failed to encode quick notes for restore")?)
|
||||
};
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO brews (id, bag_id, coffee_weight, grinder_id, grind_setting, brewer_id, filter_paper_id, water_volume, water_temp, quick_notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(i64::from(brew.id))
|
||||
.bind(i64::from(brew.bag_id))
|
||||
|
|
@ -356,6 +383,7 @@ impl BackupService {
|
|||
.bind(brew.filter_paper_id.map(i64::from))
|
||||
.bind(brew.water_volume)
|
||||
.bind(brew.water_temp)
|
||||
.bind(quick_notes_json.as_deref())
|
||||
.bind(brew.created_at)
|
||||
.bind(brew.updated_at)
|
||||
.execute(&mut **tx)
|
||||
|
|
@ -393,6 +421,28 @@ impl BackupService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_cups(
|
||||
&self,
|
||||
tx: &mut DatabaseTransaction<'_>,
|
||||
cups: &[Cup],
|
||||
) -> anyhow::Result<()> {
|
||||
for cup in cups {
|
||||
sqlx::query(
|
||||
"INSERT INTO cups (id, roast_id, cafe_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(i64::from(cup.id))
|
||||
.bind(i64::from(cup.roast_id))
|
||||
.bind(i64::from(cup.cafe_id))
|
||||
.bind(cup.created_at)
|
||||
.bind(cup.updated_at)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.context("failed to restore cup")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_timeline_events(
|
||||
&self,
|
||||
tx: &mut DatabaseTransaction<'_>,
|
||||
|
|
@ -624,6 +674,27 @@ impl CafeRecord {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CupRecord {
|
||||
id: i64,
|
||||
roast_id: i64,
|
||||
cafe_id: i64,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl CupRecord {
|
||||
fn into_domain(self) -> Cup {
|
||||
Cup {
|
||||
id: CupId::from(self.id),
|
||||
roast_id: RoastId::from(self.roast_id),
|
||||
cafe_id: CafeId::from(self.cafe_id),
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TimelineEventRecord {
|
||||
id: i64,
|
||||
|
|
|
|||
|
|
@ -19,12 +19,13 @@ fn backup_produces_valid_json() {
|
|||
let data: serde_json::Value =
|
||||
serde_json::from_str(&stdout).expect("backup output is not valid JSON");
|
||||
|
||||
assert_eq!(data["version"], 1);
|
||||
assert_eq!(data["version"], 2);
|
||||
assert!(data["roasters"].is_array());
|
||||
assert!(data["roasts"].is_array());
|
||||
assert!(data["bags"].is_array());
|
||||
assert!(data["gear"].is_array());
|
||||
assert!(data["brews"].is_array());
|
||||
assert!(data["cafes"].is_array());
|
||||
assert!(data["cups"].is_array());
|
||||
assert!(data["timeline_events"].is_array());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ async fn backup_and_restore_round_trip() {
|
|||
.await
|
||||
.expect("failed to export backup");
|
||||
|
||||
assert_eq!(backup_data.version, 1);
|
||||
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);
|
||||
|
|
@ -308,7 +308,7 @@ async fn backup_and_restore_round_trip() {
|
|||
let restored_data: BackupData =
|
||||
serde_json::from_str(&json).expect("failed to deserialize backup");
|
||||
|
||||
assert_eq!(restored_data.version, 1);
|
||||
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);
|
||||
|
|
@ -449,6 +449,7 @@ async fn restore_to_non_empty_database_fails() {
|
|||
bags: vec![],
|
||||
brews: vec![],
|
||||
cafes: vec![],
|
||||
cups: vec![],
|
||||
timeline_events: vec![],
|
||||
};
|
||||
|
||||
|
|
@ -472,7 +473,7 @@ async fn backup_empty_database() {
|
|||
.await
|
||||
.expect("failed to export empty database");
|
||||
|
||||
assert_eq!(backup_data.version, 1);
|
||||
assert_eq!(backup_data.version, 2);
|
||||
assert!(backup_data.roasters.is_empty());
|
||||
assert!(backup_data.roasts.is_empty());
|
||||
assert!(backup_data.bags.is_empty());
|
||||
|
|
@ -484,7 +485,7 @@ async fn backup_empty_database() {
|
|||
// 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);
|
||||
assert_eq!(parsed.version, 2);
|
||||
}
|
||||
|
||||
// --- API-level tests ---
|
||||
|
|
@ -521,7 +522,7 @@ async fn backup_export_returns_data() {
|
|||
assert_eq!(response.status(), reqwest::StatusCode::OK);
|
||||
|
||||
let data: BackupData = response.json().await.expect("failed to parse backup data");
|
||||
assert_eq!(data.version, 1);
|
||||
assert_eq!(data.version, 2);
|
||||
assert_eq!(data.roasters.len(), 1);
|
||||
assert_eq!(data.roasters[0].name, "Test Roasters");
|
||||
}
|
||||
|
|
@ -540,6 +541,7 @@ async fn backup_restore_requires_auth() {
|
|||
bags: vec![],
|
||||
brews: vec![],
|
||||
cafes: vec![],
|
||||
cups: vec![],
|
||||
timeline_events: vec![],
|
||||
};
|
||||
|
||||
|
|
@ -570,6 +572,7 @@ async fn backup_restore_non_empty_db_returns_conflict() {
|
|||
bags: vec![],
|
||||
brews: vec![],
|
||||
cafes: vec![],
|
||||
cups: vec![],
|
||||
timeline_events: vec![],
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue