diff --git a/src/500-application/501-rust-telemetry/services/receiver/src/main.rs b/src/500-application/501-rust-telemetry/services/receiver/src/main.rs index 88ee6c8e..d7ea7a6c 100644 --- a/src/500-application/501-rust-telemetry/services/receiver/src/main.rs +++ b/src/500-application/501-rust-telemetry/services/receiver/src/main.rs @@ -252,3 +252,108 @@ impl PayloadSerialize for Payload { Ok(Payload(payload)) } } + +#[cfg(test)] +mod tests { + use super::*; + use azure_iot_operations_protocol::common::payload_serialize::{ + FormatIndicator, PayloadSerialize, + }; + + #[test] + fn deserialize_valid_json_with_correct_content_type() { + let json_bytes = br#"{"temperature": 25.5}"#; + let content_type = "application/json".to_string(); + let result = Payload::deserialize( + json_bytes, + Some(&content_type), + &FormatIndicator::Utf8EncodedCharacterData, + ); + assert!(result.is_ok()); + let payload = result.unwrap(); + assert_eq!(payload.0["temperature"], 25.5); + } + + #[test] + fn deserialize_valid_json_without_content_type() { + let json_bytes = br#"{"key": "value"}"#; + let result = Payload::deserialize( + json_bytes, + None, + &FormatIndicator::Utf8EncodedCharacterData, + ); + assert!(result.is_ok()); + assert_eq!(result.unwrap().0["key"], "value"); + } + + #[test] + fn deserialize_rejects_invalid_content_type() { + let json_bytes = br#"{"key": "value"}"#; + let content_type = "text/plain".to_string(); + let result = Payload::deserialize( + json_bytes, + Some(&content_type), + &FormatIndicator::Utf8EncodedCharacterData, + ); + assert!(matches!( + result, + Err(DeserializationError::UnsupportedContentType(_)) + )); + } + + #[test] + fn deserialize_rejects_invalid_utf8() { + let bad_bytes: &[u8] = &[0xff, 0xfe, 0xfd]; + let content_type = "application/json".to_string(); + let result = Payload::deserialize( + bad_bytes, + Some(&content_type), + &FormatIndicator::Utf8EncodedCharacterData, + ); + assert!(matches!( + result, + Err(DeserializationError::InvalidPayload(_)) + )); + } + + #[test] + fn deserialize_rejects_invalid_json() { + let bad_json = b"not valid json {{{"; + let content_type = "application/json".to_string(); + let result = Payload::deserialize( + bad_json, + Some(&content_type), + &FormatIndicator::Utf8EncodedCharacterData, + ); + assert!(matches!( + result, + Err(DeserializationError::InvalidPayload(_)) + )); + } + + #[test] + fn deserialize_handles_nested_json() { + let json_bytes = br#"{"sensor": {"id": 1, "readings": [10, 20, 30]}}"#; + let result = Payload::deserialize( + json_bytes, + None, + &FormatIndicator::Utf8EncodedCharacterData, + ); + assert!(result.is_ok()); + let payload = result.unwrap(); + assert_eq!(payload.0["sensor"]["id"], 1); + assert_eq!(payload.0["sensor"]["readings"][1], 20); + } + + #[test] + fn deserialize_handles_empty_object() { + let json_bytes = b"{}"; + let result = Payload::deserialize( + json_bytes, + None, + &FormatIndicator::Utf8EncodedCharacterData, + ); + assert!(result.is_ok()); + assert!(result.unwrap().0.as_object().unwrap().is_empty()); + } +} diff --git a/src/500-application/501-rust-telemetry/services/receiver/src/otel.rs b/src/500-application/501-rust-telemetry/services/receiver/src/otel.rs index 09e71c96..d79b7e7a 100644 --- a/src/500-application/501-rust-telemetry/services/receiver/src/otel.rs +++ b/src/500-application/501-rust-telemetry/services/receiver/src/otel.rs @@ -227,3 +227,69 @@ pub fn handle_receive_trace(custom_user_data: &Vec<(String, String)>) { // This creates a continuous trace across services span.set_parent(cx); } + +#[cfg(test)] +mod tests { + use super::*; + use opentelemetry::propagation::Extractor; + + #[test] + fn extractor_get_returns_value_for_existing_key() { + let data = vec![ + ("traceparent".to_string(), "00-abc-def-01".to_string()), + ("tracestate".to_string(), "vendor=opaque".to_string()), + ]; + let extractor = CustomUserDataExtractor(&data); + assert_eq!(extractor.get("traceparent"), Some("00-abc-def-01")); + } + + #[test] + fn extractor_get_returns_none_for_missing_key() { + let data = vec![("traceparent".to_string(), "value".to_string())]; + let extractor = CustomUserDataExtractor(&data); + assert_eq!(extractor.get("missing"), None); + } + + #[test] + fn extractor_get_returns_none_for_empty_data() { + let data: Vec<(String, String)> = vec![]; + let extractor = CustomUserDataExtractor(&data); + assert_eq!(extractor.get("traceparent"), None); + } + + #[test] + fn extractor_keys_returns_all_keys() { + let data = vec![ + ("traceparent".to_string(), "val1".to_string()), + ("tracestate".to_string(), "val2".to_string()), + ]; + let extractor = CustomUserDataExtractor(&data); + let keys = extractor.keys(); + assert_eq!(keys.len(), 2); + assert!(keys.contains(&"traceparent")); + assert!(keys.contains(&"tracestate")); + } + + #[test] + fn extractor_keys_returns_empty_for_empty_data() { + let data: Vec<(String, String)> = vec![]; + let extractor = CustomUserDataExtractor(&data); + assert!(extractor.keys().is_empty()); + } + + #[test] + fn extractor_get_returns_first_match_for_duplicate_keys() { + let data = vec![ + ("key".to_string(), "first".to_string()), + ("key".to_string(), "second".to_string()), + ]; + let extractor = CustomUserDataExtractor(&data); + assert_eq!(extractor.get("key"), Some("first")); + } + + #[test] + fn extract_trace_context_returns_context_for_empty_data() { + let data: Vec<(String, String)> = vec![]; + let _ctx = extract_trace_context(&data); + } +} diff --git a/src/500-application/501-rust-telemetry/services/sender/src/main.rs b/src/500-application/501-rust-telemetry/services/sender/src/main.rs index 00cde8b5..4b5d2219 100644 --- a/src/500-application/501-rust-telemetry/services/sender/src/main.rs +++ b/src/500-application/501-rust-telemetry/services/sender/src/main.rs @@ -242,3 +242,72 @@ impl PayloadSerialize for Payload { unimplemented!() } } + +#[cfg(test)] +mod tests { + use super::*; + use azure_iot_operations_protocol::common::payload_serialize::{ + FormatIndicator, PayloadSerialize, + }; + + #[test] + fn serialize_json_object() { + let payload = Payload(serde_json::json!({"temperature": 42})); + let result = payload.serialize(); + assert!(result.is_ok()); + let serialized = result.unwrap(); + assert_eq!(serialized.content_type, "application/json"); + assert_eq!( + serialized.format_indicator, + FormatIndicator::Utf8EncodedCharacterData + ); + let parsed: serde_json::Value = + serde_json::from_slice(&serialized.payload).unwrap(); + assert_eq!(parsed["temperature"], 42); + } + + #[test] + fn serialize_nested_json() { + let payload = Payload(serde_json::json!({ + "sensor": {"id": 1, "readings": [10, 20]} + })); + let result = payload.serialize(); + assert!(result.is_ok()); + let serialized = result.unwrap(); + let parsed: serde_json::Value = + serde_json::from_slice(&serialized.payload).unwrap(); + assert_eq!(parsed["sensor"]["id"], 1); + assert_eq!(parsed["sensor"]["readings"][0], 10); + } + + #[test] + fn serialize_simple_value() { + let payload = Payload(serde_json::json!(99.5)); + let result = payload.serialize(); + assert!(result.is_ok()); + let serialized = result.unwrap(); + let parsed: serde_json::Value = + serde_json::from_slice(&serialized.payload).unwrap(); + assert_eq!(parsed, 99.5); + } + + #[test] + fn serialize_empty_object() { + let payload = Payload(serde_json::json!({})); + let result = payload.serialize(); + assert!(result.is_ok()); + let serialized = result.unwrap(); + let parsed: serde_json::Value = + serde_json::from_slice(&serialized.payload).unwrap(); + assert!(parsed.as_object().unwrap().is_empty()); + } + + #[test] + fn serialize_null_value() { + let payload = Payload(serde_json::Value::Null); + let result = payload.serialize(); + assert!(result.is_ok()); + let serialized = result.unwrap(); + assert_eq!(std::str::from_utf8(&serialized.payload).unwrap(), "null"); + } +} diff --git a/src/500-application/501-rust-telemetry/services/sender/src/otel.rs b/src/500-application/501-rust-telemetry/services/sender/src/otel.rs index 74a034e8..c80193a0 100644 --- a/src/500-application/501-rust-telemetry/services/sender/src/otel.rs +++ b/src/500-application/501-rust-telemetry/services/sender/src/otel.rs @@ -158,3 +158,63 @@ pub fn inject_current_context() -> Vec<(String, String)> { carrier } + +#[cfg(test)] +mod tests { + use super::*; + use opentelemetry::propagation::Injector; + + #[test] + fn injector_set_adds_key_value_pair() { + let mut carrier = Vec::new(); + let mut injector = VecInjector(&mut carrier); + injector.set("traceparent", "00-abc-def-01".to_string()); + assert_eq!(carrier.len(), 1); + assert_eq!(carrier[0].0, "traceparent"); + assert_eq!(carrier[0].1, "00-abc-def-01"); + } + + #[test] + fn injector_set_appends_multiple_pairs() { + let mut carrier = Vec::new(); + let mut injector = VecInjector(&mut carrier); + injector.set("traceparent", "tp-value".to_string()); + injector.set("tracestate", "ts-value".to_string()); + assert_eq!(carrier.len(), 2); + assert_eq!(carrier[0].0, "traceparent"); + assert_eq!(carrier[1].0, "tracestate"); + } + + #[test] + fn injector_set_allows_duplicate_keys() { + let mut carrier = Vec::new(); + let mut injector = VecInjector(&mut carrier); + injector.set("key", "first".to_string()); + injector.set("key", "second".to_string()); + assert_eq!(carrier.len(), 2); + assert_eq!(carrier[0].1, "first"); + assert_eq!(carrier[1].1, "second"); + } + + #[test] + fn injector_set_handles_empty_values() { + let mut carrier = Vec::new(); + let mut injector = VecInjector(&mut carrier); + injector.set("key", String::new()); + assert_eq!(carrier.len(), 1); + assert!(carrier[0].1.is_empty()); + } + + #[test] + fn inject_current_context_returns_empty_for_default_propagator() { + let result = inject_current_context(); + assert!(result.is_empty()); + } + + #[test] + fn inject_current_context_returns_empty_for_w3c_propagator_without_active_span() { + global::set_text_map_propagator(TraceContextPropagator::new()); + let result = inject_current_context(); + assert!(result.is_empty()); + } +} diff --git a/src/500-application/502-rust-http-connector/services/broker/src/error.rs b/src/500-application/502-rust-http-connector/services/broker/src/error.rs index 8db90877..18aaa814 100644 --- a/src/500-application/502-rust-http-connector/services/broker/src/error.rs +++ b/src/500-application/502-rust-http-connector/services/broker/src/error.rs @@ -17,3 +17,62 @@ pub fn validate_json(schema: Value, instance: Value, device_id: &str) -> Result< validate_instance(&compiled, instance, device_id) } + +#[cfg(test)] +mod tests { + use super::*; + + fn simple_schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "temperature": { "type": "number" } + }, + "required": ["temperature"] + }) + } + + #[test] + fn parse_json_schema_valid() { + let schema = parse_json_schema(r#"{"type": "object"}"#); + assert!(schema.is_ok()); + assert_eq!(schema.unwrap()["type"], "object"); + } + + #[test] + fn parse_json_schema_invalid() { + let result = parse_json_schema("not json"); + assert!(result.is_err()); + } + + #[test] + fn validate_json_matching_instance() { + let schema = simple_schema(); + let instance = serde_json::json!({"temperature": 42.5}); + let result = validate_json(schema, instance, "dev-1"); + assert_eq!(result.unwrap(), "Validation successful!"); + } + + #[test] + fn validate_json_missing_required_field() { + let schema = simple_schema(); + let instance = serde_json::json!({}); + let result = validate_json(schema, instance, "dev-1"); + assert!(result.is_err()); + let err_json: Value = serde_json::from_str(&result.unwrap_err()).unwrap(); + assert_eq!(err_json["deviceId"], "dev-1"); + assert_eq!(err_json["errorCode"], "004"); + assert!(err_json["errorMessage"].as_str().unwrap().contains("required")); + } + + #[test] + fn validate_json_wrong_type() { + let schema = simple_schema(); + let instance = serde_json::json!({"temperature": "hot"}); + let result = validate_json(schema, instance, "sensor-5"); + assert!(result.is_err()); + let err_json: Value = serde_json::from_str(&result.unwrap_err()).unwrap(); + assert_eq!(err_json["deviceId"], "sensor-5"); + assert!(err_json["errorMessage"].as_str().unwrap().contains("type")); + } +} diff --git a/src/500-application/502-rust-http-connector/services/broker/src/error_handler.rs b/src/500-application/502-rust-http-connector/services/broker/src/error_handler.rs index d35ec49f..75dc7959 100644 --- a/src/500-application/502-rust-http-connector/services/broker/src/error_handler.rs +++ b/src/500-application/502-rust-http-connector/services/broker/src/error_handler.rs @@ -28,3 +28,57 @@ impl ErrorAlert { serde_json::to_string(&error_alert).unwrap() } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn new_populates_fields() { + let alert = ErrorAlert::new( + "device-1".to_string(), + "ERR001".to_string(), + "something broke".to_string(), + ); + assert_eq!(alert.device_id, "device-1"); + assert_eq!(alert.error_code, "ERR001"); + assert_eq!(alert.error_message, "something broke"); + } + + #[test] + fn generate_alert_produces_valid_json_with_camel_case_keys() { + let json_str = + ErrorAlert::generate_alert("dev-2".to_string(), "004".to_string(), "bad".to_string()); + let v: Value = serde_json::from_str(&json_str).expect("valid JSON"); + assert_eq!(v["deviceId"], "dev-2"); + assert_eq!(v["errorCode"], "004"); + assert_eq!(v["errorMessage"], "bad"); + assert!(v["readingTime"].is_string()); + } + + #[test] + fn reading_time_is_iso8601() { + let json_str = ErrorAlert::generate_alert( + "d".to_string(), + "c".to_string(), + "m".to_string(), + ); + let v: Value = serde_json::from_str(&json_str).unwrap(); + let ts = v["readingTime"].as_str().unwrap(); + ts.parse::>().expect("valid ISO 8601 timestamp"); + } + + #[test] + fn roundtrip_deserialize() { + let json_str = ErrorAlert::generate_alert( + "dev-3".to_string(), + "005".to_string(), + "msg".to_string(), + ); + let alert: ErrorAlert = serde_json::from_str(&json_str).expect("deserializes"); + assert_eq!(alert.device_id, "dev-3"); + assert_eq!(alert.error_code, "005"); + assert_eq!(alert.error_message, "msg"); + } +} diff --git a/src/500-application/502-rust-http-connector/services/broker/src/json_validator.rs b/src/500-application/502-rust-http-connector/services/broker/src/json_validator.rs index 6186be87..e243bb5e 100644 --- a/src/500-application/502-rust-http-connector/services/broker/src/json_validator.rs +++ b/src/500-application/502-rust-http-connector/services/broker/src/json_validator.rs @@ -27,3 +27,84 @@ pub fn validate_instance( Err(alert) } } + +#[cfg(test)] +mod tests { + use super::*; + use jsonschema::Draft; + + fn build_validator(schema_json: &serde_json::Value) -> Validator { + Validator::options() + .with_draft(Draft::Draft7) + .build(schema_json) + .unwrap() + } + + #[test] + fn valid_instance_returns_ok() { + let schema = serde_json::json!({"type": "object"}); + let compiled = build_validator(&schema); + let result = validate_instance(&compiled, serde_json::json!({}), "d1"); + assert_eq!(result.unwrap(), "Validation successful!"); + } + + #[test] + fn invalid_instance_returns_error_alert_json() { + let schema = serde_json::json!({ + "type": "object", + "properties": { "x": { "type": "number" } }, + "required": ["x"] + }); + let compiled = build_validator(&schema); + let result = validate_instance(&compiled, serde_json::json!({}), "sensor-7"); + assert!(result.is_err()); + let alert: serde_json::Value = serde_json::from_str(&result.unwrap_err()).unwrap(); + assert_eq!(alert["deviceId"], "sensor-7"); + assert_eq!(alert["errorCode"], "004"); + assert!(alert["errorMessage"].as_str().unwrap().contains("required")); + } + + #[test] + fn multiple_errors_joined_with_semicolon() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "a": { "type": "number" }, + "b": { "type": "number" } + }, + "required": ["a", "b"] + }); + let compiled = build_validator(&schema); + let result = validate_instance( + &compiled, + serde_json::json!({"a": "wrong", "b": "wrong"}), + "d2", + ); + let msg = serde_json::from_str::(&result.unwrap_err()) + .unwrap()["errorMessage"] + .as_str() + .unwrap() + .to_string(); + assert!(msg.contains("; "), "multiple errors should be semicolon-separated"); + } + + #[test] + fn error_message_includes_instance_path() { + let schema = serde_json::json!({ + "type": "object", + "properties": { "val": { "type": "integer" } } + }); + let compiled = build_validator(&schema); + let result = validate_instance( + &compiled, + serde_json::json!({"val": "text"}), + "d3", + ); + let msg = serde_json::from_str::(&result.unwrap_err()) + .unwrap()["errorMessage"] + .as_str() + .unwrap() + .to_string(); + assert!(msg.contains("Instance path:")); + } +} diff --git a/src/500-application/504-mqtt-otel-trace-exporter/services/mqtt-otel-trace-exporter/src/correlation.rs b/src/500-application/504-mqtt-otel-trace-exporter/services/mqtt-otel-trace-exporter/src/correlation.rs index ff96aa65..843119ed 100644 --- a/src/500-application/504-mqtt-otel-trace-exporter/services/mqtt-otel-trace-exporter/src/correlation.rs +++ b/src/500-application/504-mqtt-otel-trace-exporter/services/mqtt-otel-trace-exporter/src/correlation.rs @@ -149,3 +149,190 @@ fn maybe_generate( )) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_payload_sets_source_and_value() { + let id = CorrelationId::from_payload("abc-123".to_owned()); + assert_eq!(id.value, "abc-123"); + assert!(!id.is_generated()); + assert!(id.fallback_reason().is_none()); + } + + #[test] + fn generated_sets_source_and_reason() { + let id = CorrelationId::generated(CorrelationFallbackReason::EmptyPayload); + assert!(id.is_generated()); + assert_eq!(id.fallback_reason(), Some("empty_payload")); + assert!(!id.value.is_empty()); + } + + #[test] + fn fallback_reason_strings() { + assert_eq!(CorrelationFallbackReason::EmptyPayload.as_str(), "empty_payload"); + assert_eq!(CorrelationFallbackReason::InvalidUtf8.as_str(), "invalid_utf8"); + assert_eq!(CorrelationFallbackReason::InvalidJson.as_str(), "invalid_json"); + assert_eq!(CorrelationFallbackReason::MissingField.as_str(), "missing_field"); + assert_eq!(CorrelationFallbackReason::UnsupportedType.as_str(), "unsupported_type"); + } + + #[test] + fn empty_payload_generates_id() { + let result = correlation_id_from_payload(b"", "id", true).unwrap(); + assert!(result.is_generated()); + assert_eq!(result.fallback_reason(), Some("empty_payload")); + } + + #[test] + fn empty_payload_errors_when_generation_disabled() { + let err = correlation_id_from_payload(b"", "id", false).unwrap_err(); + assert!(err.to_string().contains("empty_payload")); + } + + #[test] + fn invalid_utf8_generates_id() { + let payload: &[u8] = &[0xFF, 0xFE, 0xFD]; + let result = correlation_id_from_payload(payload, "id", true).unwrap(); + assert!(result.is_generated()); + assert_eq!(result.fallback_reason(), Some("invalid_utf8")); + } + + #[test] + fn invalid_utf8_errors_when_generation_disabled() { + let payload: &[u8] = &[0xFF, 0xFE]; + let err = correlation_id_from_payload(payload, "id", false).unwrap_err(); + assert!(err.to_string().contains("invalid_utf8")); + } + + #[test] + fn empty_field_name_returns_error() { + let payload = br#"{"id":"abc"}"#; + let err = correlation_id_from_payload(payload, "", true).unwrap_err(); + assert!(err.to_string().contains("correlation field name cannot be empty")); + } + + #[test] + fn whitespace_only_field_name_returns_error() { + let payload = br#"{"id":"abc"}"#; + let err = correlation_id_from_payload(payload, " ", true).unwrap_err(); + assert!(err.to_string().contains("correlation field name cannot be empty")); + } + + #[test] + fn invalid_json_generates_id() { + let payload = b"not-json"; + let result = correlation_id_from_payload(payload, "id", true).unwrap(); + assert!(result.is_generated()); + assert_eq!(result.fallback_reason(), Some("invalid_json")); + } + + #[test] + fn invalid_json_errors_when_generation_disabled() { + let payload = b"not-json"; + let err = correlation_id_from_payload(payload, "id", false).unwrap_err(); + assert!(err.to_string().contains("invalid_json")); + } + + #[test] + fn missing_field_generates_id() { + let payload = br#"{"other":"value"}"#; + let result = correlation_id_from_payload(payload, "id", true).unwrap(); + assert!(result.is_generated()); + assert_eq!(result.fallback_reason(), Some("missing_field")); + } + + #[test] + fn null_field_generates_id() { + let payload = br#"{"id":null}"#; + let result = correlation_id_from_payload(payload, "id", true).unwrap(); + assert!(result.is_generated()); + assert_eq!(result.fallback_reason(), Some("missing_field")); + } + + #[test] + fn extracts_string_value() { + let payload = br#"{"id":"trace-42"}"#; + let result = correlation_id_from_payload(payload, "id", true).unwrap(); + assert!(!result.is_generated()); + assert_eq!(result.value, "trace-42"); + } + + #[test] + fn extracts_bool_value() { + let payload = br#"{"flag":true}"#; + let result = correlation_id_from_payload(payload, "flag", true).unwrap(); + assert!(!result.is_generated()); + assert_eq!(result.value, "true"); + } + + #[test] + fn extracts_integer_value() { + let payload = br#"{"seq":99}"#; + let result = correlation_id_from_payload(payload, "seq", true).unwrap(); + assert!(!result.is_generated()); + assert_eq!(result.value, "99"); + } + + #[test] + fn extracts_float_value() { + let payload = br#"{"temp":23.5}"#; + let result = correlation_id_from_payload(payload, "temp", true).unwrap(); + assert!(!result.is_generated()); + assert_eq!(result.value, "23.5"); + } + + #[test] + fn unsupported_type_array_generates_id() { + let payload = br#"{"data":[1,2,3]}"#; + let result = correlation_id_from_payload(payload, "data", true).unwrap(); + assert!(result.is_generated()); + assert_eq!(result.fallback_reason(), Some("unsupported_type")); + } + + #[test] + fn unsupported_type_object_generates_id() { + let payload = br#"{"data":{"nested":"obj"}}"#; + let result = correlation_id_from_payload(payload, "data", true).unwrap(); + assert!(result.is_generated()); + assert_eq!(result.fallback_reason(), Some("unsupported_type")); + } + + #[test] + fn nested_field_path() { + let payload = br#"{"meta":{"trace":{"id":"deep-123"}}}"#; + let result = correlation_id_from_payload(payload, "meta.trace.id", true).unwrap(); + assert!(!result.is_generated()); + assert_eq!(result.value, "deep-123"); + } + + #[test] + fn nested_field_missing_intermediate() { + let payload = br#"{"meta":{}}"#; + let result = correlation_id_from_payload(payload, "meta.trace.id", true).unwrap(); + assert!(result.is_generated()); + assert_eq!(result.fallback_reason(), Some("missing_field")); + } + + #[test] + fn locate_value_empty_path_returns_none() { + let json: Value = serde_json::from_str(r#"{"a":1}"#).unwrap(); + assert!(locate_value(&json, &[]).is_none()); + } + + #[test] + fn locate_value_single_segment() { + let json: Value = serde_json::from_str(r#"{"key":"val"}"#).unwrap(); + let found = locate_value(&json, &["key"]).unwrap(); + assert_eq!(found.as_str(), Some("val")); + } + + #[test] + fn locate_value_multi_segment() { + let json: Value = serde_json::from_str(r#"{"a":{"b":{"c":42}}}"#).unwrap(); + let found = locate_value(&json, &["a", "b", "c"]).unwrap(); + assert_eq!(found.as_i64(), Some(42)); + } +} diff --git a/src/500-application/505-akri-rest-http-connector/services/sensor-simulator/test_models.py b/src/500-application/505-akri-rest-http-connector/services/sensor-simulator/test_models.py new file mode 100644 index 00000000..c5a39d93 --- /dev/null +++ b/src/500-application/505-akri-rest-http-connector/services/sensor-simulator/test_models.py @@ -0,0 +1,151 @@ +"""Unit tests for Pydantic models in the sensor simulator.""" + +import pytest +from pydantic import ValidationError + +from models import ( + DataType, + FieldConfig, + FieldsArrayResponse, + FieldValueResponse, + SimulatorMetadata, +) + + +class TestFieldConfigValidation: + def test_valid_integer_field(self): + cfg = FieldConfig(name="temp", data_type=DataType.INTEGER, + min_value=0, max_value=100) + assert cfg.name == "temp" + assert cfg.data_type == DataType.INTEGER + + def test_valid_float_field(self): + cfg = FieldConfig( + name="pressure", data_type=DataType.FLOAT, min_value=0.0, max_value=1.0) + assert cfg.min_value == 0.0 + + def test_valid_string_field_with_options(self): + cfg = FieldConfig(name="status", data_type=DataType.STRING, + string_options=["on", "off"]) + assert cfg.string_options == ["on", "off"] + + def test_valid_boolean_field(self): + cfg = FieldConfig(name="active", data_type=DataType.BOOLEAN) + assert cfg.data_type == DataType.BOOLEAN + + def test_string_type_requires_string_options(self): + with pytest.raises(ValidationError, match="string_options required"): + FieldConfig(name="tag", data_type=DataType.STRING) + + def test_string_type_rejects_empty_options(self): + with pytest.raises(ValidationError, match="string_options required"): + FieldConfig(name="tag", data_type=DataType.STRING, + string_options=[]) + + def test_max_less_than_min_rejected(self): + with pytest.raises(ValidationError, match="max_value must be greater"): + FieldConfig(name="x", data_type=DataType.INTEGER, + min_value=10, max_value=5) + + def test_equal_min_max_accepted(self): + cfg = FieldConfig(name="fixed", data_type=DataType.FLOAT, + min_value=5.0, max_value=5.0) + assert cfg.min_value == cfg.max_value + + def test_numeric_without_range_accepted(self): + cfg = FieldConfig(name="unbounded", data_type=DataType.INTEGER) + assert cfg.min_value is None + assert cfg.max_value is None + + def test_metadata_defaults_to_empty_dict(self): + cfg = FieldConfig(name="m", data_type=DataType.BOOLEAN) + assert cfg.metadata == {} + + +class TestFieldValueResponseValidation: + def _base(self, **overrides): + defaults = { + "field_id": "f1", + "name": "temp", + "data_type": DataType.INTEGER, + "value": 42, + "units": "C", + "timestamp": "2025-01-01T00:00:00Z", + } + defaults.update(overrides) + return FieldValueResponse(**defaults) + + def test_valid_integer_value(self): + resp = self._base() + assert resp.value == 42 + + def test_valid_float_value(self): + resp = self._base(data_type=DataType.FLOAT, value=3.14) + assert resp.value == 3.14 + + def test_int_accepted_as_float(self): + resp = self._base(data_type=DataType.FLOAT, value=7) + assert resp.value == 7 + + def test_valid_string_value(self): + resp = self._base(data_type=DataType.STRING, value="ok") + assert resp.value == "ok" + + def test_valid_boolean_value(self): + resp = self._base(data_type=DataType.BOOLEAN, value=True) + assert resp.value is True + + def test_rejects_string_for_integer_type(self): + with pytest.raises(ValidationError, match="Expected int for INTEGER"): + self._base(data_type=DataType.INTEGER, value="not_int") + + def test_rejects_bool_for_integer_type(self): + with pytest.raises(ValidationError, match="Expected int for INTEGER"): + self._base(data_type=DataType.INTEGER, value=True) + + def test_rejects_bool_for_float_type(self): + with pytest.raises(ValidationError, match="Expected float for FLOAT"): + self._base(data_type=DataType.FLOAT, value=False) + + def test_rejects_int_for_boolean_type(self): + with pytest.raises(ValidationError, match="Expected bool for BOOLEAN"): + self._base(data_type=DataType.BOOLEAN, value=1) + + def test_rejects_int_for_string_type(self): + with pytest.raises(ValidationError, match="Expected str for STRING"): + self._base(data_type=DataType.STRING, value=123) + + def test_quality_defaults_to_good(self): + resp = self._base() + assert resp.quality == "good" + + +class TestFieldsArrayResponseValidation: + def _field(self, fid="f1"): + return FieldValueResponse( + field_id=fid, + name="x", + data_type=DataType.INTEGER, + value=1, + units="", + timestamp="2025-01-01T00:00:00Z", + ) + + def test_valid_count_matches_fields(self): + resp = FieldsArrayResponse(fields=[self._field()], count=1) + assert resp.count == 1 + + def test_mismatched_count_rejected(self): + with pytest.raises(ValidationError, match="does not match actual length"): + FieldsArrayResponse(fields=[self._field()], count=5) + + def test_empty_list_with_zero_count(self): + resp = FieldsArrayResponse(fields=[], count=0) + assert resp.count == 0 + + +class TestSimulatorMetadata: + def test_defaults(self): + meta = SimulatorMetadata() + assert meta.device_id == "field-sensor-simulator-001" + assert meta.version == "2.0.0" diff --git a/src/500-application/506-ros2-connector/services/ros2-connector/src/message_types/test_base_handler.py b/src/500-application/506-ros2-connector/services/ros2-connector/src/message_types/test_base_handler.py new file mode 100644 index 00000000..2208f2f7 --- /dev/null +++ b/src/500-application/506-ros2-connector/services/ros2-connector/src/message_types/test_base_handler.py @@ -0,0 +1,115 @@ +"""Unit tests for BaseMessageHandler pure methods.""" + +import json +import time +from unittest.mock import MagicMock + +import pytest + +from base_handler import BaseMessageHandler + + +class ConcreteHandler(BaseMessageHandler): + """Minimal concrete subclass for testing the abstract base.""" + + message_type = "test_msgs/msg/Test" + message_class = None + handler_type = "test" + + def handle_message(self, msg, topic_name, logger, mqtt_publisher): + data = self.create_base_mqtt_data(topic_name) + data["payload"] = str(msg) + self.publish_to_mqtt(topic_name, data, mqtt_publisher) + + +@pytest.fixture +def handler(): + return ConcreteHandler(mqtt_topic_prefix="robot") + + +class TestFormatMqttTopic: + def test_basic_topic_formatting(self, handler): + result = handler.format_mqtt_topic("/cmd_vel") + assert result == "robot/test_cmd_vel" + + def test_slashes_replaced_with_underscores(self, handler): + result = handler.format_mqtt_topic("/a/b/c") + assert result == "robot/test_a_b_c" + + def test_empty_topic(self, handler): + result = handler.format_mqtt_topic("") + assert result == "robot/test" + + def test_custom_prefix(self): + h = ConcreteHandler(mqtt_topic_prefix="edge/device") + result = h.format_mqtt_topic("/sensor") + assert result == "edge/device/test_sensor" + + +class TestCreateBaseMqttData: + def test_contains_required_keys(self, handler): + handler.message_counts["/topic"] = 5 + data = handler.create_base_mqtt_data("/topic") + assert set(data.keys()) == {"topic", "type", "count", "timestamp"} + + def test_topic_preserved(self, handler): + handler.message_counts["/vel"] = 0 + data = handler.create_base_mqtt_data("/vel") + assert data["topic"] == "/vel" + + def test_handler_type_set(self, handler): + data = handler.create_base_mqtt_data("/x") + assert data["type"] == "test" + + def test_count_reflects_message_counts(self, handler): + handler.message_counts["/t"] = 42 + data = handler.create_base_mqtt_data("/t") + assert data["count"] == 42 + + def test_count_defaults_to_zero_for_unknown_topic(self, handler): + data = handler.create_base_mqtt_data("/never_seen") + assert data["count"] == 0 + + def test_timestamp_is_recent(self, handler): + before = time.time() + data = handler.create_base_mqtt_data("/t") + after = time.time() + assert before <= data["timestamp"] <= after + + +class TestCreateCallback: + def test_callback_increments_count(self, handler): + cb = handler.create_callback("/topic", MagicMock(), MagicMock()) + assert handler.message_counts["/topic"] == 0 + cb("msg1") + assert handler.message_counts["/topic"] == 1 + cb("msg2") + assert handler.message_counts["/topic"] == 2 + + def test_callback_calls_handle_message(self, handler): + publisher = MagicMock() + cb = handler.create_callback("/topic", MagicMock(), publisher) + cb("hello") + publisher.assert_called_once() + + def test_independent_topic_counters(self, handler): + handler.create_callback("/a", MagicMock(), MagicMock())("/m") + handler.create_callback("/b", MagicMock(), MagicMock()) + assert handler.message_counts["/a"] == 1 + assert handler.message_counts["/b"] == 0 + + +class TestPublishToMqtt: + def test_publishes_to_formatted_topic(self, handler): + publisher = MagicMock() + handler.publish_to_mqtt("/cmd_vel", {"key": "val"}, publisher) + topic_arg = publisher.call_args[0][0] + assert topic_arg == "robot/test_cmd_vel" + + def test_publishes_valid_json(self, handler): + publisher = MagicMock() + data = {"topic": "/x", "type": "test", "count": 0, "timestamp": 1.0} + handler.publish_to_mqtt("/x", data, publisher) + json_arg = publisher.call_args[0][1] + parsed = json.loads(json_arg) + assert parsed == data diff --git a/src/500-application/509-sse-connector/services/sse-server/test_events_simulator.py b/src/500-application/509-sse-connector/services/sse-server/test_events_simulator.py new file mode 100644 index 00000000..ece89367 --- /dev/null +++ b/src/500-application/509-sse-connector/services/sse-server/test_events_simulator.py @@ -0,0 +1,117 @@ +"""Unit tests for AnalyticsEventSimulator event generation methods.""" + +import pytest + +from events_simulator import AnalyticsEventSimulator + + +@pytest.fixture +def simulator(): + return AnalyticsEventSimulator( + device_id="test-cam-001", + heartbeat_interval=5, + alert_probability=0.5, + ) + + +FIXED_TS = 1_700_000_000_000 + + +class TestGenerateHeartbeatEvent: + def test_returns_heartbeat_type(self, simulator): + event = simulator.generate_heartbeat_event(FIXED_TS) + assert event["type"] == "HEARTBEAT" + + def test_returns_correct_timestamp(self, simulator): + event = simulator.generate_heartbeat_event(FIXED_TS) + assert event["timestamp"] == FIXED_TS + + def test_contains_only_expected_keys(self, simulator): + event = simulator.generate_heartbeat_event(FIXED_TS) + assert set(event.keys()) == {"type", "timestamp"} + + +class TestGenerateAnalyticsEnabledEvent: + def test_returns_enabled_type(self, simulator): + event = simulator.generate_analytics_enabled_event(FIXED_TS) + assert event["type"] == "ANALYTICS_ENABLED" + + def test_includes_analytics_type(self, simulator): + event = simulator.generate_analytics_enabled_event(FIXED_TS) + assert event["analytics_type"] == "leak detection" + + +class TestGenerateAnalyticsDisabledEvent: + def test_returns_disabled_type(self, simulator): + event = simulator.generate_analytics_disabled_event(FIXED_TS) + assert event["type"] == "ANALYTICS_DISABLED" + + def test_includes_analytics_type(self, simulator): + event = simulator.generate_analytics_disabled_event(FIXED_TS) + assert event["analytics_type"] == "leak detection" + + +class TestGenerateBasicAlertEvent: + def test_returns_alert_type(self, simulator): + event = simulator.generate_basic_alert_event(FIXED_TS) + assert event["type"] == "ALERT" + + def test_message_is_leak(self, simulator): + event = simulator.generate_basic_alert_event(FIXED_TS) + assert event["message"] == "leak" + + def test_event_id_increments(self, simulator): + first = simulator.generate_basic_alert_event(FIXED_TS) + second = simulator.generate_basic_alert_event(FIXED_TS) + assert second["event_id"] == first["event_id"] + 1 + + def test_event_id_starts_above_initial_counter(self, simulator): + event = simulator.generate_basic_alert_event(FIXED_TS) + assert event["event_id"] == 1001 + + +class TestGenerateDetailedAlertEvent: + def test_returns_alert_dlqc_type(self, simulator): + event = simulator.generate_detailed_alert_event(FIXED_TS) + assert event["type"] == "ALERT_DLQC" + + def test_contains_location_data(self, simulator): + event = simulator.generate_detailed_alert_event(FIXED_TS) + assert "leak_location" in event + assert "longitude" in event["leak_location"] + assert "latitude" in event["leak_location"] + + def test_contains_camera_metadata(self, simulator): + event = simulator.generate_detailed_alert_event(FIXED_TS) + assert "camera_id" in event + assert "camera_location" in event + assert "camera_orientation" in event + assert "depression_angle" in event + + def test_contains_environmental_data(self, simulator): + event = simulator.generate_detailed_alert_event(FIXED_TS) + assert "wind_speed" in event + assert "temperature" in event + assert "humidity" in event + + def test_contains_flow_measurements(self, simulator): + event = simulator.generate_detailed_alert_event(FIXED_TS) + assert "flow_rate" in event + assert event["unit"] == "g/s" + assert "mass" in event + assert event["mass_unit"] == "kg" + + def test_event_id_increments_across_types(self, simulator): + basic = simulator.generate_basic_alert_event(FIXED_TS) + detailed = simulator.generate_detailed_alert_event(FIXED_TS) + assert detailed["event_id"] == basic["event_id"] + 1 + + def test_latitude_in_valid_range(self, simulator): + for _ in range(20): + event = simulator.generate_detailed_alert_event(FIXED_TS) + assert -90 <= event["leak_location"]["latitude"] <= 90 + + def test_confidence_level_in_valid_range(self, simulator): + for _ in range(20): + event = simulator.generate_detailed_alert_event(FIXED_TS) + assert 20 <= event["confidence_level"] <= 95 diff --git a/src/500-application/510-onvif-connector/services/onvif-camera-simulator/test_onvif_camera.py b/src/500-application/510-onvif-connector/services/onvif-camera-simulator/test_onvif_camera.py new file mode 100644 index 00000000..5be4aee7 --- /dev/null +++ b/src/500-application/510-onvif-connector/services/onvif-camera-simulator/test_onvif_camera.py @@ -0,0 +1,176 @@ +"""Unit tests for ONVIFCameraSimulator pure methods.""" + +import os + +import pytest +from lxml import etree + +from onvif_camera import ONVIFCameraSimulator + + +@pytest.fixture +def camera(monkeypatch): + monkeypatch.delenv("ONVIF_DEVICE_ID", raising=False) + monkeypatch.delenv("ONVIF_HOST", raising=False) + monkeypatch.delenv("ONVIF_PORT", raising=False) + return ONVIFCameraSimulator() + + +NS = { + "soap": "http://www.w3.org/2003/05/soap-envelope", + "tds": "http://www.onvif.org/ver10/device/wsdl", + "trt": "http://www.onvif.org/ver10/media/wsdl", + "tptz": "http://www.onvif.org/ver20/ptz/wsdl", + "timg": "http://www.onvif.org/ver20/imaging/wsdl", + "tt": "http://www.onvif.org/ver10/schema", +} + + +def _parse_soap(response) -> etree._Element: + return etree.fromstring(response.text.encode("utf-8")) + + +class TestCreateSoapFault: + def test_returns_500_status(self, camera): + resp = camera._create_soap_fault("something broke") + assert resp.status == 500 + + def test_content_type_is_soap_xml(self, camera): + resp = camera._create_soap_fault("err") + assert resp.content_type == "application/soap+xml" + + def test_fault_string_in_body(self, camera): + resp = camera._create_soap_fault("kaboom") + root = _parse_soap(resp) + text = root.find(".//soap:Body/soap:Fault/soap:Reason/soap:Text", NS) + assert text is not None + assert text.text == "kaboom" + + +class TestHandleGetDeviceInformation: + def test_contains_manufacturer(self, camera): + root = _parse_soap(camera._handle_get_device_information()) + el = root.find(".//tds:Manufacturer", NS) + assert el is not None + assert el.text == "Edge AI Simulator" + + def test_contains_model(self, camera): + root = _parse_soap(camera._handle_get_device_information()) + el = root.find(".//tds:Model", NS) + assert el is not None + assert el.text == "ONVIF-PTZ-4K" + + def test_contains_firmware_version(self, camera): + root = _parse_soap(camera._handle_get_device_information()) + el = root.find(".//tds:FirmwareVersion", NS) + assert el is not None + assert el.text == "1.0.0" + + def test_contains_serial_number(self, camera): + root = _parse_soap(camera._handle_get_device_information()) + el = root.find(".//tds:SerialNumber", NS) + assert el is not None + assert len(el.text) == 8 + + def test_contains_hardware_id(self, camera): + root = _parse_soap(camera._handle_get_device_information()) + el = root.find(".//tds:HardwareId", NS) + assert el is not None + assert el.text == camera.device_id + + +class TestHandleGetCapabilities: + def test_includes_media_capability(self, camera): + root = _parse_soap(camera._handle_get_capabilities()) + media = root.find(".//tt:Media", NS) + assert media is not None + + def test_includes_ptz_capability(self, camera): + root = _parse_soap(camera._handle_get_capabilities()) + ptz = root.find(".//tt:PTZ", NS) + assert ptz is not None + + def test_includes_events_capability(self, camera): + root = _parse_soap(camera._handle_get_capabilities()) + events = root.find(".//tt:Events", NS) + assert events is not None + + def test_includes_imaging_capability(self, camera): + root = _parse_soap(camera._handle_get_capabilities()) + imaging = root.find(".//tt:Imaging", NS) + assert imaging is not None + + +class TestHandleGetProfiles: + def test_returns_three_profiles(self, camera): + root = _parse_soap(camera._handle_get_profiles()) + profiles = root.findall(".//trt:Profiles", NS) + assert len(profiles) == 3 + + def test_profile_tokens_match(self, camera): + root = _parse_soap(camera._handle_get_profiles()) + tokens = {p.get("token") for p in root.findall(".//trt:Profiles", NS)} + assert tokens == {"profile_s_h264", "profile_s_jpeg", "profile_t_h265"} + + def test_h264_profile_resolution(self, camera): + root = _parse_soap(camera._handle_get_profiles()) + for profile in root.findall(".//trt:Profiles", NS): + if profile.get("token") == "profile_s_h264": + w = profile.find(".//tt:Width", NS) + h = profile.find(".//tt:Height", NS) + assert w.text == "1920" + assert h.text == "1080" + + +class TestHandlePtzCommand: + def _make_ptz_element(self, pan, tilt, zoom): + ns_tt = "http://www.onvif.org/ver10/schema" + root = etree.Element("AbsoluteMove") + position = etree.SubElement(root, f"{{{ns_tt}}}Position") + pt = etree.SubElement(position, f"{{{ns_tt}}}PanTilt") + pt.set("x", str(pan)) + pt.set("y", str(tilt)) + z = etree.SubElement(position, f"{{{ns_tt}}}Zoom") + z.set("x", str(zoom)) + return root + + def test_updates_pan_position(self, camera): + elem = self._make_ptz_element(45.0, 0.0, 1.0) + camera._handle_ptz_command(elem) + assert camera.ptz_position["pan"] == 45.0 + + def test_updates_tilt_position(self, camera): + elem = self._make_ptz_element(0.0, -30.0, 1.0) + camera._handle_ptz_command(elem) + assert camera.ptz_position["tilt"] == -30.0 + + def test_updates_zoom_position(self, camera): + elem = self._make_ptz_element(0.0, 0.0, 5.0) + camera._handle_ptz_command(elem) + assert camera.ptz_position["zoom"] == 5.0 + + def test_returns_soap_response(self, camera): + elem = self._make_ptz_element(0.0, 0.0, 1.0) + resp = camera._handle_ptz_command(elem) + assert resp.content_type == "application/soap+xml" + root = _parse_soap(resp) + assert root.find(".//tptz:MoveResponse", NS) is not None + + +class TestHandleGetImagingSettings: + def test_contains_brightness(self, camera): + root = _parse_soap(camera._handle_get_imaging_settings()) + el = root.find(".//tt:Brightness", NS) + assert el is not None + assert float(el.text) == 50.0 + + def test_contains_contrast(self, camera): + root = _parse_soap(camera._handle_get_imaging_settings()) + el = root.find(".//tt:Contrast", NS) + assert float(el.text) == 50.0 + + def test_reflects_updated_settings(self, camera): + camera.imaging_settings["brightness"] = 75.0 + root = _parse_soap(camera._handle_get_imaging_settings()) + el = root.find(".//tt:Brightness", NS) + assert float(el.text) == 75.0