Files
foxhunt/ctrader-openapi/tests/mock_server_tests.rs
jgrusewski 165aefef7d test(ctrader-openapi): mock server CI tests and demo integration tests
Add 66 mock server tests covering codec, rate limiter, config, order
builders, volume conversion, proto helpers, and error types. Add 6 demo
integration tests (ignored by default, gated behind env vars).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:23:30 +01:00

1354 lines
46 KiB
Rust

//! Mock cTrader server integration tests.
//!
//! Tests the cTrader OpenAPI crate components (codec, rate limiter, config,
//! order builders, symbol resolution, proto helpers) using purely local
//! constructs -- no real broker connection required.
#![deny(clippy::unwrap_used, clippy::expect_used)]
use bytes::{BufMut, BytesMut};
use prost::Message;
use tokio_util::codec::{Decoder, Encoder};
use ctrader_openapi::codec::CTraderCodec;
use ctrader_openapi::config::{CTraderConfig, CTraderEnvironment};
use ctrader_openapi::error::RateLimitBucket;
use ctrader_openapi::proto::{self, ProtoMessage};
use ctrader_openapi::rate_limiter::RateLimiter;
use ctrader_openapi::symbols::{lots_to_volume, volume_to_lots};
// ══════════════════════════════════════════════════════════════════════
// 1. Codec roundtrip tests (extended)
// ══════════════════════════════════════════════════════════════════════
/// Helper: encode a ProtoMessage into a BytesMut, returning an error string on failure.
fn encode_msg(msg: ProtoMessage) -> std::result::Result<BytesMut, String> {
let mut codec = CTraderCodec::new();
let mut buf = BytesMut::new();
codec.encode(msg, &mut buf).map_err(|e| e.to_string())?;
Ok(buf)
}
/// Helper: decode a ProtoMessage from a BytesMut, returning an error string on failure.
fn decode_msg(buf: &mut BytesMut) -> std::result::Result<Option<ProtoMessage>, String> {
let mut codec = CTraderCodec::new();
codec.decode(buf).map_err(|e| e.to_string())
}
#[test]
fn codec_roundtrip_application_auth_req() {
let inner = proto::ProtoOaApplicationAuthReq {
payload_type: Some(proto::PT_APP_AUTH_REQ as i32),
client_id: "test-client-id".into(),
client_secret: "test-client-secret".into(),
};
let original = ProtoMessage {
payload_type: proto::PT_APP_AUTH_REQ,
payload: Some(inner.encode_to_vec()),
client_msg_id: Some("msg-001".into()),
};
let mut buf = encode_msg(original.clone()).ok().unwrap_or_default();
let decoded = decode_msg(&mut buf)
.ok()
.flatten()
.unwrap_or_else(|| ProtoMessage {
payload_type: 0,
payload: None,
client_msg_id: None,
});
assert_eq!(decoded.payload_type, original.payload_type);
assert_eq!(decoded.payload, original.payload);
assert_eq!(decoded.client_msg_id, original.client_msg_id);
// Verify the inner payload decodes correctly
let inner_decoded = proto::ProtoOaApplicationAuthReq::decode(
decoded.payload.as_deref().unwrap_or_default(),
);
assert!(inner_decoded.is_ok());
let inner_decoded = inner_decoded.unwrap_or_else(|_| proto::ProtoOaApplicationAuthReq {
payload_type: None,
client_id: String::new(),
client_secret: String::new(),
});
assert_eq!(inner_decoded.client_id, "test-client-id");
assert_eq!(inner_decoded.client_secret, "test-client-secret");
}
#[test]
fn codec_roundtrip_account_auth_req() {
let inner = proto::ProtoOaAccountAuthReq {
payload_type: Some(proto::PT_ACCOUNT_AUTH_REQ as i32),
ctid_trader_account_id: 999_888,
access_token: "abc-token-xyz".into(),
};
let original = ProtoMessage {
payload_type: proto::PT_ACCOUNT_AUTH_REQ,
payload: Some(inner.encode_to_vec()),
client_msg_id: Some("msg-002".into()),
};
let mut buf = encode_msg(original.clone()).ok().unwrap_or_default();
let decoded = decode_msg(&mut buf)
.ok()
.flatten()
.unwrap_or_else(|| ProtoMessage {
payload_type: 0,
payload: None,
client_msg_id: None,
});
assert_eq!(decoded.payload_type, proto::PT_ACCOUNT_AUTH_REQ);
assert_eq!(decoded.client_msg_id.as_deref(), Some("msg-002"));
let inner_decoded =
proto::ProtoOaAccountAuthReq::decode(decoded.payload.as_deref().unwrap_or_default());
assert!(inner_decoded.is_ok());
}
#[test]
fn codec_roundtrip_new_order_req() {
let inner = proto::ProtoOaNewOrderReq {
payload_type: Some(proto::PT_NEW_ORDER_REQ as i32),
ctid_trader_account_id: 12345,
symbol_id: 1,
order_type: proto::ProtoOaOrderType::Market as i32,
trade_side: proto::ProtoOaTradeSide::Buy as i32,
volume: 100_000,
limit_price: None,
stop_price: None,
time_in_force: None,
expiration_timestamp: None,
stop_loss: Some(1.08000),
take_profit: Some(1.12000),
comment: Some("integration test order".into()),
base_slippage_price: None,
slippage_in_points: None,
label: Some("test-label".into()),
position_id: None,
client_order_id: Some("clord-123".into()),
relative_stop_loss: None,
relative_take_profit: None,
guaranteed_stop_loss: Some(false),
trailing_stop_loss: Some(false),
stop_trigger_method: None,
};
let original = ProtoMessage {
payload_type: proto::PT_NEW_ORDER_REQ,
payload: Some(inner.encode_to_vec()),
client_msg_id: Some("order-msg-001".into()),
};
let mut buf = encode_msg(original.clone()).ok().unwrap_or_default();
let decoded = decode_msg(&mut buf)
.ok()
.flatten()
.unwrap_or_else(|| ProtoMessage {
payload_type: 0,
payload: None,
client_msg_id: None,
});
assert_eq!(decoded.payload_type, proto::PT_NEW_ORDER_REQ);
assert_eq!(decoded.payload, original.payload);
let inner_decoded =
proto::ProtoOaNewOrderReq::decode(decoded.payload.as_deref().unwrap_or_default());
assert!(inner_decoded.is_ok());
let inner_decoded = inner_decoded.unwrap_or_else(|_| proto::ProtoOaNewOrderReq::default());
assert_eq!(inner_decoded.volume, 100_000);
assert_eq!(
inner_decoded.comment.as_deref(),
Some("integration test order")
);
}
#[test]
fn codec_roundtrip_empty_payload() {
// Heartbeat has no payload and no client_msg_id
let original = ProtoMessage {
payload_type: proto::PT_HEARTBEAT_EVENT,
payload: None,
client_msg_id: None,
};
let mut buf = encode_msg(original.clone()).ok().unwrap_or_default();
let decoded = decode_msg(&mut buf)
.ok()
.flatten()
.unwrap_or_else(|| ProtoMessage {
payload_type: 0,
payload: None,
client_msg_id: None,
});
assert_eq!(decoded.payload_type, proto::PT_HEARTBEAT_EVENT);
assert!(decoded.payload.is_none());
assert!(decoded.client_msg_id.is_none());
}
#[test]
fn codec_roundtrip_large_payload() {
// Create a payload with 100KB of data
let large_payload = vec![0xABu8; 100 * 1024];
let original = ProtoMessage {
payload_type: 9999,
payload: Some(large_payload.clone()),
client_msg_id: Some("large-payload-test".into()),
};
let mut buf = encode_msg(original.clone()).ok().unwrap_or_default();
let decoded = decode_msg(&mut buf)
.ok()
.flatten()
.unwrap_or_else(|| ProtoMessage {
payload_type: 0,
payload: None,
client_msg_id: None,
});
assert_eq!(decoded.payload_type, 9999);
assert_eq!(
decoded.payload.as_ref().map(|p| p.len()),
Some(100 * 1024)
);
assert_eq!(decoded.payload, original.payload);
}
#[test]
fn codec_rejects_oversized_frame_on_decode() {
let mut codec = CTraderCodec::new();
let mut buf = BytesMut::new();
// Write a length that exceeds the 16 MiB MAX_FRAME_SIZE
buf.put_u32(16 * 1024 * 1024 + 1);
buf.extend_from_slice(&[0u8; 64]);
let result = codec.decode(&mut buf);
assert!(result.is_err());
}
#[test]
fn codec_partial_frame_returns_none_then_completes() {
let mut codec = CTraderCodec::new();
let original = ProtoMessage {
payload_type: proto::PT_APP_AUTH_RES,
payload: None,
client_msg_id: Some("partial-test".into()),
};
let mut full_buf = BytesMut::new();
let encode_result = codec.encode(original.clone(), &mut full_buf);
assert!(encode_result.is_ok());
// Feed only 2 bytes -- not enough for length prefix
let mut partial = full_buf.split_to(2);
let remainder = full_buf;
let decode_result = codec.decode(&mut partial);
assert!(decode_result.is_ok());
assert!(
decode_result.ok().flatten().is_none(),
"should return None for partial length prefix"
);
// Now combine the partial data with the remainder
let mut combined = BytesMut::new();
combined.extend_from_slice(&partial);
combined.extend_from_slice(&remainder);
let decode_result = codec.decode(&mut combined);
assert!(decode_result.is_ok());
let decoded = decode_result.ok().flatten();
assert!(decoded.is_some(), "should decode complete frame");
let decoded = decoded.unwrap_or_else(|| ProtoMessage {
payload_type: 0,
payload: None,
client_msg_id: None,
});
assert_eq!(decoded.payload_type, original.payload_type);
assert_eq!(decoded.client_msg_id, original.client_msg_id);
}
#[test]
fn codec_multiple_messages_in_single_buffer() {
let mut codec = CTraderCodec::new();
let msg1 = ProtoMessage {
payload_type: proto::PT_HEARTBEAT_EVENT,
payload: None,
client_msg_id: None,
};
let msg2 = ProtoMessage {
payload_type: proto::PT_NEW_ORDER_REQ,
payload: Some(vec![10, 20, 30]),
client_msg_id: Some("multi-1".into()),
};
let msg3 = ProtoMessage {
payload_type: proto::PT_CANCEL_ORDER_REQ,
payload: Some(vec![40, 50]),
client_msg_id: Some("multi-2".into()),
};
let mut buf = BytesMut::new();
assert!(codec.encode(msg1.clone(), &mut buf).is_ok());
assert!(codec.encode(msg2.clone(), &mut buf).is_ok());
assert!(codec.encode(msg3.clone(), &mut buf).is_ok());
let d1 = codec.decode(&mut buf).ok().flatten();
assert!(d1.is_some());
assert_eq!(
d1.as_ref().map(|m| m.payload_type),
Some(proto::PT_HEARTBEAT_EVENT)
);
let d2 = codec.decode(&mut buf).ok().flatten();
assert!(d2.is_some());
assert_eq!(
d2.as_ref().map(|m| m.payload_type),
Some(proto::PT_NEW_ORDER_REQ)
);
let d3 = codec.decode(&mut buf).ok().flatten();
assert!(d3.is_some());
assert_eq!(
d3.as_ref().map(|m| m.payload_type),
Some(proto::PT_CANCEL_ORDER_REQ)
);
// Buffer should be empty now
let d4 = codec.decode(&mut buf).ok().flatten();
assert!(d4.is_none());
}
// ══════════════════════════════════════════════════════════════════════
// 2. Rate limiter tests (extended)
// ══════════════════════════════════════════════════════════════════════
#[tokio::test]
async fn rate_limiter_non_historical_burst_capacity() {
let limiter = RateLimiter::new();
// Should be able to acquire 50 tokens (full burst) without blocking
for i in 0..50 {
let result = limiter.acquire(RateLimitBucket::NonHistorical).await;
assert!(result.is_ok(), "failed at iteration {i}");
}
}
#[tokio::test]
async fn rate_limiter_historical_burst_capacity() {
let limiter = RateLimiter::new();
// Should be able to acquire 5 tokens (full burst) without blocking
for i in 0..5 {
let result = limiter.acquire(RateLimitBucket::Historical).await;
assert!(result.is_ok(), "failed at iteration {i}");
}
}
#[tokio::test]
async fn rate_limiter_bucket_independence() {
let limiter = RateLimiter::new();
// Exhaust non-historical bucket
for _ in 0..50 {
let result = limiter.acquire(RateLimitBucket::NonHistorical).await;
assert!(result.is_ok());
}
// Historical bucket should still have tokens
for _ in 0..5 {
let result = limiter.acquire(RateLimitBucket::Historical).await;
assert!(result.is_ok());
}
}
#[tokio::test]
async fn rate_limiter_bucket_independence_reverse() {
let limiter = RateLimiter::new();
// Exhaust historical bucket first
for _ in 0..5 {
let result = limiter.acquire(RateLimitBucket::Historical).await;
assert!(result.is_ok());
}
// Non-historical bucket should still have all 50 tokens
for _ in 0..50 {
let result = limiter.acquire(RateLimitBucket::NonHistorical).await;
assert!(result.is_ok());
}
}
#[tokio::test]
async fn rate_limiter_concurrent_access() {
use std::sync::Arc;
let limiter = Arc::new(RateLimiter::new());
let mut handles = Vec::new();
// Spawn 10 tasks that each try to acquire 5 tokens concurrently
for _ in 0..10 {
let limiter = Arc::clone(&limiter);
handles.push(tokio::spawn(async move {
for _ in 0..5 {
let result = limiter.acquire(RateLimitBucket::NonHistorical).await;
assert!(result.is_ok());
}
}));
}
for handle in handles {
let _ = handle.await;
}
// If we get here without deadlock or panic, concurrency is safe
}
#[tokio::test]
async fn rate_limiter_refill_after_sleep() {
let limiter = RateLimiter::new();
// Exhaust the historical bucket (capacity 5)
for _ in 0..5 {
let result = limiter.acquire(RateLimitBucket::Historical).await;
assert!(result.is_ok());
}
// Sleep long enough for at least 1 token to refill (5 tokens/s => ~200ms per token)
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
// Should succeed after refill
let result = limiter.acquire(RateLimitBucket::Historical).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn rate_limiter_clone_shares_state() {
let limiter = RateLimiter::new();
let limiter2 = limiter.clone();
// Exhaust via the first handle
for _ in 0..50 {
let result = limiter.acquire(RateLimitBucket::NonHistorical).await;
assert!(result.is_ok());
}
// Sleep to let 1 token refill
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// The clone should see the same exhausted state, but after refill
// at 50 tokens/s, 50ms = ~2.5 tokens refilled => at least 1 available
let result = limiter2.acquire(RateLimitBucket::NonHistorical).await;
assert!(result.is_ok());
}
// ══════════════════════════════════════════════════════════════════════
// 3. Config validation tests
// ══════════════════════════════════════════════════════════════════════
#[test]
fn config_demo_environment_host_port() {
let env = CTraderEnvironment::Demo;
assert_eq!(env.host(), "demo.ctraderapi.com");
assert_eq!(env.port(), 5035);
}
#[test]
fn config_live_environment_host_port() {
let env = CTraderEnvironment::Live;
assert_eq!(env.host(), "live.ctraderapi.com");
assert_eq!(env.port(), 5035);
}
#[test]
fn config_default_environment_is_demo() {
let env = CTraderEnvironment::default();
assert_eq!(env, CTraderEnvironment::Demo);
}
#[test]
fn config_default_values_via_serde() {
let json = r#"{
"client_id": "test-id",
"client_secret": "test-secret",
"access_token": "test-token",
"account_id": 12345
}"#;
let config: std::result::Result<CTraderConfig, _> = serde_json::from_str(json);
assert!(config.is_ok());
let config = config.unwrap_or_else(|_| CTraderConfig {
client_id: String::new(),
client_secret: String::new(),
access_token: String::new(),
account_id: 0,
environment: CTraderEnvironment::Demo,
heartbeat_interval_secs: 0,
request_timeout_ms: 0,
max_reconnect_attempts: 0,
});
assert_eq!(config.environment, CTraderEnvironment::Demo);
assert_eq!(config.heartbeat_interval_secs, 10);
assert_eq!(config.request_timeout_ms, 5000);
assert_eq!(config.max_reconnect_attempts, 5);
}
#[test]
fn config_custom_values_via_serde() {
let json = r#"{
"client_id": "my-app",
"client_secret": "secret-123",
"access_token": "tok-abc",
"account_id": 99999,
"environment": "live",
"heartbeat_interval_secs": 5,
"request_timeout_ms": 3000,
"max_reconnect_attempts": 10
}"#;
let config: std::result::Result<CTraderConfig, _> = serde_json::from_str(json);
assert!(config.is_ok());
let config = config.unwrap_or_else(|_| CTraderConfig {
client_id: String::new(),
client_secret: String::new(),
access_token: String::new(),
account_id: 0,
environment: CTraderEnvironment::Demo,
heartbeat_interval_secs: 0,
request_timeout_ms: 0,
max_reconnect_attempts: 0,
});
assert_eq!(config.client_id, "my-app");
assert_eq!(config.client_secret, "secret-123");
assert_eq!(config.access_token, "tok-abc");
assert_eq!(config.account_id, 99999);
assert_eq!(config.environment, CTraderEnvironment::Live);
assert_eq!(config.heartbeat_interval_secs, 5);
assert_eq!(config.request_timeout_ms, 3000);
assert_eq!(config.max_reconnect_attempts, 10);
}
#[test]
fn config_serialization_roundtrip() {
let original = CTraderConfig {
client_id: "roundtrip-id".into(),
client_secret: "roundtrip-secret".into(),
access_token: "roundtrip-token".into(),
account_id: 42,
environment: CTraderEnvironment::Live,
heartbeat_interval_secs: 7,
request_timeout_ms: 2000,
max_reconnect_attempts: 3,
};
let json = serde_json::to_string(&original);
assert!(json.is_ok());
let json = json.unwrap_or_default();
let restored: std::result::Result<CTraderConfig, _> = serde_json::from_str(&json);
assert!(restored.is_ok());
let restored = restored.unwrap_or_else(|_| CTraderConfig {
client_id: String::new(),
client_secret: String::new(),
access_token: String::new(),
account_id: 0,
environment: CTraderEnvironment::Demo,
heartbeat_interval_secs: 0,
request_timeout_ms: 0,
max_reconnect_attempts: 0,
});
assert_eq!(restored.client_id, original.client_id);
assert_eq!(restored.account_id, original.account_id);
assert_eq!(restored.environment, original.environment);
assert_eq!(
restored.heartbeat_interval_secs,
original.heartbeat_interval_secs
);
assert_eq!(restored.request_timeout_ms, original.request_timeout_ms);
assert_eq!(
restored.max_reconnect_attempts,
original.max_reconnect_attempts
);
}
#[test]
fn config_both_environments_use_same_port() {
assert_eq!(CTraderEnvironment::Demo.port(), CTraderEnvironment::Live.port());
}
#[test]
fn config_environments_have_different_hosts() {
assert_ne!(
CTraderEnvironment::Demo.host(),
CTraderEnvironment::Live.host()
);
}
// ══════════════════════════════════════════════════════════════════════
// 4. Order builder tests (extended)
// ══════════════════════════════════════════════════════════════════════
#[test]
fn order_builder_market_buy() {
let msg = ctrader_openapi::orders::new_order(
12345,
1,
proto::ProtoOaTradeSide::Buy,
100_000,
proto::ProtoOaOrderType::Market,
None,
None,
None,
None,
Some("market buy".into()),
);
assert_eq!(msg.payload_type, proto::PT_NEW_ORDER_REQ);
assert!(msg.payload.is_some());
let decoded =
proto::ProtoOaNewOrderReq::decode(msg.payload.as_deref().unwrap_or_default());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert_eq!(decoded.ctid_trader_account_id, 12345);
assert_eq!(decoded.symbol_id, 1);
assert_eq!(decoded.trade_side, proto::ProtoOaTradeSide::Buy as i32);
assert_eq!(decoded.volume, 100_000);
assert_eq!(
decoded.order_type,
proto::ProtoOaOrderType::Market as i32
);
assert_eq!(decoded.comment.as_deref(), Some("market buy"));
assert!(decoded.limit_price.is_none());
assert!(decoded.stop_price.is_none());
}
#[test]
fn order_builder_market_sell() {
let msg = ctrader_openapi::orders::new_order(
12345,
2,
proto::ProtoOaTradeSide::Sell,
50_000,
proto::ProtoOaOrderType::Market,
None,
None,
None,
None,
None,
);
assert_eq!(msg.payload_type, proto::PT_NEW_ORDER_REQ);
let decoded =
proto::ProtoOaNewOrderReq::decode(msg.payload.as_deref().unwrap_or_default());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert_eq!(decoded.trade_side, proto::ProtoOaTradeSide::Sell as i32);
assert_eq!(decoded.volume, 50_000);
assert!(decoded.comment.is_none());
}
#[test]
fn order_builder_limit_order() {
let msg = ctrader_openapi::orders::new_order(
12345,
1,
proto::ProtoOaTradeSide::Buy,
200_000,
proto::ProtoOaOrderType::Limit,
Some(1.10000),
None,
Some(1.08000),
Some(1.15000),
Some("limit order test".into()),
);
assert_eq!(msg.payload_type, proto::PT_NEW_ORDER_REQ);
let decoded =
proto::ProtoOaNewOrderReq::decode(msg.payload.as_deref().unwrap_or_default());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert_eq!(
decoded.order_type,
proto::ProtoOaOrderType::Limit as i32
);
assert!((decoded.limit_price.unwrap_or(0.0) - 1.10000).abs() < 1e-10);
assert!(decoded.stop_price.is_none());
assert!((decoded.stop_loss.unwrap_or(0.0) - 1.08000).abs() < 1e-10);
assert!((decoded.take_profit.unwrap_or(0.0) - 1.15000).abs() < 1e-10);
}
#[test]
fn order_builder_stop_order() {
let msg = ctrader_openapi::orders::new_order(
12345,
3,
proto::ProtoOaTradeSide::Sell,
150_000,
proto::ProtoOaOrderType::Stop,
None,
Some(1.09000),
Some(1.11000),
Some(1.05000),
None,
);
assert_eq!(msg.payload_type, proto::PT_NEW_ORDER_REQ);
let decoded =
proto::ProtoOaNewOrderReq::decode(msg.payload.as_deref().unwrap_or_default());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert_eq!(
decoded.order_type,
proto::ProtoOaOrderType::Stop as i32
);
assert!((decoded.stop_price.unwrap_or(0.0) - 1.09000).abs() < 1e-10);
assert!((decoded.stop_loss.unwrap_or(0.0) - 1.11000).abs() < 1e-10);
assert!((decoded.take_profit.unwrap_or(0.0) - 1.05000).abs() < 1e-10);
assert!(decoded.limit_price.is_none());
}
#[test]
fn order_builder_cancel_order() {
let msg = ctrader_openapi::orders::cancel_order(12345, 999);
assert_eq!(msg.payload_type, proto::PT_CANCEL_ORDER_REQ);
let decoded =
proto::ProtoOaCancelOrderReq::decode(msg.payload.as_deref().unwrap_or_default());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert_eq!(decoded.ctid_trader_account_id, 12345);
assert_eq!(decoded.order_id, 999);
}
#[test]
fn order_builder_amend_order() {
let msg = ctrader_openapi::orders::amend_order(
12345,
888,
Some(300_000),
Some(1.12000),
None,
Some(1.09000),
Some(1.16000),
);
assert_eq!(msg.payload_type, proto::PT_AMEND_ORDER_REQ);
let decoded =
proto::ProtoOaAmendOrderReq::decode(msg.payload.as_deref().unwrap_or_default());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert_eq!(decoded.order_id, 888);
assert_eq!(decoded.volume, Some(300_000));
assert!((decoded.limit_price.unwrap_or(0.0) - 1.12000).abs() < 1e-10);
assert!(decoded.stop_price.is_none());
assert!((decoded.stop_loss.unwrap_or(0.0) - 1.09000).abs() < 1e-10);
assert!((decoded.take_profit.unwrap_or(0.0) - 1.16000).abs() < 1e-10);
}
#[test]
fn order_builder_close_position() {
let msg = ctrader_openapi::orders::close_position(12345, 777, 50_000);
assert_eq!(msg.payload_type, proto::PT_CLOSE_POSITION_REQ);
let decoded =
proto::ProtoOaClosePositionReq::decode(msg.payload.as_deref().unwrap_or_default());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert_eq!(decoded.ctid_trader_account_id, 12345);
assert_eq!(decoded.position_id, 777);
assert_eq!(decoded.volume, 50_000);
}
#[test]
fn order_builder_zero_volume() {
let msg = ctrader_openapi::orders::new_order(
12345,
1,
proto::ProtoOaTradeSide::Buy,
0,
proto::ProtoOaOrderType::Market,
None,
None,
None,
None,
None,
);
let decoded =
proto::ProtoOaNewOrderReq::decode(msg.payload.as_deref().unwrap_or_default());
assert!(decoded.is_ok());
assert_eq!(decoded.unwrap_or_default().volume, 0);
}
#[test]
fn order_builder_very_large_volume() {
let large_volume: i64 = 10_000_000_000; // 100,000 lots
let msg = ctrader_openapi::orders::new_order(
12345,
1,
proto::ProtoOaTradeSide::Buy,
large_volume,
proto::ProtoOaOrderType::Market,
None,
None,
None,
None,
None,
);
let decoded =
proto::ProtoOaNewOrderReq::decode(msg.payload.as_deref().unwrap_or_default());
assert!(decoded.is_ok());
assert_eq!(decoded.unwrap_or_default().volume, large_volume);
}
#[test]
fn order_builder_very_large_price() {
let msg = ctrader_openapi::orders::new_order(
12345,
1,
proto::ProtoOaTradeSide::Buy,
100_000,
proto::ProtoOaOrderType::Limit,
Some(999_999.99),
None,
None,
None,
None,
);
let decoded =
proto::ProtoOaNewOrderReq::decode(msg.payload.as_deref().unwrap_or_default());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert!((decoded.limit_price.unwrap_or(0.0) - 999_999.99).abs() < 1e-6);
}
#[test]
fn order_builder_negative_account_id() {
// Negative account IDs should be handled gracefully (proto uses i64)
let msg = ctrader_openapi::orders::new_order(
-1,
1,
proto::ProtoOaTradeSide::Buy,
100_000,
proto::ProtoOaOrderType::Market,
None,
None,
None,
None,
None,
);
let decoded =
proto::ProtoOaNewOrderReq::decode(msg.payload.as_deref().unwrap_or_default());
assert!(decoded.is_ok());
assert_eq!(decoded.unwrap_or_default().ctid_trader_account_id, -1);
}
// ══════════════════════════════════════════════════════════════════════
// 5. Symbol resolution / volume conversion tests
// ══════════════════════════════════════════════════════════════════════
#[test]
fn lots_to_volume_standard_lot() {
assert_eq!(lots_to_volume(1.0), 100_000);
}
#[test]
fn lots_to_volume_mini_lot() {
assert_eq!(lots_to_volume(0.1), 10_000);
}
#[test]
fn lots_to_volume_micro_lot() {
assert_eq!(lots_to_volume(0.01), 1_000);
}
#[test]
fn lots_to_volume_zero() {
assert_eq!(lots_to_volume(0.0), 0);
}
#[test]
fn lots_to_volume_fractional() {
assert_eq!(lots_to_volume(1.5), 150_000);
}
#[test]
fn lots_to_volume_large() {
assert_eq!(lots_to_volume(100.0), 10_000_000);
}
#[test]
fn volume_to_lots_roundtrip() {
let lots = 2.35;
let volume = lots_to_volume(lots);
let back = volume_to_lots(volume);
assert!((back - lots).abs() < 1e-10);
}
#[test]
fn volume_to_lots_zero() {
assert!((volume_to_lots(0) - 0.0).abs() < 1e-10);
}
#[test]
fn volume_to_lots_micro() {
let lots = volume_to_lots(1_000);
assert!((lots - 0.01).abs() < 1e-10);
}
// ══════════════════════════════════════════════════════════════════════
// 6. Proto helper tests
// ══════════════════════════════════════════════════════════════════════
#[test]
fn payload_type_name_known_types() {
assert_eq!(proto::payload_type_name(proto::PT_HEARTBEAT_EVENT), "HeartbeatEvent");
assert_eq!(proto::payload_type_name(proto::PT_ERROR_RES), "ErrorRes");
assert_eq!(proto::payload_type_name(proto::PT_APP_AUTH_REQ), "ApplicationAuthReq");
assert_eq!(proto::payload_type_name(proto::PT_APP_AUTH_RES), "ApplicationAuthRes");
assert_eq!(proto::payload_type_name(proto::PT_ACCOUNT_AUTH_REQ), "AccountAuthReq");
assert_eq!(proto::payload_type_name(proto::PT_ACCOUNT_AUTH_RES), "AccountAuthRes");
assert_eq!(proto::payload_type_name(proto::PT_NEW_ORDER_REQ), "NewOrderReq");
assert_eq!(proto::payload_type_name(proto::PT_CANCEL_ORDER_REQ), "CancelOrderReq");
assert_eq!(proto::payload_type_name(proto::PT_EXECUTION_EVENT), "ExecutionEvent");
assert_eq!(proto::payload_type_name(proto::PT_SPOT_EVENT), "SpotEvent");
assert_eq!(proto::payload_type_name(proto::PT_SYMBOLS_LIST_REQ), "SymbolsListReq");
assert_eq!(proto::payload_type_name(proto::PT_SYMBOLS_LIST_RES), "SymbolsListRes");
assert_eq!(proto::payload_type_name(proto::PT_TRADER_REQ), "TraderReq");
assert_eq!(proto::payload_type_name(proto::PT_TRADER_RES), "TraderRes");
assert_eq!(proto::payload_type_name(proto::PT_RECONCILE_REQ), "ReconcileReq");
assert_eq!(proto::payload_type_name(proto::PT_RECONCILE_RES), "ReconcileRes");
}
#[test]
fn payload_type_name_unknown() {
assert_eq!(proto::payload_type_name(0), "Unknown");
assert_eq!(proto::payload_type_name(99999), "Unknown");
}
#[test]
fn is_event_returns_true_for_events() {
assert!(proto::is_event(proto::PT_HEARTBEAT_EVENT));
assert!(proto::is_event(proto::PT_EXECUTION_EVENT));
assert!(proto::is_event(proto::PT_SPOT_EVENT));
assert!(proto::is_event(proto::PT_ORDER_ERROR_EVENT));
assert!(proto::is_event(proto::PT_CLIENT_DISCONNECT_EVENT));
assert!(proto::is_event(proto::PT_ACCOUNTS_TOKEN_INVALIDATED));
assert!(proto::is_event(proto::PT_ACCOUNT_DISCONNECT_EVENT));
}
#[test]
fn is_event_returns_false_for_req_res() {
assert!(!proto::is_event(proto::PT_APP_AUTH_REQ));
assert!(!proto::is_event(proto::PT_APP_AUTH_RES));
assert!(!proto::is_event(proto::PT_ACCOUNT_AUTH_REQ));
assert!(!proto::is_event(proto::PT_NEW_ORDER_REQ));
assert!(!proto::is_event(proto::PT_SYMBOLS_LIST_REQ));
assert!(!proto::is_event(proto::PT_SYMBOLS_LIST_RES));
assert!(!proto::is_event(proto::PT_TRADER_REQ));
assert!(!proto::is_event(proto::PT_RECONCILE_RES));
assert!(!proto::is_event(proto::PT_OA_ERROR_RES));
}
#[test]
fn payload_type_constants_match_proto_enum() {
// Verify our constants match the proto enum values
assert_eq!(proto::PT_HEARTBEAT_EVENT, 51);
assert_eq!(proto::PT_ERROR_RES, 50);
assert_eq!(proto::PT_APP_AUTH_REQ, 2100);
assert_eq!(proto::PT_APP_AUTH_RES, 2101);
assert_eq!(proto::PT_ACCOUNT_AUTH_REQ, 2102);
assert_eq!(proto::PT_ACCOUNT_AUTH_RES, 2103);
assert_eq!(proto::PT_VERSION_REQ, 2104);
assert_eq!(proto::PT_VERSION_RES, 2105);
assert_eq!(proto::PT_NEW_ORDER_REQ, 2106);
assert_eq!(proto::PT_CANCEL_ORDER_REQ, 2108);
assert_eq!(proto::PT_AMEND_ORDER_REQ, 2109);
assert_eq!(proto::PT_CLOSE_POSITION_REQ, 2111);
assert_eq!(proto::PT_SYMBOLS_LIST_REQ, 2114);
assert_eq!(proto::PT_SYMBOLS_LIST_RES, 2115);
assert_eq!(proto::PT_EXECUTION_EVENT, 2126);
assert_eq!(proto::PT_ORDER_ERROR_EVENT, 2132);
assert_eq!(proto::PT_SPOT_EVENT, 2131);
assert_eq!(proto::PT_OA_ERROR_RES, 2142);
assert_eq!(proto::PT_GET_ACCOUNTS_REQ, 2149);
assert_eq!(proto::PT_GET_ACCOUNTS_RES, 2150);
}
// ══════════════════════════════════════════════════════════════════════
// 7. Execution event parsing tests
// ══════════════════════════════════════════════════════════════════════
#[test]
fn parse_execution_event_returns_none_for_wrong_type() {
let msg = ProtoMessage {
payload_type: proto::PT_HEARTBEAT_EVENT,
payload: None,
client_msg_id: None,
};
assert!(ctrader_openapi::orders::parse_execution_event(&msg).is_none());
}
#[test]
fn parse_execution_event_returns_none_for_missing_payload() {
let msg = ProtoMessage {
payload_type: proto::PT_EXECUTION_EVENT,
payload: None,
client_msg_id: None,
};
assert!(ctrader_openapi::orders::parse_execution_event(&msg).is_none());
}
#[test]
fn parse_execution_event_returns_none_for_invalid_payload() {
let msg = ProtoMessage {
payload_type: proto::PT_EXECUTION_EVENT,
payload: Some(vec![0xFF, 0xFF, 0xFF]),
client_msg_id: None,
};
// Should return None rather than panicking on bad payload
assert!(ctrader_openapi::orders::parse_execution_event(&msg).is_none());
}
#[test]
fn extract_order_id_returns_none_for_non_execution() {
let msg = ProtoMessage {
payload_type: proto::PT_NEW_ORDER_REQ,
payload: Some(vec![1, 2, 3]),
client_msg_id: None,
};
assert!(ctrader_openapi::orders::extract_order_id(&msg).is_none());
}
// ══════════════════════════════════════════════════════════════════════
// 8. Error type tests
// ══════════════════════════════════════════════════════════════════════
#[test]
fn error_display_rate_limit_non_historical() {
let err = ctrader_openapi::error::CTraderError::RateLimitExceeded {
bucket: RateLimitBucket::NonHistorical,
};
let msg = err.to_string();
assert!(msg.contains("non-historical"));
}
#[test]
fn error_display_rate_limit_historical() {
let err = ctrader_openapi::error::CTraderError::RateLimitExceeded {
bucket: RateLimitBucket::Historical,
};
let msg = err.to_string();
assert!(msg.contains("historical"));
}
#[test]
fn error_display_unknown_symbol() {
let err = ctrader_openapi::error::CTraderError::UnknownSymbol("XYZABC".into());
let msg = err.to_string();
assert!(msg.contains("XYZABC"));
}
#[test]
fn error_display_timeout() {
let err =
ctrader_openapi::error::CTraderError::Timeout(std::time::Duration::from_secs(5));
let msg = err.to_string();
assert!(msg.contains("5"));
}
#[test]
fn error_display_server_error() {
let err = ctrader_openapi::error::CTraderError::ServerError {
code: "NO_QUOTES".into(),
description: "No liquidity".into(),
};
let msg = err.to_string();
assert!(msg.contains("NO_QUOTES"));
assert!(msg.contains("No liquidity"));
}
#[test]
fn error_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
let ctrader_err: ctrader_openapi::error::CTraderError = io_err.into();
let msg = ctrader_err.to_string();
assert!(msg.contains("refused"));
}
#[test]
fn rate_limit_bucket_display() {
let nh = format!("{}", RateLimitBucket::NonHistorical);
assert!(nh.contains("50/s"));
let h = format!("{}", RateLimitBucket::Historical);
assert!(h.contains("5/s"));
}
// ══════════════════════════════════════════════════════════════════════
// 9. Proto message construction tests (auth, error)
// ══════════════════════════════════════════════════════════════════════
#[test]
fn construct_oa_error_res() {
let oa_err = proto::ProtoOaErrorRes {
payload_type: Some(proto::PT_OA_ERROR_RES as i32),
ctid_trader_account_id: Some(12345),
error_code: "CH_CLIENT_AUTH_FAILURE".into(),
description: Some("Invalid credentials".into()),
maintenance_end_timestamp: None,
retry_after: None,
};
let bytes = oa_err.encode_to_vec();
let decoded = proto::ProtoOaErrorRes::decode(bytes.as_slice());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert_eq!(decoded.error_code, "CH_CLIENT_AUTH_FAILURE");
assert_eq!(decoded.description.as_deref(), Some("Invalid credentials"));
assert_eq!(decoded.ctid_trader_account_id, Some(12345));
}
#[test]
fn construct_app_auth_res() {
let res = proto::ProtoOaApplicationAuthRes {
payload_type: Some(proto::PT_APP_AUTH_RES as i32),
};
let bytes = res.encode_to_vec();
let envelope = ProtoMessage {
payload_type: proto::PT_APP_AUTH_RES,
payload: Some(bytes),
client_msg_id: None,
};
assert_eq!(envelope.payload_type, 2101);
assert!(envelope.payload.is_some());
}
#[test]
fn construct_account_auth_res() {
let res = proto::ProtoOaAccountAuthRes {
payload_type: Some(proto::PT_ACCOUNT_AUTH_RES as i32),
ctid_trader_account_id: 99999,
};
let bytes = res.encode_to_vec();
let decoded = proto::ProtoOaAccountAuthRes::decode(bytes.as_slice());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert_eq!(decoded.ctid_trader_account_id, 99999);
}
#[test]
fn construct_version_req_res() {
// VersionReq has no extra fields
let req = proto::ProtoOaVersionReq {
payload_type: Some(proto::PT_VERSION_REQ as i32),
};
let bytes = req.encode_to_vec();
assert!(!bytes.is_empty());
let res = proto::ProtoOaVersionRes {
payload_type: Some(proto::PT_VERSION_RES as i32),
version: "4.0".into(),
};
let bytes = res.encode_to_vec();
let decoded = proto::ProtoOaVersionRes::decode(bytes.as_slice());
assert!(decoded.is_ok());
assert_eq!(decoded.unwrap_or_default().version, "4.0");
}
// ══════════════════════════════════════════════════════════════════════
// 10. Symbol list proto construction test
// ══════════════════════════════════════════════════════════════════════
#[test]
fn construct_symbols_list_response() {
let symbols = vec![
proto::ProtoOaLightSymbol {
symbol_id: 1,
symbol_name: Some("EURUSD".into()),
enabled: Some(true),
base_asset_id: Some(1),
quote_asset_id: Some(2),
symbol_category_id: None,
description: Some("Euro vs US Dollar".into()),
sorting_number: None,
},
proto::ProtoOaLightSymbol {
symbol_id: 2,
symbol_name: Some("GBPUSD".into()),
enabled: Some(true),
base_asset_id: Some(3),
quote_asset_id: Some(2),
symbol_category_id: None,
description: Some("British Pound vs US Dollar".into()),
sorting_number: None,
},
proto::ProtoOaLightSymbol {
symbol_id: 3,
symbol_name: Some("USDJPY".into()),
enabled: Some(false),
base_asset_id: Some(2),
quote_asset_id: Some(4),
symbol_category_id: None,
description: None,
sorting_number: None,
},
];
let res = proto::ProtoOaSymbolsListRes {
payload_type: Some(proto::PT_SYMBOLS_LIST_RES as i32),
ctid_trader_account_id: 12345,
symbol: symbols,
archived_symbol: vec![],
};
let bytes = res.encode_to_vec();
let decoded = proto::ProtoOaSymbolsListRes::decode(bytes.as_slice());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert_eq!(decoded.symbol.len(), 3);
let first = decoded.symbol.first();
assert!(first.is_some());
let first = first.unwrap_or(&proto::ProtoOaLightSymbol {
symbol_id: 0,
symbol_name: None,
enabled: None,
base_asset_id: None,
quote_asset_id: None,
symbol_category_id: None,
description: None,
sorting_number: None,
});
assert_eq!(first.symbol_id, 1);
assert_eq!(first.symbol_name.as_deref(), Some("EURUSD"));
assert_eq!(first.enabled, Some(true));
}
// ══════════════════════════════════════════════════════════════════════
// 11. Spot event proto construction test
// ══════════════════════════════════════════════════════════════════════
#[test]
fn construct_spot_event() {
let spot = proto::ProtoOaSpotEvent {
payload_type: Some(proto::PT_SPOT_EVENT as i32),
ctid_trader_account_id: 12345,
symbol_id: 1,
bid: Some(110_000), // 1.10000 in 1/100000 units
ask: Some(110_020), // 1.10020
trendbar: vec![],
session_close: None,
timestamp: Some(1_700_000_000_000),
};
let bytes = spot.encode_to_vec();
let decoded = proto::ProtoOaSpotEvent::decode(bytes.as_slice());
assert!(decoded.is_ok());
let decoded = decoded.unwrap_or_default();
assert_eq!(decoded.symbol_id, 1);
assert_eq!(decoded.bid, Some(110_000));
assert_eq!(decoded.ask, Some(110_020));
assert_eq!(decoded.timestamp, Some(1_700_000_000_000));
}
// ══════════════════════════════════════════════════════════════════════
// 12. Codec with realistic auth sequence
// ══════════════════════════════════════════════════════════════════════
#[test]
fn codec_full_auth_sequence_roundtrip() {
let mut codec = CTraderCodec::new();
let mut buf = BytesMut::new();
// Step 1: Encode ApplicationAuthReq
let app_auth = proto::ProtoOaApplicationAuthReq {
payload_type: Some(proto::PT_APP_AUTH_REQ as i32),
client_id: "my-client-id".into(),
client_secret: "my-secret".into(),
};
let req1 = ProtoMessage {
payload_type: proto::PT_APP_AUTH_REQ,
payload: Some(app_auth.encode_to_vec()),
client_msg_id: Some("auth-1".into()),
};
assert!(codec.encode(req1, &mut buf).is_ok());
// Step 2: Encode ApplicationAuthRes (mock server response)
let app_auth_res = proto::ProtoOaApplicationAuthRes {
payload_type: Some(proto::PT_APP_AUTH_RES as i32),
};
let res1 = ProtoMessage {
payload_type: proto::PT_APP_AUTH_RES,
payload: Some(app_auth_res.encode_to_vec()),
client_msg_id: Some("auth-1".into()),
};
assert!(codec.encode(res1, &mut buf).is_ok());
// Step 3: Encode AccountAuthReq
let acct_auth = proto::ProtoOaAccountAuthReq {
payload_type: Some(proto::PT_ACCOUNT_AUTH_REQ as i32),
ctid_trader_account_id: 12345,
access_token: "tok-abc".into(),
};
let req2 = ProtoMessage {
payload_type: proto::PT_ACCOUNT_AUTH_REQ,
payload: Some(acct_auth.encode_to_vec()),
client_msg_id: Some("auth-2".into()),
};
assert!(codec.encode(req2, &mut buf).is_ok());
// Step 4: Encode AccountAuthRes (mock server response)
let acct_auth_res = proto::ProtoOaAccountAuthRes {
payload_type: Some(proto::PT_ACCOUNT_AUTH_RES as i32),
ctid_trader_account_id: 12345,
};
let res2 = ProtoMessage {
payload_type: proto::PT_ACCOUNT_AUTH_RES,
payload: Some(acct_auth_res.encode_to_vec()),
client_msg_id: Some("auth-2".into()),
};
assert!(codec.encode(res2, &mut buf).is_ok());
// Now decode all 4 messages in order
let d1 = codec.decode(&mut buf).ok().flatten();
assert_eq!(
d1.as_ref().map(|m| m.payload_type),
Some(proto::PT_APP_AUTH_REQ)
);
assert_eq!(
d1.as_ref().and_then(|m| m.client_msg_id.as_deref()),
Some("auth-1")
);
let d2 = codec.decode(&mut buf).ok().flatten();
assert_eq!(
d2.as_ref().map(|m| m.payload_type),
Some(proto::PT_APP_AUTH_RES)
);
let d3 = codec.decode(&mut buf).ok().flatten();
assert_eq!(
d3.as_ref().map(|m| m.payload_type),
Some(proto::PT_ACCOUNT_AUTH_REQ)
);
let d4 = codec.decode(&mut buf).ok().flatten();
assert_eq!(
d4.as_ref().map(|m| m.payload_type),
Some(proto::PT_ACCOUNT_AUTH_RES)
);
// Buffer should be empty
assert!(codec.decode(&mut buf).ok().flatten().is_none());
}