use zero_engine_client::{AutoState, ExecuteSide, HttpClient, HttpError, Mode};
use zero_testkit::mock_engine::MockEngine;
#[tokio::test]
async fn post_execute_round_trips_typed_body_and_response() {
let mock = MockEngine::spawn().await.expect("spawn mock");
let client = HttpClient::new(mock.base_url(), None).expect("client");
let resp = client
.post_execute("BTC", ExecuteSide::Buy, 0.25)
.await
.expect("execute accepted");
assert!(resp.accepted, "mock echoes accepted=true");
assert!(
!resp.simulated,
"simulated must default to false when mode unset"
);
let captures = mock.received_executes();
assert_eq!(captures.len(), 1, "one upstream call, no double-send");
let cap = &captures[0];
let body = &cap.body;
assert_eq!(body["coin"], "BTC");
assert_eq!(body["side"], "buy");
assert_eq!(body["size"].as_f64(), Some(0.25));
let key = body["idempotency_key"].as_str().expect("key in body");
assert_eq!(key.len(), 36, "UUID v4 stringifies to 36 chars");
assert_eq!(key.matches('-').count(), 4);
assert_eq!(
cap.headers.get("x-idempotency-key").map(String::as_str),
Some(key),
);
assert_eq!(
cap.headers.get("content-type").map(String::as_str),
Some("application/json"),
);
assert!(
!cap.headers.contains_key("x-zero-mode"),
"no mode override attached by default",
);
mock.shutdown().await;
}
#[tokio::test]
async fn post_execute_emits_unique_idempotency_key_per_call() {
let mock = MockEngine::spawn().await.expect("spawn mock");
let client = HttpClient::new(mock.base_url(), None).expect("client");
client
.post_execute("ETH", ExecuteSide::Sell, 1.0)
.await
.expect("first execute");
client
.post_execute("ETH", ExecuteSide::Sell, 1.0)
.await
.expect("second execute");
let captures = mock.received_executes();
assert_eq!(captures.len(), 2);
let key1 = captures[0].body["idempotency_key"].as_str().unwrap();
let key2 = captures[1].body["idempotency_key"].as_str().unwrap();
assert_ne!(key1, key2, "each /execute mints a fresh key");
mock.shutdown().await;
}
#[tokio::test]
async fn post_execute_honors_paper_mode_header() {
let mock = MockEngine::spawn().await.expect("spawn mock");
let client = HttpClient::new(mock.base_url(), None)
.expect("client")
.with_mode(Mode::Paper);
let resp = client
.post_execute("SOL", ExecuteSide::Buy, 5.0)
.await
.expect("paper execute accepted");
assert!(
resp.simulated,
"paper mode must propagate into the response's simulated flag",
);
let captures = mock.received_executes();
assert_eq!(
captures[0].headers.get("x-zero-mode").map(String::as_str),
Some("paper"),
);
mock.shutdown().await;
}
#[tokio::test]
async fn post_execute_honors_live_mode_header() {
let mock = MockEngine::spawn().await.expect("spawn mock");
let client = HttpClient::new(mock.base_url(), None)
.expect("client")
.with_mode(Mode::Live);
let resp = client
.post_execute("ARB", ExecuteSide::Sell, 10.0)
.await
.expect("live execute accepted");
assert!(
!resp.simulated,
"live mode must not flip the simulated flag",
);
let captures = mock.received_executes();
assert_eq!(
captures[0].headers.get("x-zero-mode").map(String::as_str),
Some("live"),
);
mock.shutdown().await;
}
#[tokio::test]
async fn live_control_endpoints_post_without_retry_surface() {
let mock = MockEngine::spawn().await.expect("spawn mock");
let client = HttpClient::new(mock.base_url(), None)
.expect("client")
.with_mode(Mode::Live);
let kill = client.post_live_kill().await.expect("kill accepted");
assert!(kill.ok);
assert_eq!(kill.state.as_deref(), Some("killed"));
let pause = client.post_live_pause().await.expect("pause accepted");
assert!(pause.ok);
assert_eq!(pause.state.as_deref(), Some("paused"));
let flatten = client.post_live_flatten().await.expect("flatten accepted");
assert!(flatten.ok);
assert_eq!(flatten.orders.len(), 1);
assert_eq!(
mock.received_live_controls(),
vec!["/live/kill", "/live/pause", "/live/flatten"],
);
mock.shutdown().await;
}
#[tokio::test]
async fn post_auto_toggle_honors_paper_mode_header() {
let mock = MockEngine::spawn().await.expect("spawn mock");
let client = HttpClient::new(mock.base_url(), None)
.expect("client")
.with_mode(Mode::Paper);
let resp = client.post_auto_toggle(true).await.expect("flipped");
assert_eq!(resp.state, AutoState::On);
assert!(resp.simulated, "paper-mode flip must carry simulated=true");
let captures = mock.received_auto_toggles();
assert_eq!(captures.len(), 1);
assert_eq!(
captures[0].headers.get("x-zero-mode").map(String::as_str),
Some("paper"),
);
assert!(
!captures[0].headers.contains_key("x-idempotency-key"),
"auto/toggle must not carry idempotency-key",
);
mock.shutdown().await;
}
#[tokio::test]
async fn post_auto_toggle_surfaces_engine_refusal_verbatim() {
let mock = MockEngine::spawn().await.expect("spawn mock");
mock.with_overrides(|o| {
o.auto_toggle_echo_state = Some(false);
o.auto_toggle_reason = Some("operator state is TILT".into());
});
let client = HttpClient::new(mock.base_url(), None).expect("client");
let resp = client.post_auto_toggle(true).await.expect("delivered");
assert_eq!(
resp.state,
AutoState::Off,
"engine refusal must land verbatim, not optimistically",
);
assert_eq!(resp.reason.as_deref(), Some("operator state is TILT"));
mock.shutdown().await;
}
#[tokio::test]
async fn post_execute_never_retries_on_503() {
let mock = MockEngine::spawn().await.expect("spawn mock");
mock.with_overrides(|o| o.post_transient_fail = true);
let client = HttpClient::new(mock.base_url(), None).expect("client");
let err = client
.post_execute("BTC", ExecuteSide::Buy, 0.1)
.await
.expect_err("503 must surface typed");
assert!(
matches!(
err,
HttpError::Status { status, .. } if status == reqwest::StatusCode::SERVICE_UNAVAILABLE
),
"expected 503 Status, got {err:?}",
);
mock.with_overrides(|o| o.post_transient_fail = false);
let ok = client
.post_execute("BTC", ExecuteSide::Buy, 0.1)
.await
.expect("second call succeeds");
assert!(ok.accepted);
mock.shutdown().await;
}
#[tokio::test]
async fn post_auto_toggle_never_retries_on_503() {
let mock = MockEngine::spawn().await.expect("spawn mock");
mock.with_overrides(|o| o.post_transient_fail = true);
let client = HttpClient::new(mock.base_url(), None).expect("client");
let err = client
.post_auto_toggle(true)
.await
.expect_err("503 must surface typed");
assert!(
matches!(
err,
HttpError::Status { status, .. } if status == reqwest::StatusCode::SERVICE_UNAVAILABLE
),
"expected 503 Status, got {err:?}",
);
mock.shutdown().await;
}
#[tokio::test]
async fn post_execute_never_retries_on_500() {
let mock = MockEngine::spawn().await.expect("spawn mock");
mock.with_overrides(|o| o.post_server_error = true);
let client = HttpClient::new(mock.base_url(), None).expect("client");
let err = client
.post_execute("BTC", ExecuteSide::Buy, 0.1)
.await
.expect_err("500 must surface typed");
assert!(
matches!(
err,
HttpError::Status { status, .. } if status == reqwest::StatusCode::INTERNAL_SERVER_ERROR
),
"expected 500 Status, got {err:?}",
);
mock.shutdown().await;
}
#[tokio::test]
async fn post_execute_never_retries_on_timeout() {
let client = HttpClient::new("http://127.0.0.1:1", None).expect("client");
let started = std::time::Instant::now();
let result = tokio::time::timeout(
std::time::Duration::from_secs(12),
client.post_execute("BTC", ExecuteSide::Buy, 0.1),
)
.await;
let elapsed = started.elapsed();
let err = result
.expect("must complete before 12s bound (retry would exceed)")
.expect_err("transport error must surface");
assert!(
matches!(err, HttpError::Unreachable(_) | HttpError::Timeout(_)),
"unexpected variant: {err:?}",
);
assert!(
elapsed < std::time::Duration::from_secs(11),
"single-attempt budget should leave plenty of headroom; was {elapsed:?}",
);
}
#[tokio::test]
async fn execute_simulated_flag_is_engine_asserted_not_locally_guessed() {
let mock = MockEngine::spawn().await.expect("spawn mock");
mock.with_overrides(|o| o.force_simulated = true);
let client = HttpClient::new(mock.base_url(), None)
.expect("client")
.with_mode(Mode::Live);
let resp = client
.post_execute("BTC", ExecuteSide::Buy, 0.5)
.await
.expect("live request, simulated reply");
assert!(
resp.simulated,
"engine truth beats client's local mode assumption",
);
mock.shutdown().await;
}