feat: include entity images in backup, restore, and reset

Images were not included in the backup/restore system, so a backup
would lose all uploaded images and a reset would orphan them.

Add BackupImage with base64-encoded binary data to BackupData,
export/restore/reset the entity_images table, and verify it in
the empty-database check. Backward-compatible with existing v2
backups via #[serde(default)].
This commit is contained in:
Jon Seager 2026-02-10 16:50:11 +00:00
parent c3781de30c
commit b28234b52f
No known key found for this signature in database
2 changed files with 140 additions and 2 deletions

View file

@ -42,6 +42,31 @@ fn decode_json_opt<T: serde::de::DeserializeOwned>(
} }
} }
mod base64_serde {
use base64::{Engine, engine::general_purpose::STANDARD};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(data: &[u8], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&STANDARD.encode(data))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
let s = String::deserialize(d)?;
STANDARD.decode(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BackupImage {
pub entity_type: String,
pub entity_id: i64,
pub content_type: String,
#[serde(with = "base64_serde")]
pub image_data: Vec<u8>,
#[serde(with = "base64_serde")]
pub thumbnail_data: Vec<u8>,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct BackupData { pub struct BackupData {
pub version: u32, pub version: u32,
@ -56,6 +81,8 @@ pub struct BackupData {
#[serde(default)] #[serde(default)]
pub cups: Vec<Cup>, pub cups: Vec<Cup>,
pub timeline_events: Vec<TimelineEvent>, pub timeline_events: Vec<TimelineEvent>,
#[serde(default)]
pub images: Vec<BackupImage>,
} }
pub struct BackupService { pub struct BackupService {
@ -76,6 +103,7 @@ impl BackupService {
let cafes = self.export_cafes().await?; let cafes = self.export_cafes().await?;
let cups = self.export_cups().await?; let cups = self.export_cups().await?;
let timeline_events = self.export_timeline_events().await?; let timeline_events = self.export_timeline_events().await?;
let images = self.export_images().await?;
Ok(BackupData { Ok(BackupData {
version: 2, version: 2,
@ -88,6 +116,7 @@ impl BackupService {
cafes, cafes,
cups, cups,
timeline_events, timeline_events,
images,
}) })
} }
@ -109,6 +138,7 @@ impl BackupService {
self.restore_cups(&mut tx, &data.cups).await?; self.restore_cups(&mut tx, &data.cups).await?;
self.restore_timeline_events(&mut tx, &data.timeline_events) self.restore_timeline_events(&mut tx, &data.timeline_events)
.await?; .await?;
self.restore_images(&mut tx, &data.images).await?;
tx.commit().await.context("failed to commit transaction")?; tx.commit().await.context("failed to commit transaction")?;
@ -130,6 +160,7 @@ impl BackupService {
// Delete in FK-safe order: children before parents. // Delete in FK-safe order: children before parents.
// brews has RESTRICT FK → gear; cups has RESTRICT FK → roasts, cafes. // brews has RESTRICT FK → gear; cups has RESTRICT FK → roasts, cafes.
let tables = [ let tables = [
"entity_images",
"brews", "brews",
"cups", "cups",
"bags", "bags",
@ -256,6 +287,17 @@ impl BackupService {
.collect::<anyhow::Result<Vec<_>>>() .collect::<anyhow::Result<Vec<_>>>()
} }
async fn export_images(&self) -> anyhow::Result<Vec<BackupImage>> {
let records = sqlx::query_as::<_, ImageRecord>(
"SELECT entity_type, entity_id, content_type, image_data, thumbnail_data FROM entity_images ORDER BY entity_type, entity_id",
)
.fetch_all(&self.pool)
.await
.context("failed to export images")?;
Ok(records.into_iter().map(ImageRecord::into_backup).collect())
}
// --- Restore methods --- // --- Restore methods ---
async fn verify_empty_database(&self) -> anyhow::Result<()> { async fn verify_empty_database(&self) -> anyhow::Result<()> {
@ -268,6 +310,7 @@ impl BackupService {
"cafes", "cafes",
"cups", "cups",
"timeline_events", "timeline_events",
"entity_images",
]; ];
for table in tables { for table in tables {
@ -523,6 +566,28 @@ impl BackupService {
Ok(()) Ok(())
} }
async fn restore_images(
&self,
tx: &mut DatabaseTransaction<'_>,
images: &[BackupImage],
) -> anyhow::Result<()> {
for image in images {
sqlx::query(
"INSERT INTO entity_images (entity_type, entity_id, content_type, image_data, thumbnail_data) VALUES (?, ?, ?, ?, ?)",
)
.bind(&image.entity_type)
.bind(image.entity_id)
.bind(&image.content_type)
.bind(&image.image_data)
.bind(&image.thumbnail_data)
.execute(&mut **tx)
.await
.context("failed to restore image")?;
}
Ok(())
}
} }
// --- Record types for export queries --- // --- Record types for export queries ---
@ -773,3 +838,24 @@ impl TimelineEventRecord {
}) })
} }
} }
#[derive(sqlx::FromRow)]
struct ImageRecord {
entity_type: String,
entity_id: i64,
content_type: String,
image_data: Vec<u8>,
thumbnail_data: Vec<u8>,
}
impl ImageRecord {
fn into_backup(self) -> BackupImage {
BackupImage {
entity_type: self.entity_type,
entity_id: self.entity_id,
content_type: self.content_type,
image_data: self.image_data,
thumbnail_data: self.thumbnail_data,
}
}
}

View file

@ -14,7 +14,7 @@ use brewlog::domain::roasters::{NewRoaster, Roaster, RoasterSortKey};
use brewlog::domain::roasts::{NewRoast, Roast, RoastSortKey}; use brewlog::domain::roasts::{NewRoast, Roast, RoastSortKey};
use brewlog::domain::timeline::TimelineEvent; use brewlog::domain::timeline::TimelineEvent;
use brewlog::infrastructure::backup::{BackupData, BackupService}; use brewlog::infrastructure::backup::{BackupData, BackupService};
use brewlog::infrastructure::database::Database; use brewlog::infrastructure::database::{Database, DatabasePool};
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::cafes::SqlCafeRepository;
@ -26,6 +26,7 @@ use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepo
use super::helpers::{create_default_roaster, spawn_app, spawn_app_with_auth}; use super::helpers::{create_default_roaster, spawn_app, spawn_app_with_auth};
struct TestDb { struct TestDb {
pool: DatabasePool,
roaster_repo: Arc<dyn RoasterRepository>, roaster_repo: Arc<dyn RoasterRepository>,
roast_repo: Arc<dyn RoastRepository>, roast_repo: Arc<dyn RoastRepository>,
bag_repo: Arc<dyn BagRepository>, bag_repo: Arc<dyn BagRepository>,
@ -68,6 +69,7 @@ async fn create_test_db() -> TestDb {
let cafe_service = CafeService::new(Arc::clone(&cafe_repo), Arc::clone(&timeline_repo)); let cafe_service = CafeService::new(Arc::clone(&cafe_repo), Arc::clone(&timeline_repo));
TestDb { TestDb {
pool: pool.clone(),
roaster_repo, roaster_repo,
roast_repo, roast_repo,
bag_repo, bag_repo,
@ -143,6 +145,28 @@ async fn list_all_timeline_events(repo: &dyn TimelineEventRepository) -> Vec<Tim
.expect("failed to list timeline events") .expect("failed to list timeline events")
} }
async fn insert_test_image(pool: &DatabasePool, entity_type: &str, entity_id: i64) {
sqlx::query(
"INSERT INTO entity_images (entity_type, entity_id, content_type, image_data, thumbnail_data) VALUES (?, ?, ?, ?, ?)",
)
.bind(entity_type)
.bind(entity_id)
.bind("image/png")
.bind(b"fake-image-data".as_slice())
.bind(b"fake-thumb-data".as_slice())
.execute(pool)
.await
.expect("failed to insert test image");
}
async fn count_images(pool: &DatabasePool) -> i64 {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM entity_images")
.fetch_one(pool)
.await
.expect("failed to count images");
row.0
}
/// 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, Cafe) { async fn populate_test_data(db: &TestDb) -> (Roaster, Roast, Bag, Gear, Gear, Gear, Brew, Cafe) {
// Create roaster (via service to generate timeline event) // Create roaster (via service to generate timeline event)
@ -289,6 +313,9 @@ async fn backup_and_restore_round_trip() {
let (roaster, roast, bag, grinder, brewer, filter_paper, brew, cafe) = let (roaster, roast, bag, grinder, brewer, filter_paper, brew, cafe) =
populate_test_data(&source).await; populate_test_data(&source).await;
// Insert a test image for the roaster
insert_test_image(&source.pool, "roaster", i64::from(roaster.id)).await;
// Verify timeline events were created (roaster + roast inserts create them) // Verify timeline events were created (roaster + roast inserts create them)
let source_timeline = list_all_timeline_events(source.timeline_repo.as_ref()).await; let source_timeline = list_all_timeline_events(source.timeline_repo.as_ref()).await;
assert!( assert!(
@ -311,6 +338,9 @@ async fn backup_and_restore_round_trip() {
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.cafes.len(), 1);
assert_eq!(backup_data.timeline_events.len(), source_timeline.len()); assert_eq!(backup_data.timeline_events.len(), source_timeline.len());
assert_eq!(backup_data.images.len(), 1);
assert_eq!(backup_data.images[0].entity_type, "roaster");
assert_eq!(backup_data.images[0].content_type, "image/png");
// 3. Serialize to JSON and deserialize back (verify serde round-trip) // 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 json = serde_json::to_string_pretty(&backup_data).expect("failed to serialize backup");
@ -324,6 +354,7 @@ async fn backup_and_restore_round_trip() {
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); assert_eq!(restored_data.cafes.len(), 1);
assert_eq!(restored_data.images.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;
@ -431,6 +462,18 @@ async fn backup_and_restore_round_trip() {
assert_eq!(target_event.slug, source_event.slug); assert_eq!(target_event.slug, source_event.slug);
assert_eq!(target_event.roaster_slug, source_event.roaster_slug); assert_eq!(target_event.roaster_slug, source_event.roaster_slug);
} }
// Images
assert_eq!(count_images(&target.pool).await, 1);
let target_backup = target
.backup_service
.export()
.await
.expect("failed to re-export");
assert_eq!(target_backup.images.len(), 1);
assert_eq!(target_backup.images[0].entity_type, "roaster");
assert_eq!(target_backup.images[0].image_data, b"fake-image-data");
assert_eq!(target_backup.images[0].thumbnail_data, b"fake-thumb-data");
} }
#[tokio::test] #[tokio::test]
@ -461,6 +504,7 @@ async fn restore_to_non_empty_database_fails() {
cafes: vec![], cafes: vec![],
cups: vec![], cups: vec![],
timeline_events: vec![], timeline_events: vec![],
images: vec![],
}; };
// Restore should fail because the database is not empty // Restore should fail because the database is not empty
@ -491,6 +535,7 @@ async fn backup_empty_database() {
assert!(backup_data.brews.is_empty()); assert!(backup_data.brews.is_empty());
assert!(backup_data.cafes.is_empty()); assert!(backup_data.cafes.is_empty());
assert!(backup_data.timeline_events.is_empty()); assert!(backup_data.timeline_events.is_empty());
assert!(backup_data.images.is_empty());
// Should serialize to valid JSON // Should serialize to valid JSON
let json = serde_json::to_string_pretty(&backup_data).expect("failed to serialize"); let json = serde_json::to_string_pretty(&backup_data).expect("failed to serialize");
@ -553,6 +598,7 @@ async fn backup_restore_requires_auth() {
cafes: vec![], cafes: vec![],
cups: vec![], cups: vec![],
timeline_events: vec![], timeline_events: vec![],
images: vec![],
}; };
let response = client let response = client
@ -584,6 +630,7 @@ async fn backup_restore_non_empty_db_returns_conflict() {
cafes: vec![], cafes: vec![],
cups: vec![], cups: vec![],
timeline_events: vec![], timeline_events: vec![],
images: vec![],
}; };
let response = client let response = client
@ -661,7 +708,10 @@ async fn reset_requires_auth() {
#[tokio::test] #[tokio::test]
async fn reset_clears_all_data() { async fn reset_clears_all_data() {
let db = create_test_db().await; let db = create_test_db().await;
populate_test_data(&db).await; let (roaster, ..) = populate_test_data(&db).await;
// Insert an image
insert_test_image(&db.pool, "roaster", i64::from(roaster.id)).await;
// Verify data exists before reset // Verify data exists before reset
assert!(!list_all_roasters(db.roaster_repo.as_ref()).await.is_empty()); assert!(!list_all_roasters(db.roaster_repo.as_ref()).await.is_empty());
@ -675,6 +725,7 @@ async fn reset_clears_all_data() {
.await .await
.is_empty() .is_empty()
); );
assert_eq!(count_images(&db.pool).await, 1);
// Reset // Reset
db.backup_service db.backup_service
@ -694,6 +745,7 @@ async fn reset_clears_all_data() {
.await .await
.is_empty() .is_empty()
); );
assert_eq!(count_images(&db.pool).await, 0);
} }
#[tokio::test] #[tokio::test]