Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,145 @@ pub async fn rollback_latest() -> Result<Option<RollbackEntry>> {
save_manifest(&m).await?;
Ok(Some(entry))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
use std::sync::{Mutex, OnceLock};

fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}

#[test]
fn rollback_entry_serialization() {
let entry = RollbackEntry {
job_id: "job-1".to_string(),
backup_path: "/backups/status.bak".to_string(),
install_path: "/usr/bin/status".to_string(),
timestamp: Utc::now(),
};
let json = serde_json::to_string(&entry).unwrap();
let deserialized: RollbackEntry = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.job_id, "job-1");
assert_eq!(deserialized.backup_path, "/backups/status.bak");
assert_eq!(deserialized.install_path, "/usr/bin/status");
}

#[test]
fn rollback_manifest_default_is_empty() {
let manifest = RollbackManifest::default();
assert!(manifest.entries.is_empty());
}

#[test]
fn rollback_manifest_serialization_roundtrip() {
let manifest = RollbackManifest {
entries: vec![
RollbackEntry {
job_id: "job-1".to_string(),
backup_path: "/backups/a.bak".to_string(),
install_path: "/usr/bin/status".to_string(),
timestamp: Utc::now(),
},
RollbackEntry {
job_id: "job-2".to_string(),
backup_path: "/backups/b.bak".to_string(),
install_path: "/usr/bin/status".to_string(),
timestamp: Utc::now(),
},
],
};
let json = serde_json::to_string_pretty(&manifest).unwrap();
let deserialized: RollbackManifest = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.entries.len(), 2);
assert_eq!(deserialized.entries[0].job_id, "job-1");
assert_eq!(deserialized.entries[1].job_id, "job-2");
}

#[tokio::test]
async fn load_manifest_nonexistent_returns_default() {
let _lock = env_lock().lock().expect("env lock poisoned");
let dir = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("UPDATE_STORAGE_PATH", dir.path().to_str().unwrap());
let manifest = load_manifest().await.unwrap();
assert!(manifest.entries.is_empty());
}

#[tokio::test]
async fn save_and_load_manifest_roundtrip() {
let _lock = env_lock().lock().expect("env lock poisoned");
let dir = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("UPDATE_STORAGE_PATH", dir.path().to_str().unwrap());

let manifest = RollbackManifest {
entries: vec![RollbackEntry {
job_id: "test-job".to_string(),
backup_path: "/backup/test.bak".to_string(),
install_path: "/usr/bin/status".to_string(),
timestamp: Utc::now(),
}],
};
save_manifest(&manifest).await.unwrap();

let loaded = load_manifest().await.unwrap();
assert_eq!(loaded.entries.len(), 1);
assert_eq!(loaded.entries[0].job_id, "test-job");
}
Comment on lines +183 to +202
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests set/remove UPDATE_STORAGE_PATH but don’t restore any pre-existing value, which can leak state into other tests (or affect local runs when the env var is set). Save the original value before mutation and restore it in a Drop guard so cleanup happens even if an assertion fails.

Copilot uses AI. Check for mistakes.

#[tokio::test]
async fn record_rollback_appends_entry() {
let _lock = env_lock().lock().expect("env lock poisoned");
let dir = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("UPDATE_STORAGE_PATH", dir.path().to_str().unwrap());

// Save an initial empty manifest
save_manifest(&RollbackManifest::default()).await.unwrap();

record_rollback("job-1", "/backup/1.bak", "/usr/bin/status")
.await
.unwrap();
record_rollback("job-2", "/backup/2.bak", "/usr/bin/status")
.await
.unwrap();

let loaded = load_manifest().await.unwrap();
assert_eq!(loaded.entries.len(), 2);
assert_eq!(loaded.entries[0].job_id, "job-1");
assert_eq!(loaded.entries[1].job_id, "job-2");
}

#[tokio::test]
async fn backup_current_binary_creates_file() {
let _lock = env_lock().lock().expect("env lock poisoned");
let dir = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("UPDATE_STORAGE_PATH", dir.path().to_str().unwrap());

// Create a fake binary to back up
let src = dir.path().join("status");
tokio::fs::write(&src, b"fake binary content")
.await
.unwrap();

let backup_path = backup_current_binary(src.to_str().unwrap(), "test-job")
.await
.unwrap();
assert!(Path::new(&backup_path).exists());

let content = tokio::fs::read(&backup_path).await.unwrap();
assert_eq!(content, b"fake binary content");
}

#[tokio::test]
async fn rollback_latest_with_empty_manifest_returns_none() {
let _lock = env_lock().lock().expect("env lock poisoned");
let dir = tempfile::tempdir().unwrap();
let _env = EnvGuard::set("UPDATE_STORAGE_PATH", dir.path().to_str().unwrap());

save_manifest(&RollbackManifest::default()).await.unwrap();
let result = rollback_latest().await.unwrap();
assert!(result.is_none());
}
}
59 changes: 59 additions & 0 deletions src/commands/version_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,62 @@ pub async fn check_remote_version() -> Result<Option<RemoteVersion>> {
.context("parsing remote version response")?;
Ok(Some(rv))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::EnvGuard;
use std::sync::{Mutex, OnceLock};

fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}

#[test]
fn remote_version_deserialize_with_checksum() {
let json = r#"{"version": "1.2.3", "checksum": "abc123"}"#;
let rv: RemoteVersion = serde_json::from_str(json).unwrap();
assert_eq!(rv.version, "1.2.3");
assert_eq!(rv.checksum, Some("abc123".to_string()));
}

#[test]
fn remote_version_deserialize_without_checksum() {
let json = r#"{"version": "1.2.3"}"#;
let rv: RemoteVersion = serde_json::from_str(json).unwrap();
assert_eq!(rv.version, "1.2.3");
assert_eq!(rv.checksum, None);
}

#[test]
fn remote_version_deserialize_null_checksum() {
let json = r#"{"version": "0.1.0", "checksum": null}"#;
let rv: RemoteVersion = serde_json::from_str(json).unwrap();
assert_eq!(rv.version, "0.1.0");
assert_eq!(rv.checksum, None);
}

#[test]
fn remote_version_deserialize_missing_version_fails() {
let json = r#"{"checksum": "abc"}"#;
let result: std::result::Result<RemoteVersion, _> = serde_json::from_str(json);
assert!(result.is_err());
}

#[tokio::test]
async fn check_remote_version_no_env_returns_none() {
let _lock = env_lock().lock().expect("env lock poisoned");
let _env = EnvGuard::remove("UPDATE_SERVER_URL");
let result = check_remote_version().await.unwrap();
assert!(result.is_none());
}

#[tokio::test]
async fn check_remote_version_empty_env_returns_none() {
let _lock = env_lock().lock().expect("env lock poisoned");
let _env = EnvGuard::set("UPDATE_SERVER_URL", "");
let result = check_remote_version().await.unwrap();
assert!(result.is_none());
}
Comment on lines +80 to +94
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests modify UPDATE_SERVER_URL and either remove it or leave it empty without restoring any pre-existing value. To avoid leaking env changes across the test suite, capture the original value and restore it in a Drop guard (or use a small helper like with_env_var).

Copilot uses AI. Check for mistakes.
}
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@ pub mod security;
pub mod transport;
pub mod utils;

#[cfg(test)]
pub(crate) mod test_utils;

// Crate version exposed for runtime queries
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
140 changes: 140 additions & 0 deletions src/monitoring/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,143 @@ pub fn spawn_heartbeat(
}
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn metrics_snapshot_default() {
let snapshot = MetricsSnapshot::default();
assert_eq!(snapshot.timestamp_ms, 0);
assert_eq!(snapshot.cpu_usage_pct, 0.0);
assert_eq!(snapshot.memory_total_bytes, 0);
assert_eq!(snapshot.memory_used_bytes, 0);
assert_eq!(snapshot.memory_used_pct, 0.0);
assert_eq!(snapshot.disk_total_bytes, 0);
assert_eq!(snapshot.disk_used_bytes, 0);
assert_eq!(snapshot.disk_used_pct, 0.0);
}

#[test]
fn metrics_snapshot_serialization() {
let snapshot = MetricsSnapshot {
timestamp_ms: 1700000000000,
cpu_usage_pct: 45.5,
memory_total_bytes: 16_000_000_000,
memory_used_bytes: 8_000_000_000,
memory_used_pct: 50.0,
disk_total_bytes: 500_000_000_000,
disk_used_bytes: 250_000_000_000,
disk_used_pct: 50.0,
};
let json = serde_json::to_string(&snapshot).unwrap();
assert!(json.contains("\"cpu_usage_pct\":45.5"));
assert!(json.contains("\"memory_total_bytes\":16000000000"));
}

#[test]
fn control_plane_display() {
assert_eq!(ControlPlane::StatusPanel.to_string(), "status_panel");
assert_eq!(ControlPlane::ComposeAgent.to_string(), "compose_agent");
}

#[test]
fn control_plane_serialization() {
let json = serde_json::to_string(&ControlPlane::StatusPanel).unwrap();
assert_eq!(json, "\"status_panel\"");
let json = serde_json::to_string(&ControlPlane::ComposeAgent).unwrap();
assert_eq!(json, "\"compose_agent\"");
}

#[test]
fn control_plane_equality() {
assert_eq!(ControlPlane::StatusPanel, ControlPlane::StatusPanel);
assert_ne!(ControlPlane::StatusPanel, ControlPlane::ComposeAgent);
}

#[test]
fn command_execution_metrics_default() {
let metrics = CommandExecutionMetrics::default();
assert_eq!(metrics.status_panel_count, 0);
assert_eq!(metrics.compose_agent_count, 0);
assert_eq!(metrics.total_count, 0);
assert!(metrics.last_control_plane.is_none());
assert_eq!(metrics.last_command_timestamp_ms, 0);
}

#[test]
fn record_status_panel_execution() {
let mut metrics = CommandExecutionMetrics::default();
metrics.record_execution(ControlPlane::StatusPanel);

assert_eq!(metrics.status_panel_count, 1);
assert_eq!(metrics.compose_agent_count, 0);
assert_eq!(metrics.total_count, 1);
assert_eq!(metrics.last_control_plane, Some("status_panel".to_string()));
assert!(metrics.last_command_timestamp_ms > 0);
}

#[test]
fn record_compose_agent_execution() {
let mut metrics = CommandExecutionMetrics::default();
metrics.record_execution(ControlPlane::ComposeAgent);

assert_eq!(metrics.status_panel_count, 0);
assert_eq!(metrics.compose_agent_count, 1);
assert_eq!(metrics.total_count, 1);
assert_eq!(
metrics.last_control_plane,
Some("compose_agent".to_string())
);
}

#[test]
fn record_multiple_executions() {
let mut metrics = CommandExecutionMetrics::default();
metrics.record_execution(ControlPlane::StatusPanel);
metrics.record_execution(ControlPlane::StatusPanel);
metrics.record_execution(ControlPlane::ComposeAgent);

assert_eq!(metrics.status_panel_count, 2);
assert_eq!(metrics.compose_agent_count, 1);
assert_eq!(metrics.total_count, 3);
assert_eq!(
metrics.last_control_plane,
Some("compose_agent".to_string())
);
}

#[tokio::test]
async fn metrics_collector_snapshot_returns_valid_data() {
let collector = MetricsCollector::new();
let snapshot = collector.snapshot().await;

assert!(snapshot.timestamp_ms > 0);
// On any machine, total memory should be > 0
assert!(snapshot.memory_total_bytes > 0);
// Used memory should not exceed total
assert!(snapshot.memory_used_bytes <= snapshot.memory_total_bytes);
// Percentages should be 0-100 range
assert!(snapshot.memory_used_pct >= 0.0 && snapshot.memory_used_pct <= 100.0);
assert!(snapshot.disk_used_pct >= 0.0 && snapshot.disk_used_pct <= 100.0);
}

#[test]
fn command_execution_metrics_serialization() {
let mut metrics = CommandExecutionMetrics::default();
metrics.record_execution(ControlPlane::StatusPanel);

let json = serde_json::to_string(&metrics).unwrap();
assert!(json.contains("\"status_panel_count\":1"));
assert!(json.contains("\"compose_agent_count\":0"));
assert!(json.contains("\"total_count\":1"));
}

#[test]
fn metrics_collector_default() {
// Verify Default trait works
let collector = MetricsCollector::default();
let _ = format!("{:?}", collector);
}
}
Loading
Loading