Skip to content
35 changes: 35 additions & 0 deletions src/backend/services/analytics-api/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,38 @@ impl AppConfig {
Ok(config)
}
}

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

type R = Result<(), Box<dyn std::error::Error>>;

// 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::<AppConfig>();
assert!(res.is_err(), "missing clickhouse_url must fail");
}
}
24 changes: 24 additions & 0 deletions src/backend/services/analytics-api/src/domain/metric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,27 @@ pub struct TableColumn {
#[serde(skip_serializing_if = "Option::is_none")]
pub field_description: Option<String>,
}

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

type R = Result<(), Box<dyn std::error::Error>>;

// The only non-trivial behavior in this module is our custom
// `deserialize_optional_nullable`: description is Option<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 {
Comment thread
SharedQA marked this conversation as resolved.
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(())
}
}
70 changes: 70 additions & 0 deletions src/backend/services/analytics-api/src/domain/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,73 @@ pub enum BatchQueryResult {
pub struct BatchQueryResponse {
pub results: Vec<BatchQueryResult>,
}

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

type R = Result<(), Box<dyn std::error::Error>>;

// (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(())
}
}
68 changes: 68 additions & 0 deletions src/backend/services/analytics-api/src/domain/threshold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>>;

#[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(())
}
}
Loading