diff --git a/ctrader-openapi/tests/demo_integration.rs b/ctrader-openapi/tests/demo_integration.rs new file mode 100644 index 000000000..3a0de6108 --- /dev/null +++ b/ctrader-openapi/tests/demo_integration.rs @@ -0,0 +1,336 @@ +//! Integration tests against a real cTrader demo server. +//! +//! All tests are `#[ignore]` by default — they require live credentials +//! via environment variables and network access to `demo.ctraderapi.com:5035`. +//! +//! # Required environment variables +//! +//! - `CTRADER_CLIENT_ID` — OAuth2 application client ID +//! - `CTRADER_CLIENT_SECRET` — OAuth2 application client secret +//! - `CTRADER_ACCESS_TOKEN` — OAuth2 access token +//! - `CTRADER_ACCOUNT_ID` — cTID trader account ID +//! +//! # Running +//! +//! ```bash +//! CTRADER_CLIENT_ID=xxx CTRADER_CLIENT_SECRET=xxx \ +//! CTRADER_ACCESS_TOKEN=xxx CTRADER_ACCOUNT_ID=12345 \ +//! cargo test -p ctrader-openapi --test demo_integration -- --ignored +//! ``` + +#![deny(clippy::unwrap_used, clippy::expect_used)] + +use std::time::Duration; + +use ctrader_openapi::config::{CTraderConfig, CTraderEnvironment}; +use ctrader_openapi::orders; +use ctrader_openapi::proto::{ProtoOaOrderType, ProtoOaTradeSide}; +use ctrader_openapi::CTraderClient; + +/// Convenience error type for integration tests. +type TestResult = Result<(), Box>; + +/// Build a `CTraderConfig` from environment variables. +/// +/// Returns `None` if any required variable is missing or unparseable, +/// which causes tests to skip gracefully. +fn demo_config() -> Option { + let client_id = std::env::var("CTRADER_CLIENT_ID").ok()?; + let client_secret = std::env::var("CTRADER_CLIENT_SECRET").ok()?; + let access_token = std::env::var("CTRADER_ACCESS_TOKEN").ok()?; + let account_id: i64 = std::env::var("CTRADER_ACCOUNT_ID").ok()?.parse().ok()?; + + Some(CTraderConfig { + client_id, + client_secret, + access_token, + account_id, + environment: CTraderEnvironment::Demo, + heartbeat_interval_secs: 10, + request_timeout_ms: 5000, + max_reconnect_attempts: 3, + }) +} + +// ── Test 1: Connect and authenticate ──────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_demo_connect_and_authenticate() -> TestResult { + let config = match demo_config() { + Some(c) => c, + None => { + eprintln!("skipping: CTRADER_* env vars not set"); + return Ok(()); + } + }; + + let account_id = config.account_id; + let client = CTraderClient::connect(config).await?; + + // Verify we got a valid client with the right account ID + assert_eq!(client.account_id(), account_id); + + // Symbol mapper must have been loaded during connect + assert!( + !client.symbols().is_empty(), + "symbol list should not be empty after connect" + ); + + Ok(()) +} + +// ── Test 2: Load and verify symbols ───────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_demo_load_symbols() -> TestResult { + let config = match demo_config() { + Some(c) => c, + None => { + eprintln!("skipping: CTRADER_* env vars not set"); + return Ok(()); + } + }; + + let client = CTraderClient::connect(config).await?; + + // Symbols are loaded during connect — verify EURUSD exists + let eurusd_id = client.symbol_id("EURUSD"); + assert!( + eurusd_id.is_ok(), + "EURUSD should be in the symbol list, got: {eurusd_id:?}" + ); + + let eurusd_info = client.symbols().resolve("EURUSD"); + assert!(eurusd_info.is_some(), "EURUSD should resolve to SymbolInfo"); + + if let Some(info) = eurusd_info { + assert!(info.enabled, "EURUSD should be enabled for trading"); + assert!(info.symbol_id > 0, "EURUSD symbol ID should be positive"); + } + + // Verify total symbol count is reasonable (ICMarkets demo typically has 200+) + assert!( + client.symbols().len() > 10, + "expected >10 symbols, got {}", + client.symbols().len() + ); + + Ok(()) +} + +// ── Test 3: Account info ──────────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_demo_account_info() -> TestResult { + let config = match demo_config() { + Some(c) => c, + None => { + eprintln!("skipping: CTRADER_* env vars not set"); + return Ok(()); + } + }; + + let client = CTraderClient::connect(config).await?; + let info = client.get_account_info().await?; + + // Demo accounts have positive balance + assert!( + info.balance > 0, + "demo account balance should be > 0, got {}", + info.balance + ); + + // Deposit asset ID should be valid + assert!( + info.deposit_asset_id > 0, + "deposit_asset_id should be positive, got {}", + info.deposit_asset_id + ); + + // Registration timestamp should be non-zero + assert!( + info.registration_timestamp > 0, + "registration_timestamp should be positive" + ); + + eprintln!( + "account info: balance={}, leverage=1:{}, deposit_asset={}", + info.balance, + info.leverage_in_cents / 100, + info.deposit_asset_id + ); + + Ok(()) +} + +// ── Test 4: Submit and cancel a limit order ───────────────────────── + +#[tokio::test] +#[ignore] +async fn test_demo_submit_and_cancel_limit_order() -> TestResult { + let config = match demo_config() { + Some(c) => c, + None => { + eprintln!("skipping: CTRADER_* env vars not set"); + return Ok(()); + } + }; + + let client = CTraderClient::connect(config).await?; + + // Subscribe to execution events before placing order + let mut exec_rx = client.subscribe_executions(); + + // Submit a far-from-market BUY LIMIT on EURUSD at a very low price. + // 0.01 lots = 1000 volume, price 0.50000 is far enough from market. + let order_result = client + .submit_order( + "EURUSD", + ProtoOaTradeSide::Buy, + 1000, // 0.01 lots + ProtoOaOrderType::Limit, // limit order + Some(0.50000), // far-from-market limit price + None, // no stop price + None, // no stop loss + None, // no take profit + Some("foxhunt-demo-test".into()), + ) + .await; + + let order_response = match order_result { + Ok(resp) => resp, + Err(e) => { + eprintln!("order submission failed (may be expected on some accounts): {e}"); + return Ok(()); + } + }; + + // Try to extract the order ID from the execution event response + let order_id = orders::extract_order_id(&order_response); + eprintln!( + "order response payload_type={}", + order_response.payload_type + ); + + // Also check the broadcast channel for the execution event + let broadcast_event = tokio::time::timeout(Duration::from_secs(5), exec_rx.recv()).await; + if let Ok(Ok(event)) = broadcast_event { + if let Some(exec_info) = orders::parse_execution_event(&event) { + eprintln!( + "execution event: order_id={}, side={}, volume={}", + exec_info.order_id, exec_info.trade_side, exec_info.volume + ); + } + } + + // If we got an order ID, cancel it + if let Some(oid) = order_id { + eprintln!("cancelling order {oid}"); + let cancel_result = client.cancel_order(oid).await; + match cancel_result { + Ok(_) => eprintln!("order {oid} cancelled successfully"), + Err(e) => eprintln!("cancel failed (order may have been rejected): {e}"), + } + } else { + // Even without an extracted order ID, reconcile to check + eprintln!("could not extract order ID from response, checking via reconcile"); + let reconcile = client.get_positions().await; + if let Ok(r) = reconcile { + eprintln!( + "reconcile: {} positions, {} pending orders", + r.positions.len(), + r.orders.len() + ); + } + } + + Ok(()) +} + +// ── Test 5: Reconcile positions ───────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_demo_reconcile_positions() -> TestResult { + let config = match demo_config() { + Some(c) => c, + None => { + eprintln!("skipping: CTRADER_* env vars not set"); + return Ok(()); + } + }; + + let client = CTraderClient::connect(config).await?; + let result = client.get_positions().await?; + + // Result should be valid (positions/orders may be empty on fresh demo) + eprintln!( + "reconcile result: {} open positions, {} pending orders", + result.positions.len(), + result.orders.len() + ); + + // Verify positions have valid structure if any exist + for pos in &result.positions { + assert!(pos.position_id > 0, "position ID should be positive"); + } + + // Verify orders have valid structure if any exist + for ord in &result.orders { + assert!(ord.order_id > 0, "order ID should be positive"); + } + + Ok(()) +} + +// ── Test 6: Spot subscription ─────────────────────────────────────── + +#[tokio::test] +#[ignore] +async fn test_demo_spot_subscription() -> TestResult { + let config = match demo_config() { + Some(c) => c, + None => { + eprintln!("skipping: CTRADER_* env vars not set"); + return Ok(()); + } + }; + + let client = CTraderClient::connect(config).await?; + + // Subscribe to EURUSD spots + let mut spot_rx = client.subscribe_spots(&["EURUSD"]).await?; + + // Wait for at least one tick within 10 seconds + let tick_result = tokio::time::timeout(Duration::from_secs(10), spot_rx.recv()).await; + + match tick_result { + Ok(Ok(msg)) => { + assert_eq!( + msg.payload_type, + ctrader_openapi::proto::PT_SPOT_EVENT, + "expected SpotEvent payload type" + ); + eprintln!("received spot tick (payload_type={})", msg.payload_type); + } + Ok(Err(e)) => { + // Broadcast channel lagged or closed — acceptable in test + eprintln!("spot rx error (may be lag): {e}"); + } + Err(_elapsed) => { + // Market may be closed (weekend) — not a hard failure + eprintln!("no spot tick received within 10s (market may be closed)"); + } + } + + // Unsubscribe cleanly + let unsub_result = client.unsubscribe_spots(&["EURUSD"]).await; + if let Err(e) = unsub_result { + eprintln!("unsubscribe warning: {e}"); + } + + Ok(()) +} diff --git a/ctrader-openapi/tests/mock_server_tests.rs b/ctrader-openapi/tests/mock_server_tests.rs new file mode 100644 index 000000000..932730c25 --- /dev/null +++ b/ctrader-openapi/tests/mock_server_tests.rs @@ -0,0 +1,1353 @@ +//! 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()); +}