Skip to main content

max / goingson

15.4 KB · 471 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, ProjectStatus,
15 ProjectType, SyncAccountRepository, TaskAnnotations, TaskCrud, TaskStatus, TaskTimeTracking,
16 };
17 use goingson_db_sqlite::{
18 SqliteAttachmentRepository, SqliteDailyNoteRepository, SqliteEventRepository,
19 SqliteMilestoneRepository, SqliteProjectRepository, SqliteSyncAccountRepository,
20 SqliteTaskRepository, restore_all,
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: ProjectType::default(),
39 status: ProjectStatus::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!(
148 second.is_completed,
149 "precondition: second subtask completed"
150 );
151 assert_eq!(first.position, 0);
152 assert_eq!(second.position, 1);
153
154 // One annotation, back-dated to a distinct timestamp so a reset-to-now
155 // regression is observable.
156 let annotation = tasks
157 .add_annotation(task.id, user_id, "an old note")
158 .await
159 .unwrap()
160 .unwrap();
161 sqlx::query("UPDATE annotations SET timestamp = ? WHERE id = ?")
162 .bind("2020-01-01 00:00:00")
163 .bind(annotation.id.to_string())
164 .execute(&pool)
165 .await
166 .unwrap();
167
168 // The backup payload as it would be exported (fully hydrated task).
169 let backup_task = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap();
170 assert_eq!(backup_task.subtasks.len(), 2);
171 assert_eq!(backup_task.annotations.len(), 1);
172
173 // Lose the task (cascades subtasks + annotations).
174 sqlx::query("DELETE FROM tasks WHERE id = ?")
175 .bind(task.id.to_string())
176 .execute(&pool)
177 .await
178 .unwrap();
179
180 let input = RestoreInput {
181 projects: vec![],
182 tasks: vec![backup_task.clone()],
183 events: vec![],
184 emails: vec![],
185 contacts: vec![],
186 time_sessions: vec![],
187 milestones: vec![],
188 daily_notes: vec![],
189 attachments: vec![],
190 sync_accounts: vec![],
191 saved_views: vec![],
192 weekly_reviews: vec![],
193 monthly_goals: vec![],
194 monthly_reflections: vec![],
195 };
196 let r = restore_all(&pool, user_id, &input).await.unwrap();
197 assert_eq!(r.tasks_restored, 1);
198 assert_eq!(r.subtasks_restored, 2);
199 assert_eq!(r.annotations_restored, 1);
200
201 let restored = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap();
202
203 // Subtask completion state and ordering survive verbatim.
204 let restored_first = restored
205 .subtasks
206 .iter()
207 .find(|s| s.id == first.id)
208 .expect("first subtask restored");
209 let restored_second = restored
210 .subtasks
211 .iter()
212 .find(|s| s.id == second.id)
213 .expect("second subtask restored");
214 assert!(
215 !restored_first.is_completed,
216 "incomplete subtask must stay incomplete"
217 );
218 assert!(
219 restored_second.is_completed,
220 "completed subtask must stay completed (regression: create-helper reset it)"
221 );
222 assert_eq!(restored_first.position, 0, "subtask order preserved");
223 assert_eq!(restored_second.position, 1, "subtask order preserved");
224
225 // Annotation timestamp survives verbatim (not reset to restore-time now).
226 let restored_annotation = &restored.annotations[0];
227 assert_eq!(
228 restored_annotation.id, annotation.id,
229 "annotation id preserved"
230 );
231 assert_eq!(
232 restored_annotation.timestamp.format("%Y").to_string(),
233 "2020",
234 "annotation timestamp must survive restore (regression: reset to now)"
235 );
236
237 // Re-restore is a no-op: no duplicate children.
238 let r2 = restore_all(&pool, user_id, &input).await.unwrap();
239 assert_eq!(r2.tasks_restored, 0);
240 assert_eq!(r2.subtasks_restored, 0);
241 let again = tasks.get_by_id(task.id, user_id).await.unwrap().unwrap();
242 assert_eq!(again.subtasks.len(), 2, "no duplicate subtasks");
243 assert_eq!(again.annotations.len(), 1, "no duplicate annotations");
244 }
245
246 #[tokio::test]
247 async fn restore_event_preserves_external_metadata() {
248 let pool = common::setup_test_db().await;
249 let user_id = common::create_test_user(&pool).await;
250 let events = SqliteEventRepository::new(pool.clone());
251
252 // Manufacture an event, then stamp external-sync metadata that the normal
253 // create path never writes (external_source/external_id/is_read_only).
254 let ev = events
255 .create(
256 user_id,
257 NewEvent::builder("Sync Meeting", chrono::Utc::now()).build(),
258 )
259 .await
260 .unwrap();
261 sqlx::query(
262 "UPDATE events SET external_source = ?, external_id = ?, is_read_only = 1 WHERE id = ?",
263 )
264 .bind("google")
265 .bind("evt-123")
266 .bind(ev.id.to_string())
267 .execute(&pool)
268 .await
269 .unwrap();
270 let full = events.get_by_id(ev.id, user_id).await.unwrap().unwrap();
271 assert_eq!(full.external_source.as_deref(), Some("google"));
272
273 // Lose it, then restore verbatim through the transactional path.
274 sqlx::query("DELETE FROM events WHERE id = ?")
275 .bind(ev.id.to_string())
276 .execute(&pool)
277 .await
278 .unwrap();
279 let input = RestoreInput {
280 projects: vec![],
281 tasks: vec![],
282 events: vec![full.clone()],
283 emails: vec![],
284 contacts: vec![],
285 time_sessions: vec![],
286 milestones: vec![],
287 daily_notes: vec![],
288 attachments: vec![],
289 sync_accounts: vec![],
290 saved_views: vec![],
291 weekly_reviews: vec![],
292 monthly_goals: vec![],
293 monthly_reflections: vec![],
294 };
295 let r = restore_all(&pool, user_id, &input).await.unwrap();
296 assert_eq!(r.events_restored, 1);
297
298 let restored = events
299 .get_by_id(ev.id, user_id)
300 .await
301 .unwrap()
302 .expect("event restored");
303 assert_eq!(restored.id, ev.id);
304 assert_eq!(
305 restored.external_source.as_deref(),
306 Some("google"),
307 "external_source must survive restore"
308 );
309 assert_eq!(
310 restored.external_id.as_deref(),
311 Some("evt-123"),
312 "external_id must survive restore"
313 );
314 assert!(restored.is_read_only, "is_read_only must survive restore");
315 }
316
317 #[tokio::test]
318 async fn restore_round_trips_supplemental_collections() {
319 let pool = common::setup_test_db().await;
320 let user = common::create_test_user(&pool).await;
321
322 let projects = SqliteProjectRepository::new(pool.clone());
323 let tasks = SqliteTaskRepository::new(pool.clone());
324 let milestones = SqliteMilestoneRepository::new(pool.clone());
325 let attachments = SqliteAttachmentRepository::new(pool.clone());
326 let daily_notes = SqliteDailyNoteRepository::new(pool.clone());
327 let sync_accounts = SqliteSyncAccountRepository::new(pool.clone());
328
329 let project = projects
330 .create(
331 user,
332 NewProject {
333 name: "P".into(),
334 description: String::new(),
335 project_type: ProjectType::default(),
336 status: ProjectStatus::default(),
337 },
338 )
339 .await
340 .unwrap();
341 let task = tasks
342 .create(user, NewTask::builder("T").build())
343 .await
344 .unwrap();
345
346 let milestone = milestones
347 .create(
348 user,
349 NewMilestone {
350 project_id: project.id,
351 name: "M1".into(),
352 description: "desc".into(),
353 position: 0,
354 target_date: chrono::NaiveDate::from_ymd_opt(2024, 12, 31),
355 },
356 )
357 .await
358 .unwrap();
359 let session = tasks
360 .log_manual_time(
361 task.id,
362 user,
363 PositiveMinutes::try_new(45).unwrap(),
364 chrono::Utc::now(),
365 )
366 .await
367 .unwrap();
368 let note = daily_notes
369 .upsert(
370 user,
371 chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
372 "went well",
373 "could improve",
374 true,
375 )
376 .await
377 .unwrap();
378 let attachment = attachments
379 .create(
380 user,
381 NewAttachment {
382 task_id: Some(task.id),
383 project_id: None,
384 filename: "f.pdf".into(),
385 file_size: 1234,
386 mime_type: "application/pdf".into(),
387 blob_hash: "deadbeef".into(),
388 source_email_id: None,
389 },
390 )
391 .await
392 .unwrap();
393 let account = sync_accounts
394 .create(user, "google", "My Calendar", Some("me@example.com"))
395 .await
396 .unwrap();
397
398 // The backup payload, then wipe everything (CASCADE handles the children).
399 let input = RestoreInput {
400 projects: vec![project.clone()],
401 tasks: vec![tasks.get_by_id(task.id, user).await.unwrap().unwrap()],
402 events: vec![],
403 emails: vec![],
404 contacts: vec![],
405 time_sessions: vec![session.clone()],
406 milestones: vec![milestone.clone()],
407 daily_notes: vec![note.clone()],
408 attachments: vec![attachment.clone()],
409 sync_accounts: vec![account.clone()],
410 saved_views: vec![],
411 weekly_reviews: vec![],
412 monthly_goals: vec![],
413 monthly_reflections: vec![],
414 };
415 // Deleting tasks cascades time_sessions + attachments; deleting projects
416 // cascades milestones; daily_notes and sync_accounts are independent.
417 for table in ["tasks", "daily_notes", "sync_accounts", "projects"] {
418 sqlx::query(sqlx::AssertSqlSafe(format!("DELETE FROM {table}")))
419 .execute(&pool)
420 .await
421 .unwrap();
422 }
423 assert!(milestones.list_all(user).await.unwrap().is_empty());
424 assert!(tasks.list_all_time_sessions(user).await.unwrap().is_empty());
425 assert!(attachments.list_all(user).await.unwrap().is_empty());
426
427 let r = restore_all(&pool, user, &input).await.unwrap();
428 assert_eq!(r.milestones_restored, 1);
429 assert_eq!(r.time_sessions_restored, 1);
430 assert_eq!(r.daily_notes_restored, 1);
431 assert_eq!(r.attachments_restored, 1);
432 assert_eq!(r.sync_accounts_restored, 1);
433
434 let ms = milestones.list_all(user).await.unwrap();
435 assert_eq!(ms.len(), 1);
436 assert_eq!(ms[0].id, milestone.id);
437 assert_eq!(
438 ms[0].target_date, milestone.target_date,
439 "target_date preserved"
440 );
441
442 let ts = tasks.list_all_time_sessions(user).await.unwrap();
443 assert_eq!(ts.len(), 1);
444 assert_eq!(ts[0].id, session.id);
445 assert_eq!(
446 ts[0].duration_minutes, session.duration_minutes,
447 "minutes preserved"
448 );
449
450 let dn = daily_notes.list_all(user).await.unwrap();
451 assert_eq!(dn.len(), 1);
452 assert!(dn[0].is_reviewed, "is_reviewed preserved");
453
454 let at = attachments.list_all(user).await.unwrap();
455 assert_eq!(at.len(), 1);
456 assert_eq!(at[0].blob_hash, "deadbeef", "blob_hash preserved");
457 assert_eq!(at[0].file_size, 1234, "file_size preserved");
458
459 let sa = sync_accounts.list_all(user).await.unwrap();
460 assert_eq!(sa.len(), 1);
461 assert_eq!(sa[0].provider, "google");
462 assert_eq!(sa[0].email.as_deref(), Some("me@example.com"));
463
464 // Idempotent re-restore.
465 let r2 = restore_all(&pool, user, &input).await.unwrap();
466 assert_eq!(r2.milestones_restored, 0);
467 assert_eq!(r2.time_sessions_restored, 0);
468 assert_eq!(r2.attachments_restored, 0);
469 assert_eq!(r2.sync_accounts_restored, 0);
470 }
471