Skip to main content

max / goingson

5.9 KB · 167 lines History Blame Raw
1 //! Backup completeness + restore-ordering guards (ultra-fuzz CHRONIC-D / C1).
2 //!
3 //! `schema_tables_are_all_classified` is the structural enforcement: every table in
4 //! the live schema must be either in `BACKUP_TABLES` or `EXCLUDED_TABLES`. Adding a
5 //! migration that creates a user table without registering it fails here, which is
6 //! what stops the recurring "backup silently omitted table X" finding.
7 //!
8 //! `restore_handles_recurring_task_self_fk` is the C1 regression guard: a recurring
9 //! child instance whose `recurrence_parent_id` points at a parent that sorts later in
10 //! the backup must restore without aborting the whole transaction.
11
12 mod common;
13
14 use goingson_core::backup_restore::{RestoreInput, RestoreResult};
15 use goingson_db_sqlite::{BACKUP_TABLES, EXCLUDED_TABLES, restore_all};
16 use sqlx::Row;
17
18 fn empty_input() -> RestoreInput {
19 RestoreInput {
20 projects: vec![],
21 tasks: vec![],
22 events: vec![],
23 emails: vec![],
24 contacts: vec![],
25 time_sessions: vec![],
26 milestones: vec![],
27 daily_notes: vec![],
28 attachments: vec![],
29 sync_accounts: vec![],
30 saved_views: vec![],
31 weekly_reviews: vec![],
32 monthly_goals: vec![],
33 monthly_reflections: vec![],
34 }
35 }
36
37 #[tokio::test]
38 async fn schema_tables_are_all_classified() {
39 let pool = common::setup_test_db().await;
40
41 let rows = sqlx::query(
42 "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
43 )
44 .fetch_all(&pool)
45 .await
46 .expect("query sqlite_master");
47
48 for row in rows {
49 let name: String = row.get("name");
50 // FTS5 shadow/content tables (e.g. tasks_fts, tasks_fts_data) are derived
51 // search indexes, not user content; the sqlx migration ledger is bookkeeping.
52 if name.contains("_fts") || name == "_sqlx_migrations" {
53 continue;
54 }
55 let classified =
56 BACKUP_TABLES.contains(&name.as_str()) || EXCLUDED_TABLES.contains(&name.as_str());
57 assert!(
58 classified,
59 "table `{name}` is in the schema but neither backed up (BACKUP_TABLES) nor \
60 excluded (EXCLUDED_TABLES). Add it to one in restore.rs -- a new user table \
61 must be a deliberate backup decision, not silently dropped."
62 );
63 }
64
65 // And the reverse: nothing in BACKUP_TABLES should be a phantom (typo) name.
66 for t in BACKUP_TABLES {
67 let exists: Option<String> =
68 sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
69 .bind(t)
70 .fetch_optional(&pool)
71 .await
72 .expect("lookup backup table");
73 assert!(
74 exists.is_some(),
75 "BACKUP_TABLES lists `{t}` which is not a real table"
76 );
77 }
78 }
79
80 #[tokio::test]
81 async fn restore_handles_recurring_task_self_fk() {
82 use goingson_core::{
83 NewProject, NewTask, ProjectRepository, ProjectStatus, ProjectType, TaskCrud, TaskStatus,
84 };
85 use goingson_db_sqlite::{SqliteProjectRepository, SqliteTaskRepository};
86
87 // Build a parent + child task where the child references the parent via
88 // recurrence_parent_id, then order them child-first in the RestoreInput so the
89 // FK target is inserted second. Pre-fix this aborted the whole restore (FK 787).
90 let src = common::setup_test_db().await;
91 let user_id = common::create_test_user(&src).await;
92 let projects = SqliteProjectRepository::new(src.clone());
93 let tasks = SqliteTaskRepository::new(src.clone());
94
95 let project = projects
96 .create(
97 user_id,
98 NewProject {
99 name: "Recurring".into(),
100 description: String::new(),
101 project_type: ProjectType::default(),
102 status: ProjectStatus::default(),
103 },
104 )
105 .await
106 .unwrap();
107
108 let mut parent = tasks
109 .create(
110 user_id,
111 NewTask::builder("Parent occurrence")
112 .project_id(project.id)
113 .build(),
114 )
115 .await
116 .unwrap();
117 let mut child = tasks
118 .create(
119 user_id,
120 NewTask::builder("Next occurrence")
121 .project_id(project.id)
122 .build(),
123 )
124 .await
125 .unwrap();
126 parent.status = TaskStatus::Completed;
127 child.recurrence_parent_id = Some(parent.id);
128
129 // Restore into a fresh DB with the child BEFORE the parent in the vec.
130 let dst = common::setup_test_db().await;
131 let _ = common::create_test_user(&dst).await; // satisfy users FK on restored rows
132 // Restored rows carry the source user_id; recreate that exact user in dst.
133 sqlx::query("INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) VALUES (?, ?, 'x', 'x', '2026-01-01 00:00:00')")
134 .bind(user_id.to_string())
135 .bind(format!("u-{user_id}@example.com"))
136 .execute(&dst)
137 .await
138 .unwrap();
139 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')")
140 .bind(project.id.to_string())
141 .bind(user_id.to_string())
142 .execute(&dst)
143 .await
144 .unwrap();
145
146 let input = RestoreInput {
147 tasks: vec![child.clone(), parent.clone()],
148 ..empty_input()
149 };
150
151 let result: RestoreResult = restore_all(&dst, user_id, &input)
152 .await
153 .expect("restore must not abort on self-FK ordering (C1)");
154 assert_eq!(result.tasks_restored, 2, "both occurrences restored");
155
156 let count: i64 =
157 sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE recurrence_parent_id = ?")
158 .bind(parent.id.to_string())
159 .fetch_one(&dst)
160 .await
161 .unwrap();
162 assert_eq!(
163 count, 1,
164 "child occurrence kept its recurrence_parent_id link"
165 );
166 }
167