use std::sync::mpsc;
use std::thread;
use mockserver_client::*;
struct Captured {
method: String,
url: String,
body: String,
}
fn stub(status: u16, resp_body: &'static str) -> (MockServerClient, mpsc::Receiver<Captured>) {
let server = tiny_http::Server::http("127.0.0.1:0").expect("bind stub server");
let port = match server.server_addr() {
tiny_http::ListenAddr::IP(addr) => addr.port(),
#[allow(unreachable_patterns)]
_ => panic!("expected IP listen address"),
};
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut request = server.recv().expect("stub recv");
let method = request.method().as_str().to_string();
let url = request.url().to_string();
let mut body = String::new();
request.as_reader().read_to_string(&mut body).ok();
tx.send(Captured { method, url, body }).ok();
let response =
tiny_http::Response::from_string(resp_body).with_status_code(tiny_http::StatusCode(status));
request.respond(response).ok();
});
let client = ClientBuilder::new("127.0.0.1", port)
.build()
.expect("build client");
(client, rx)
}
#[test]
fn test_load_scenario_serializes_to_spec_shape() {
let scenario = LoadScenario::new(
"checkout-load",
LoadProfile::of(vec![
LoadStage::vu_ramp(1, 10, 10_000, RampCurve::Linear),
LoadStage::vu_hold(10, 30_000),
LoadStage::pause(5_000),
]),
vec![LoadStep::new(
HttpRequest::new()
.method("GET")
.path("/api/item/$iteration.index")
.header("Host", "target.svc:8080")
.socket_address(SocketAddress::new("target.svc", 8080).scheme("HTTP")),
)
.think_time(Delay::milliseconds(20))],
)
.template_type("VELOCITY")
.max_requests(5000);
let json = serde_json::to_value(&scenario).unwrap();
assert_eq!(json["name"], "checkout-load");
assert_eq!(json["templateType"], "VELOCITY");
assert_eq!(json["maxRequests"], 5000);
let stages = &json["profile"]["stages"];
assert_eq!(stages[0]["type"], "VU");
assert_eq!(stages[0]["startVus"], 1);
assert_eq!(stages[0]["endVus"], 10);
assert_eq!(stages[0]["durationMillis"], 10000);
assert_eq!(stages[0]["curve"], "LINEAR");
assert_eq!(stages[1]["type"], "VU");
assert_eq!(stages[1]["vus"], 10);
assert_eq!(stages[1]["durationMillis"], 30000);
assert!(stages[1].get("curve").is_none());
assert!(stages[1].get("startVus").is_none());
assert_eq!(stages[2]["type"], "PAUSE");
assert_eq!(stages[2]["durationMillis"], 5000);
assert!(stages[2].get("vus").is_none());
let step = &json["steps"][0];
assert_eq!(step["request"]["method"], "GET");
assert_eq!(step["request"]["path"], "/api/item/$iteration.index");
assert_eq!(step["request"]["socketAddress"]["host"], "target.svc");
assert_eq!(step["request"]["socketAddress"]["port"], 8080);
assert_eq!(step["request"]["socketAddress"]["scheme"], "HTTP");
assert_eq!(step["thinkTime"]["timeUnit"], "MILLISECONDS");
assert_eq!(step["thinkTime"]["value"], 20);
}
#[test]
fn test_load_profile_linear_shape() {
let profile = LoadProfile::linear(1, 50, 60_000);
let json = serde_json::to_value(&profile).unwrap();
let stage = &json["stages"][0];
assert_eq!(stage["type"], "VU");
assert_eq!(stage["curve"], "LINEAR");
assert_eq!(stage["startVus"], 1);
assert_eq!(stage["endVus"], 50);
assert_eq!(stage["durationMillis"], 60000);
assert!(stage.get("vus").is_none());
assert!(stage.get("rate").is_none());
assert!(stage.get("maxVus").is_none());
}
#[test]
fn test_load_profile_rate_stage_shape() {
let profile = LoadProfile::of(vec![
LoadStage::rate_hold(100.0, 30_000).max_vus(20),
LoadStage::rate_ramp(10.0, 200.0, 60_000, RampCurve::Exponential),
]);
let json = serde_json::to_value(&profile).unwrap();
let hold = &json["stages"][0];
assert_eq!(hold["type"], "RATE");
assert_eq!(hold["rate"], 100.0);
assert_eq!(hold["durationMillis"], 30000);
assert_eq!(hold["maxVus"], 20);
assert!(hold.get("curve").is_none());
assert!(hold.get("vus").is_none());
let ramp = &json["stages"][1];
assert_eq!(ramp["type"], "RATE");
assert_eq!(ramp["curve"], "EXPONENTIAL");
assert_eq!(ramp["startRate"], 10.0);
assert_eq!(ramp["endRate"], 200.0);
assert!(ramp.get("rate").is_none());
assert!(ramp.get("maxVus").is_none());
}
#[test]
fn test_load_scenario_serializes_start_delay_millis() {
let scenario = LoadScenario::new(
"delayed",
LoadProfile::constant(1, 1000),
vec![LoadStep::new(HttpRequest::new().method("GET").path("/x"))],
)
.start_delay_millis(2500);
let json = serde_json::to_value(&scenario).unwrap();
assert_eq!(json["startDelayMillis"], 2500);
}
#[test]
fn test_load_scenario_omits_start_delay_millis_when_unset() {
let scenario = LoadScenario::new(
"s",
LoadProfile::constant(1, 1000),
vec![LoadStep::new(HttpRequest::new().method("GET").path("/x"))],
);
let json = serde_json::to_value(&scenario).unwrap();
assert!(json.get("startDelayMillis").is_none());
}
#[test]
fn test_load_scenario_registers_with_put() {
let (client, rx) = stub(200, r#"{"name":"s","state":"LOADED"}"#);
let scenario = LoadScenario::new(
"s",
LoadProfile::constant(1, 1000),
vec![LoadStep::new(HttpRequest::new().method("GET").path("/x"))],
);
let result = client.load_scenario(&scenario).unwrap();
assert_eq!(result["name"], "s");
assert_eq!(result["state"], "LOADED");
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "PUT");
assert_eq!(cap.url, "/mockserver/loadScenario");
let sent: serde_json::Value = serde_json::from_str(&cap.body).unwrap();
assert_eq!(sent["name"], "s");
assert_eq!(sent["profile"]["stages"][0]["type"], "VU");
assert_eq!(sent["profile"]["stages"][0]["vus"], 1);
}
#[test]
fn test_load_scenarios_lists_with_get() {
let (client, rx) = stub(200, r#"{"scenarios":[{"name":"s","state":"LOADED"}]}"#);
let result = client.load_scenarios().unwrap();
assert_eq!(result["scenarios"][0]["name"], "s");
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "GET");
assert_eq!(cap.url, "/mockserver/loadScenario");
}
#[test]
fn test_get_load_scenario_sends_get_to_named_path() {
let (client, rx) = stub(200, r#"{"name":"checkout","state":"RUNNING"}"#);
let result = client.get_load_scenario("checkout").unwrap();
assert_eq!(result["state"], "RUNNING");
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "GET");
assert_eq!(cap.url, "/mockserver/loadScenario/checkout");
}
#[test]
fn test_get_load_scenario_404_is_not_found() {
let (client, _rx) = stub(404, r#"{"error":"no scenario named bogus"}"#);
match client.get_load_scenario("bogus") {
Err(Error::NotFound(msg)) => assert!(msg.contains("bogus")),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn test_delete_load_scenario_sends_delete_to_named_path() {
let (client, rx) = stub(200, r#"{"name":"checkout","state":"STOPPED"}"#);
let result = client.delete_load_scenario("checkout").unwrap();
assert_eq!(result["name"], "checkout");
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "DELETE");
assert_eq!(cap.url, "/mockserver/loadScenario/checkout");
}
#[test]
fn test_clear_load_scenarios_sends_delete_to_collection() {
let (client, rx) = stub(200, "");
let result = client.clear_load_scenarios().unwrap();
assert!(result.is_null());
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "DELETE");
assert_eq!(cap.url, "/mockserver/loadScenario");
}
#[test]
fn test_start_load_scenarios_sends_put_with_names() {
let (client, rx) = stub(
200,
r#"{"started":[{"name":"a","state":"PENDING"}],"status":"started"}"#,
);
let result = client.start_load_scenarios(&["a", "b"]).unwrap();
assert_eq!(result["status"], "started");
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "PUT");
assert_eq!(cap.url, "/mockserver/loadScenario/start");
let sent: serde_json::Value = serde_json::from_str(&cap.body).unwrap();
assert_eq!(sent["names"][0], "a");
assert_eq!(sent["names"][1], "b");
}
#[test]
fn test_start_load_scenarios_403_is_feature_disabled() {
let (client, _rx) = stub(403, r#"{"error":"load generation not enabled"}"#);
match client.start_load_scenarios(&["a"]) {
Err(Error::FeatureDisabled(msg)) => assert!(msg.contains("not enabled")),
other => panic!("expected FeatureDisabled, got {other:?}"),
}
}
#[test]
fn test_start_load_scenarios_404_is_not_found() {
let (client, _rx) = stub(404, r#"{"error":"unknown scenario nope"}"#);
match client.start_load_scenarios(&["nope"]) {
Err(Error::NotFound(msg)) => assert!(msg.contains("nope")),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn test_stop_load_scenarios_with_names_sends_names_body() {
let (client, rx) = stub(200, r#"{"stopped":["a"],"status":"stopped"}"#);
let result = client.stop_load_scenarios(&["a"]).unwrap();
assert_eq!(result["status"], "stopped");
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "PUT");
assert_eq!(cap.url, "/mockserver/loadScenario/stop");
let sent: serde_json::Value = serde_json::from_str(&cap.body).unwrap();
assert_eq!(sent["names"][0], "a");
}
#[test]
fn test_stop_load_scenarios_empty_sends_empty_body() {
let (client, rx) = stub(200, r#"{"stopped":["a","b"],"status":"stopped"}"#);
let empty: &[&str] = &[];
let result = client.stop_load_scenarios(empty).unwrap();
assert_eq!(result["status"], "stopped");
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "PUT");
assert_eq!(cap.url, "/mockserver/loadScenario/stop");
assert_eq!(cap.body, "");
}
#[test]
fn test_run_load_scenario_registers_then_starts() {
let server = tiny_http::Server::http("127.0.0.1:0").expect("bind stub server");
let port = match server.server_addr() {
tiny_http::ListenAddr::IP(addr) => addr.port(),
#[allow(unreachable_patterns)]
_ => panic!("expected IP listen address"),
};
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for (i, body) in [
r#"{"name":"run-me","state":"LOADED"}"#,
r#"{"started":[{"name":"run-me","state":"PENDING"}],"status":"started"}"#,
]
.iter()
.enumerate()
{
let mut request = server.recv().expect("stub recv");
let method = request.method().as_str().to_string();
let url = request.url().to_string();
let mut req_body = String::new();
request.as_reader().read_to_string(&mut req_body).ok();
tx.send((i, method, url, req_body)).ok();
request
.respond(tiny_http::Response::from_string(*body))
.ok();
}
});
let client = ClientBuilder::new("127.0.0.1", port)
.build()
.expect("build client");
let scenario = LoadScenario::new(
"run-me",
LoadProfile::constant(1, 1000),
vec![LoadStep::new(HttpRequest::new().method("GET").path("/x"))],
);
let result = client.run_load_scenario(&scenario).unwrap();
assert_eq!(result["status"], "started");
let (_, m0, u0, _) = rx.recv().unwrap();
assert_eq!(m0, "PUT");
assert_eq!(u0, "/mockserver/loadScenario");
let (_, m1, u1, b1) = rx.recv().unwrap();
assert_eq!(m1, "PUT");
assert_eq!(u1, "/mockserver/loadScenario/start");
let sent: serde_json::Value = serde_json::from_str(&b1).unwrap();
assert_eq!(sent["names"][0], "run-me");
}
#[test]
fn test_set_service_chaos_sends_put_with_host_and_chaos() {
let (client, rx) = stub(200, "{}");
let profile = HttpChaosProfile::new()
.error_status(503)
.error_probability(0.3)
.latency(Delay::milliseconds(200));
client
.set_service_chaos("payments.internal:8443", &profile, Some(60_000))
.unwrap();
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "PUT");
assert_eq!(cap.url, "/mockserver/serviceChaos");
let sent: serde_json::Value = serde_json::from_str(&cap.body).unwrap();
assert_eq!(sent["host"], "payments.internal:8443");
assert_eq!(sent["chaos"]["errorStatus"], 503);
assert_eq!(sent["chaos"]["errorProbability"], 0.3);
assert_eq!(sent["chaos"]["latency"]["timeUnit"], "MILLISECONDS");
assert_eq!(sent["chaos"]["latency"]["value"], 200);
assert_eq!(sent["ttlMillis"], 60000);
}
#[test]
fn test_set_service_chaos_without_ttl_omits_field() {
let (client, rx) = stub(200, "{}");
let profile = HttpChaosProfile::new().error_status(500);
client.set_service_chaos("h", &profile, None).unwrap();
let cap = rx.recv().unwrap();
let sent: serde_json::Value = serde_json::from_str(&cap.body).unwrap();
assert!(sent.get("ttlMillis").is_none());
}
#[test]
fn test_remove_and_clear_service_chaos() {
let (client, rx) = stub(200, "{}");
client.remove_service_chaos("h").unwrap();
let cap = rx.recv().unwrap();
let sent: serde_json::Value = serde_json::from_str(&cap.body).unwrap();
assert_eq!(sent["host"], "h");
assert_eq!(sent["remove"], true);
let (client, rx) = stub(200, "{}");
client.clear_service_chaos().unwrap();
let cap = rx.recv().unwrap();
let sent: serde_json::Value = serde_json::from_str(&cap.body).unwrap();
assert_eq!(sent["clear"], true);
}
#[test]
fn test_slo_criteria_serializes_to_spec_shape() {
let criteria = SloCriteria::new(vec![
SloObjective::new("LATENCY_P95", "LESS_THAN", 250.0).scope("FORWARD"),
SloObjective::new("ERROR_RATE", "LESS_THAN_OR_EQUAL", 0.01),
])
.name("checkout-slo")
.window(SloWindow::lookback(60_000))
.minimum_sample_count(20)
.upstream_hosts(vec!["payments.svc".to_string()]);
let json = serde_json::to_value(&criteria).unwrap();
assert_eq!(json["name"], "checkout-slo");
assert_eq!(json["window"]["type"], "LOOKBACK");
assert_eq!(json["window"]["lookbackMillis"], 60000);
assert_eq!(json["minimumSampleCount"], 20);
assert_eq!(json["upstreamHosts"][0], "payments.svc");
assert_eq!(json["objectives"][0]["sli"], "LATENCY_P95");
assert_eq!(json["objectives"][0]["comparator"], "LESS_THAN");
assert_eq!(json["objectives"][0]["threshold"], 250.0);
assert_eq!(json["objectives"][0]["scope"], "FORWARD");
assert_eq!(json["objectives"][1]["sli"], "ERROR_RATE");
assert!(json["objectives"][1].get("scope").is_none());
}
#[test]
fn test_verify_slo_200_is_pass_verdict() {
let (client, rx) = stub(
200,
r#"{"name":"checkout-slo","result":"PASS","sampleCount":42,
"objectiveResults":[{"sli":"LATENCY_P95","result":"PASS","observedValue":120.0}]}"#,
);
let criteria = SloCriteria::new(vec![SloObjective::new("LATENCY_P95", "LESS_THAN", 250.0)]);
let verdict = client.verify_slo(&criteria).unwrap();
assert!(verdict.is_pass());
assert_eq!(verdict.name.as_deref(), Some("checkout-slo"));
assert_eq!(verdict.sample_count, Some(42));
assert_eq!(verdict.objective_results.len(), 1);
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "PUT");
assert_eq!(cap.url, "/mockserver/verifySLO");
}
#[test]
fn test_verify_slo_406_is_fail_verdict_not_error() {
let (client, _rx) = stub(406, r#"{"name":"s","result":"FAIL","sampleCount":10}"#);
let criteria = SloCriteria::new(vec![SloObjective::new("ERROR_RATE", "LESS_THAN", 0.01)]);
let verdict = client.verify_slo(&criteria).unwrap();
assert!(verdict.is_fail());
}
#[test]
fn test_verify_slo_400_is_feature_disabled() {
let (client, _rx) = stub(400, r#"{"error":"SLO tracking disabled"}"#);
let criteria = SloCriteria::new(vec![SloObjective::new("ERROR_RATE", "LESS_THAN", 0.01)]);
match client.verify_slo(&criteria) {
Err(Error::FeatureDisabled(msg)) => assert!(msg.contains("disabled")),
other => panic!("expected FeatureDisabled, got {other:?}"),
}
}
#[test]
fn test_preemption_request_serializes_to_spec_shape() {
let req = PreemptionRequest::new()
.mode("both")
.drain_millis(10_000)
.ttl_millis(60_000)
.last_stream_id(3);
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["mode"], "both");
assert_eq!(json["drainMillis"], 10000);
assert_eq!(json["ttlMillis"], 60000);
assert_eq!(json["lastStreamId"], 3);
}
#[test]
fn test_empty_preemption_request_serializes_to_empty_object() {
let json = serde_json::to_value(PreemptionRequest::new()).unwrap();
assert_eq!(json, serde_json::json!({}));
}
#[test]
fn test_set_preemption_returns_status() {
let (client, rx) = stub(200, r#"{"state":"draining","inFlight":2,"mode":"both"}"#);
let status = client.set_preemption(&PreemptionRequest::new().mode("both")).unwrap();
assert_eq!(status.state.as_deref(), Some("draining"));
assert_eq!(status.in_flight, Some(2));
assert_eq!(status.mode.as_deref(), Some("both"));
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "PUT");
assert_eq!(cap.url, "/mockserver/preemption");
}
#[test]
fn test_preemption_status_sends_get() {
let (client, rx) = stub(200, r#"{"state":"inactive","inFlight":0}"#);
let status = client.preemption_status().unwrap();
assert_eq!(status.state.as_deref(), Some("inactive"));
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "GET");
assert_eq!(cap.url, "/mockserver/preemption");
}
#[test]
fn test_clear_preemption_sends_delete() {
let (client, rx) = stub(200, r#"{"state":"inactive"}"#);
client.clear_preemption().unwrap();
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "DELETE");
assert_eq!(cap.url, "/mockserver/preemption");
}
#[test]
fn test_chaos_experiment_serializes_to_spec_shape() {
let experiment = ChaosExperiment::new(vec![
ChaosStage::new(10_000).profile(
"api.example.com",
HttpChaosProfile::new().error_status(500).error_probability(0.1),
),
ChaosStage::new(10_000).profile(
"api.example.com",
HttpChaosProfile::new().error_status(500).error_probability(0.5),
),
])
.name("gradual-degradation")
.loop_back(true);
let json = serde_json::to_value(&experiment).unwrap();
assert_eq!(json["name"], "gradual-degradation");
assert_eq!(json["loop"], true);
assert!(json.get("loopBack").is_none());
assert_eq!(json["stages"][0]["durationMillis"], 10000);
assert_eq!(
json["stages"][0]["profiles"]["api.example.com"]["errorStatus"],
500
);
assert_eq!(
json["stages"][1]["profiles"]["api.example.com"]["errorProbability"],
0.5
);
}
#[test]
fn test_start_chaos_experiment_sends_put() {
let (client, rx) = stub(200, r#"{"status":"started","name":"e"}"#);
let experiment = ChaosExperiment::new(vec![ChaosStage::new(1000)
.profile("h", HttpChaosProfile::new().error_status(500))])
.name("e");
let result = client.start_chaos_experiment(&experiment).unwrap();
assert_eq!(result["status"], "started");
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "PUT");
assert_eq!(cap.url, "/mockserver/chaosExperiment");
let sent: serde_json::Value = serde_json::from_str(&cap.body).unwrap();
assert_eq!(sent["stages"][0]["durationMillis"], 1000);
}
#[test]
fn test_start_chaos_experiment_400_is_invalid_request() {
let (client, _rx) = stub(400, r#"{"error":"empty stages"}"#);
let experiment = ChaosExperiment::new(vec![]);
match client.start_chaos_experiment(&experiment) {
Err(Error::InvalidRequest(msg)) => assert!(msg.contains("stages")),
other => panic!("expected InvalidRequest, got {other:?}"),
}
}
#[test]
fn test_slo_verdict_deserializes_full_wire_shape() {
let wire = r#"{
"name":"checkout-slo",
"result":"FAIL",
"windowFromEpochMillis":1000,
"windowToEpochMillis":2000,
"sampleCount":30,
"objectiveResults":[
{"sli":"LATENCY_P95","comparator":"LESS_THAN","threshold":250.0,
"observedValue":310.5,"result":"FAIL","detail":"observed 310.5 > 250.0"}
]
}"#;
let verdict: SloVerdict = serde_json::from_str(wire).unwrap();
assert!(verdict.is_fail());
assert_eq!(verdict.window_from_epoch_millis, Some(1000));
assert_eq!(verdict.window_to_epoch_millis, Some(2000));
assert_eq!(verdict.sample_count, Some(30));
let r = &verdict.objective_results[0];
assert_eq!(r.sli.as_deref(), Some("LATENCY_P95"));
assert_eq!(r.observed_value, Some(310.5));
assert_eq!(r.result.as_deref(), Some("FAIL"));
}