//! Integration tests for transactional backup restore. //! //! Covers: completed-status + completed_at preservation, event-metadata round-trip, //! idempotent re-restore (restoring the same backup twice must not duplicate the //! dataset), and verbatim sub-collection fidelity, subtask completion state and //! ordering, and annotation timestamps, which the old create-helper restore path //! silently dropped (ultra-fuzz Run #26 SERIOUS finding). mod common; use goingson_core::backup_restore::RestoreInput; use goingson_core::{ AttachmentRepository, DailyNoteRepository, EventRepository, MilestoneRepository, NewAttachment, NewEvent, NewMilestone, NewProject, NewTask, PositiveMinutes, ProjectRepository, ProjectStatus, ProjectType, SyncAccountRepository, TaskAnnotations, TaskCrud, TaskStatus, TaskTimeTracking, }; use goingson_db_sqlite::{ SqliteAttachmentRepository, SqliteDailyNoteRepository, SqliteEventRepository, SqliteMilestoneRepository, SqliteProjectRepository, SqliteSyncAccountRepository, SqliteTaskRepository, restore_all, }; #[tokio::test] async fn restore_preserves_completed_status_and_is_idempotent() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let projects = SqliteProjectRepository::new(pool.clone()); let tasks = SqliteTaskRepository::new(pool.clone()); // Manufacture a project and a COMPLETED task. let project = projects .create( user_id, NewProject { name: "Backup Project".into(), description: String::new(), project_type: ProjectType::default(), status: ProjectStatus::default(), }, ) .await .unwrap(); let task = tasks .create(user_id, NewTask::builder("Finish the report").build()) .await .unwrap(); tasks.complete(task.id, user_id).await.unwrap(); let completed = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap(); assert_eq!(completed.status, TaskStatus::Completed); assert!(completed.completed_at.is_some()); // Simulate data loss: hard-delete the rows. sqlx::query("DELETE FROM tasks WHERE id = ?") .bind(task.id.to_string()) .execute(&pool) .await .unwrap(); sqlx::query("DELETE FROM projects WHERE id = ?") .bind(project.id.to_string()) .execute(&pool) .await .unwrap(); assert!(tasks.get_by_id(task.id, user_id).await.unwrap().is_none()); let input = RestoreInput { projects: vec![project.clone()], tasks: vec![completed.clone()], 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![], }; // First restore recreates with status + completed_at + id preserved. let r1 = restore_all(&pool, user_id, &input).await.unwrap(); assert_eq!(r1.projects_restored, 1); assert_eq!(r1.tasks_restored, 1); let restored = tasks .get_by_id(task.id, user_id) .await .unwrap() .expect("task restored"); assert_eq!( restored.status, TaskStatus::Completed, "completed status must survive restore" ); assert!( restored.completed_at.is_some(), "completed_at must survive restore" ); assert_eq!(restored.id, task.id, "original id preserved"); // Second restore is a no-op, no duplication. let r2 = restore_all(&pool, user_id, &input).await.unwrap(); assert_eq!(r2.projects_restored, 0); assert_eq!(r2.tasks_restored, 0); assert_eq!( tasks.list_all(user_id).await.unwrap().len(), 1, "no duplicate task after re-restore" ); assert_eq!( projects.list_all(user_id).await.unwrap().len(), 1, "no duplicate project after re-restore" ); } #[tokio::test] async fn restore_preserves_subtask_completion_ordering_and_annotation_timestamp() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let tasks = SqliteTaskRepository::new(pool.clone()); let task = tasks .create(user_id, NewTask::builder("Ship the thing").build()) .await .unwrap(); // Two subtasks; the second is completed. Positions 0 and 1. let first = tasks .add_subtask(task.id, user_id, "do A") .await .unwrap() .unwrap(); let second = tasks .add_subtask(task.id, user_id, "do B") .await .unwrap() .unwrap(); let second = tasks .toggle_subtask(second.id, user_id) .await .unwrap() .unwrap(); assert!( second.is_completed, "precondition: second subtask completed" ); assert_eq!(first.position, 0); assert_eq!(second.position, 1); // One annotation, back-dated to a distinct timestamp so a reset-to-now // regression is observable. let annotation = tasks .add_annotation(task.id, user_id, "an old note") .await .unwrap() .unwrap(); sqlx::query("UPDATE annotations SET timestamp = ? WHERE id = ?") .bind("2020-01-01 00:00:00") .bind(annotation.id.to_string()) .execute(&pool) .await .unwrap(); // The backup payload as it would be exported (fully hydrated task). let backup_task = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap(); assert_eq!(backup_task.subtasks.len(), 2); assert_eq!(backup_task.annotations.len(), 1); // Lose the task (cascades subtasks + annotations). sqlx::query("DELETE FROM tasks WHERE id = ?") .bind(task.id.to_string()) .execute(&pool) .await .unwrap(); let input = RestoreInput { projects: vec![], tasks: vec![backup_task.clone()], 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![], }; let r = restore_all(&pool, user_id, &input).await.unwrap(); assert_eq!(r.tasks_restored, 1); assert_eq!(r.subtasks_restored, 2); assert_eq!(r.annotations_restored, 1); let restored = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap(); // Subtask completion state and ordering survive verbatim. let restored_first = restored .subtasks .iter() .find(|s| s.id == first.id) .expect("first subtask restored"); let restored_second = restored .subtasks .iter() .find(|s| s.id == second.id) .expect("second subtask restored"); assert!( !restored_first.is_completed, "incomplete subtask must stay incomplete" ); assert!( restored_second.is_completed, "completed subtask must stay completed (regression: create-helper reset it)" ); assert_eq!(restored_first.position, 0, "subtask order preserved"); assert_eq!(restored_second.position, 1, "subtask order preserved"); // Annotation timestamp survives verbatim (not reset to restore-time now). let restored_annotation = &restored.annotations[0]; assert_eq!( restored_annotation.id, annotation.id, "annotation id preserved" ); assert_eq!( restored_annotation.timestamp.format("%Y").to_string(), "2020", "annotation timestamp must survive restore (regression: reset to now)" ); // Re-restore is a no-op: no duplicate children. let r2 = restore_all(&pool, user_id, &input).await.unwrap(); assert_eq!(r2.tasks_restored, 0); assert_eq!(r2.subtasks_restored, 0); let again = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap(); assert_eq!(again.subtasks.len(), 2, "no duplicate subtasks"); assert_eq!(again.annotations.len(), 1, "no duplicate annotations"); } #[tokio::test] async fn restore_event_preserves_external_metadata() { let pool = common::setup_test_db().await; let user_id = common::create_test_user(&pool).await; let events = SqliteEventRepository::new(pool.clone()); // Manufacture an event, then stamp external-sync metadata that the normal // create path never writes (external_source/external_id/is_read_only). let ev = events .create( user_id, NewEvent::builder("Sync Meeting", chrono::Utc::now()).build(), ) .await .unwrap(); sqlx::query( "UPDATE events SET external_source = ?, external_id = ?, is_read_only = 1 WHERE id = ?", ) .bind("google") .bind("evt-123") .bind(ev.id.to_string()) .execute(&pool) .await .unwrap(); let full = events.get_by_id(ev.id, user_id).await.unwrap().unwrap(); assert_eq!(full.external_source.as_deref(), Some("google")); // Lose it, then restore verbatim through the transactional path. sqlx::query("DELETE FROM events WHERE id = ?") .bind(ev.id.to_string()) .execute(&pool) .await .unwrap(); let input = RestoreInput { projects: vec![], tasks: vec![], events: vec![full.clone()], 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![], }; let r = restore_all(&pool, user_id, &input).await.unwrap(); assert_eq!(r.events_restored, 1); let restored = events .get_by_id(ev.id, user_id) .await .unwrap() .expect("event restored"); assert_eq!(restored.id, ev.id); assert_eq!( restored.external_source.as_deref(), Some("google"), "external_source must survive restore" ); assert_eq!( restored.external_id.as_deref(), Some("evt-123"), "external_id must survive restore" ); assert!(restored.is_read_only, "is_read_only must survive restore"); } #[tokio::test] async fn restore_round_trips_supplemental_collections() { let pool = common::setup_test_db().await; let user = common::create_test_user(&pool).await; let projects = SqliteProjectRepository::new(pool.clone()); let tasks = SqliteTaskRepository::new(pool.clone()); let milestones = SqliteMilestoneRepository::new(pool.clone()); let attachments = SqliteAttachmentRepository::new(pool.clone()); let daily_notes = SqliteDailyNoteRepository::new(pool.clone()); let sync_accounts = SqliteSyncAccountRepository::new(pool.clone()); let project = projects .create( user, NewProject { name: "P".into(), description: String::new(), project_type: ProjectType::default(), status: ProjectStatus::default(), }, ) .await .unwrap(); let task = tasks .create(user, NewTask::builder("T").build()) .await .unwrap(); let milestone = milestones .create( user, NewMilestone { project_id: project.id, name: "M1".into(), description: "desc".into(), position: 0, target_date: chrono::NaiveDate::from_ymd_opt(2024, 12, 31), }, ) .await .unwrap(); let session = tasks .log_manual_time( task.id, user, PositiveMinutes::try_new(45).unwrap(), chrono::Utc::now(), ) .await .unwrap(); let note = daily_notes .upsert( user, chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), "went well", "could improve", true, ) .await .unwrap(); let attachment = attachments .create( user, NewAttachment { task_id: Some(task.id), project_id: None, filename: "f.pdf".into(), file_size: 1234, mime_type: "application/pdf".into(), blob_hash: "deadbeef".into(), source_email_id: None, }, ) .await .unwrap(); let account = sync_accounts .create(user, "google", "My Calendar", Some("me@example.com")) .await .unwrap(); // The backup payload, then wipe everything (CASCADE handles the children). let input = RestoreInput { projects: vec![project.clone()], tasks: vec![tasks.get_by_id(task.id, user).await.unwrap().unwrap()], events: vec![], emails: vec![], contacts: vec![], time_sessions: vec![session.clone()], milestones: vec![milestone.clone()], daily_notes: vec![note.clone()], attachments: vec![attachment.clone()], sync_accounts: vec![account.clone()], saved_views: vec![], weekly_reviews: vec![], monthly_goals: vec![], monthly_reflections: vec![], }; // Deleting tasks cascades time_sessions + attachments; deleting projects // cascades milestones; daily_notes and sync_accounts are independent. for table in ["tasks", "daily_notes", "sync_accounts", "projects"] { sqlx::query(sqlx::AssertSqlSafe(format!("DELETE FROM {table}"))) .execute(&pool) .await .unwrap(); } assert!(milestones.list_all(user).await.unwrap().is_empty()); assert!(tasks.list_all_time_sessions(user).await.unwrap().is_empty()); assert!(attachments.list_all(user).await.unwrap().is_empty()); let r = restore_all(&pool, user, &input).await.unwrap(); assert_eq!(r.milestones_restored, 1); assert_eq!(r.time_sessions_restored, 1); assert_eq!(r.daily_notes_restored, 1); assert_eq!(r.attachments_restored, 1); assert_eq!(r.sync_accounts_restored, 1); let ms = milestones.list_all(user).await.unwrap(); assert_eq!(ms.len(), 1); assert_eq!(ms[0].id, milestone.id); assert_eq!( ms[0].target_date, milestone.target_date, "target_date preserved" ); let ts = tasks.list_all_time_sessions(user).await.unwrap(); assert_eq!(ts.len(), 1); assert_eq!(ts[0].id, session.id); assert_eq!( ts[0].duration_minutes, session.duration_minutes, "minutes preserved" ); let dn = daily_notes.list_all(user).await.unwrap(); assert_eq!(dn.len(), 1); assert!(dn[0].is_reviewed, "is_reviewed preserved"); let at = attachments.list_all(user).await.unwrap(); assert_eq!(at.len(), 1); assert_eq!(at[0].blob_hash, "deadbeef", "blob_hash preserved"); assert_eq!(at[0].file_size, 1234, "file_size preserved"); let sa = sync_accounts.list_all(user).await.unwrap(); assert_eq!(sa.len(), 1); assert_eq!(sa[0].provider, "google"); assert_eq!(sa[0].email.as_deref(), Some("me@example.com")); // Idempotent re-restore. let r2 = restore_all(&pool, user, &input).await.unwrap(); assert_eq!(r2.milestones_restored, 0); assert_eq!(r2.time_sessions_restored, 0); assert_eq!(r2.attachments_restored, 0); assert_eq!(r2.sync_accounts_restored, 0); }