//! 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 { 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, 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 = 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 = 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 = 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()); }