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

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

337 lines
11 KiB
Rust

//! 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(())
}