//! Backup completeness + restore-ordering guards (ultra-fuzz CHRONIC-D / C1). //! //! `schema_tables_are_all_classified` is the structural enforcement: every table in //! the live schema must be either in `BACKUP_TABLES` or `EXCLUDED_TABLES`. Adding a //! migration that creates a user table without registering it fails here, which is //! what stops the recurring "backup silently omitted table X" finding. //! //! `restore_handles_recurring_task_self_fk` is the C1 regression guard: a recurring //! child instance whose `recurrence_parent_id` points at a parent that sorts later in //! the backup must restore without aborting the whole transaction. mod common; use goingson_core::backup_restore::{RestoreInput, RestoreResult}; use goingson_db_sqlite::{BACKUP_TABLES, EXCLUDED_TABLES, restore_all}; use sqlx::Row; fn empty_input() -> RestoreInput { RestoreInput { projects: vec![], tasks: vec![], events: vec![], emails: vec![], contacts: vec![], time_sessions: vec![], milestones: vec![], daily_notes: vec![], attachments: vec![], sync_accounts: vec![], saved_views: vec![], weekly_reviews: vec![], monthly_goals: vec![], monthly_reflections: vec![], } } #[tokio::test] async fn schema_tables_are_all_classified() { let pool = common::setup_test_db().await; let rows = sqlx::query( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", ) .fetch_all(&pool) .await .expect("query sqlite_master"); for row in rows { let name: String = row.get("name"); // FTS5 shadow/content tables (e.g. tasks_fts, tasks_fts_data) are derived // search indexes, not user content; the sqlx migration ledger is bookkeeping. if name.contains("_fts") || name == "_sqlx_migrations" { continue; } let classified = BACKUP_TABLES.contains(&name.as_str()) || EXCLUDED_TABLES.contains(&name.as_str()); assert!( classified, "table `{name}` is in the schema but neither backed up (BACKUP_TABLES) nor \ excluded (EXCLUDED_TABLES). Add it to one in restore.rs -- a new user table \ must be a deliberate backup decision, not silently dropped." ); } // And the reverse: nothing in BACKUP_TABLES should be a phantom (typo) name. for t in BACKUP_TABLES { let exists: Option = sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' AND name = ?") .bind(t) .fetch_optional(&pool) .await .expect("lookup backup table"); assert!( exists.is_some(), "BACKUP_TABLES lists `{t}` which is not a real table" ); } } #[tokio::test] async fn restore_handles_recurring_task_self_fk() { use goingson_core::{ NewProject, NewTask, ProjectRepository, ProjectStatus, ProjectType, TaskCrud, TaskStatus, }; use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository}; // Build a parent + child task where the child references the parent via // recurrence_parent_id, then order them child-first in the RestoreInput so the // FK target is inserted second. Pre-fix this aborted the whole restore (FK 787). let src = common::setup_test_db().await; let user_id = common::create_test_user(&src).await; let projects = SqliteProjectRepository::new(src.clone()); let tasks = SqliteTaskRepository::new(src.clone()); let project = projects .create( user_id, NewProject { name: "Recurring".into(), description: String::new(), project_type: ProjectType::default(), status: ProjectStatus::default(), }, ) .await .unwrap(); let mut parent = tasks .create( user_id, NewTask::builder("Parent occurrence") .project_id(project.id) .build(), ) .await .unwrap(); let mut child = tasks .create( user_id, NewTask::builder("Next occurrence") .project_id(project.id) .build(), ) .await .unwrap(); parent.status = TaskStatus::Completed; child.recurrence_parent_id = Some(parent.id); // Restore into a fresh DB with the child BEFORE the parent in the vec. let dst = common::setup_test_db().await; let _ = common::create_test_user(&dst).await; // satisfy users FK on restored rows // Restored rows carry the source user_id; recreate that exact user in dst. sqlx::query("INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, 'x', 'x', '2026-01-01 00:00:00')") .bind(user_id.to_string()) .bind(format!("u-{user_id}@example.com")) .execute(&dst) .await .unwrap(); sqlx::query("INSERT OR IGNORE INTO projects (id, user_id, name, description, project_type, status, created_at) VALUES (?, ?, 'Recurring', '', 'work', 'active', '2026-01-01 00:00:00')") .bind(project.id.to_string()) .bind(user_id.to_string()) .execute(&dst) .await .unwrap(); let input = RestoreInput { tasks: vec![child.clone(), parent.clone()], ..empty_input() }; let result: RestoreResult = restore_all(&dst, user_id, &input) .await .expect("restore must not abort on self-FK ordering (C1)"); assert_eq!(result.tasks_restored, 2, "both occurrences restored"); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE recurrence_parent_id = ?") .bind(parent.id.to_string()) .fetch_one(&dst) .await .unwrap(); assert_eq!( count, 1, "child occurrence kept its recurrence_parent_id link" ); }