use std::collections::HashMap;
use super::apply::{apply_chassis_scrape, build_chassis_services};
use super::config::RedfishSchema;
use super::parser::{ParseOutcome, parse_redfish_power};
use super::scraper::{ScraperError, scraper_error_reason};
use super::state::ServiceEnergy;
fn services(names: &[&str]) -> Vec<String> {
names.iter().map(|s| (*s).to_string()).collect()
}
fn ops(entries: &[(&str, u64)]) -> HashMap<String, u64> {
entries
.iter()
.map(|(svc, ops)| ((*svc).to_string(), *ops))
.collect()
}
fn mappings(entries: &[(&str, &str)]) -> HashMap<String, String> {
entries
.iter()
.map(|(svc, chassis)| ((*svc).to_string(), (*chassis).to_string()))
.collect()
}
#[test]
fn single_chassis_single_service_publishes_coefficient() {
let mut next: HashMap<String, ServiceEnergy> = HashMap::new();
let chassis_services = services(&["order-svc"]);
let deltas = ops(&[("order-svc", 100)]);
let changed = apply_chassis_scrape(&mut next, &chassis_services, 300.0, 60.0, &deltas, 1000);
assert!(changed);
assert_eq!(next.len(), 1);
assert!((next["order-svc"].energy_per_op_kwh - 5e-5).abs() < 1e-12);
}
#[test]
fn two_services_on_same_chassis_share_coefficient() {
let mut next: HashMap<String, ServiceEnergy> = HashMap::new();
let chassis_services = services(&["svc-a", "svc-b"]);
let deltas = ops(&[("svc-a", 100), ("svc-b", 200)]);
apply_chassis_scrape(&mut next, &chassis_services, 300.0, 60.0, &deltas, 1000);
assert_eq!(next.len(), 2);
let expected = (300.0 * 60.0 / 3_600_000.0) / 300.0;
assert!((next["svc-a"].energy_per_op_kwh - expected).abs() < 1e-15);
assert!((next["svc-b"].energy_per_op_kwh - expected).abs() < 1e-15);
}
#[test]
fn service_on_other_chassis_unaffected() {
let mut next: HashMap<String, ServiceEnergy> = HashMap::new();
let chassis_services = services(&["svc-a"]); let deltas = ops(&[("svc-a", 100), ("svc-b", 50)]);
apply_chassis_scrape(&mut next, &chassis_services, 300.0, 60.0, &deltas, 1000);
assert_eq!(next.len(), 1);
assert!(next.contains_key("svc-a"));
assert!(!next.contains_key("svc-b"));
}
#[test]
fn zero_ops_keeps_previous_entry() {
let mut next: HashMap<String, ServiceEnergy> = HashMap::new();
next.insert(
"svc-a".to_string(),
ServiceEnergy {
energy_per_op_kwh: 7e-7,
last_update_ms: 100,
},
);
let chassis_services = services(&["svc-a"]);
let deltas = ops(&[]);
let changed = apply_chassis_scrape(&mut next, &chassis_services, 300.0, 60.0, &deltas, 200);
assert!(!changed);
assert!((next["svc-a"].energy_per_op_kwh - 7e-7).abs() < f64::EPSILON);
assert_eq!(next["svc-a"].last_update_ms, 100);
}
#[test]
fn negative_watts_ignored() {
let mut next: HashMap<String, ServiceEnergy> = HashMap::new();
next.insert(
"svc-a".to_string(),
ServiceEnergy {
energy_per_op_kwh: 7e-7,
last_update_ms: 100,
},
);
let chassis_services = services(&["svc-a"]);
let deltas = ops(&[("svc-a", 100)]);
let changed = apply_chassis_scrape(&mut next, &chassis_services, -1.0, 60.0, &deltas, 200);
assert!(!changed);
assert!((next["svc-a"].energy_per_op_kwh - 7e-7).abs() < f64::EPSILON);
}
#[test]
fn nan_watts_ignored() {
let mut next: HashMap<String, ServiceEnergy> = HashMap::new();
let chassis_services = services(&["svc-a"]);
let deltas = ops(&[("svc-a", 100)]);
let changed = apply_chassis_scrape(&mut next, &chassis_services, f64::NAN, 60.0, &deltas, 200);
assert!(!changed);
assert!(next.is_empty());
}
#[test]
fn non_finite_scrape_interval_rejected() {
let mut next: HashMap<String, ServiceEnergy> = HashMap::new();
let chassis_services = services(&["svc-a"]);
let deltas = ops(&[("svc-a", 100)]);
assert!(!apply_chassis_scrape(
&mut next,
&chassis_services,
300.0,
f64::NAN,
&deltas,
200,
));
assert!(!apply_chassis_scrape(
&mut next,
&chassis_services,
300.0,
f64::INFINITY,
&deltas,
200,
));
assert!(next.is_empty());
}
#[test]
fn empty_chassis_services_is_noop() {
let mut next: HashMap<String, ServiceEnergy> = HashMap::new();
let deltas = ops(&[("svc-a", 100)]);
let changed = apply_chassis_scrape(&mut next, &[], 300.0, 60.0, &deltas, 200);
assert!(!changed);
assert!(next.is_empty());
}
#[test]
fn build_chassis_services_groups_by_chassis() {
let m = mappings(&[
("svc-a", "chassis-1"),
("svc-b", "chassis-1"),
("svc-c", "chassis-2"),
]);
let by_chassis = build_chassis_services(&m);
assert_eq!(by_chassis.len(), 2);
let mut chassis1 = by_chassis.get("chassis-1").cloned().unwrap();
chassis1.sort();
assert_eq!(chassis1, vec!["svc-a".to_string(), "svc-b".to_string()]);
assert_eq!(
by_chassis.get("chassis-2").unwrap(),
&vec!["svc-c".to_string()]
);
}
#[test]
fn scraper_error_reason_maps_fetch_errors() {
use crate::http_client::FetchError;
use crate::report::metrics::RedfishScrapeReason;
let utf8_err = ScraperError::Utf8(String::from_utf8(vec![0xff, 0xfe]).unwrap_err());
assert_eq!(
scraper_error_reason(&utf8_err),
RedfishScrapeReason::InvalidUtf8
);
assert_eq!(
scraper_error_reason(&ScraperError::InvalidJson),
RedfishScrapeReason::InvalidJson
);
assert_eq!(
scraper_error_reason(&ScraperError::PathMissing),
RedfishScrapeReason::PathMissing
);
assert_eq!(
scraper_error_reason(&ScraperError::InvalidValue),
RedfishScrapeReason::InvalidValue
);
assert_eq!(
scraper_error_reason(&ScraperError::Fetch(FetchError::Timeout)),
RedfishScrapeReason::Timeout
);
assert_eq!(
scraper_error_reason(&ScraperError::Fetch(FetchError::HttpStatus(500))),
RedfishScrapeReason::HttpError
);
assert_eq!(
scraper_error_reason(&ScraperError::Fetch(FetchError::BodyRead("eof".into()))),
RedfishScrapeReason::BodyReadError
);
}
#[test]
fn parses_dell_idrac_response() {
let body = r#"{
"@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power",
"Id": "Power",
"Name": "Power",
"PowerControl": [
{
"@odata.id": "/redfish/v1/Chassis/System.Embedded.1/Power#/PowerControl/0",
"MemberId": "0",
"Name": "System Power Control",
"PowerConsumedWatts": 287.0,
"PowerCapacityWatts": 750.0
}
]
}"#;
assert_eq!(
parse_redfish_power(body, RedfishSchema::LegacyPower),
ParseOutcome::Ok(287.0)
);
}
#[test]
fn parses_hpe_ilo_response() {
let body = r#"{
"@odata.id": "/redfish/v1/Chassis/1/Power/",
"Id": "Power",
"Name": "PowerMetrics",
"PowerControl": [
{
"@odata.id": "/redfish/v1/Chassis/1/Power/#PowerControl/0",
"MemberId": "0",
"PowerConsumedWatts": 412.5,
"PowerCapacityWatts": 1000
}
],
"Oem": {
"Hpe": {
"PowerRegulationEnabled": false
}
}
}"#;
assert_eq!(
parse_redfish_power(body, RedfishSchema::LegacyPower),
ParseOutcome::Ok(412.5)
);
}
#[test]
fn parses_openbmc_reference_response() {
let body = r#"{
"@odata.id": "/redfish/v1/Chassis/chassis/Power",
"Id": "Power",
"Name": "Power",
"PowerControl": [
{
"@odata.id": "/redfish/v1/Chassis/chassis/Power#/PowerControl/0",
"MemberId": "0",
"Name": "Chassis Power Control",
"PowerConsumedWatts": 198.4
}
]
}"#;
assert_eq!(
parse_redfish_power(body, RedfishSchema::LegacyPower),
ParseOutcome::Ok(198.4)
);
}
#[test]
fn rejects_dell_response_in_transition_state() {
let body = r#"{
"PowerControl": [
{
"MemberId": "0",
"PowerConsumedWatts": null
}
]
}"#;
assert_eq!(
parse_redfish_power(body, RedfishSchema::LegacyPower),
ParseOutcome::InvalidValue
);
}
#[test]
fn rejects_empty_power_control_array() {
let body = r#"{"PowerControl": []}"#;
assert_eq!(
parse_redfish_power(body, RedfishSchema::LegacyPower),
ParseOutcome::PathMissing
);
}
#[test]
fn multi_chassis_each_gets_independent_coefficient() {
let mut next: HashMap<String, ServiceEnergy> = HashMap::new();
let chassis_1 = services(&["svc-a"]);
let chassis_2 = services(&["svc-b"]);
let deltas = ops(&[("svc-a", 100), ("svc-b", 200)]);
assert!(apply_chassis_scrape(
&mut next, &chassis_1, 360.0, 10.0, &deltas, 1000,
));
assert!(apply_chassis_scrape(
&mut next, &chassis_2, 720.0, 10.0, &deltas, 1000,
));
assert_eq!(next.len(), 2);
let a = (360.0 * 10.0 / 3_600_000.0) / 100.0;
let b = (720.0 * 10.0 / 3_600_000.0) / 200.0;
assert!((next["svc-a"].energy_per_op_kwh - a).abs() < 1e-15);
assert!((next["svc-b"].energy_per_op_kwh - b).abs() < 1e-15);
}
#[tokio::test]
async fn spawn_scraper_with_ca_bundle_path_aborts_immediately() {
use super::config::{RedfishConfig, RedfishEndpoint};
use super::scraper::spawn_scraper;
use crate::report::metrics::MetricsState;
use crate::score::redfish::RedfishState;
use std::sync::Arc;
use std::time::Duration;
let mut endpoints = HashMap::new();
endpoints.insert(
"chassis-1".to_string(),
RedfishEndpoint {
url: "https://127.0.0.1:12345/redfish/v1/Chassis/1/Power".to_string(),
schema: RedfishSchema::LegacyPower,
},
);
let mut mappings = HashMap::new();
mappings.insert("svc-a".to_string(), "chassis-1".to_string());
let cfg = RedfishConfig {
endpoints,
scrape_interval: Duration::from_secs(15),
service_mappings: mappings,
ca_bundle_path: Some("/tmp/perf-sentinel-fake-ca-bundle.pem".to_string()),
auth_header: None,
};
let state = RedfishState::new();
let metrics = Arc::new(MetricsState::new());
let handle = spawn_scraper(cfg, state, metrics);
tokio::time::timeout(Duration::from_millis(500), handle)
.await
.expect("scraper should exit fast on ca_bundle_path")
.expect("scraper task should complete without panic");
}
#[tokio::test]
async fn spawn_scraper_staleness_gauge_climbs_when_every_chassis_fails() {
use super::config::{RedfishConfig, RedfishEndpoint};
use super::scraper::spawn_scraper;
use crate::report::metrics::MetricsState;
use crate::score::redfish::RedfishState;
use std::sync::Arc;
use std::time::Duration;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let mut endpoints = HashMap::new();
endpoints.insert(
"chassis-1".to_string(),
RedfishEndpoint {
url: format!("http://{addr}/Power"),
schema: RedfishSchema::LegacyPower,
},
);
let mut mappings = HashMap::new();
mappings.insert("svc-a".to_string(), "chassis-1".to_string());
let cfg = RedfishConfig {
endpoints,
scrape_interval: Duration::from_millis(50),
service_mappings: mappings,
ca_bundle_path: None,
auth_header: None,
};
let state = RedfishState::new();
let metrics = Arc::new(MetricsState::new());
let handle = spawn_scraper(cfg, state, metrics.clone());
let mut age = 0.0;
for _ in 0..320 {
tokio::time::sleep(Duration::from_millis(25)).await;
age = metrics.redfish_last_scrape_age_seconds.get();
if age > 0.0 {
break;
}
}
handle.abort();
assert!(
age > 0.0,
"staleness gauge should climb on never-succeeded scraper, got {age}"
);
}