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
This commit is contained in:
parent
47feedb273
commit
e58ea5758f
5 changed files with 143 additions and 2 deletions
|
|
@ -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<AppState>,
|
||||
_auth_user: AuthenticatedUser,
|
||||
) -> Result<Response, ApiError> {
|
||||
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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ pub(super) fn router() -> axum::Router<AppState> {
|
|||
"/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<AppState> {
|
||||
|
|
|
|||
|
|
@ -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<Vec<Roaster>> {
|
||||
|
|
|
|||
|
|
@ -224,11 +224,11 @@
|
|||
<p class="mt-1 text-sm text-text-secondary">Export all coffee data as JSON, restore from a previous backup, or reset to start fresh.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||
<a
|
||||
href="/api/v1/backup"
|
||||
download
|
||||
class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-text transition hover:bg-accent-hover inline-block"
|
||||
class="rounded-md bg-accent px-4 py-2 text-center text-sm font-medium text-accent-text transition hover:bg-accent-hover"
|
||||
>
|
||||
Download Backup
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue