Move 17 library crates into crates/, CLI binary into bin/fxt, consolidate 10 test crates into testing/, split config crate from deployment config files. Root directory reduced from 38+ to ~17 directories. All Cargo.toml paths and build.rs proto refs updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1354 lines
46 KiB
Rust
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());
|
|
}
|