Skip to main content

max / goingson

15.3 KB · 455 lines History Blame Raw
1 //! Integration tests for transactional backup restore.
2 //!
3 //! Covers: completed-status + completed_at preservation, event-metadata round-trip,
4 //! idempotent re-restore (restoring the same backup twice must not duplicate the
5 //! dataset), and verbatim sub-collection fidelity -- subtask completion state and
6 //! ordering, and annotation timestamps -- which the old create-helper restore path
7 //! silently dropped (ultra-fuzz Run #26 SERIOUS finding).
8
9 mod common;
10
11 use goingson_core::backup_restore::RestoreInput;
12 use goingson_core::{
13 AttachmentRepository, DailyNoteRepository, EventRepository, MilestoneRepository, NewAttachment,
14 NewEvent, NewMilestone, NewProject, NewTask, PositiveMinutes, ProjectRepository, SyncAccountRepository,
15 TaskAnnotations, TaskCrud, TaskTimeTracking, TaskStatus,
16 };
17 use goingson_db_sqlite::{
18 restore_all, SqliteAttachmentRepository, SqliteDailyNoteRepository, SqliteEventRepository,
19 SqliteMilestoneRepository, SqliteProjectRepository, SqliteSyncAccountRepository,
20 SqliteTaskRepository,
21 };
22
23 #[tokio::test]
24 async fn restore_preserves_completed_status_and_is_idempotent() {
25 let pool = common::setup_test_db().await;
26 let user_id = common::create_test_user(&pool).await;
27
28 let projects = SqliteProjectRepository::new(pool.clone());
29 let tasks = SqliteTaskRepository::new(pool.clone());
30
31 // Manufacture a project and a COMPLETED task.
32 let project = projects
33 .create(
34 user_id,
35 NewProject {
36 name: "Backup Project".into(),
37 description: String::new(),
38 project_type: Default::default(),
39 status: Default::default(),
40 },
41 )
42 .await
43 .unwrap();
44
45 let task = tasks
46 .create(user_id, NewTask::builder("Finish the report").build())
47 .await
48 .unwrap();
49 tasks.complete(task.id, user_id).await.unwrap();
50 let completed = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap();
51 assert_eq!(completed.status, TaskStatus::Completed);
52 assert!(completed.completed_at.is_some());
53
54 // Simulate data loss: hard-delete the rows.
55 sqlx::query("DELETE FROM tasks WHERE id = ?")
56 .bind(task.id.to_string())
57 .execute(&pool)
58 .await
59 .unwrap();
60 sqlx::query("DELETE FROM projects WHERE id = ?")
61 .bind(project.id.to_string())
62 .execute(&pool)
63 .await
64 .unwrap();
65 assert!(tasks.get_by_id(task.id, user_id).await.unwrap().is_none());
66
67 let input = RestoreInput {
68 projects: vec![project.clone()],
69 tasks: vec![completed.clone()],
70 events: vec![],
71 emails: vec![],
72 contacts: vec![],
73 time_sessions: vec![],
74 milestones: vec![],
75 daily_notes: vec![],
76 attachments: vec![],
77 sync_accounts: vec![],
78 saved_views: vec![],
79 weekly_reviews: vec![],
80 monthly_goals: vec![],
81 monthly_reflections: vec![],
82 };
83
84 // First restore recreates with status + completed_at + id preserved.
85 let r1 = restore_all(&pool, user_id, &input).await.unwrap();
86 assert_eq!(r1.projects_restored, 1);
87 assert_eq!(r1.tasks_restored, 1);
88 let restored = tasks
89 .get_by_id(task.id, user_id)
90 .await
91 .unwrap()
92 .expect("task restored");
93 assert_eq!(
94 restored.status,
95 TaskStatus::Completed,
96 "completed status must survive restore"
97 );
98 assert!(
99 restored.completed_at.is_some(),
100 "completed_at must survive restore"
101 );
102 assert_eq!(restored.id, task.id, "original id preserved");
103
104 // Second restore is a no-op -- no duplication.
105 let r2 = restore_all(&pool, user_id, &input).await.unwrap();
106 assert_eq!(r2.projects_restored, 0);
107 assert_eq!(r2.tasks_restored, 0);
108 assert_eq!(
109 tasks.list_all(user_id).await.unwrap().len(),
110 1,
111 "no duplicate task after re-restore"
112 );
113 assert_eq!(
114 projects.list_all(user_id).await.unwrap().len(),
115 1,
116 "no duplicate project after re-restore"
117 );
118 }
119
120 #[tokio::test]
121 async fn restore_preserves_subtask_completion_ordering_and_annotation_timestamp() {
122 let pool = common::setup_test_db().await;
123 let user_id = common::create_test_user(&pool).await;
124 let tasks = SqliteTaskRepository::new(pool.clone());
125
126 let task = tasks
127 .create(user_id, NewTask::builder("Ship the thing").build())
128 .await
129 .unwrap();
130
131 // Two subtasks; the second is completed. Positions 0 and 1.
132 let first = tasks
133 .add_subtask(task.id, user_id, "do A")
134 .await
135 .unwrap()
136 .unwrap();
137 let second = tasks
138 .add_subtask(task.id, user_id, "do B")
139 .await
140 .unwrap()
141 .unwrap();
142 let second = tasks
143 .toggle_subtask(second.id, user_id)
144 .await
145 .unwrap()
146 .unwrap();
147 assert!(second.is_completed, "precondition: second subtask completed");
148 assert_eq!(first.position, 0);
149 assert_eq!(second.position, 1);
150
151 // One annotation, back-dated to a distinct timestamp so a reset-to-now
152 // regression is observable.
153 let annotation = tasks
154 .add_annotation(task.id, user_id, "an old note")
155 .await
156 .unwrap()
157 .unwrap();
158 sqlx::query("UPDATE annotations SET timestamp = ? WHERE id = ?")
159 .bind("2020-01-01 00:00:00")
160 .bind(annotation.id.to_string())
161 .execute(&pool)
162 .await
163 .unwrap();
164
165 // The backup payload as it would be exported (fully hydrated task).
166 let backup_task = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap();
167 assert_eq!(backup_task.subtasks.len(), 2);
168 assert_eq!(backup_task.annotations.len(), 1);
169
170 // Lose the task (cascades subtasks + annotations).
171 sqlx::query("DELETE FROM tasks WHERE id = ?")
172 .bind(task.id.to_string())
173 .execute(&pool)
174 .await
175 .unwrap();
176
177 let input = RestoreInput {
178 projects: vec![],
179 tasks: vec![backup_task.clone()],
180 events: vec![],
181 emails: vec![],
182 contacts: vec![],
183 time_sessions: vec![],
184 milestones: vec![],
185 daily_notes: vec![],
186 attachments: vec![],
187 sync_accounts: vec![],
188 saved_views: vec![],
189 weekly_reviews: vec![],
190 monthly_goals: vec![],
191 monthly_reflections: vec![],
192 };
193 let r = restore_all(&pool, user_id, &input).await.unwrap();
194 assert_eq!(r.tasks_restored, 1);
195 assert_eq!(r.subtasks_restored, 2);
196 assert_eq!(r.annotations_restored, 1);
197
198 let restored = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap();
199
200 // Subtask completion state and ordering survive verbatim.
201 let restored_first = restored
202 .subtasks
203 .iter()
204 .find(|s| s.id == first.id)
205 .expect("first subtask restored");
206 let restored_second = restored
207 .subtasks
208 .iter()
209 .find(|s| s.id == second.id)
210 .expect("second subtask restored");
211 assert!(
212 !restored_first.is_completed,
213 "incomplete subtask must stay incomplete"
214 );
215 assert!(
216 restored_second.is_completed,
217 "completed subtask must stay completed (regression: create-helper reset it)"
218 );
219 assert_eq!(restored_first.position, 0, "subtask order preserved");
220 assert_eq!(restored_second.position, 1, "subtask order preserved");
221
222 // Annotation timestamp survives verbatim (not reset to restore-time now).
223 let restored_annotation = &restored.annotations[0];
224 assert_eq!(
225 restored_annotation.id, annotation.id,
226 "annotation id preserved"
227 );
228 assert_eq!(
229 restored_annotation.timestamp.format("%Y").to_string(),
230 "2020",
231 "annotation timestamp must survive restore (regression: reset to now)"
232 );
233
234 // Re-restore is a no-op: no duplicate children.
235 let r2 = restore_all(&pool, user_id, &input).await.unwrap();
236 assert_eq!(r2.tasks_restored, 0);
237 assert_eq!(r2.subtasks_restored, 0);
238 let again = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap();
239 assert_eq!(again.subtasks.len(), 2, "no duplicate subtasks");
240 assert_eq!(again.annotations.len(), 1, "no duplicate annotations");
241 }
242
243 #[tokio::test]
244 async fn restore_event_preserves_external_metadata() {
245 let pool = common::setup_test_db().await;
246 let user_id = common::create_test_user(&pool).await;
247 let events = SqliteEventRepository::new(pool.clone());
248
249 // Manufacture an event, then stamp external-sync metadata that the normal
250 // create path never writes (external_source/external_id/is_read_only).
251 let ev = events
252 .create(
253 user_id,
254 NewEvent::builder("Sync Meeting", chrono::Utc::now()).build(),
255 )
256 .await
257 .unwrap();
258 sqlx::query("UPDATE events SET external_source = ?, external_id = ?, is_read_only = 1 WHERE id = ?")
259 .bind("google")
260 .bind("evt-123")
261 .bind(ev.id.to_string())
262 .execute(&pool)
263 .await
264 .unwrap();
265 let full = events.get_by_id(ev.id, user_id).await.unwrap().unwrap();
266 assert_eq!(full.external_source.as_deref(), Some("google"));
267
268 // Lose it, then restore verbatim through the transactional path.
269 sqlx::query("DELETE FROM events WHERE id = ?")
270 .bind(ev.id.to_string())
271 .execute(&pool)
272 .await
273 .unwrap();
274 let input = RestoreInput {
275 projects: vec![],
276 tasks: vec![],
277 events: vec![full.clone()],
278 emails: vec![],
279 contacts: vec![],
280 time_sessions: vec![],
281 milestones: vec![],
282 daily_notes: vec![],
283 attachments: vec![],
284 sync_accounts: vec![],
285 saved_views: vec![],
286 weekly_reviews: vec![],
287 monthly_goals: vec![],
288 monthly_reflections: vec![],
289 };
290 let r = restore_all(&pool, user_id, &input).await.unwrap();
291 assert_eq!(r.events_restored, 1);
292
293 let restored = events
294 .get_by_id(ev.id, user_id)
295 .await
296 .unwrap()
297 .expect("event restored");
298 assert_eq!(restored.id, ev.id);
299 assert_eq!(
300 restored.external_source.as_deref(),
301 Some("google"),
302 "external_source must survive restore"
303 );
304 assert_eq!(
305 restored.external_id.as_deref(),
306 Some("evt-123"),
307 "external_id must survive restore"
308 );
309 assert!(restored.is_read_only, "is_read_only must survive restore");
310 }
311
312 #[tokio::test]
313 async fn restore_round_trips_supplemental_collections() {
314 let pool = common::setup_test_db().await;
315 let user = common::create_test_user(&pool).await;
316
317 let projects = SqliteProjectRepository::new(pool.clone());
318 let tasks = SqliteTaskRepository::new(pool.clone());
319 let milestones = SqliteMilestoneRepository::new(pool.clone());
320 let attachments = SqliteAttachmentRepository::new(pool.clone());
321 let daily_notes = SqliteDailyNoteRepository::new(pool.clone());
322 let sync_accounts = SqliteSyncAccountRepository::new(pool.clone());
323
324 let project = projects
325 .create(
326 user,
327 NewProject {
328 name: "P".into(),
329 description: String::new(),
330 project_type: Default::default(),
331 status: Default::default(),
332 },
333 )
334 .await
335 .unwrap();
336 let task = tasks
337 .create(user, NewTask::builder("T").build())
338 .await
339 .unwrap();
340
341 let milestone = milestones
342 .create(
343 user,
344 NewMilestone {
345 project_id: project.id,
346 name: "M1".into(),
347 description: "desc".into(),
348 position: 0,
349 target_date: chrono::NaiveDate::from_ymd_opt(2024, 12, 31),
350 },
351 )
352 .await
353 .unwrap();
354 let session = tasks
355 .log_manual_time(task.id, user, PositiveMinutes::try_new(45).unwrap(), chrono::Utc::now())
356 .await
357 .unwrap();
358 let note = daily_notes
359 .upsert(
360 user,
361 chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
362 "went well",
363 "could improve",
364 true,
365 )
366 .await
367 .unwrap();
368 let attachment = attachments
369 .create(
370 user,
371 NewAttachment {
372 task_id: Some(task.id),
373 project_id: None,
374 filename: "f.pdf".into(),
375 file_size: 1234,
376 mime_type: "application/pdf".into(),
377 blob_hash: "deadbeef".into(),
378 source_email_id: None,
379 },
380 )
381 .await
382 .unwrap();
383 let account = sync_accounts
384 .create(user, "google", "My Calendar", Some("me@example.com"))
385 .await
386 .unwrap();
387
388 // The backup payload, then wipe everything (CASCADE handles the children).
389 let input = RestoreInput {
390 projects: vec![project.clone()],
391 tasks: vec![tasks.get_by_id(task.id, user).await.unwrap().unwrap()],
392 events: vec![],
393 emails: vec![],
394 contacts: vec![],
395 time_sessions: vec![session.clone()],
396 milestones: vec![milestone.clone()],
397 daily_notes: vec![note.clone()],
398 attachments: vec![attachment.clone()],
399 sync_accounts: vec![account.clone()],
400 saved_views: vec![],
401 weekly_reviews: vec![],
402 monthly_goals: vec![],
403 monthly_reflections: vec![],
404 };
405 // Deleting tasks cascades time_sessions + attachments; deleting projects
406 // cascades milestones; daily_notes and sync_accounts are independent.
407 for table in ["tasks", "daily_notes", "sync_accounts", "projects"] {
408 sqlx::query(&format!("DELETE FROM {table}"))
409 .execute(&pool)
410 .await
411 .unwrap();
412 }
413 assert!(milestones.list_all(user).await.unwrap().is_empty());
414 assert!(tasks.list_all_time_sessions(user).await.unwrap().is_empty());
415 assert!(attachments.list_all(user).await.unwrap().is_empty());
416
417 let r = restore_all(&pool, user, &input).await.unwrap();
418 assert_eq!(r.milestones_restored, 1);
419 assert_eq!(r.time_sessions_restored, 1);
420 assert_eq!(r.daily_notes_restored, 1);
421 assert_eq!(r.attachments_restored, 1);
422 assert_eq!(r.sync_accounts_restored, 1);
423
424 let ms = milestones.list_all(user).await.unwrap();
425 assert_eq!(ms.len(), 1);
426 assert_eq!(ms[0].id, milestone.id);
427 assert_eq!(ms[0].target_date, milestone.target_date, "target_date preserved");
428
429 let ts = tasks.list_all_time_sessions(user).await.unwrap();
430 assert_eq!(ts.len(), 1);
431 assert_eq!(ts[0].id, session.id);
432 assert_eq!(ts[0].duration_minutes, session.duration_minutes, "minutes preserved");
433
434 let dn = daily_notes.list_all(user).await.unwrap();
435 assert_eq!(dn.len(), 1);
436 assert!(dn[0].is_reviewed, "is_reviewed preserved");
437
438 let at = attachments.list_all(user).await.unwrap();
439 assert_eq!(at.len(), 1);
440 assert_eq!(at[0].blob_hash, "deadbeef", "blob_hash preserved");
441 assert_eq!(at[0].file_size, 1234, "file_size preserved");
442
443 let sa = sync_accounts.list_all(user).await.unwrap();
444 assert_eq!(sa.len(), 1);
445 assert_eq!(sa[0].provider, "google");
446 assert_eq!(sa[0].email.as_deref(), Some("me@example.com"));
447
448 // Idempotent re-restore.
449 let r2 = restore_all(&pool, user, &input).await.unwrap();
450 assert_eq!(r2.milestones_restored, 0);
451 assert_eq!(r2.time_sessions_restored, 0);
452 assert_eq!(r2.attachments_restored, 0);
453 assert_eq!(r2.sync_accounts_restored, 0);
454 }
455