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:
parent
c3781de30c
commit
b28234b52f
2 changed files with 140 additions and 2 deletions
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue