| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 155 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 253 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 416 |
|
| 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 |
|
| 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 |
|