From 05eb31e11765bf110ba97c82d3ee432b27139c56 Mon Sep 17 00:00:00 2001 From: nutcas3 Date: Sat, 25 Apr 2026 03:17:21 +0300 Subject: [PATCH] feat: Add comprehensive unit tests for charging engine components - Add credit_management_tests.rs with tests for SubscriberAccount, UsageEvent, UsageType, AccountStatus, and integer arithmetic operations - Add error_tests.rs with tests for ChargingError Display implementation, cloning, and log_error function - Add rating_billing_tests.rs with tests for ChargingRule variants, as_str conversion, billing period parsing, timestamp filtering, and cost calculations - Add validation_tests.rs with tests for validate_bytes, validate --- .../tests/credit_management_tests.rs | 135 +++++++++++++++++ apps/charging-engine/tests/error_tests.rs | 51 +++++++ .../tests/rating_billing_tests.rs | 142 ++++++++++++++++++ .../charging-engine/tests/validation_tests.rs | 127 ++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 apps/charging-engine/tests/credit_management_tests.rs create mode 100644 apps/charging-engine/tests/error_tests.rs create mode 100644 apps/charging-engine/tests/rating_billing_tests.rs create mode 100644 apps/charging-engine/tests/validation_tests.rs diff --git a/apps/charging-engine/tests/credit_management_tests.rs b/apps/charging-engine/tests/credit_management_tests.rs new file mode 100644 index 0000000..4cd0a73 --- /dev/null +++ b/apps/charging-engine/tests/credit_management_tests.rs @@ -0,0 +1,135 @@ +use charging_engine::charging::types::{SubscriberAccount, AccountStatus, UsageEvent, UsageType}; +use chrono::Utc; + +#[test] +fn test_subscriber_account_creation() { + let account = SubscriberAccount { + imsi: "123456789012345".to_string(), + balance: 1000, + data_limit: 1_000_000_000, + data_used: 0, + voice_limit: 1000, + voice_used: 0, + sms_limit: 100, + sms_used: 0, + status: AccountStatus::Active, + last_updated: Utc::now(), + }; + + assert_eq!(account.imsi, "123456789012345"); + assert_eq!(account.balance, 1000); + assert_eq!(account.data_limit, 1_000_000_000); + assert!(matches!(account.status, AccountStatus::Active)); +} + +#[test] +fn test_usage_event_creation() { + let event = UsageEvent { + imsi: "123456789012345".to_string(), + session_id: "session-123".to_string(), + usage_type: UsageType::Data, + volume: 1_000_000, + timestamp: Utc::now(), + rate: 0.01, + cost: 10.0, + }; + + assert_eq!(event.imsi, "123456789012345"); + assert_eq!(event.volume, 1_000_000); + assert!(matches!(event.usage_type, UsageType::Data)); + assert_eq!(event.cost, 10.0); +} + +#[test] +fn test_usage_type_data() { + let data_type = UsageType::Data; + let voice_type = UsageType::Voice; + let sms_type = UsageType::SMS; + + assert!(matches!(data_type, UsageType::Data)); + assert!(matches!(voice_type, UsageType::Voice)); + assert!(matches!(sms_type, UsageType::SMS)); +} + +#[test] +fn test_account_status_variants() { + let active = AccountStatus::Active; + let suspended = AccountStatus::Suspended; + let terminated = AccountStatus::Terminated; + let blocked = AccountStatus::Blocked; + + assert!(matches!(active, AccountStatus::Active)); + assert!(matches!(suspended, AccountStatus::Suspended)); + assert!(matches!(terminated, AccountStatus::Terminated)); + assert!(matches!(blocked, AccountStatus::Blocked)); +} + +#[test] +fn test_checked_add_no_overflow() { + let value: u64 = u64::MAX - 100; + let result = value.checked_add(100); + assert!(result.is_some()); + assert_eq!(result.unwrap(), u64::MAX); +} + +#[test] +fn test_checked_add_overflow() { + let value: u64 = u64::MAX; + let result = value.checked_add(1); + assert!(result.is_none()); +} + +#[test] +fn test_checked_sub_no_underflow() { + let value: u64 = 100; + let result = value.checked_sub(50); + assert!(result.is_some()); + assert_eq!(result.unwrap(), 50); +} + +#[test] +fn test_checked_sub_underflow() { + let value: u64 = 10; + let result = value.checked_sub(20); + assert!(result.is_none()); +} + +#[test] +fn test_integer_arithmetic_bytes_to_mb() { + let bytes: u64 = 2_097_152; // 2 MB + let mb = bytes / 1_048_576; + let remainder = bytes % 1_048_576; + + assert_eq!(mb, 2); + assert_eq!(remainder, 0); +} + +#[test] +fn test_integer_arithmetic_bytes_to_mb_with_remainder() { + let bytes: u64 = 1_500_000; // 1.43 MB + let mb = bytes / 1_048_576; + let remainder = bytes % 1_048_576; + + assert_eq!(mb, 1); + assert_eq!(remainder, 451_424); +} + +#[test] +fn test_integer_arithmetic_seconds_to_minutes() { + let seconds: u64 = 120; // 2 minutes + let minutes = seconds / 60; + let remainder = seconds % 60; + + assert_eq!(minutes, 2); + assert_eq!(remainder, 0); +} + +#[test] +fn test_integer_arithmetic_seconds_to_minutes_with_remainder() { + let seconds: u64 = 90; // 1.5 minutes + let minutes = seconds / 60; + let remainder = seconds % 60; + + assert_eq!(minutes, 1); + assert_eq!(remainder, 30); +} diff --git a/apps/charging-engine/tests/error_tests.rs b/apps/charging-engine/tests/error_tests.rs new file mode 100644 index 0000000..2e37aff --- /dev/null +++ b/apps/charging-engine/tests/error_tests.rs @@ -0,0 +1,51 @@ +use charging_engine::errors::{ChargingError, log_error}; + +#[test] +fn test_charging_error_display() { + let err = ChargingError::RedisConnection("connection failed".to_string()); + assert_eq!(err.to_string(), "Redis connection error: connection failed"); + + let err = ChargingError::RedisOperation("operation failed".to_string()); + assert_eq!(err.to_string(), "Redis operation error: operation failed"); + + let err = ChargingError::DatabaseError("query failed".to_string()); + assert_eq!(err.to_string(), "Database error: query failed"); + + let err = ChargingError::SubscriberNotFound("12345".to_string()); + assert_eq!(err.to_string(), "Subscriber not found: 12345"); + + let err = ChargingError::RatingPlanNotFound("basic".to_string()); + assert_eq!(err.to_string(), "Rating plan not found: basic"); + + let err = ChargingError::InsufficientCredit { available: 100, requested: 200 }; + assert_eq!(err.to_string(), "Insufficient credit: available=100, requested=200"); + + let err = ChargingError::UsageBlocked("user blocked".to_string()); + assert_eq!(err.to_string(), "Usage blocked: user blocked"); + + let err = ChargingError::InvalidInput("bad input".to_string()); + assert_eq!(err.to_string(), "Invalid input: bad input"); + + let err = ChargingError::SerializationError("json error".to_string()); + assert_eq!(err.to_string(), "Serialization error: json error"); + + let err = ChargingError::InternalError("internal error".to_string()); + assert_eq!(err.to_string(), "Internal error: internal error"); +} + +#[test] +fn test_charging_error_clone() { + let err1 = ChargingError::RedisConnection("test".to_string()); + let err2 = err1.clone(); + assert_eq!(err1.to_string(), err2.to_string()); +} + +#[test] +fn test_log_error() { + // This test just ensures log_error doesn't panic + log_error(&ChargingError::RedisConnection("test".to_string())); + log_error(&ChargingError::DatabaseError("test".to_string())); + log_error(&ChargingError::SubscriberNotFound("123".to_string())); + log_error(&ChargingError::InsufficientCredit { available: 100, requested: 200 }); + log_error(&ChargingError::InvalidInput("test".to_string())); +} diff --git a/apps/charging-engine/tests/rating_billing_tests.rs b/apps/charging-engine/tests/rating_billing_tests.rs new file mode 100644 index 0000000..810c681 --- /dev/null +++ b/apps/charging-engine/tests/rating_billing_tests.rs @@ -0,0 +1,142 @@ +use charging_engine::charging::types::{ChargingRule, UsageEvent, UsageType}; +use chrono::{Utc, Datelike}; + +#[test] +fn test_charging_rule_variants() { + let allowed = ChargingRule::Allowed; + let insufficient_credit = ChargingRule::InsufficientCredit; + let data_limit = ChargingRule::DataLimitExceeded; + let voice_limit = ChargingRule::VoiceLimitExceeded; + let sms_limit = ChargingRule::SmsLimitExceeded; + let user_blocked = ChargingRule::UserBlocked; + let blocked = ChargingRule::Blocked; + + assert!(matches!(allowed, ChargingRule::Allowed)); + assert!(matches!(insufficient_credit, ChargingRule::InsufficientCredit)); + assert!(matches!(data_limit, ChargingRule::DataLimitExceeded)); + assert!(matches!(voice_limit, ChargingRule::VoiceLimitExceeded)); + assert!(matches!(sms_limit, ChargingRule::SmsLimitExceeded)); + assert!(matches!(user_blocked, ChargingRule::UserBlocked)); + assert!(matches!(blocked, ChargingRule::Blocked)); +} + +#[test] +fn test_charging_rule_as_str() { + assert_eq!(ChargingRule::Allowed.as_str(), "ALLOWED"); + assert_eq!(ChargingRule::InsufficientCredit.as_str(), "INSUFFICIENT_CREDIT"); + assert_eq!(ChargingRule::DataLimitExceeded.as_str(), "DATA_LIMIT_EXCEEDED"); + assert_eq!(ChargingRule::VoiceLimitExceeded.as_str(), "VOICE_LIMIT_EXCEEDED"); + assert_eq!(ChargingRule::SmsLimitExceeded.as_str(), "SMS_LIMIT_EXCEEDED"); + assert_eq!(ChargingRule::UserBlocked.as_str(), "USER_BLOCKED"); + assert_eq!(ChargingRule::Blocked.as_str(), "BLOCKED"); +} + +#[test] +fn test_charging_rule_equality() { + assert_eq!(ChargingRule::Allowed, ChargingRule::Allowed); + assert_eq!(ChargingRule::InsufficientCredit, ChargingRule::InsufficientCredit); + assert_ne!(ChargingRule::Allowed, ChargingRule::Blocked); +} + +#[test] +fn test_charging_rule_clone() { + let rule1 = ChargingRule::DataLimitExceeded; + let rule2 = rule1.clone(); + assert_eq!(rule1, rule2); +} + +#[test] +fn test_billing_period_parsing() { + let period = "2024-01"; + let parts: Vec<&str> = period.split('-').collect(); + assert_eq!(parts.len(), 2); + + let year = parts[0].parse::().unwrap(); + let month = parts.get(1).and_then(|m| m.parse::().ok()).unwrap(); + + assert_eq!(year, 2024); + assert_eq!(month, 1); +} + +#[test] +fn test_billing_period_parsing_invalid() { + let period = "invalid"; + let parts: Vec<&str> = period.split('-').collect(); + let year = parts[0].parse::().ok(); + assert!(year.is_none()); +} + +#[test] +fn test_usage_event_timestamp_filtering() { + let event = UsageEvent { + imsi: "123456789012345".to_string(), + session_id: "session-123".to_string(), + usage_type: UsageType::Data, + volume: 1_000_000, + timestamp: Utc::now(), + rate: 0.01, + cost: 10.0, + }; + + let current_year = event.timestamp.year(); + let current_month = event.timestamp.month(); + + assert!(current_year > 2020); + assert!(current_month >= 1 && current_month <= 12); +} + +#[test] +fn test_cost_calculation_data() { + let volume_bytes: u64 = 2_097_152; // 2 MB + let data_rate: f64 = 0.01; + + let data_mb = volume_bytes / 1_048_576; + let remainder_bytes = volume_bytes % 1_048_576; + + let cost_full_mb = data_mb as f64 * data_rate; + let cost_partial = (remainder_bytes as f64 / 1_048_576.0) * data_rate; + let total_cost = cost_full_mb + cost_partial; + + assert_eq!(data_mb, 2); + assert_eq!(cost_full_mb, 0.02); + assert!(total_cost >= 0.02); +} + +#[test] +fn test_cost_calculation_voice() { + let volume_seconds: u64 = 120; // 2 minutes + let voice_rate: f64 = 0.05; + + let voice_minutes = volume_seconds / 60; + let remainder_seconds = volume_seconds % 60; + + let cost_full_min = voice_minutes as f64 * voice_rate; + let cost_partial = (remainder_seconds as f64 / 60.0) * voice_rate; + let total_cost = cost_full_min + cost_partial; + + assert_eq!(voice_minutes, 2); + assert_eq!(cost_full_min, 0.10); + assert!(total_cost >= 0.10); +} + +#[test] +fn test_cost_calculation_sms() { + let volume: u64 = 10; + let sms_rate: f64 = 0.10; + + let cost = volume as f64 * sms_rate; + + assert_eq!(cost, 1.0); +} + +#[test] +fn test_rate_calculation() { + let cost: f64 = 10.0; + let volume: u64 = 1_000_000; + + if volume > 0 { + let rate = cost / volume as f64; + assert!(rate > 0.0); + assert!(rate < cost); + } +} diff --git a/apps/charging-engine/tests/validation_tests.rs b/apps/charging-engine/tests/validation_tests.rs new file mode 100644 index 0000000..26fca74 --- /dev/null +++ b/apps/charging-engine/tests/validation_tests.rs @@ -0,0 +1,127 @@ +use charging_engine::errors::{validate_bytes, validate_session_id, validate_amount, validate_ip, ChargingError}; + +#[test] +fn test_validate_bytes_zero() { + let result = validate_bytes(0); + assert!(result.is_err()); + match result.unwrap_err() { + ChargingError::InvalidInput(msg) => assert!(msg.contains("cannot be zero")), + _ => panic!("Expected InvalidInput error"), + } +} + +#[test] +fn test_validate_bytes_exceeds_limit() { + std::env::set_var("MAX_BYTES_LIMIT", "1000000000000"); + let result = validate_bytes(2_000_000_000_000); // 2TB + assert!(result.is_err()); + match result.unwrap_err() { + ChargingError::InvalidInput(_) => (), + _ => panic!("Expected InvalidInput error"), + } +} + +#[test] +fn test_validate_bytes_valid() { + let result = validate_bytes(1_000_000_000); // 1GB + assert!(result.is_ok()); +} + +#[test] +fn test_validate_session_id_empty() { + let result = validate_session_id(""); + assert!(result.is_err()); + match result.unwrap_err() { + ChargingError::InvalidInput(msg) => assert!(msg.contains("cannot be empty")), + _ => panic!("Expected InvalidInput error"), + } +} + +#[test] +fn test_validate_session_id_too_long() { + let result = validate_session_id(&"a".repeat(65)); + assert!(result.is_err()); + match result.unwrap_err() { + ChargingError::InvalidInput(msg) => assert!(msg.contains("too long")), + _ => panic!("Expected InvalidInput error"), + } +} + +#[test] +fn test_validate_session_id_valid() { + let result = validate_session_id("session-123"); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_amount_zero() { + let result = validate_amount(0.0); + assert!(result.is_err()); + match result.unwrap_err() { + ChargingError::InvalidInput(msg) => assert!(msg.contains("must be positive")), + _ => panic!("Expected InvalidInput error"), + } +} + +#[test] +fn test_validate_amount_negative() { + let result = validate_amount(-1.0); + assert!(result.is_err()); + match result.unwrap_err() { + ChargingError::InvalidInput(msg) => assert!(msg.contains("must be positive")), + _ => panic!("Expected InvalidInput error"), + } +} + +#[test] +fn test_validate_amount_exceeds_limit() { + std::env::set_var("MAX_AMOUNT_LIMIT", "10000"); + let result = validate_amount(20_000.0); + assert!(result.is_err()); + match result.unwrap_err() { + ChargingError::InvalidInput(_) => (), + _ => panic!("Expected InvalidInput error"), + } +} + +#[test] +fn test_validate_amount_valid() { + let result = validate_amount(100.0); + assert!(result.is_ok()); +} + +#[test] +fn test_validate_ip_empty() { + let result = validate_ip(""); + assert!(result.is_err()); + match result.unwrap_err() { + ChargingError::InvalidInput(msg) => assert!(msg.contains("cannot be empty")), + _ => panic!("Expected InvalidInput error"), + } +} + +#[test] +fn test_validate_ip_invalid_format() { + let result = validate_ip("invalid"); + assert!(result.is_err()); + match result.unwrap_err() { + ChargingError::InvalidInput(msg) => assert!(msg.contains("Invalid IP address format")), + _ => panic!("Expected InvalidInput error"), + } +} + +#[test] +fn test_validate_ip_zero_octet() { + let result = validate_ip("0.0.0.0"); + assert!(result.is_err()); + match result.unwrap_err() { + ChargingError::InvalidInput(msg) => assert!(msg.contains("cannot be 0.0.0.0")), + _ => panic!("Expected InvalidInput error"), + } +} + +#[test] +fn test_validate_ip_valid() { + let result = validate_ip("192.168.1.1"); + assert!(result.is_ok()); +}