diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5d0860b..8b8e1df 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,6 +26,7 @@ test: - cargo check --release - cargo fmt -- --check - cargo clippy -- -D warnings + - cargo test only: - branches - merge_requests diff --git a/src/health.rs b/src/health.rs index 5eedb00..47a93f8 100644 --- a/src/health.rs +++ b/src/health.rs @@ -61,3 +61,47 @@ pub async fn start_health_server(port: u16) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_health_status_serialization() { + let status = HealthStatus { + status: "healthy".to_string(), + version: "0.1.0".to_string(), + uptime_seconds: 42, + }; + + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains(r#""status":"healthy""#)); + assert!(json.contains(r#""version":"0.1.0""#)); + assert!(json.contains(r#""uptime_seconds":42"#)); + } + + #[test] + fn test_health_status_deserialization() { + let json = r#"{"status":"healthy","version":"0.1.0","uptime_seconds":42}"#; + let status: HealthStatus = serde_json::from_str(json).unwrap(); + assert_eq!(status.status, "healthy"); + assert_eq!(status.version, "0.1.0"); + assert_eq!(status.uptime_seconds, 42); + } + + #[test] + fn test_health_status_clone() { + let status = HealthStatus { + status: "healthy".to_string(), + version: "0.1.0".to_string(), + uptime_seconds: 42, + }; + let cloned = status.clone(); + assert_eq!(status.status, cloned.status); + assert_eq!(status.version, cloned.version); + assert_eq!(status.uptime_seconds, cloned.uptime_seconds); + } + + // Note: start_health_server is tested manually/via integration tests + // Unit testing it requires complex async server mocking which is not practical +} diff --git a/src/main.rs b/src/main.rs index 843ff49..48fc68f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,20 @@ fn init_logger() { .ok(); } +/// Convert HTTP(S) URL to WebSocket URL +fn convert_to_websocket_url(url: &str) -> String { + if url.starts_with("http://") { + url.replace("http://", "ws://") + } else if url.starts_with("https://") { + url.replace("https://", "wss://") + } else if url.starts_with("ws://") || url.starts_with("wss://") { + url.to_string() + } else { + // Default to wss:// for bare domains + format!("wss://{}", url) + } +} + #[derive(Parser)] #[command(name = "towerops-agent")] #[command(about = "Towerops remote SNMP polling agent", long_about = None)] @@ -71,16 +85,7 @@ async fn main() { version::check_for_updates(); // Convert HTTP(S) URL to WebSocket URL - let ws_url = if args.api_url.starts_with("http://") { - args.api_url.replace("http://", "ws://") - } else if args.api_url.starts_with("https://") { - args.api_url.replace("https://", "wss://") - } else if args.api_url.starts_with("ws://") || args.api_url.starts_with("wss://") { - args.api_url.clone() - } else { - // Default to wss:// for bare domains - format!("wss://{}", args.api_url) - }; + let ws_url = convert_to_websocket_url(&args.api_url); info!("WebSocket URL: {}", ws_url); @@ -133,3 +138,90 @@ async fn main() { } } } + +#[cfg(test)] +mod tests { + use super::*; + use log::Log; + + #[test] + fn test_simple_logger_enabled() { + let logger = SimpleLogger { + level: LevelFilter::Info, + }; + + // Info level should be enabled + let info_metadata = log::MetadataBuilder::new() + .level(log::Level::Info) + .target("test") + .build(); + assert!(logger.enabled(&info_metadata)); + + // Debug level should not be enabled + let debug_metadata = log::MetadataBuilder::new() + .level(log::Level::Debug) + .target("test") + .build(); + assert!(!logger.enabled(&debug_metadata)); + + // Error level should be enabled + let error_metadata = log::MetadataBuilder::new() + .level(log::Level::Error) + .target("test") + .build(); + assert!(logger.enabled(&error_metadata)); + } + + #[test] + fn test_simple_logger_flush() { + let logger = SimpleLogger { + level: LevelFilter::Info, + }; + // flush() does nothing, just verify it's callable + logger.flush(); + } + + #[test] + fn test_convert_http_to_websocket() { + assert_eq!( + convert_to_websocket_url("http://localhost:4000"), + "ws://localhost:4000" + ); + } + + #[test] + fn test_convert_https_to_websocket() { + assert_eq!( + convert_to_websocket_url("https://app.towerops.com"), + "wss://app.towerops.com" + ); + } + + #[test] + fn test_websocket_url_unchanged() { + assert_eq!( + convert_to_websocket_url("ws://localhost:4000"), + "ws://localhost:4000" + ); + assert_eq!( + convert_to_websocket_url("wss://app.towerops.com"), + "wss://app.towerops.com" + ); + } + + #[test] + fn test_bare_domain_gets_wss() { + assert_eq!( + convert_to_websocket_url("app.towerops.com"), + "wss://app.towerops.com" + ); + assert_eq!( + convert_to_websocket_url("localhost:4000"), + "wss://localhost:4000" + ); + } + + // Note: main() function and init_logger() are not unit tested as they + // involve global state and tokio runtime initialization. + // They are tested via manual/integration testing. +} diff --git a/src/ping.rs b/src/ping.rs index 83ce5e4..ebcae8c 100644 --- a/src/ping.rs +++ b/src/ping.rs @@ -108,7 +108,7 @@ fn build_icmp_echo_request(identifier: u16, sequence: u16) -> Vec { } fn parse_icmp_reply(packet: &[u8], expected_identifier: u16, expected_sequence: u16) -> Result<()> { - // ICMP reply might be wrapped in an IP header (20 bytes minimum) + // ICMP reply might be wrapped in an IP header // Try both raw ICMP and IP-wrapped formats // Try to parse as raw ICMP first @@ -116,14 +116,40 @@ fn parse_icmp_reply(packet: &[u8], expected_identifier: u16, expected_sequence: return Ok(()); } - // Try to parse with IP header (skip first 20 bytes) - if packet.len() > 20 { - if let Ok(()) = try_parse_icmp(&packet[20..], expected_identifier, expected_sequence) { - return Ok(()); + // Check if this looks like an IP packet (version 4 in high nibble of first byte) + if packet.len() >= 20 && (packet[0] >> 4) == 4 { + // Extract IP header length from IHL field (low nibble of first byte) + // IHL is in 32-bit words, so multiply by 4 to get bytes + let ihl = (packet[0] & 0x0F) as usize * 4; + + if ihl >= 20 && packet.len() > ihl { + // Try to parse ICMP after skipping the IP header + if let Ok(()) = try_parse_icmp(&packet[ihl..], expected_identifier, expected_sequence) { + return Ok(()); + } } } - Err(anyhow!("Invalid ICMP reply packet")) + // Log diagnostic information to help debug + let packet_preview = if packet.len() >= 8 { + format!( + "type={} code={} id={} seq={} len={}", + packet[0], + packet.get(1).unwrap_or(&0), + u16::from_be_bytes([*packet.get(4).unwrap_or(&0), *packet.get(5).unwrap_or(&0)]), + u16::from_be_bytes([*packet.get(6).unwrap_or(&0), *packet.get(7).unwrap_or(&0)]), + packet.len() + ) + } else { + format!("len={} (too short)", packet.len()) + }; + + Err(anyhow!( + "Invalid ICMP reply packet (expected id={}, seq={}): {}", + expected_identifier, + expected_sequence, + packet_preview + )) } fn try_parse_icmp(packet: &[u8], expected_identifier: u16, expected_sequence: u16) -> Result<()> { @@ -183,18 +209,24 @@ mod tests { #[test] fn test_icmp_checksum() { - // Known ICMP echo request packet with correct checksum - let packet = vec![ - 0x08, 0x00, 0xf7, 0xff, 0x00, 0x01, 0x00, 0x01, 0x61, 0x62, 0x63, 0x64, + // Test that checksum calculation is consistent + // ICMP echo request packet: type=8, code=0, id=1, seq=1, data="abcd" + let mut packet = vec![ + 0x08, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x61, 0x62, 0x63, 0x64, ]; - // Calculate checksum for packet with checksum field zeroed - let mut test_packet = packet.clone(); - test_packet[2] = 0; - test_packet[3] = 0; + // Calculate checksum + let checksum = icmp_checksum(&packet); + assert_ne!(checksum, 0, "Checksum should not be zero"); - let checksum = icmp_checksum(&test_packet); - assert_eq!(checksum, 0xf7ff); + // Insert checksum into packet + packet[2..4].copy_from_slice(&checksum.to_be_bytes()); + + // Verify: recalculating checksum with checksum field included should give 0 + // (because the sum of all words including the checksum should wrap to 0xFFFF, + // and one's complement of 0xFFFF is 0) + let verification = icmp_checksum(&packet); + assert_eq!(verification, 0, "Checksum verification should be 0"); } #[test] @@ -217,4 +249,102 @@ mod tests { let checksum = u16::from_be_bytes([packet[2], packet[3]]); assert_ne!(checksum, 0); } + + #[test] + fn test_try_parse_icmp_success() { + // Valid ICMP echo reply packet + let packet = vec![ + 0x00, 0x00, 0x00, 0x00, // type=0 (reply), code=0, checksum + 0x12, 0x34, // identifier + 0x56, 0x78, // sequence + 0x61, 0x62, 0x63, 0x64, // data + ]; + + let result = try_parse_icmp(&packet, 0x1234, 0x5678); + assert!(result.is_ok()); + } + + #[test] + fn test_try_parse_icmp_too_short() { + let packet = vec![0x00, 0x00, 0x00]; + let result = try_parse_icmp(&packet, 0x1234, 0x5678); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("too short")); + } + + #[test] + fn test_try_parse_icmp_wrong_type() { + let packet = vec![ + 0x08, 0x00, 0x00, 0x00, // type=8 (request, not reply), code=0 + 0x12, 0x34, // identifier + 0x56, 0x78, // sequence + ]; + + let result = try_parse_icmp(&packet, 0x1234, 0x5678); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("mismatch")); + } + + #[test] + fn test_try_parse_icmp_wrong_identifier() { + let packet = vec![ + 0x00, 0x00, 0x00, 0x00, // type=0, code=0 + 0x99, 0x99, // wrong identifier + 0x56, 0x78, // sequence + ]; + + let result = try_parse_icmp(&packet, 0x1234, 0x5678); + assert!(result.is_err()); + } + + #[test] + fn test_parse_icmp_reply_raw() { + // Raw ICMP packet (no IP header) + let packet = vec![ + 0x00, 0x00, 0x00, 0x00, // type=0, code=0, checksum + 0x12, 0x34, // identifier + 0x56, 0x78, // sequence + ]; + + let result = parse_icmp_reply(&packet, 0x1234, 0x5678); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_icmp_reply_with_ip_header() { + // IPv4 packet with ICMP payload + let packet = vec![ + 0x45, 0x00, 0x00, 0x54, // IPv4 header: version=4, IHL=5 (20 bytes) + 0x00, 0x00, 0x00, 0x00, 0x40, 0x01, 0x00, 0x00, // TTL, protocol=ICMP + 0xc0, 0xa8, 0x01, 0x01, // Source IP + 0xc0, 0xa8, 0x01, 0x02, // Dest IP + // ICMP payload starts here (at byte 20) + 0x00, 0x00, 0x00, 0x00, // type=0, code=0, checksum + 0x12, 0x34, // identifier + 0x56, 0x78, // sequence + ]; + + let result = parse_icmp_reply(&packet, 0x1234, 0x5678); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_icmp_reply_invalid() { + let packet = vec![0x00, 0x00]; + let result = parse_icmp_reply(&packet, 0x1234, 0x5678); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid ICMP reply")); + } + + #[test] + fn test_parse_icmp_reply_short_packet_error_message() { + let packet = vec![0x01, 0x02, 0x03]; + let result = parse_icmp_reply(&packet, 0x1234, 0x5678); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("too short")); + } } diff --git a/src/snmp/client.rs b/src/snmp/client.rs index 170e4fb..368943d 100644 --- a/src/snmp/client.rs +++ b/src/snmp/client.rs @@ -216,3 +216,205 @@ fn map_snmp_error(err: snmp::SnmpError) -> SnmpError { _ => SnmpError::RequestFailed(format!("{:?}", err)), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_snmp_client_new() { + let client = SnmpClient::new(); + // Just verify we can create it + assert!(format!("{:?}", client).contains("SnmpClient")); + } + + #[test] + fn test_snmp_client_default() { + let client = SnmpClient::default(); + assert!(format!("{:?}", client).contains("SnmpClient")); + } + + #[test] + fn test_parse_oid_valid() { + let result = parse_oid("1.3.6.1.2.1.1.1.0"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec![1, 3, 6, 1, 2, 1, 1, 1, 0]); + } + + #[test] + fn test_parse_oid_single() { + let result = parse_oid("1"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec![1]); + } + + #[test] + fn test_parse_oid_invalid() { + let result = parse_oid("1.3.6.abc.2.1"); + assert!(result.is_err()); + match result { + Err(SnmpError::InvalidOid(msg)) => { + assert!(msg.contains("abc")); + } + _ => panic!("Expected InvalidOid error"), + } + } + + #[test] + fn test_parse_oid_empty() { + let result = parse_oid(""); + assert!(result.is_err()); + } + + #[test] + fn test_format_oid() { + let oid = vec![1, 3, 6, 1, 2, 1, 1, 1, 0]; + let result = format_oid(&oid); + assert_eq!(result, "1.3.6.1.2.1.1.1.0"); + } + + #[test] + fn test_format_oid_single() { + let oid = vec![42]; + let result = format_oid(&oid); + assert_eq!(result, "42"); + } + + #[test] + fn test_format_oid_empty() { + let oid = vec![]; + let result = format_oid(&oid); + assert_eq!(result, ""); + } + + #[test] + fn test_starts_with_true() { + let oid = vec![1, 3, 6, 1, 2, 1, 1, 1, 0]; + let base = vec![1, 3, 6, 1]; + assert!(starts_with(&oid, &base)); + } + + #[test] + fn test_starts_with_exact_match() { + let oid = vec![1, 3, 6, 1]; + let base = vec![1, 3, 6, 1]; + assert!(starts_with(&oid, &base)); + } + + #[test] + fn test_starts_with_false() { + let oid = vec![1, 3, 6, 1, 2, 1]; + let base = vec![1, 3, 7]; + assert!(!starts_with(&oid, &base)); + } + + #[test] + fn test_starts_with_oid_too_short() { + let oid = vec![1, 3]; + let base = vec![1, 3, 6, 1]; + assert!(!starts_with(&oid, &base)); + } + + #[test] + fn test_convert_value_integer() { + let value = snmp::Value::Integer(42); + let result = convert_value(value).unwrap(); + match result { + SnmpValue::Integer(v) => assert_eq!(v, 42), + _ => panic!("Expected Integer"), + } + } + + #[test] + fn test_convert_value_octet_string() { + let value = snmp::Value::OctetString(b"test".as_slice()); + let result = convert_value(value).unwrap(); + match result { + SnmpValue::String(s) => assert_eq!(s, "test"), + _ => panic!("Expected String"), + } + } + + #[test] + fn test_convert_value_counter32() { + let value = snmp::Value::Counter32(12345); + let result = convert_value(value).unwrap(); + match result { + SnmpValue::Counter32(v) => assert_eq!(v, 12345), + _ => panic!("Expected Counter32"), + } + } + + #[test] + fn test_convert_value_counter64() { + let value = snmp::Value::Counter64(9876543210); + let result = convert_value(value).unwrap(); + match result { + SnmpValue::Counter64(v) => assert_eq!(v, 9876543210), + _ => panic!("Expected Counter64"), + } + } + + #[test] + fn test_convert_value_unsigned32() { + let value = snmp::Value::Unsigned32(999); + let result = convert_value(value).unwrap(); + match result { + SnmpValue::Gauge32(v) => assert_eq!(v, 999), + _ => panic!("Expected Gauge32"), + } + } + + #[test] + fn test_convert_value_timeticks() { + let value = snmp::Value::Timeticks(12345678); + let result = convert_value(value).unwrap(); + match result { + SnmpValue::TimeTicks(v) => assert_eq!(v, 12345678), + _ => panic!("Expected TimeTicks"), + } + } + + #[test] + fn test_convert_value_ip_address() { + let value = snmp::Value::IpAddress([192, 168, 1, 1]); + let result = convert_value(value).unwrap(); + match result { + SnmpValue::IpAddress(ip) => assert_eq!(ip, "192.168.1.1"), + _ => panic!("Expected IpAddress"), + } + } + + #[test] + fn test_map_snmp_error_send() { + let err = snmp::SnmpError::SendError; + let result = map_snmp_error(err); + match result { + SnmpError::NetworkUnreachable => {} + _ => panic!("Expected NetworkUnreachable"), + } + } + + #[test] + fn test_map_snmp_error_receive() { + let err = snmp::SnmpError::ReceiveError; + let result = map_snmp_error(err); + match result { + SnmpError::Timeout => {} + _ => panic!("Expected Timeout"), + } + } + + #[test] + fn test_map_snmp_error_community() { + let err = snmp::SnmpError::CommunityMismatch; + let result = map_snmp_error(err); + match result { + SnmpError::AuthFailure => {} + _ => panic!("Expected AuthFailure"), + } + } + + // Note: get() and walk() methods require actual network operations + // and are tested via integration tests, not unit tests +} diff --git a/src/snmp/types.rs b/src/snmp/types.rs index c8552b6..205d3fd 100644 --- a/src/snmp/types.rs +++ b/src/snmp/types.rs @@ -54,3 +54,57 @@ impl SnmpValue { self.as_i64().map(|v| v as f64) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_snmp_error_display() { + assert_eq!( + format!("{}", SnmpError::RequestFailed("test error".to_string())), + "SNMP request failed: test error" + ); + assert_eq!( + format!("{}", SnmpError::InvalidOid("1.2.3".to_string())), + "Invalid OID: 1.2.3" + ); + assert_eq!(format!("{}", SnmpError::Timeout), "Timeout"); + assert_eq!( + format!("{}", SnmpError::AuthFailure), + "Authentication failure" + ); + assert_eq!( + format!("{}", SnmpError::NetworkUnreachable), + "Network unreachable" + ); + } + + #[test] + fn test_snmp_error_is_error() { + let error: &dyn std::error::Error = &SnmpError::Timeout; + assert_eq!(format!("{}", error), "Timeout"); + } + + #[test] + fn test_snmp_value_as_i64() { + assert_eq!(SnmpValue::Integer(42).as_i64(), Some(42)); + assert_eq!(SnmpValue::Counter32(100).as_i64(), Some(100)); + assert_eq!(SnmpValue::Counter64(1000).as_i64(), Some(1000)); + assert_eq!(SnmpValue::Gauge32(50).as_i64(), Some(50)); + assert_eq!(SnmpValue::TimeTicks(200).as_i64(), Some(200)); + assert_eq!(SnmpValue::String("test".to_string()).as_i64(), None); + assert_eq!(SnmpValue::IpAddress("1.2.3.4".to_string()).as_i64(), None); + } + + #[test] + fn test_snmp_value_as_f64() { + assert_eq!(SnmpValue::Integer(42).as_f64(), Some(42.0)); + assert_eq!(SnmpValue::Counter32(100).as_f64(), Some(100.0)); + assert_eq!(SnmpValue::Counter64(1000).as_f64(), Some(1000.0)); + assert_eq!(SnmpValue::Gauge32(50).as_f64(), Some(50.0)); + assert_eq!(SnmpValue::TimeTicks(200).as_f64(), Some(200.0)); + assert_eq!(SnmpValue::String("test".to_string()).as_f64(), None); + assert_eq!(SnmpValue::IpAddress("1.2.3.4".to_string()).as_f64(), None); + } +} diff --git a/src/version.rs b/src/version.rs index b393f2f..de76336 100644 --- a/src/version.rs +++ b/src/version.rs @@ -107,6 +107,13 @@ fn get_latest_version() -> Result> { .call()? .into_json()?; + extract_latest_version_from_response(response) +} + +/// Extract the latest version from Docker Hub response +fn extract_latest_version_from_response( + response: DockerHubResponse, +) -> Result> { // Filter for semver tags and find the latest let mut versions: Vec = response .results @@ -122,3 +129,163 @@ fn get_latest_version() -> Result> { .map(|v| format!("{}.{}.{}", v.major, v.minor, v.patch)) .ok_or_else(|| "No valid semver tags found".into()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_current_version() { + let version = current_version(); + assert!(!version.is_empty(), "Version should not be empty"); + // Version comes from env! macro at compile time + // Just verify it's a non-empty string + } + + #[test] + fn test_version_parse_valid() { + let v = Version::parse("1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + } + + #[test] + fn test_version_parse_with_v_prefix() { + let v = Version::parse("v2.5.8").unwrap(); + assert_eq!(v.major, 2); + assert_eq!(v.minor, 5); + assert_eq!(v.patch, 8); + } + + #[test] + fn test_version_parse_invalid() { + assert!(Version::parse("1.2").is_none()); + assert!(Version::parse("1.2.3.4").is_none()); + assert!(Version::parse("a.b.c").is_none()); + assert!(Version::parse("1.2.x").is_none()); + assert!(Version::parse("").is_none()); + } + + #[test] + fn test_version_comparison_major() { + let v1 = Version::parse("2.0.0").unwrap(); + let v2 = Version::parse("1.9.9").unwrap(); + assert!(v1 > v2); + assert!(v2 < v1); + } + + #[test] + fn test_version_comparison_minor() { + let v1 = Version::parse("1.5.0").unwrap(); + let v2 = Version::parse("1.4.9").unwrap(); + assert!(v1 > v2); + assert!(v2 < v1); + } + + #[test] + fn test_version_comparison_patch() { + let v1 = Version::parse("1.2.4").unwrap(); + let v2 = Version::parse("1.2.3").unwrap(); + assert!(v1 > v2); + assert!(v2 < v1); + } + + #[test] + fn test_version_comparison_equal() { + let v1 = Version::parse("1.2.3").unwrap(); + let v2 = Version::parse("1.2.3").unwrap(); + assert_eq!(v1, v2); + assert!(!(v1 > v2)); + assert!(!(v1 < v2)); + } + + #[test] + fn test_version_sorting() { + let mut versions = vec![ + Version::parse("1.2.3").unwrap(), + Version::parse("2.0.0").unwrap(), + Version::parse("1.5.0").unwrap(), + Version::parse("1.2.10").unwrap(), + ]; + versions.sort(); + assert_eq!(versions[0], Version::parse("1.2.3").unwrap()); + assert_eq!(versions[1], Version::parse("1.2.10").unwrap()); + assert_eq!(versions[2], Version::parse("1.5.0").unwrap()); + assert_eq!(versions[3], Version::parse("2.0.0").unwrap()); + } + + #[test] + fn test_dockerhub_response_deserialize() { + let json = r#"{"results":[{"name":"v1.0.0"},{"name":"v1.0.1"}]}"#; + let response: DockerHubResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.results.len(), 2); + assert_eq!(response.results[0].name, "v1.0.0"); + assert_eq!(response.results[1].name, "v1.0.1"); + } + + #[test] + fn test_extract_latest_version_from_response() { + let response = DockerHubResponse { + results: vec![ + DockerHubTag { + name: "v1.0.0".to_string(), + }, + DockerHubTag { + name: "v1.2.0".to_string(), + }, + DockerHubTag { + name: "v1.1.5".to_string(), + }, + DockerHubTag { + name: "latest".to_string(), + }, // Should be ignored + ], + }; + + let latest = extract_latest_version_from_response(response).unwrap(); + assert_eq!(latest, "1.2.0"); + } + + #[test] + fn test_extract_latest_version_no_valid_tags() { + let response = DockerHubResponse { + results: vec![ + DockerHubTag { + name: "latest".to_string(), + }, + DockerHubTag { + name: "main".to_string(), + }, + ], + }; + + let result = extract_latest_version_from_response(response); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("No valid semver")); + } + + #[test] + fn test_extract_latest_version_empty() { + let response = DockerHubResponse { results: vec![] }; + + let result = extract_latest_version_from_response(response); + assert!(result.is_err()); + } + + #[test] + fn test_version_debug() { + let v = Version { + major: 1, + minor: 2, + patch: 3, + }; + let debug_str = format!("{:?}", v); + assert!(debug_str.contains("1")); + assert!(debug_str.contains("2")); + assert!(debug_str.contains("3")); + } + + // Note: check_for_updates() and get_latest_version() are tested via integration tests + // as they require network access to Docker Hub +} diff --git a/src/websocket_client.rs b/src/websocket_client.rs index 9429eb1..ee51b74 100644 --- a/src/websocket_client.rs +++ b/src/websocket_client.rs @@ -511,3 +511,112 @@ async fn run_monitoring_task( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_value_to_string_integer() { + let value = SnmpValue::Integer(42); + assert_eq!(value_to_string(value), "42"); + } + + #[test] + fn test_value_to_string_string() { + let value = SnmpValue::String("test".to_string()); + assert_eq!(value_to_string(value), "test"); + } + + #[test] + fn test_value_to_string_counter32() { + let value = SnmpValue::Counter32(12345); + assert_eq!(value_to_string(value), "12345"); + } + + #[test] + fn test_value_to_string_counter64() { + let value = SnmpValue::Counter64(9876543210); + assert_eq!(value_to_string(value), "9876543210"); + } + + #[test] + fn test_value_to_string_gauge32() { + let value = SnmpValue::Gauge32(999); + assert_eq!(value_to_string(value), "999"); + } + + #[test] + fn test_value_to_string_timeticks() { + let value = SnmpValue::TimeTicks(12345678); + assert_eq!(value_to_string(value), "12345678"); + } + + #[test] + fn test_value_to_string_ip_address() { + let value = SnmpValue::IpAddress("192.168.1.1".to_string()); + assert_eq!(value_to_string(value), "192.168.1.1"); + } + + #[test] + fn test_generate_agent_id() { + let id = generate_agent_id(); + assert!(id.starts_with("agent-")); + + // Verify the timestamp part is a number + let timestamp_str = id.strip_prefix("agent-").unwrap(); + let timestamp: u64 = timestamp_str.parse().expect("Timestamp should be a number"); + assert!(timestamp > 0); + } + + #[test] + fn test_get_uptime_seconds() { + let uptime = get_uptime_seconds(); + // Currently returns 0 (not implemented), just verify it's callable + assert_eq!(uptime, 0); + } + + #[test] + fn test_get_local_ip() { + let ip = get_local_ip(); + // Currently returns None (not implemented), just verify it's callable + assert!(ip.is_none()); + } + + #[test] + fn test_phoenix_message_serialization() { + let msg = PhoenixMessage { + topic: "agent:123".to_string(), + event: "phx_join".to_string(), + payload: serde_json::json!({"token": "test"}), + reference: Some("1".to_string()), + }; + + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("agent:123")); + assert!(json.contains("phx_join")); + assert!(json.contains("token")); + assert!(json.contains("test")); + } + + #[test] + fn test_phoenix_message_deserialization() { + let json = + r#"{"topic":"agent:123","event":"phx_reply","payload":{"status":"ok"},"ref":"1"}"#; + let msg: PhoenixMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.topic, "agent:123"); + assert_eq!(msg.event, "phx_reply"); + assert_eq!(msg.reference, Some("1".to_string())); + } + + #[test] + fn test_phoenix_message_no_reference() { + let json = r#"{"topic":"agent:123","event":"job","payload":{},"ref":null}"#; + let msg: PhoenixMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.topic, "agent:123"); + assert_eq!(msg.event, "job"); + assert!(msg.reference.is_none()); + } + + // Note: AgentClient methods require WebSocket connection and are tested via integration tests +}