test(ctrader-openapi): mock server CI tests and demo integration tests
Add 66 mock server tests covering codec, rate limiter, config, order builders, volume conversion, proto helpers, and error types. Add 6 demo integration tests (ignored by default, gated behind env vars). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
336
ctrader-openapi/tests/demo_integration.rs
Normal file
336
ctrader-openapi/tests/demo_integration.rs
Normal file
@@ -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<dyn std::error::Error>>;
|
||||
|
||||
/// 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<CTraderConfig> {
|
||||
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(())
|
||||
}
|
||||
1353
ctrader-openapi/tests/mock_server_tests.rs
Normal file
1353
ctrader-openapi/tests/mock_server_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user