| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 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 |
|
| 51 |
|
| 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 |
|
| 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 |
|
| 88 |
|
| 89 |
|
| 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 |
|
| 130 |
let dst = common::setup_test_db().await; |
| 131 |
let _ = common::create_test_user(&dst).await; |
| 132 |
|
| 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 |
|