a73x

tests/sync_lock_test.rs

Ref:   Size: 8.5 KiB

mod common;

use std::fs;
use std::path::{Path, PathBuf};

use tempfile::TempDir;

use git_collab::error::Error;
use git_collab::sync_lock::{SyncLock, SyncLockInfo};

// ===========================================================================
// Test helpers
// ===========================================================================

/// Return the `.git/collab/` path for a temp repo, creating it if needed.
fn collab_dir(repo_path: &Path) -> PathBuf {
    let dir = repo_path.join(".git").join("collab");
    fs::create_dir_all(&dir).unwrap();
    dir
}

/// Write a lockfile with the given PID and timestamp.
fn write_lockfile(collab_dir: &Path, pid: u32, timestamp: &str) {
    let info = SyncLockInfo {
        pid,
        timestamp: timestamp.to_string(),
    };
    let lock_path = collab_dir.join("sync.lock");
    fs::write(&lock_path, info.to_json()).unwrap();
}

// ===========================================================================
// Phase 2: Foundational tests — serialization
// ===========================================================================

#[test]
fn test_sync_lock_info_roundtrip() {
    let info = SyncLockInfo {
        pid: 12345,
        timestamp: "2026-03-21T10:30:00+00:00".to_string(),
    };
    let json = info.to_json();
    let parsed = SyncLockInfo::from_json(&json).unwrap();
    assert_eq!(parsed.pid, 12345);
    assert_eq!(parsed.timestamp, "2026-03-21T10:30:00+00:00");
}

#[test]
fn test_sync_lock_info_from_invalid_json() {
    let result = SyncLockInfo::from_json("not json");
    assert!(result.is_err());
}

// ===========================================================================
// Phase 3: User Story 1 — Prevent Concurrent Sync Corruption
// ===========================================================================

// T006: Test that SyncLock::acquire creates lockfile with correct PID and timestamp
#[test]
fn test_acquire_creates_lockfile() {
    let dir = TempDir::new().unwrap();
    let repo = git2::Repository::init(dir.path()).unwrap();

    let lock = SyncLock::acquire(&repo).unwrap();

    assert!(lock.lock_path.exists(), "lockfile should exist after acquire");

    let content = fs::read_to_string(&lock.lock_path).unwrap();
    let info = SyncLockInfo::from_json(&content).unwrap();
    assert_eq!(info.pid, std::process::id());
    // Timestamp should be a valid RFC 3339 string
    chrono::DateTime::parse_from_rfc3339(&info.timestamp)
        .expect("timestamp should be valid RFC 3339");
}

// T007: Test that SyncLock::acquire returns SyncLocked when lockfile exists with live PID
#[test]
fn test_acquire_returns_sync_locked_when_lock_held() {
    let dir = TempDir::new().unwrap();
    let repo = git2::Repository::init(dir.path()).unwrap();

    // Write a lockfile with our own PID (guaranteed alive)
    let collab = collab_dir(dir.path());
    let current_pid = std::process::id();
    write_lockfile(&collab, current_pid, &chrono::Utc::now().to_rfc3339());

    let result = SyncLock::acquire(&repo);
    assert!(result.is_err(), "acquire should fail when lock is held");

    match result.unwrap_err() {
        Error::SyncLocked { pid, since } => {
            assert_eq!(pid, current_pid);
            assert!(!since.is_empty());
        }
        other => panic!("expected SyncLocked error, got: {}", other),
    }
}

// T008: Test that dropping SyncLock deletes the lockfile
#[test]
fn test_drop_deletes_lockfile() {
    let dir = TempDir::new().unwrap();
    let repo = git2::Repository::init(dir.path()).unwrap();

    let lock = SyncLock::acquire(&repo).unwrap();
    let lock_path = lock.lock_path.clone();
    assert!(lock_path.exists());

    drop(lock);
    assert!(!lock_path.exists(), "lockfile should be deleted after drop");
}

// ===========================================================================
// Phase 4: User Story 2 — Clear Error Message
// ===========================================================================

// T015: Test that SyncLocked error message includes PID and human-readable lock age
#[test]
fn test_sync_locked_error_message_includes_pid_and_age() {
    let dir = TempDir::new().unwrap();
    let repo = git2::Repository::init(dir.path()).unwrap();

    let collab = collab_dir(dir.path());
    let current_pid = std::process::id();
    // Lock from 3 seconds ago
    let ts = (chrono::Utc::now() - chrono::Duration::seconds(3)).to_rfc3339();
    write_lockfile(&collab, current_pid, &ts);

    let err = SyncLock::acquire(&repo).unwrap_err();
    let msg = err.to_string();

    assert!(
        msg.contains(&current_pid.to_string()),
        "error should contain PID, got: {}",
        msg
    );
    // Should contain human-readable age
    assert!(
        msg.contains("ago"),
        "error should contain human-readable age, got: {}",
        msg
    );
}

// T016: Test that error message suggests waiting or checking process
#[test]
fn test_sync_locked_error_message_suggests_action() {
    let dir = TempDir::new().unwrap();
    let repo = git2::Repository::init(dir.path()).unwrap();

    let collab = collab_dir(dir.path());
    let current_pid = std::process::id();
    write_lockfile(&collab, current_pid, &chrono::Utc::now().to_rfc3339());

    let err = SyncLock::acquire(&repo).unwrap_err();
    let msg = err.to_string();

    assert!(
        msg.contains("wait") || msg.contains("remove"),
        "error should suggest action, got: {}",
        msg
    );
}

// ===========================================================================
// Phase 5: User Story 3 — Stale Lock Recovery
// ===========================================================================

// T019: Test that acquire succeeds when lockfile exists with a dead PID
#[test]
fn test_acquire_succeeds_with_dead_pid() {
    let dir = TempDir::new().unwrap();
    let repo = git2::Repository::init(dir.path()).unwrap();

    let collab = collab_dir(dir.path());
    // PID 999999 is almost certainly not running
    write_lockfile(&collab, 999999, &chrono::Utc::now().to_rfc3339());

    let lock = SyncLock::acquire(&repo);
    assert!(lock.is_ok(), "acquire should succeed with dead PID: {:?}", lock.err());

    // The new lock should be ours
    let lock = lock.unwrap();
    let content = fs::read_to_string(&lock.lock_path).unwrap();
    let info = SyncLockInfo::from_json(&content).unwrap();
    assert_eq!(info.pid, std::process::id());
}

// T020: Test that acquire succeeds when lockfile is older than 10 minutes
#[test]
fn test_acquire_succeeds_with_old_lock() {
    let dir = TempDir::new().unwrap();
    let repo = git2::Repository::init(dir.path()).unwrap();

    let collab = collab_dir(dir.path());
    // Lock from 11 minutes ago with our own PID (alive but stale by age)
    let old_ts = (chrono::Utc::now() - chrono::Duration::minutes(11)).to_rfc3339();
    write_lockfile(&collab, std::process::id(), &old_ts);

    let lock = SyncLock::acquire(&repo);
    assert!(
        lock.is_ok(),
        "acquire should succeed with stale (old) lock: {:?}",
        lock.err()
    );
}

// T021: Test that acquire succeeds when lockfile contains invalid JSON
#[test]
fn test_acquire_succeeds_with_corrupted_lockfile() {
    let dir = TempDir::new().unwrap();
    let repo = git2::Repository::init(dir.path()).unwrap();

    let collab = collab_dir(dir.path());
    let lock_path = collab.join("sync.lock");
    fs::write(&lock_path, "this is not json at all").unwrap();

    let lock = SyncLock::acquire(&repo);
    assert!(
        lock.is_ok(),
        "acquire should succeed with corrupted lockfile: {:?}",
        lock.err()
    );
}

// T022: Test format_lock_age helper
#[test]
fn test_format_lock_age() {
    use git_collab::sync_lock::format_lock_age;

    let now = chrono::Utc::now();
    let three_sec_ago = (now - chrono::Duration::seconds(3)).to_rfc3339();
    let result = format_lock_age(&three_sec_ago);
    assert!(result.contains("second"), "expected 'second' in: {}", result);
    assert!(result.contains("ago"), "expected 'ago' in: {}", result);

    let two_min_ago = (now - chrono::Duration::minutes(2)).to_rfc3339();
    let result = format_lock_age(&two_min_ago);
    assert!(result.contains("minute"), "expected 'minute' in: {}", result);

    // Invalid timestamp should fall back gracefully
    let result = format_lock_age("not-a-timestamp");
    assert!(!result.is_empty());
}

// T023: Test is_process_alive
#[test]
fn test_is_process_alive() {
    use git_collab::sync_lock::is_process_alive;

    // Our own process should be alive
    assert!(is_process_alive(std::process::id()));

    // PID 999999 should not be alive (almost certainly)
    assert!(!is_process_alive(999999));
}