diff --git a/src/backend/services/analytics-api/src/config.rs b/src/backend/services/analytics-api/src/config.rs index 40506635d..2e1fa7b6c 100644 --- a/src/backend/services/analytics-api/src/config.rs +++ b/src/backend/services/analytics-api/src/config.rs @@ -98,3 +98,38 @@ impl AppConfig { Ok(config) } } + +#[cfg(test)] +mod tests { + use super::*; + + type R = Result<(), Box>; + + // NOTE: tests that merely asserted the default helpers return their literals + // (and that a minimal config extracts to those same literals) were removed — + // they tested that constants are constants / that Figment works, not our + // config. What's worth guarding is the two real behaviors below: that an + // explicit value wins over the default (layering precedence), and that a + // field with no default is mandatory (fail-fast on misconfig). + + #[test] + fn explicit_values_override_defaults() -> R { + let cfg: AppConfig = Figment::new() + .merge(Yaml::string( + "database_url: d\nclickhouse_url: c\nbind_addr: 127.0.0.1:9000\nclickhouse_database: other\n", + )) + .extract()?; + assert_eq!(cfg.bind_addr, "127.0.0.1:9000"); + assert_eq!(cfg.clickhouse_database, "other"); + Ok(()) + } + + #[test] + fn missing_required_field_errors() { + // clickhouse_url has no default → extraction must fail without it. + let res = Figment::new() + .merge(Yaml::string("database_url: only\n")) + .extract::(); + assert!(res.is_err(), "missing clickhouse_url must fail"); + } +} diff --git a/src/backend/services/analytics-api/src/domain/metric.rs b/src/backend/services/analytics-api/src/domain/metric.rs index cb3876f77..d914e5fae 100644 --- a/src/backend/services/analytics-api/src/domain/metric.rs +++ b/src/backend/services/analytics-api/src/domain/metric.rs @@ -80,3 +80,27 @@ pub struct TableColumn { #[serde(skip_serializing_if = "Option::is_none")] pub field_description: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + type R = Result<(), Box>; + + // The only non-trivial behavior in this module is our custom + // `deserialize_optional_nullable`: description is Option> so PATCH + // can distinguish absent (leave unchanged) / explicit null (clear) / value + // (set). That branching is our code — serde's defaults can't express it — so + // it earns a test. The plain serialize/deserialize round-trips this module + // had before tested serde, not us, and were removed. + #[test] + fn update_description_is_triple_state() -> R { + let absent: UpdateMetricRequest = serde_json::from_str("{}")?; + assert_eq!(absent.description, None); // absent → leave unchanged + let null: UpdateMetricRequest = serde_json::from_str(r#"{"description":null}"#)?; + assert_eq!(null.description, Some(None)); // explicit null → clear + let val: UpdateMetricRequest = serde_json::from_str(r#"{"description":"hi"}"#)?; + assert_eq!(val.description, Some(Some("hi".to_owned()))); // value → set + Ok(()) + } +} diff --git a/src/backend/services/analytics-api/src/domain/query.rs b/src/backend/services/analytics-api/src/domain/query.rs index c47eecadd..a0a864576 100644 --- a/src/backend/services/analytics-api/src/domain/query.rs +++ b/src/backend/services/analytics-api/src/domain/query.rs @@ -85,3 +85,73 @@ pub enum BatchQueryResult { pub struct BatchQueryResponse { pub results: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + type R = Result<(), Box>; + + // (Removed `default_top_is_25` — asserting a function returns its own literal + // tests nothing. The default is still covered, meaningfully, by + // `query_request_applies_defaults_when_empty` below, which proves the field is + // actually wired to default_top() through deserialization.) + + #[test] + fn query_request_maps_odata_params() -> R { + let q: QueryRequest = serde_json::from_str( + r#"{"$filter":"metric_date ge '2026-03-01'","$orderby":"metric_date desc","$select":"person_id","$top":50}"#, + )?; + assert_eq!(q.filter.as_deref(), Some("metric_date ge '2026-03-01'")); + assert_eq!(q.orderby.as_deref(), Some("metric_date desc")); + assert_eq!(q.select.as_deref(), Some("person_id")); + assert_eq!(q.top, 50); + assert!(q.skip.is_none()); + Ok(()) + } + + #[test] + fn query_request_applies_defaults_when_empty() -> R { + let q: QueryRequest = serde_json::from_str("{}")?; + assert_eq!(q.top, 25, "$top defaults to default_top()"); + assert!(q.filter.is_none()); + assert!(q.orderby.is_none()); + assert!(q.select.is_none()); + assert!(q.skip.is_none()); + Ok(()) + } + + #[test] + fn batch_request_flattens_query_into_each_item() -> R { + let b: BatchQueryRequest = serde_json::from_str( + r#"{"queries":[{"id":"a","metric_id":"11111111-1111-1111-1111-111111111111","$top":10,"$filter":"x eq 1"}]}"#, + )?; + assert_eq!(b.queries.len(), 1); + let item = &b.queries[0]; + assert_eq!(item.id.as_deref(), Some("a")); + assert_eq!(item.query.top, 10); + assert_eq!(item.query.filter.as_deref(), Some("x eq 1")); + Ok(()) + } + + #[test] + fn batch_result_ok_serializes_with_lowercase_status_tag() -> R { + let r = BatchQueryResult::Ok { + id: Some("a".to_owned()), + metric_id: Uuid::nil(), + response: QueryResponse { + items: vec![], + page_info: PageInfo { + has_next: false, + cursor: None, + }, + }, + }; + let json = serde_json::to_string(&r)?; + assert!( + json.contains("\"status\":\"ok\""), + "tag = lowercase variant: {json}" + ); + Ok(()) + } +} diff --git a/src/backend/services/analytics-api/src/domain/threshold.rs b/src/backend/services/analytics-api/src/domain/threshold.rs index 10b30cf52..dab44e3e6 100644 --- a/src/backend/services/analytics-api/src/domain/threshold.rs +++ b/src/backend/services/analytics-api/src/domain/threshold.rs @@ -59,3 +59,71 @@ pub fn threshold_matches(value: f64, operator: &str, threshold: f64) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + type R = Result<(), Box>; + + #[test] + fn each_operator_matches_and_rejects_correctly() { + assert!(threshold_matches(5.0, "gt", 4.0)); + assert!(!threshold_matches(4.0, "gt", 4.0)); + assert!(threshold_matches(4.0, "ge", 4.0)); + assert!(!threshold_matches(3.9, "ge", 4.0)); + assert!(threshold_matches(3.0, "lt", 4.0)); + assert!(!threshold_matches(4.0, "lt", 4.0)); + assert!(threshold_matches(4.0, "le", 4.0)); + assert!(!threshold_matches(4.1, "le", 4.0)); + assert!(threshold_matches(4.0, "eq", 4.0)); + assert!(!threshold_matches(4.1, "eq", 4.0)); + } + + #[test] + fn eq_tolerates_floating_point_error() { + // 0.1 + 0.2 != 0.3 in IEEE-754; the epsilon compare must still match. + assert!(threshold_matches(0.1 + 0.2, "eq", 0.3)); + } + + #[test] + fn unknown_operator_never_matches() { + assert!(!threshold_matches(5.0, "between", 4.0)); + assert!(!threshold_matches(5.0, "", 4.0)); + assert!(!threshold_matches(5.0, "GT", 4.0)); // case-sensitive + } + + #[test] + fn valid_sets_match_their_messages() { + assert_eq!(VALID_OPERATORS, &["gt", "ge", "lt", "le", "eq"]); + assert_eq!(VALID_LEVELS, &["good", "warning", "critical"]); + for op in VALID_OPERATORS { + assert!(INVALID_OPERATOR_MSG.contains(op)); + } + for lvl in VALID_LEVELS { + assert!(INVALID_LEVEL_MSG.contains(lvl)); + } + } + + #[test] + fn create_request_deserializes() -> R { + let req: CreateThresholdRequest = serde_json::from_str( + r#"{"field_name":"score","operator":"ge","value":4.0,"level":"good"}"#, + )?; + assert_eq!(req.field_name, "score"); + assert_eq!(req.operator, "ge"); + assert!((req.value - 4.0).abs() < f64::EPSILON); + assert_eq!(req.level, "good"); + Ok(()) + } + + #[test] + fn update_request_allows_partial_fields() -> R { + let req: UpdateThresholdRequest = serde_json::from_str(r#"{"value":9.5}"#)?; + assert_eq!(req.value, Some(9.5)); + assert!(req.field_name.is_none()); + assert!(req.operator.is_none()); + assert!(req.level.is_none()); + Ok(()) + } +}