qqq-cli 0.12.0

Local-first task queue for coding agent sessions
use super::compatibility::*;
use rusqlite::Connection;
use serde_json::Value;
use std::{fs, process::Stdio};

#[test]
fn repeated_normal_opens_are_idempotent_and_keep_claim_constraints() {
    let originals = tree_hashes();
    for entry in catalog().databases {
        let dir = copy_database(&entry);
        assert_migrated(dir.path(), &entry);
        let project = dir.path().join(".qqq");
        let before = hashes_at(&project);
        assert_migrated(dir.path(), &entry);
        assert_eq!(
            hashes_at(&project),
            before,
            "reopen schema {}",
            entry.schema_version
        );
        let conn = Connection::open(project.join("qqq.db")).unwrap();
        conn.execute_batch("BEGIN").unwrap();
        for sql in [
            "UPDATE tasks SET claim_key=NULL WHERE id=8",
            "UPDATE tasks SET claim_key='invalid-owner' WHERE id=3",
            "UPDATE tasks SET claim_key='invalid-owner' WHERE id=34",
            "UPDATE tasks SET claim_key='fixture-owner-linked' WHERE id=55",
            "UPDATE tasks SET status='pending' WHERE id=13",
        ] {
            assert!(
                conn.execute(sql, []).is_err(),
                "migrated CHECK/unique constraint missing: {sql}"
            );
        }
        conn.execute_batch("ROLLBACK").unwrap();
        assert_eq!(canonical(&conn), expected(&entry)["canonical"]);
        drop(conn);
        assert_eq!(hashes_at(&project), before);
    }
    assert_eq!(tree_hashes(), originals);
}

#[test]
fn simultaneous_openers_migrate_each_historical_schema_once() {
    let originals = tree_hashes();
    for entry in catalog().databases {
        let dir = copy_database(&entry);
        let lock = Connection::open(dir.path().join(".qqq/qqq.db")).unwrap();
        lock.execute_batch("BEGIN IMMEDIATE").unwrap();
        let children: Vec<_> = (0..6)
            .map(|_| {
                command(dir.path())
                    .args(["list", "--all", "--include-archived", "--json"])
                    .stdout(Stdio::piped())
                    .stderr(Stdio::piped())
                    .spawn()
                    .unwrap()
            })
            .collect();
        lock.execute_batch("ROLLBACK").unwrap();
        drop(lock);
        for child in children {
            let output = child.wait_with_output().unwrap();
            assert!(
                output.status.success(),
                "schema {}: {}",
                entry.schema_version,
                String::from_utf8_lossy(&output.stderr)
            );
            let tasks: Value = serde_json::from_slice(&output.stdout).unwrap();
            assert_eq!(tasks.as_array().unwrap().len(), 8);
        }
        assert_migrated(dir.path(), &entry);
        let before = hashes_at(&dir.path().join(".qqq"));
        assert_migrated(dir.path(), &entry);
        assert_eq!(hashes_at(&dir.path().join(".qqq")), before);
    }
    assert_eq!(tree_hashes(), originals);
}

#[test]
fn late_sql_migration_failures_roll_back_all_schemas_and_retry_cleanly() {
    let originals = tree_hashes();
    let catalog = catalog();
    for entry in catalog
        .databases
        .iter()
        .filter(|entry| entry.schema_version < catalog.support.current_schema)
    {
        let dir = copy_database(entry);
        let project = dir.path().join(".qqq");
        let conn = Connection::open(project.join("qqq.db")).unwrap();
        let (inject, remove, error) = if entry.schema_version < 11 {
            (
                "CREATE TABLE task_dependencies(sentinel TEXT)",
                "DROP TABLE task_dependencies",
                "already exists",
            )
        } else if entry.schema_version < 12 {
            (
                "ALTER TABLE tasks ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'",
                "ALTER TABLE tasks DROP COLUMN tags",
                "duplicate column",
            )
        } else {
            (
                "CREATE TABLE claim_processes(sentinel TEXT)",
                "DROP TABLE claim_processes",
                "already exists",
            )
        };
        conn.execute_batch(inject).unwrap();
        drop(conn);
        let before = hashes_at(&project);
        for json in [false, true] {
            failure(
                dir.path(),
                &["list", "--all"],
                json,
                error,
                "DATABASE_ERROR",
            );
            assert_eq!(
                hashes_at(&project),
                before,
                "SQL rollback schema {}",
                entry.schema_version
            );
            let conn = read_only(&project.join("qqq.db"));
            assert_eq!(
                conn.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))
                    .unwrap(),
                entry.schema_version
            );
        }
        Connection::open(project.join("qqq.db"))
            .unwrap()
            .execute_batch(remove)
            .unwrap();
        assert_migrated(dir.path(), entry);
    }
    assert_eq!(tree_hashes(), originals);
}

#[test]
fn embedded_image_write_conflict_rolls_back_then_retry_preserves_bytes() {
    let originals = tree_hashes();
    for entry in catalog()
        .databases
        .iter()
        .filter(|entry| entry.schema_version < 6)
    {
        let dir = copy_database(entry);
        let project = dir.path().join(".qqq");
        let conflict = project.join("images/13/19.gif");
        fs::create_dir_all(conflict.parent().unwrap()).unwrap();
        fs::write(&conflict, b"synthetic conflict").unwrap();
        let before = hashes_at(&project);
        for json in [false, true] {
            failure(
                dir.path(),
                &["list", "--all"],
                json,
                "Stored image path conflicts",
                "COMMAND_ERROR",
            );
            assert_eq!(hashes_at(&project), before);
            assert!(
                !project.join("images/3/7.png").exists(),
                "first newly written image must roll back"
            );
        }
        fs::remove_file(conflict).unwrap();
        assert_migrated(dir.path(), entry);
    }
    assert_eq!(tree_hashes(), originals);
}