From e58ea5758fb3e7e3c58fe5367db09619558e1248 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Sat, 7 Feb 2026 08:45:30 +0000 Subject: [PATCH] feat(backup): add database reset action to account page - Add reset() method to BackupService that deletes all 8 coffee tables in a transaction, respecting FK constraint order - Add POST /api/v1/backup/reset endpoint (requires auth) - Add "Reset Database" button with double-confirmation to account page - Stack data buttons vertically on mobile for better layout --- src/application/routes/api/backup.rs | 16 ++++++ src/application/routes/api/mod.rs | 1 + src/infrastructure/backup.rs | 38 ++++++++++++ templates/pages/account.html | 4 +- tests/server/backup.rs | 86 ++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 2 deletions(-) diff --git a/src/application/routes/api/backup.rs b/src/application/routes/api/backup.rs index 2b876e8..c432f37 100644 --- a/src/application/routes/api/backup.rs +++ b/src/application/routes/api/backup.rs @@ -58,3 +58,19 @@ pub(crate) async fn restore_backup( })?; Ok(StatusCode::NO_CONTENT.into_response()) } + +/// POST /api/v1/backup/reset — delete all coffee data (requires authentication) +pub(crate) async fn reset_database( + State(state): State, + _auth_user: AuthenticatedUser, +) -> Result { + state + .backup_service + .reset() + .await + .map_err(|e| AppError::unexpected(e.to_string()))?; + + tracing::info!("database reset: all coffee data deleted"); + + Ok(StatusCode::NO_CONTENT.into_response()) +} diff --git a/src/application/routes/api/mod.rs b/src/application/routes/api/mod.rs index d7002a1..8cbbdc3 100644 --- a/src/application/routes/api/mod.rs +++ b/src/application/routes/api/mod.rs @@ -90,6 +90,7 @@ pub(super) fn router() -> axum::Router { "/backup/restore", post(backup::restore_backup).layer(DefaultBodyLimit::max(50 * 1024 * 1024)), ) + .route("/backup/reset", post(backup::reset_database)) } pub(super) fn webauthn_router() -> axum::Router { diff --git a/src/infrastructure/backup.rs b/src/infrastructure/backup.rs index 6f75551..71f21c1 100644 --- a/src/infrastructure/backup.rs +++ b/src/infrastructure/backup.rs @@ -115,6 +115,44 @@ impl BackupService { Ok(()) } + /// Delete all coffee data, leaving auth tables intact. + /// + /// After a successful reset the database is in the same state that + /// `verify_empty_database()` requires, so a subsequent `restore()` will + /// succeed. + pub async fn reset(&self) -> anyhow::Result<()> { + let mut tx = self + .pool + .begin() + .await + .context("failed to begin transaction")?; + + // Delete in FK-safe order: children before parents. + // brews has RESTRICT FK → gear; cups has RESTRICT FK → roasts, cafes. + let tables = [ + "brews", + "cups", + "bags", + "roasts", + "timeline_events", + "gear", + "cafes", + "roasters", + ]; + + for table in tables { + let query = format!("DELETE FROM {table}"); + sqlx::query(&query) + .execute(&mut *tx) + .await + .with_context(|| format!("failed to delete from {table}"))?; + } + + tx.commit().await.context("failed to commit transaction")?; + + Ok(()) + } + // --- Export methods --- async fn export_roasters(&self) -> anyhow::Result> { diff --git a/templates/pages/account.html b/templates/pages/account.html index 1ddc496..86442d0 100644 --- a/templates/pages/account.html +++ b/templates/pages/account.html @@ -224,11 +224,11 @@

Export all coffee data as JSON, restore from a previous backup, or reset to start fresh.

-
+
Download Backup diff --git a/tests/server/backup.rs b/tests/server/backup.rs index 0fd15b6..0998d07 100644 --- a/tests/server/backup.rs +++ b/tests/server/backup.rs @@ -631,3 +631,89 @@ async fn backup_round_trip_via_api() { assert_eq!(roasters.len(), 1); assert_eq!(roasters[0].name, "Test Roasters"); } + +// --- Reset tests --- + +#[tokio::test] +async fn reset_requires_auth() { + let app = spawn_app().await; + let client = reqwest::Client::new(); + + let response = client + .post(app.api_url("/backup/reset")) + .send() + .await + .expect("failed to send request"); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn reset_clears_all_data() { + let db = create_test_db().await; + populate_test_data(&db).await; + + // Verify data exists before reset + assert!(!list_all_roasters(db.roaster_repo.as_ref()).await.is_empty()); + assert!(!list_all_roasts(db.roast_repo.as_ref()).await.is_empty()); + assert!(!list_all_bags(db.bag_repo.as_ref()).await.is_empty()); + assert!(!list_all_gear(db.gear_repo.as_ref()).await.is_empty()); + assert!(!list_all_brews(db.brew_repo.as_ref()).await.is_empty()); + assert!(!list_all_cafes(db.cafe_repo.as_ref()).await.is_empty()); + assert!( + !list_all_timeline_events(db.timeline_repo.as_ref()) + .await + .is_empty() + ); + + // Reset + db.backup_service + .reset() + .await + .expect("failed to reset database"); + + // Verify all tables are empty + assert!(list_all_roasters(db.roaster_repo.as_ref()).await.is_empty()); + assert!(list_all_roasts(db.roast_repo.as_ref()).await.is_empty()); + assert!(list_all_bags(db.bag_repo.as_ref()).await.is_empty()); + assert!(list_all_gear(db.gear_repo.as_ref()).await.is_empty()); + assert!(list_all_brews(db.brew_repo.as_ref()).await.is_empty()); + assert!(list_all_cafes(db.cafe_repo.as_ref()).await.is_empty()); + assert!( + list_all_timeline_events(db.timeline_repo.as_ref()) + .await + .is_empty() + ); +} + +#[tokio::test] +async fn reset_then_restore_succeeds() { + let db = create_test_db().await; + populate_test_data(&db).await; + + // Export before reset + let backup = db.backup_service.export().await.expect("failed to export"); + assert_eq!(backup.roasters.len(), 1); + assert_eq!(backup.gear.len(), 3); + assert_eq!(backup.brews.len(), 1); + + // Reset + db.backup_service + .reset() + .await + .expect("failed to reset database"); + + // Restore should succeed (database is now empty) + db.backup_service + .restore(backup) + .await + .expect("failed to restore after reset"); + + // Verify data is back + assert_eq!(list_all_roasters(db.roaster_repo.as_ref()).await.len(), 1); + assert_eq!(list_all_roasts(db.roast_repo.as_ref()).await.len(), 1); + assert_eq!(list_all_bags(db.bag_repo.as_ref()).await.len(), 1); + assert_eq!(list_all_gear(db.gear_repo.as_ref()).await.len(), 3); + assert_eq!(list_all_brews(db.brew_repo.as_ref()).await.len(), 1); + assert_eq!(list_all_cafes(db.cafe_repo.as_ref()).await.len(), 1); +}