1//! Integration tests for the migration system.
2
3use assert_cmd::Command;
4use tempfile::TempDir;
5
6fn td() -> Command {
7 Command::cargo_bin("td").unwrap()
8}
9
10fn init_tmp() -> TempDir {
11 let tmp = TempDir::new().unwrap();
12 td().arg("init").current_dir(&tmp).assert().success();
13 tmp
14}
15
16#[test]
17fn fresh_init_sets_latest_version() {
18 let tmp = init_tmp();
19 let conn = rusqlite::Connection::open(tmp.path().join(".td/tasks.db")).unwrap();
20 let version: u32 = conn
21 .pragma_query_value(None, "user_version", |row| row.get(0))
22 .unwrap();
23 // Version should be 4 (migration 0001 + 0002 + 0003 + 0004).
24 assert_eq!(version, 4);
25}
26
27#[test]
28fn legacy_db_is_migrated_on_open() {
29 let tmp = TempDir::new().unwrap();
30 let td_dir = tmp.path().join(".td");
31 std::fs::create_dir_all(&td_dir).unwrap();
32
33 // Create a v0 database with the old schema (no effort column).
34 let conn = rusqlite::Connection::open(td_dir.join("tasks.db")).unwrap();
35 conn.execute_batch(
36 "CREATE TABLE tasks (
37 id TEXT PRIMARY KEY,
38 title TEXT NOT NULL,
39 description TEXT DEFAULT '',
40 type TEXT DEFAULT 'task',
41 priority INTEGER DEFAULT 2,
42 status TEXT DEFAULT 'open',
43 parent TEXT DEFAULT '',
44 created TEXT NOT NULL,
45 updated TEXT NOT NULL
46 );
47 CREATE TABLE labels (
48 task_id TEXT, label TEXT,
49 PRIMARY KEY (task_id, label),
50 FOREIGN KEY (task_id) REFERENCES tasks(id)
51 );
52 CREATE TABLE blockers (
53 task_id TEXT, blocker_id TEXT,
54 PRIMARY KEY (task_id, blocker_id),
55 FOREIGN KEY (task_id) REFERENCES tasks(id)
56 );
57 INSERT INTO tasks (id, title, created, updated)
58 VALUES ('td-legacy', 'Old task', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z');",
59 )
60 .unwrap();
61 drop(conn);
62
63 // Opening via td (list) should migrate and succeed.
64 td().args(["--json", "list"])
65 .current_dir(&tmp)
66 .assert()
67 .success();
68
69 // Verify the task survived migration and got default effort.
70 let out = td()
71 .args(["--json", "show", "td-legacy"])
72 .current_dir(&tmp)
73 .output()
74 .unwrap();
75 let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
76 assert_eq!(v["title"].as_str().unwrap(), "Old task");
77 assert_eq!(v["effort"].as_i64().unwrap(), 2); // default medium
78
79 // Verify version is now latest.
80 let conn = rusqlite::Connection::open(td_dir.join("tasks.db")).unwrap();
81 let version: u32 = conn
82 .pragma_query_value(None, "user_version", |row| row.get(0))
83 .unwrap();
84 assert_eq!(version, 4);
85}
86
87#[test]
88fn effort_column_exists_after_init() {
89 let tmp = init_tmp();
90 let conn = rusqlite::Connection::open(tmp.path().join(".td/tasks.db")).unwrap();
91
92 // Verify the effort column is present by inserting a row that sets it.
93 conn.execute(
94 "INSERT INTO tasks (id, title, effort, created, updated) VALUES ('td-test', 'Test', 3, '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')",
95 [],
96 )
97 .unwrap();
98
99 let effort: i32 = conn
100 .query_row("SELECT effort FROM tasks WHERE id = 'td-test'", [], |r| {
101 r.get(0)
102 })
103 .unwrap();
104 assert_eq!(effort, 3);
105}
106
107#[test]
108fn blocker_fk_rejects_nonexistent_blocker_id() {
109 let tmp = init_tmp();
110 let conn = rusqlite::Connection::open(tmp.path().join(".td/tasks.db")).unwrap();
111 conn.execute_batch("PRAGMA foreign_keys = ON").unwrap();
112
113 conn.execute(
114 "INSERT INTO tasks (id, title, created, updated) \
115 VALUES ('td-real', 'Real task', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')",
116 [],
117 )
118 .unwrap();
119
120 // Inserting a blocker that references a nonexistent task should fail.
121 let result = conn.execute(
122 "INSERT INTO blockers (task_id, blocker_id) VALUES ('td-real', 'td-ghost')",
123 [],
124 );
125 assert!(
126 result.is_err(),
127 "expected FK violation for nonexistent blocker_id"
128 );
129}
130
131#[test]
132fn migration_cleans_dangling_blocker_ids() {
133 let tmp = TempDir::new().unwrap();
134 let td_dir = tmp.path().join(".td");
135 std::fs::create_dir_all(&td_dir).unwrap();
136
137 // Create a v2 database (pre-0003) with a dangling blocker_id.
138 let conn = rusqlite::Connection::open(td_dir.join("tasks.db")).unwrap();
139 conn.execute_batch(
140 "CREATE TABLE tasks (
141 id TEXT PRIMARY KEY,
142 title TEXT NOT NULL,
143 description TEXT DEFAULT '',
144 type TEXT DEFAULT 'task',
145 priority INTEGER DEFAULT 2,
146 status TEXT DEFAULT 'open',
147 parent TEXT DEFAULT '',
148 created TEXT NOT NULL,
149 updated TEXT NOT NULL,
150 effort INTEGER NOT NULL DEFAULT 2
151 );
152 CREATE TABLE labels (
153 task_id TEXT, label TEXT,
154 PRIMARY KEY (task_id, label),
155 FOREIGN KEY (task_id) REFERENCES tasks(id)
156 );
157 CREATE TABLE blockers (
158 task_id TEXT, blocker_id TEXT,
159 PRIMARY KEY (task_id, blocker_id),
160 FOREIGN KEY (task_id) REFERENCES tasks(id)
161 );
162 INSERT INTO tasks (id, title, created, updated)
163 VALUES ('td-a', 'Task A', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z');
164 INSERT INTO tasks (id, title, created, updated)
165 VALUES ('td-b', 'Task B', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z');
166 -- Valid blocker
167 INSERT INTO blockers (task_id, blocker_id) VALUES ('td-a', 'td-b');
168 -- Dangling blocker referencing a task that doesn't exist
169 INSERT INTO blockers (task_id, blocker_id) VALUES ('td-a', 'td-gone');
170 PRAGMA user_version = 2;",
171 )
172 .unwrap();
173 drop(conn);
174
175 // Running any command triggers migration.
176 td().args(["--json", "list"])
177 .current_dir(&tmp)
178 .assert()
179 .success();
180
181 // The valid blocker should survive; the dangling one should be gone.
182 let conn = rusqlite::Connection::open(td_dir.join("tasks.db")).unwrap();
183 let count: i32 = conn
184 .query_row(
185 "SELECT COUNT(*) FROM blockers WHERE task_id = 'td-a'",
186 [],
187 |r| r.get(0),
188 )
189 .unwrap();
190 assert_eq!(count, 1, "only the valid blocker should remain");
191
192 let blocker: String = conn
193 .query_row(
194 "SELECT blocker_id FROM blockers WHERE task_id = 'td-a'",
195 [],
196 |r| r.get(0),
197 )
198 .unwrap();
199 assert_eq!(blocker, "td-b");
200}