cli_migrate.rs

  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}