Skip to main content

tuitbot_core/storage/
backup.rs

1//! SQLite backup and restore using `VACUUM INTO`.
2//!
3//! Provides consistent backups even during active writes, validation
4//! of backup files, and atomic restore with safety backup.
5
6use std::path::{Path, PathBuf};
7use std::time::Instant;
8
9use chrono::Utc;
10use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
11use sqlx::Row;
12
13use super::DbPool;
14use crate::error::StorageError;
15
16/// Result of a successful backup operation.
17#[derive(Debug, Clone)]
18pub struct BackupResult {
19    /// Path to the backup file.
20    pub path: PathBuf,
21    /// Size of the backup file in bytes.
22    pub size_bytes: u64,
23    /// Duration of the backup operation in milliseconds.
24    pub duration_ms: u64,
25}
26
27/// Information about an existing backup file.
28#[derive(Debug, Clone)]
29pub struct BackupInfo {
30    /// Path to the backup file.
31    pub path: PathBuf,
32    /// File size in bytes.
33    pub size_bytes: u64,
34    /// Timestamp extracted from filename (if parseable).
35    pub timestamp: Option<String>,
36}
37
38/// Result of validating a backup file.
39#[derive(Debug, Clone)]
40pub struct ValidationResult {
41    /// Whether the backup passed all checks.
42    pub valid: bool,
43    /// Tables found in the backup.
44    pub tables: Vec<String>,
45    /// Human-readable messages about the validation.
46    pub messages: Vec<String>,
47}
48
49/// Create a consistent backup of the database using `VACUUM INTO`.
50///
51/// The backup is written to `backup_dir` with a timestamped filename.
52/// Returns the backup result on success.
53pub async fn create_backup(pool: &DbPool, backup_dir: &Path) -> Result<BackupResult, StorageError> {
54    create_backup_with_prefix(pool, backup_dir, "tuitbot").await
55}
56
57/// Create a backup with a custom filename prefix.
58async fn create_backup_with_prefix(
59    pool: &DbPool,
60    backup_dir: &Path,
61    prefix: &str,
62) -> Result<BackupResult, StorageError> {
63    std::fs::create_dir_all(backup_dir).map_err(|e| StorageError::Connection {
64        source: sqlx::Error::Configuration(
65            format!(
66                "failed to create backup directory {}: {e}",
67                backup_dir.display()
68            )
69            .into(),
70        ),
71    })?;
72
73    let timestamp = Utc::now().format("%Y%m%d_%H%M%S_%3f");
74    let filename = format!("{prefix}_{timestamp}.db");
75    let backup_path = backup_dir.join(&filename);
76
77    let start = Instant::now();
78
79    let path_str = backup_path
80        .to_str()
81        .ok_or_else(|| StorageError::Connection {
82            source: sqlx::Error::Configuration("backup path is not valid UTF-8".into()),
83        })?
84        .to_string();
85
86    // VACUUM INTO creates a consistent copy of the database.
87    let query = format!("VACUUM INTO '{}'", path_str.replace('\'', "''"));
88    sqlx::query(&query)
89        .execute(pool)
90        .await
91        .map_err(|e| StorageError::Query { source: e })?;
92
93    let duration_ms = start.elapsed().as_millis() as u64;
94
95    let metadata = std::fs::metadata(&backup_path).map_err(|e| StorageError::Connection {
96        source: sqlx::Error::Configuration(format!("failed to stat backup file: {e}").into()),
97    })?;
98
99    Ok(BackupResult {
100        path: backup_path,
101        size_bytes: metadata.len(),
102        duration_ms,
103    })
104}
105
106/// Validate a backup file by opening it read-only and checking expected tables.
107pub async fn validate_backup(backup_path: &Path) -> Result<ValidationResult, StorageError> {
108    if !backup_path.exists() {
109        return Ok(ValidationResult {
110            valid: false,
111            tables: vec![],
112            messages: vec![format!("File not found: {}", backup_path.display())],
113        });
114    }
115
116    // Quick check: verify the file starts with the SQLite magic header.
117    // This avoids confusing duplicated low-level SQLite errors for
118    // plain-text files, empty files, or other non-database inputs.
119    match std::fs::read(backup_path) {
120        Ok(bytes) if bytes.len() < 16 => {
121            return Ok(ValidationResult {
122                valid: false,
123                tables: vec![],
124                messages: vec![format!(
125                    "File is too small to be a SQLite database: {}",
126                    backup_path.display()
127                )],
128            });
129        }
130        Ok(bytes) if !bytes.starts_with(b"SQLite format 3\0") => {
131            return Ok(ValidationResult {
132                valid: false,
133                tables: vec![],
134                messages: vec![format!(
135                    "File is not a valid SQLite database: {}",
136                    backup_path.display()
137                )],
138            });
139        }
140        Err(e) => {
141            return Ok(ValidationResult {
142                valid: false,
143                tables: vec![],
144                messages: vec![format!(
145                    "Cannot read backup file {}: {}",
146                    backup_path.display(),
147                    e
148                )],
149            });
150        }
151        Ok(_) => {} // Valid header, proceed with full validation
152    }
153
154    let path_str = backup_path.to_string_lossy();
155    let options = SqliteConnectOptions::new()
156        .filename(&*path_str)
157        .read_only(true);
158
159    let pool = SqlitePoolOptions::new()
160        .max_connections(1)
161        .connect_with(options)
162        .await
163        .map_err(|e| StorageError::Connection { source: e })?;
164
165    let mut messages = Vec::new();
166
167    // Check tables.
168    let rows = sqlx::query(
169        "SELECT name FROM sqlite_master WHERE type='table' \
170         AND name NOT LIKE 'sqlite_%' AND name != '_sqlx_migrations' \
171         ORDER BY name",
172    )
173    .fetch_all(&pool)
174    .await
175    .map_err(|e| StorageError::Query { source: e })?;
176
177    let tables: Vec<String> = rows.iter().map(|r| r.get("name")).collect();
178
179    // Check for expected core tables.
180    let expected = [
181        "action_log",
182        "discovered_tweets",
183        "replies_sent",
184        "rate_limits",
185    ];
186    let mut missing = Vec::new();
187    for table in &expected {
188        if !tables.iter().any(|t| t == table) {
189            missing.push(*table);
190        }
191    }
192
193    let valid = missing.is_empty() && !tables.is_empty();
194
195    if valid {
196        messages.push(format!("Valid backup with {} tables", tables.len()));
197    } else if tables.is_empty() {
198        messages.push("No tables found in backup".to_string());
199    } else {
200        messages.push(format!("Missing expected tables: {}", missing.join(", ")));
201    }
202
203    // Integrity check.
204    let integrity: String = sqlx::query_scalar("PRAGMA integrity_check")
205        .fetch_one(&pool)
206        .await
207        .unwrap_or_else(|_| "error".to_string());
208
209    if integrity != "ok" {
210        messages.push(format!("Integrity check failed: {integrity}"));
211        return Ok(ValidationResult {
212            valid: false,
213            tables,
214            messages,
215        });
216    }
217
218    pool.close().await;
219
220    Ok(ValidationResult {
221        valid,
222        tables,
223        messages,
224    })
225}
226
227/// Restore from a backup file to the target database path.
228///
229/// 1. Validates the backup.
230/// 2. Creates a safety backup of the current database.
231/// 3. Atomically copies the backup via temp file + rename.
232pub async fn restore_from_backup(
233    backup_path: &Path,
234    target_path: &Path,
235) -> Result<(), StorageError> {
236    // 1. Validate.
237    let validation = validate_backup(backup_path).await?;
238    if !validation.valid {
239        return Err(StorageError::Connection {
240            source: sqlx::Error::Configuration(
241                format!(
242                    "Backup validation failed: {}",
243                    validation.messages.join("; ")
244                )
245                .into(),
246            ),
247        });
248    }
249
250    // 2. Safety backup of current database (if it exists).
251    if target_path.exists() {
252        let parent = target_path.parent().unwrap_or_else(|| Path::new("."));
253        let safety_name = format!("pre_restore_{}.db", Utc::now().format("%Y%m%d_%H%M%S"));
254        let safety_path = parent.join(safety_name);
255        std::fs::copy(target_path, &safety_path).map_err(|e| StorageError::Connection {
256            source: sqlx::Error::Configuration(
257                format!("Failed to create safety backup: {e}").into(),
258            ),
259        })?;
260        tracing::info!(
261            path = %safety_path.display(),
262            "Created safety backup of current database"
263        );
264    }
265
266    // 3. Atomic copy via temp + rename.
267    let parent = target_path.parent().unwrap_or_else(|| Path::new("."));
268    let temp_path = parent.join(format!(
269        ".tuitbot_restore_{}.tmp",
270        Utc::now().timestamp_millis()
271    ));
272
273    std::fs::copy(backup_path, &temp_path).map_err(|e| StorageError::Connection {
274        source: sqlx::Error::Configuration(format!("Failed to copy backup: {e}").into()),
275    })?;
276
277    std::fs::rename(&temp_path, target_path).map_err(|e| StorageError::Connection {
278        source: sqlx::Error::Configuration(format!("Failed to rename temp to target: {e}").into()),
279    })?;
280
281    // Clean up WAL/SHM files from the old database.
282    let wal_path = target_path.with_extension("db-wal");
283    let shm_path = target_path.with_extension("db-shm");
284    let _ = std::fs::remove_file(wal_path);
285    let _ = std::fs::remove_file(shm_path);
286
287    Ok(())
288}
289
290/// List backup files in a directory, sorted newest first.
291pub fn list_backups(backup_dir: &Path) -> Vec<BackupInfo> {
292    let mut backups = Vec::new();
293
294    let entries = match std::fs::read_dir(backup_dir) {
295        Ok(e) => e,
296        Err(_) => return backups,
297    };
298
299    for entry in entries.flatten() {
300        let path = entry.path();
301        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
302
303        if !name.starts_with("tuitbot_") || !name.ends_with(".db") {
304            continue;
305        }
306
307        let size_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
308
309        // Extract timestamp from filename: tuitbot_YYYYMMDD_HHMMSS.db
310        let timestamp = name
311            .strip_prefix("tuitbot_")
312            .and_then(|s| s.strip_suffix(".db"))
313            .map(|s| s.to_string());
314
315        backups.push(BackupInfo {
316            path,
317            size_bytes,
318            timestamp,
319        });
320    }
321
322    // Sort newest first by timestamp string.
323    backups.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
324    backups
325}
326
327/// Delete the oldest backups, keeping `keep` most recent.
328///
329/// Returns the number of backups deleted.
330pub fn prune_backups(backup_dir: &Path, keep: usize) -> Result<u32, StorageError> {
331    let backups = list_backups(backup_dir);
332    let mut deleted = 0u32;
333
334    if backups.len() <= keep {
335        return Ok(0);
336    }
337
338    for backup in backups.iter().skip(keep) {
339        if let Err(e) = std::fs::remove_file(&backup.path) {
340            tracing::warn!(
341                path = %backup.path.display(),
342                error = %e,
343                "Failed to prune backup"
344            );
345        } else {
346            deleted += 1;
347        }
348    }
349
350    Ok(deleted)
351}
352
353/// Create a pre-migration backup of an existing database.
354///
355/// Skips if the database file doesn't exist or is empty (fresh init).
356/// Creates the backup in a `backups/` sibling directory with a
357/// `pre_migration_` prefix. Prunes old pre-migration backups (keep 3).
358pub async fn preflight_migration_backup(db_path: &Path) -> Result<Option<PathBuf>, StorageError> {
359    // Skip if DB doesn't exist or is empty.
360    let metadata = match std::fs::metadata(db_path) {
361        Ok(m) if m.len() > 0 => m,
362        _ => return Ok(None),
363    };
364
365    tracing::info!(
366        db = %db_path.display(),
367        size_bytes = metadata.len(),
368        "Creating pre-migration backup"
369    );
370
371    // Open a temporary read-only pool to the existing DB.
372    let path_str = db_path.to_string_lossy();
373    let options = SqliteConnectOptions::new()
374        .filename(&*path_str)
375        .read_only(true);
376
377    let pool = SqlitePoolOptions::new()
378        .max_connections(1)
379        .connect_with(options)
380        .await
381        .map_err(|e| StorageError::Connection { source: e })?;
382
383    let backup_dir = db_path
384        .parent()
385        .unwrap_or_else(|| Path::new("."))
386        .join("backups");
387
388    let result = create_backup_with_prefix(&pool, &backup_dir, "pre_migration").await?;
389
390    pool.close().await;
391
392    tracing::info!(
393        path = %result.path.display(),
394        size_bytes = result.size_bytes,
395        duration_ms = result.duration_ms,
396        "Pre-migration backup complete"
397    );
398
399    // Prune old pre-migration backups (keep 3).
400    prune_preflight_backups(&backup_dir, 3);
401
402    Ok(Some(result.path))
403}
404
405/// Prune old pre-migration backups, keeping `keep` most recent.
406fn prune_preflight_backups(backup_dir: &Path, keep: usize) {
407    let entries = match std::fs::read_dir(backup_dir) {
408        Ok(e) => e,
409        Err(_) => return,
410    };
411
412    let mut pre_migration: Vec<PathBuf> = entries
413        .flatten()
414        .map(|e| e.path())
415        .filter(|p| {
416            p.file_name()
417                .and_then(|n| n.to_str())
418                .is_some_and(|n| n.starts_with("pre_migration_") && n.ends_with(".db"))
419        })
420        .collect();
421
422    // Sort newest first.
423    pre_migration.sort_by(|a, b| b.cmp(a));
424
425    for path in pre_migration.iter().skip(keep) {
426        let _ = std::fs::remove_file(path);
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::storage::init_db;
434
435    /// Create a file-based test DB (VACUUM INTO doesn't work with in-memory DBs).
436    async fn file_test_db(dir: &std::path::Path) -> (DbPool, PathBuf) {
437        let db_path = dir.join("test.db");
438        let pool = init_db(&db_path.to_string_lossy())
439            .await
440            .expect("init file db");
441        (pool, db_path)
442    }
443
444    #[tokio::test]
445    async fn create_and_validate_backup() {
446        let dir = tempfile::tempdir().expect("create temp dir");
447        let (pool, _db_path) = file_test_db(dir.path()).await;
448
449        // Insert some data so the backup isn't empty.
450        sqlx::query(
451            "INSERT INTO action_log (action_type, status, message) \
452             VALUES ('test', 'success', 'backup test')",
453        )
454        .execute(&pool)
455        .await
456        .expect("insert");
457
458        let backup_dir = dir.path().join("backups");
459        let result = create_backup(&pool, &backup_dir).await.expect("backup");
460
461        assert!(result.path.exists());
462        assert!(result.size_bytes > 0);
463        assert!(result
464            .path
465            .file_name()
466            .unwrap()
467            .to_str()
468            .unwrap()
469            .starts_with("tuitbot_"));
470
471        // Validate.
472        let validation = validate_backup(&result.path).await.expect("validate");
473        assert!(validation.valid);
474        assert!(!validation.tables.is_empty());
475        assert!(validation.tables.contains(&"action_log".to_string()));
476
477        pool.close().await;
478    }
479
480    #[tokio::test]
481    async fn validate_nonexistent_file() {
482        let result = validate_backup(Path::new("/nonexistent/backup.db"))
483            .await
484            .expect("validate");
485        assert!(!result.valid);
486    }
487
488    #[tokio::test]
489    async fn list_and_prune_backups() {
490        let dir = tempfile::tempdir().expect("create temp dir");
491
492        // Create fake backup files.
493        for i in 1..=5 {
494            let name = format!("tuitbot_20240101_00000{i}.db");
495            std::fs::write(dir.path().join(name), "fake").expect("write");
496        }
497
498        let backups = list_backups(dir.path());
499        assert_eq!(backups.len(), 5);
500        // Newest first.
501        assert!(
502            backups[0].timestamp.as_deref().unwrap() > backups[4].timestamp.as_deref().unwrap()
503        );
504
505        // Prune to keep 2.
506        let pruned = prune_backups(dir.path(), 2).expect("prune");
507        assert_eq!(pruned, 3);
508
509        let remaining = list_backups(dir.path());
510        assert_eq!(remaining.len(), 2);
511    }
512
513    #[tokio::test]
514    async fn backup_and_restore() {
515        let dir = tempfile::tempdir().expect("create temp dir");
516        let (pool, _db_path) = file_test_db(dir.path()).await;
517
518        sqlx::query(
519            "INSERT INTO action_log (action_type, status, message) \
520             VALUES ('test', 'success', 'restore test')",
521        )
522        .execute(&pool)
523        .await
524        .expect("insert");
525
526        let backup_dir = dir.path().join("backups");
527        let result = create_backup(&pool, &backup_dir).await.expect("backup");
528        pool.close().await;
529
530        // Create a target path.
531        let target = dir.path().join("restored.db");
532
533        restore_from_backup(&result.path, &target)
534            .await
535            .expect("restore");
536
537        assert!(target.exists());
538
539        // Verify restored DB has the data.
540        let options = SqliteConnectOptions::new()
541            .filename(target.to_string_lossy().as_ref())
542            .read_only(true);
543        let restored_pool = SqlitePoolOptions::new()
544            .max_connections(1)
545            .connect_with(options)
546            .await
547            .expect("open restored");
548
549        let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM action_log")
550            .fetch_one(&restored_pool)
551            .await
552            .expect("count");
553        assert_eq!(count.0, 1);
554        restored_pool.close().await;
555    }
556
557    // ── Additional coverage tests ────────────────────────────────────
558
559    #[tokio::test]
560    async fn validate_backup_too_small_file() {
561        let dir = tempfile::tempdir().expect("create temp dir");
562        let tiny_file = dir.path().join("tiny.db");
563        std::fs::write(&tiny_file, b"small").expect("write tiny");
564
565        let result = validate_backup(&tiny_file).await.expect("validate");
566        assert!(!result.valid);
567        assert!(result.messages[0].contains("too small"));
568    }
569
570    #[tokio::test]
571    async fn validate_backup_not_sqlite() {
572        let dir = tempfile::tempdir().expect("create temp dir");
573        let fake_file = dir.path().join("fake.db");
574        // 16+ bytes but not SQLite header
575        std::fs::write(&fake_file, b"This is not a SQLite database at all!").expect("write fake");
576
577        let result = validate_backup(&fake_file).await.expect("validate");
578        assert!(!result.valid);
579        assert!(result.messages[0].contains("not a valid SQLite"));
580    }
581
582    #[tokio::test]
583    async fn restore_from_invalid_backup_fails() {
584        let dir = tempfile::tempdir().expect("create temp dir");
585        let bad_backup = dir.path().join("bad.db");
586        std::fs::write(&bad_backup, b"not a database").expect("write bad");
587
588        let target = dir.path().join("target.db");
589        let err = restore_from_backup(&bad_backup, &target).await;
590        assert!(err.is_err(), "restore from invalid backup should fail");
591    }
592
593    #[tokio::test]
594    #[cfg_attr(
595        target_os = "windows",
596        ignore = "SQLite WAL lock prevents rename on Windows CI"
597    )]
598    async fn restore_over_existing_db_creates_safety_backup() {
599        let dir = tempfile::tempdir().expect("create temp dir");
600        let (pool, db_path) = file_test_db(dir.path()).await;
601
602        // Insert data into the original DB.
603        sqlx::query(
604            "INSERT INTO action_log (action_type, status, message) \
605             VALUES ('original', 'success', 'original data')",
606        )
607        .execute(&pool)
608        .await
609        .expect("insert");
610
611        // Create backup.
612        let backup_dir = dir.path().join("backups");
613        let result = create_backup(&pool, &backup_dir).await.expect("backup");
614        // Fully close and drop the pool so Windows releases the file lock.
615        pool.close().await;
616        drop(pool);
617
618        // Restore over the existing DB — should create pre_restore_*.db
619        restore_from_backup(&result.path, &db_path)
620            .await
621            .expect("restore");
622
623        // Check that a pre_restore file was created.
624        let entries: Vec<_> = std::fs::read_dir(dir.path())
625            .unwrap()
626            .flatten()
627            .filter(|e| {
628                e.file_name()
629                    .to_str()
630                    .is_some_and(|n| n.starts_with("pre_restore_"))
631            })
632            .collect();
633        assert!(
634            !entries.is_empty(),
635            "safety backup should be created when restoring over existing DB"
636        );
637    }
638
639    #[test]
640    fn list_backups_empty_dir() {
641        let dir = tempfile::tempdir().expect("create temp dir");
642        let backups = list_backups(dir.path());
643        assert!(backups.is_empty());
644    }
645
646    #[test]
647    fn list_backups_ignores_non_tuitbot_files() {
648        let dir = tempfile::tempdir().expect("create temp dir");
649        std::fs::write(dir.path().join("random_file.db"), "data").expect("write");
650        std::fs::write(dir.path().join("pre_migration_123.db"), "data").expect("write");
651        std::fs::write(dir.path().join("tuitbot_20240101_000001.txt"), "data").expect("write");
652
653        let backups = list_backups(dir.path());
654        assert!(backups.is_empty(), "should skip non-tuitbot_ .db files");
655    }
656
657    #[test]
658    fn list_backups_nonexistent_dir() {
659        let backups = list_backups(Path::new("/nonexistent/directory/xyz"));
660        assert!(backups.is_empty());
661    }
662
663    #[test]
664    fn prune_backups_when_fewer_than_keep() {
665        let dir = tempfile::tempdir().expect("create temp dir");
666        std::fs::write(dir.path().join("tuitbot_20240101_000001.db"), "data").expect("write");
667
668        let pruned = prune_backups(dir.path(), 5).expect("prune");
669        assert_eq!(pruned, 0, "nothing should be pruned when fewer than keep");
670    }
671
672    #[test]
673    fn prune_backups_exact_count() {
674        let dir = tempfile::tempdir().expect("create temp dir");
675        for i in 1..=3 {
676            let name = format!("tuitbot_20240101_00000{i}.db");
677            std::fs::write(dir.path().join(name), "data").expect("write");
678        }
679
680        let pruned = prune_backups(dir.path(), 3).expect("prune");
681        assert_eq!(pruned, 0, "nothing should be pruned when count == keep");
682    }
683
684    #[tokio::test]
685    async fn create_backup_duration_is_positive() {
686        let dir = tempfile::tempdir().expect("create temp dir");
687        let (pool, _db_path) = file_test_db(dir.path()).await;
688
689        let backup_dir = dir.path().join("backups");
690        let result = create_backup(&pool, &backup_dir).await.expect("backup");
691        // Duration may be 0 on very fast systems, but should not be negative.
692        assert!(result.duration_ms < 60_000, "backup should be fast");
693        pool.close().await;
694    }
695
696    #[tokio::test]
697    async fn backup_result_has_tuitbot_prefix() {
698        let dir = tempfile::tempdir().expect("create temp dir");
699        let (pool, _db_path) = file_test_db(dir.path()).await;
700
701        let backup_dir = dir.path().join("backups");
702        let result = create_backup(&pool, &backup_dir).await.expect("backup");
703
704        let filename = result
705            .path
706            .file_name()
707            .unwrap()
708            .to_str()
709            .unwrap()
710            .to_string();
711        assert!(filename.starts_with("tuitbot_"));
712        assert!(filename.ends_with(".db"));
713
714        pool.close().await;
715    }
716
717    #[tokio::test]
718    async fn preflight_migration_backup_skips_nonexistent_db() {
719        let dir = tempfile::tempdir().expect("create temp dir");
720        let fake_db = dir.path().join("nonexistent.db");
721
722        let result = preflight_migration_backup(&fake_db)
723            .await
724            .expect("preflight");
725        assert!(result.is_none(), "should skip nonexistent DB");
726    }
727
728    #[tokio::test]
729    async fn preflight_migration_backup_creates_backup() {
730        let dir = tempfile::tempdir().expect("create temp dir");
731        let (pool, db_path) = file_test_db(dir.path()).await;
732
733        // Insert data so the DB is non-empty.
734        sqlx::query(
735            "INSERT INTO action_log (action_type, status, message) \
736             VALUES ('test', 'success', 'preflight test')",
737        )
738        .execute(&pool)
739        .await
740        .expect("insert");
741        pool.close().await;
742
743        let result = preflight_migration_backup(&db_path)
744            .await
745            .expect("preflight");
746        assert!(result.is_some(), "should create backup for existing DB");
747        let backup_path = result.unwrap();
748        assert!(backup_path.exists());
749        assert!(backup_path
750            .file_name()
751            .unwrap()
752            .to_str()
753            .unwrap()
754            .starts_with("pre_migration_"));
755    }
756
757    #[tokio::test]
758    async fn validate_backup_with_valid_db() {
759        let dir = tempfile::tempdir().expect("create temp dir");
760        let (pool, _db_path) = file_test_db(dir.path()).await;
761
762        // Create a backup.
763        let backup_dir = dir.path().join("backups");
764        let result = create_backup(&pool, &backup_dir).await.expect("backup");
765        pool.close().await;
766
767        // Validate.
768        let validation = validate_backup(&result.path).await.expect("validate");
769        assert!(
770            validation.valid,
771            "valid backup should pass: {:?}",
772            validation.messages
773        );
774        assert!(validation.tables.contains(&"action_log".to_string()));
775        assert!(validation.tables.contains(&"rate_limits".to_string()));
776        assert!(
777            validation
778                .messages
779                .iter()
780                .any(|m| m.contains("Valid backup")),
781            "should report valid: {:?}",
782            validation.messages
783        );
784    }
785}