Skip to content

Commit 5b2b6a1

Browse files
committed
feat(harness): add integration tests for fee_stats, error injection, and response delay
Closes #230 Closes #231 Closes #232 Closes #233 Co-Authored-By: temiport25 <temiport25@users.noreply.github.com>
1 parent 318dfb9 commit 5b2b6a1

5 files changed

Lines changed: 111 additions & 58 deletions

File tree

packages/devkit/src/cli/export.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
use crate::simulation::fee_model::FeePoint;
1+
use crate::simulation::fee_model::FeePoint;
22
use std::fmt::Write as FmtWrite;
33
use std::path::{Path, PathBuf};
44

5-
use crate::simulation::fee_model::FeePoint;
6-
75
/// Arguments for the `export` subcommand.
86
pub struct ExportArgs {
97
/// Source SQLite database path.
@@ -56,7 +54,6 @@ impl Window {
5654
pub struct Export;
5755

5856
impl Export {
59-
/// Serialize fee points to CSV.
6057
/// Filter points by window relative to the latest timestamp.
6158
pub fn filter_window<'a>(points: &'a [FeePoint], window: Window) -> &'a [FeePoint] {
6259
match window.cutoff_seconds() {
@@ -116,6 +113,10 @@ mod tests {
116113
]
117114
}
118115

116+
fn sample() -> Vec<FeePoint> {
117+
vec![FeePoint { timestamp: 1000, fee: 100, ledger: 1, is_spike: false }]
118+
}
119+
119120
#[test]
120121
fn window_1h_filters() {
121122
let p = pts();
@@ -128,8 +129,6 @@ mod tests {
128129
fn window_all_keeps_all() {
129130
let p = pts();
130131
assert_eq!(Export::filter_window(&p, Window::All).len(), 2);
131-
fn sample() -> Vec<FeePoint> {
132-
vec![FeePoint { timestamp: 1000, fee: 100, ledger: 1, is_spike: false }]
133132
}
134133

135134
#[test]
@@ -144,4 +143,4 @@ mod tests {
144143
assert!(json.starts_with('['));
145144
assert!(json.ends_with(']'));
146145
}
147-
}
146+
}

packages/devkit/src/cli/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ pub mod benchmark;
22
pub mod export;
33
pub mod replay;
44

5+
use clap::{Parser, Subcommand};
6+
57
/// Arguments for the `simulate` subcommand.
68
pub struct SimulateArgs {
79
/// Base fee floor in stroops.
@@ -30,6 +32,8 @@ impl SimulateArgs {
3032
self.duration, self.base_fee, self.spike_prob
3133
);
3234
}
35+
}
36+
3337
/// Arguments for the `mock` subcommand.
3438
pub struct MockArgs {
3539
/// Scenario to load (e.g. "normal", "congested", "spike").
@@ -55,7 +59,7 @@ impl MockArgs {
5559
self.port, self.scenario
5660
);
5761
}
58-
use clap::{Parser, Subcommand};
62+
}
5963

6064
/// Developer toolkit for the Stellar fee tracker.
6165
#[derive(Parser)]

packages/devkit/src/cli/replay.rs

Lines changed: 22 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,24 @@ pub struct ReplayArgs {
88
pub db: PathBuf,
99
/// Show a progress bar during replay.
1010
pub progress: bool,
11+
/// Playback speed multiplier (1.0 = real-time).
12+
pub speed: f32,
13+
/// Start of the replay window (ISO-8601 timestamp).
14+
pub from: Option<String>,
15+
/// End of the replay window (ISO-8601 timestamp).
16+
pub to: Option<String>,
17+
}
18+
19+
impl Default for ReplayArgs {
20+
fn default() -> Self {
21+
Self {
22+
db: PathBuf::from("stellar_fees.db"),
23+
progress: false,
24+
speed: 1.0,
25+
from: None,
26+
to: None,
27+
}
28+
}
1129
}
1230

1331
impl ReplayArgs {
@@ -27,54 +45,21 @@ impl ReplayArgs {
2745
bar.inc(1);
2846
}
2947
bar.finish_with_message("replay complete");
30-
use clap::Args;
31-
32-
/// Arguments for the `replay` subcommand.
33-
#[derive(Args)]
34-
pub struct ReplayArgs {
35-
/// Path to the SQLite database file.
36-
pub db: PathBuf,
37-
/// Playback speed multiplier (1.0 = real-time).
38-
#[arg(long, default_value = "1.0")]
39-
pub speed: f32,
40-
/// Start of the replay window (ISO-8601 timestamp).
41-
#[arg(long)]
42-
pub from: Option<String>,
43-
/// End of the replay window (ISO-8601 timestamp).
44-
#[arg(long)]
45-
pub to: Option<String>,
46-
}
48+
}
4749

48-
impl ReplayArgs {
4950
/// Replays fee records filtered by the given time window.
50-
pub fn run(&self) {
51+
pub fn run_windowed(&self) {
5152
eprintln!(
5253
"Replaying from {} at {:.1}x speed, window {:?}..{:?}",
5354
self.db.display(),
5455
self.speed,
5556
self.from,
5657
self.to
5758
);
58-
/// Path to the SQLite database file containing recorded fee data.
59-
pub db: PathBuf,
60-
/// Playback speed multiplier (1.0 = real-time, 10.0 = 10x faster).
61-
#[arg(long, default_value = "1.0")]
62-
pub speed: f32,
63-
}
64-
65-
impl ReplayArgs {
66-
/// Replays fee records at the specified speed multiplier.
67-
pub fn run(&self) {
68-
eprintln!(
69-
"Replaying from {} at {:.1}x speed",
70-
self.db.display(),
71-
self.speed
72-
);
73-
}
59+
}
7460

75-
impl ReplayArgs {
7661
/// Replays fee records from the database to stdout as a JSON stream.
77-
pub fn run(&self) {
62+
pub fn run_json(&self) {
7863
eprintln!("Replaying fee records from {}", self.db.display());
7964
println!("[]");
8065
}

packages/devkit/src/test_helpers/mod.rs

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
use crate::simulation::fee_model::{FeeModel, FeeModelConfig};
2-
use crate::types::FeeRecord;
1+
use crate::simulation::fee_model::{FeeModel, FeeModelConfig, FeePoint};
2+
3+
use rand::rngs::SmallRng;
4+
use rand::{Rng, SeedableRng};
35

46
/// Returns a deterministic fee sequence of `count` records seeded by `seed`.
5-
pub fn make_fee_sequence(count: usize, seed: u64) -> Vec<FeeRecord> {
7+
pub fn make_fee_sequence(count: usize, seed: u64) -> Vec<FeePoint> {
68
let config = FeeModelConfig {
79
seed: Some(seed),
810
..Default::default()
@@ -11,7 +13,7 @@ pub fn make_fee_sequence(count: usize, seed: u64) -> Vec<FeeRecord> {
1113
}
1214

1315
/// Returns a fee sequence where every record is flagged as a spike.
14-
pub fn make_spike_sequence(count: usize) -> Vec<FeeRecord> {
16+
pub fn make_spike_sequence(count: usize) -> Vec<FeePoint> {
1517
let config = FeeModelConfig {
1618
spike_probability: 1.0,
1719
seed: Some(0),
@@ -21,18 +23,14 @@ pub fn make_spike_sequence(count: usize) -> Vec<FeeRecord> {
2123
}
2224

2325
/// Returns a fee sequence with no spikes (baseline load only).
24-
pub fn make_baseline_sequence(count: usize) -> Vec<FeeRecord> {
26+
pub fn make_baseline_sequence(count: usize) -> Vec<FeePoint> {
2527
let config = FeeModelConfig {
2628
spike_probability: 0.0,
2729
seed: Some(1),
2830
..Default::default()
2931
};
3032
FeeModel::new(config).generate(count, 0)
3133
}
32-
//! Test helpers: deterministic fee sequence generator and SQLite fixture builder.
33-
34-
use rand::{Rng, SeedableRng};
35-
use rand::rngs::SmallRng;
3634

3735
/// Generates a deterministic fee sequence from a seed for repeatable tests.
3836
pub struct FeeGenerator {
@@ -58,21 +56,21 @@ impl FeeGenerator {
5856

5957
/// A simple in-memory fee record for fixture use.
6058
#[derive(Debug, Clone, PartialEq)]
61-
pub struct FeeRecord {
59+
pub struct FixtureFeeRecord {
6260
pub timestamp: u64,
6361
pub fee_amount: u64,
6462
pub ledger_sequence: u64,
6563
pub tx_hash: String,
6664
}
6765

68-
/// Builds a vec of FeeRecord fixtures for testing.
66+
/// Builds a vec of FixtureFeeRecord fixtures for testing.
6967
pub struct FixtureBuilder;
7068

7169
impl FixtureBuilder {
7270
/// Build `n` sequential fee records starting at `base_timestamp`.
73-
pub fn build(n: usize, base_timestamp: u64, base_fee: u64) -> Vec<FeeRecord> {
71+
pub fn build(n: usize, base_timestamp: u64, base_fee: u64) -> Vec<FixtureFeeRecord> {
7472
(0..n)
75-
.map(|i| FeeRecord {
73+
.map(|i| FixtureFeeRecord {
7674
timestamp: base_timestamp + i as u64,
7775
fee_amount: base_fee,
7876
ledger_sequence: 1000 + i as u64,
@@ -97,6 +95,9 @@ mod tests {
9795
let records = FixtureBuilder::build(3, 1000, 100);
9896
assert_eq!(records[0].timestamp, 1000);
9997
assert_eq!(records[2].timestamp, 1002);
98+
}
99+
100+
#[test]
100101
fn same_seed_produces_same_sequence() {
101102
let a = FeeGenerator::new(42).generate(10, 100, 1000);
102103
let b = FeeGenerator::new(42).generate(10, 100, 1000);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
use std::time::{Duration, Instant};
2+
3+
use stellar_devkit::harness::horizon_mock::HorizonMock;
4+
5+
/// Spin up mock server with normal.json. Assert response status 200 and fee_charged.mode = "100".
6+
#[test]
7+
fn normal_scenario_fee_stats_returns_mode_100() {
8+
let path = std::path::Path::new("src/harness/scenarios/normal.json");
9+
let mock = HorizonMock::new("normal").with_scenario_path(path);
10+
11+
let body = mock.fee_stats_payload().expect("fee_stats_payload failed");
12+
assert!(!body.is_empty(), "response body must not be empty");
13+
14+
let json: serde_json::Value =
15+
serde_json::from_str(&body).expect("response is not valid JSON");
16+
let mode = json["fee_stats"]["fee_charged"]["mode"]
17+
.as_str()
18+
.expect("fee_charged.mode missing");
19+
assert_eq!(mode, "100", "expected fee_charged.mode == \"100\", got {}", mode);
20+
}
21+
22+
/// Spin up with congested.json. Assert fee_charged.p95 parses to a value > 100000.
23+
#[test]
24+
fn congested_scenario_fee_stats_p95_exceeds_100k() {
25+
let path = std::path::Path::new("src/harness/scenarios/congested.json");
26+
let mock = HorizonMock::new("congested").with_scenario_path(path);
27+
28+
let body = mock.fee_stats_payload().expect("fee_stats_payload failed");
29+
let json: serde_json::Value =
30+
serde_json::from_str(&body).expect("response is not valid JSON");
31+
32+
let p95: u64 = json["fee_stats"]["fee_charged"]["p95"]
33+
.as_str()
34+
.expect("fee_charged.p95 missing")
35+
.parse()
36+
.expect("fee_charged.p95 is not a number");
37+
assert!(p95 > 100_000, "expected p95 > 100000, got {}", p95);
38+
}
39+
40+
/// Set error_rate=1.0. Assert every request returns 503 (should_inject_error always true).
41+
#[test]
42+
fn error_injection_rate_1_always_injects() {
43+
let mock = HorizonMock::new("normal").with_error_rate(1.0);
44+
for _ in 0..20 {
45+
assert!(
46+
mock.should_inject_error(),
47+
"expected should_inject_error() == true with error_rate=1.0"
48+
);
49+
}
50+
}
51+
52+
/// Set delay_ms=200. Assert response time >= 200ms.
53+
#[test]
54+
fn response_delay_200ms_respected() {
55+
let mock = HorizonMock::new("normal").with_delay_ms(200);
56+
let start = Instant::now();
57+
mock.apply_delay();
58+
let elapsed = start.elapsed();
59+
assert!(
60+
elapsed >= Duration::from_millis(200),
61+
"expected elapsed >= 200ms, got {:?}",
62+
elapsed
63+
);
64+
}

0 commit comments

Comments
 (0)