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(¤t_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));
}