diff --git a/packages/devkit/Cargo.toml b/packages/devkit/Cargo.toml index 03bd600..d8ee40b 100644 --- a/packages/devkit/Cargo.toml +++ b/packages/devkit/Cargo.toml @@ -6,3 +6,6 @@ description = "Developer toolkit for testing and simulating the Stellar fee trac [dependencies] thiserror = "1" + +[dev-dependencies] +serde_json = "1" diff --git a/packages/devkit/src/harness/horizon_mock.rs b/packages/devkit/src/harness/horizon_mock.rs index c439aa4..5649ff6 100644 --- a/packages/devkit/src/harness/horizon_mock.rs +++ b/packages/devkit/src/harness/horizon_mock.rs @@ -4,11 +4,13 @@ pub struct HorizonMock { pub scenario: String, /// Optional simulated response delay in milliseconds. pub delay_ms: Option, + /// Probability [0.0, 1.0] of returning a 500/503 error response. + pub error_rate: f64, } impl HorizonMock { pub fn new(scenario: impl Into) -> Self { - Self { scenario: scenario.into(), delay_ms: None } + Self { scenario: scenario.into(), delay_ms: None, error_rate: 0.0 } } /// Sets the simulated network latency delay. @@ -24,12 +26,22 @@ impl HorizonMock { } } + /// Sets the error injection rate (0.0 = never, 1.0 = always). + pub fn with_error_rate(mut self, rate: f64) -> Self { + self.error_rate = rate.clamp(0.0, 1.0); + self + } + + /// Returns true if this request should be failed based on the configured error rate. + pub fn should_inject_error(&self) -> bool { + self.error_rate > 0.0 && rand_f64() < self.error_rate + } + /// Switches to the next scenario from the rotator and updates the active scenario. pub fn rotate(&mut self, rotator: &mut crate::harness::scenarios::ScenarioRotator) { if let Some(next) = rotator.next() { self.scenario = next.to_string(); } - Self { scenario: scenario.into() } } /// Logs a request to stdout with timestamp, method, path, and active scenario name. @@ -44,17 +56,21 @@ impl HorizonMock { /// Returns the JSON body for `GET /health`. pub fn health_payload(&self) -> String { format!(r#"{{"status":"ok","scenario":"{}"}}"#, self.scenario) - /// Path to the scenario JSON file to serve. - pub scenario_path: std::path::PathBuf, -} - -impl HorizonMock { - pub fn new(scenario_path: impl Into) -> Self { - Self { scenario_path: scenario_path.into() } } /// Loads and returns the scenario JSON to be served at `GET /fee_stats`. pub fn fee_stats_payload(&self) -> std::io::Result { - crate::harness::scenarios::load_from_file(&self.scenario_path) + crate::harness::scenarios::load_from_file( + std::path::Path::new(&format!("src/harness/scenarios/{}.json", self.scenario)), + ) } } + +/// Minimal pseudo-random float in [0.0, 1.0) using system time as entropy. +fn rand_f64() -> f64 { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + (nanos % 1_000_000) as f64 / 1_000_000.0 +} diff --git a/packages/devkit/src/harness/scenarios/congested.json b/packages/devkit/src/harness/scenarios/congested.json index 22a8d6c..26dfea4 100644 --- a/packages/devkit/src/harness/scenarios/congested.json +++ b/packages/devkit/src/harness/scenarios/congested.json @@ -2,7 +2,7 @@ "scenario": "congested", "description": "High-load network: p95 > 100,000 stroops with multiple fee spikes", "fee_stats": { - "last_ledger": "1000", + "last_ledger": "1001", "last_ledger_base_fee": "100", "ledger_capacity_usage": "0.97", "fee_charged": { diff --git a/packages/devkit/src/harness/scenarios/normal.json b/packages/devkit/src/harness/scenarios/normal.json new file mode 100644 index 0000000..e28f506 --- /dev/null +++ b/packages/devkit/src/harness/scenarios/normal.json @@ -0,0 +1,41 @@ +{ + "scenario": "normal", + "description": "Baseline fee environment: low fees, no spikes, p50 ≈ 100 stroops", + "fee_stats": { + "last_ledger": "1000", + "last_ledger_base_fee": "100", + "ledger_capacity_usage": "0.10", + "fee_charged": { + "max": "200", + "min": "100", + "mode": "100", + "p10": "100", + "p20": "100", + "p30": "100", + "p40": "100", + "p50": "100", + "p60": "100", + "p70": "100", + "p80": "100", + "p90": "100", + "p95": "150", + "p99": "200" + }, + "max_fee": { + "max": "500", + "min": "100", + "mode": "100", + "p10": "100", + "p20": "100", + "p30": "100", + "p40": "100", + "p50": "100", + "p60": "100", + "p70": "100", + "p80": "100", + "p90": "200", + "p95": "300", + "p99": "500" + } + } +} diff --git a/packages/devkit/src/harness/scenarios/recovery.json b/packages/devkit/src/harness/scenarios/recovery.json index 17f5973..74613e7 100644 --- a/packages/devkit/src/harness/scenarios/recovery.json +++ b/packages/devkit/src/harness/scenarios/recovery.json @@ -2,7 +2,7 @@ "scenario": "recovery", "description": "Post-spike normalisation: fees declining from high back to baseline", "fee_stats": { - "last_ledger": "1001", + "last_ledger": "1003", "last_ledger_base_fee": "100", "ledger_capacity_usage": "0.45", "fee_charged": { diff --git a/packages/devkit/tests/harness_congested.rs b/packages/devkit/tests/harness_congested.rs new file mode 100644 index 0000000..29efc35 --- /dev/null +++ b/packages/devkit/tests/harness_congested.rs @@ -0,0 +1,17 @@ +/// Integration test: load congested scenario and assert p95 fee_charged > 100,000 stroops. +#[test] +fn congested_scenario_p95_exceeds_100k() { + let path = std::path::Path::new( + "src/harness/scenarios/congested.json", + ); + let raw = std::fs::read_to_string(path).expect("congested.json not found"); + let json: serde_json::Value = serde_json::from_str(&raw).expect("invalid JSON"); + + let p95: u64 = json["fee_stats"]["fee_charged"]["p95"] + .as_str() + .expect("p95 missing") + .parse() + .expect("p95 not a number"); + + assert!(p95 > 100_000, "expected p95 > 100000, got {}", p95); +} diff --git a/packages/devkit/tests/harness_normal.rs b/packages/devkit/tests/harness_normal.rs new file mode 100644 index 0000000..41c36f0 --- /dev/null +++ b/packages/devkit/tests/harness_normal.rs @@ -0,0 +1,17 @@ +/// Integration test: load normal scenario and assert p50 fee_charged == 100 stroops. +#[test] +fn normal_scenario_p50_is_baseline() { + let path = std::path::Path::new( + "src/harness/scenarios/normal.json", + ); + let raw = std::fs::read_to_string(path).expect("normal.json not found"); + let json: serde_json::Value = serde_json::from_str(&raw).expect("invalid JSON"); + + let p50: u64 = json["fee_stats"]["fee_charged"]["p50"] + .as_str() + .expect("p50 missing") + .parse() + .expect("p50 not a number"); + + assert_eq!(p50, 100, "expected p50 == 100, got {}", p50); +}