use std::time::Duration;
use tokio::time::timeout;
use asterdex_sdk::{
AsterDexError, CancelOrderParams, PlaceOrderParams, FuturesClient, UserDataEvent,
WebSocketClient, WebSocketMessage, WebSocketStream,
};
const WS_BASE_URL_DEFAULT: &str = "wss://fstream.asterdex-testnet.com";
const MSG_TIMEOUT_SECS: u64 = 15;
fn build_ws_client() -> WebSocketClient {
let _ = dotenvy::dotenv();
let url = std::env::var("ASTERDEX_WS_BASE_URL")
.unwrap_or_else(|_| WS_BASE_URL_DEFAULT.to_string());
WebSocketClient::new(&url).expect("failed to build WebSocketClient")
}
fn build_rest_client() -> FuturesClient {
let _ = dotenvy::dotenv();
FuturesClient::from_env().expect("FuturesClient::from_env() failed — check .env")
}
async fn recv_typed(stream: &mut WebSocketStream, timeout_secs: u64) -> Option<WebSocketMessage> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
eprintln!("recv_typed: timed out after {timeout_secs}s");
return None;
}
match timeout(remaining, stream.recv()).await {
Ok(Some(Ok(raw))) => {
match serde_json::from_value::<WebSocketMessage>(raw.clone()) {
Ok(WebSocketMessage::Unknown(_)) => {
continue;
}
Ok(msg) => return Some(msg),
Err(e) => {
eprintln!("recv_typed: parse error: {e} — raw: {raw}");
continue;
}
}
}
Ok(Some(Err(e))) => {
eprintln!("recv_typed: stream error: {e:?}");
return None;
}
Ok(None) => {
eprintln!("recv_typed: stream closed");
return None;
}
Err(_elapsed) => {
eprintln!("recv_typed: timed out after {timeout_secs}s");
return None;
}
}
}
}
async fn recv_raw(
stream: &mut WebSocketStream,
timeout_secs: u64,
) -> Option<serde_json::Value> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
eprintln!("recv_raw: timed out after {timeout_secs}s");
return None;
}
match timeout(remaining, stream.recv()).await {
Ok(Some(Ok(raw))) => {
if raw.get("result").is_some() || raw.get("id").is_some() {
continue;
}
return Some(raw);
}
Ok(Some(Err(e))) => {
eprintln!("recv_raw: stream error: {e:?}");
return None;
}
Ok(None) => return None,
Err(_) => return None,
}
}
}
#[tokio::test]
#[ignore]
async fn test_ws_connect_receives_message() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["btcusdt@markPrice"])
.await
.expect("subscribe to btcusdt@markPrice failed");
let msg = recv_raw(&mut stream, MSG_TIMEOUT_SECS).await;
assert!(
msg.is_some(),
"no message received from btcusdt@markPrice within {MSG_TIMEOUT_SECS}s"
);
}
#[tokio::test]
#[ignore]
async fn test_ws_mark_price_stream() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["btcusdt@markPrice"])
.await
.expect("subscribe to btcusdt@markPrice failed");
let msg = recv_typed(&mut stream, MSG_TIMEOUT_SECS).await;
let msg = msg.expect("no message received within timeout from btcusdt@markPrice");
match msg {
WebSocketMessage::MarkPrice(event) => {
assert_eq!(event.symbol, "BTCUSDT");
let price: f64 = event.mark_price.parse().expect("mark_price not f64");
assert!(price > 0.0, "mark_price must be positive");
assert!(event.next_funding_time > 0);
}
other => panic!("expected MarkPrice, got: {other:?}"),
}
}
#[tokio::test]
#[ignore]
async fn test_ws_mini_ticker_stream() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["btcusdt@miniTicker"])
.await
.expect("subscribe to btcusdt@miniTicker failed");
let msg = recv_typed(&mut stream, MSG_TIMEOUT_SECS).await;
let msg = msg.expect("no message received within timeout from btcusdt@miniTicker");
match msg {
WebSocketMessage::MiniTicker(event) => {
assert_eq!(event.symbol, "BTCUSDT");
let close: f64 = event.close_price.parse().expect("close_price not f64");
assert!(close > 0.0, "close price must be positive");
}
other => panic!("expected MiniTicker, got: {other:?}"),
}
}
#[tokio::test]
#[ignore]
async fn test_ws_ticker_stream() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["btcusdt@ticker"])
.await
.expect("subscribe to btcusdt@ticker failed");
let msg = recv_typed(&mut stream, MSG_TIMEOUT_SECS).await;
let msg = msg.expect("no message received within timeout from btcusdt@ticker");
match msg {
WebSocketMessage::Ticker(event) => {
assert_eq!(event.symbol, "BTCUSDT");
let last: f64 = event.last_price.parse().expect("last_price not f64");
assert!(last > 0.0);
assert!(event.trade_count > 0);
}
other => panic!("expected Ticker, got: {other:?}"),
}
}
#[tokio::test]
#[ignore]
async fn test_ws_agg_trade_stream() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["btcusdt@aggTrade"])
.await
.expect("subscribe to btcusdt@aggTrade failed");
match recv_typed(&mut stream, MSG_TIMEOUT_SECS).await {
None => eprintln!("btcusdt@aggTrade: no message within {MSG_TIMEOUT_SECS}s — low-activity testnet, skipping"),
Some(WebSocketMessage::AggTrade(event)) => {
assert_eq!(event.symbol, "BTCUSDT");
let price: f64 = event.price.parse().expect("price not f64");
assert!(price > 0.0);
assert!(event.agg_trade_id > 0);
}
Some(other) => panic!("expected AggTrade, got: {other:?}"),
}
}
#[tokio::test]
#[ignore]
async fn test_ws_book_ticker_stream() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["btcusdt@bookTicker"])
.await
.expect("subscribe to btcusdt@bookTicker failed");
match recv_typed(&mut stream, MSG_TIMEOUT_SECS).await {
None => eprintln!("btcusdt@bookTicker: no message within {MSG_TIMEOUT_SECS}s — skipping"),
Some(WebSocketMessage::BookTicker(event)) => {
assert_eq!(event.symbol, "BTCUSDT");
let bid: f64 = event.best_bid_price.parse().expect("bid not f64");
let ask: f64 = event.best_ask_price.parse().expect("ask not f64");
assert!(bid > 0.0 && ask > 0.0);
assert!(bid < ask, "bid {bid} >= ask {ask}");
}
Some(other) => panic!("expected BookTicker, got: {other:?}"),
}
}
#[tokio::test]
#[ignore]
async fn test_ws_kline_stream() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["btcusdt@kline_1m"])
.await
.expect("subscribe to btcusdt@kline_1m failed");
match recv_typed(&mut stream, MSG_TIMEOUT_SECS).await {
None => eprintln!("btcusdt@kline_1m: no message within {MSG_TIMEOUT_SECS}s — skipping"),
Some(WebSocketMessage::Kline(event)) => {
assert_eq!(event.symbol, "BTCUSDT");
assert_eq!(event.kline.interval, "1m");
let open: f64 = event.kline.open.parse().expect("open not f64");
assert!(open > 0.0);
}
Some(other) => panic!("expected Kline, got: {other:?}"),
}
}
#[tokio::test]
#[ignore]
async fn test_ws_partial_depth_stream() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["btcusdt@depth5"])
.await
.expect("subscribe to btcusdt@depth5 failed");
let (bids, asks) = match recv_typed(&mut stream, MSG_TIMEOUT_SECS).await {
None => {
eprintln!("btcusdt@depth5: no message within {MSG_TIMEOUT_SECS}s — skipping");
return;
}
Some(WebSocketMessage::PartialDepth(event)) => (event.bids, event.asks),
Some(WebSocketMessage::DiffDepth(event)) => (event.bids, event.asks),
Some(other) => panic!("expected PartialDepth or DiffDepth, got: {other:?}"),
};
assert!(!bids.is_empty(), "bids is empty");
assert!(!asks.is_empty(), "asks is empty");
let best_bid: f64 = bids[0][0].parse().expect("bid price not f64");
let best_ask: f64 = asks[0][0].parse().expect("ask price not f64");
assert!(best_bid > 0.0 && best_ask > 0.0);
assert!(best_bid < best_ask, "bid {best_bid} >= ask {best_ask}");
}
#[tokio::test]
#[ignore]
async fn test_ws_diff_depth_stream() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["btcusdt@depth"])
.await
.expect("subscribe to btcusdt@depth failed");
match recv_typed(&mut stream, MSG_TIMEOUT_SECS).await {
None => eprintln!("btcusdt@depth: no message within {MSG_TIMEOUT_SECS}s — skipping"),
Some(WebSocketMessage::DiffDepth(event)) => {
assert_eq!(event.symbol, "BTCUSDT");
assert!(event.final_update_id >= event.first_update_id);
}
Some(other) => panic!("expected DiffDepth, got: {other:?}"),
}
}
#[tokio::test]
#[ignore]
async fn test_ws_all_mark_prices_stream() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["!markPrice@arr"])
.await
.expect("subscribe to !markPrice@arr failed");
let msg = recv_typed(&mut stream, MSG_TIMEOUT_SECS).await;
let msg = msg.expect("no message from !markPrice@arr within timeout");
match msg {
WebSocketMessage::AllMarkPrices(prices) => {
assert!(!prices.is_empty(), "AllMarkPrices array is empty");
assert!(
prices.iter().any(|mp| mp.symbol == "BTCUSDT"),
"BTCUSDT not in AllMarkPrices"
);
}
other => panic!("expected AllMarkPrices, got: {other:?}"),
}
}
#[tokio::test]
#[ignore]
async fn test_ws_all_mini_tickers_stream() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["!miniTicker@arr"])
.await
.expect("subscribe to !miniTicker@arr failed");
let msg = recv_typed(&mut stream, MSG_TIMEOUT_SECS).await;
let msg = msg.expect("no message from !miniTicker@arr within timeout");
match msg {
WebSocketMessage::AllMiniTickers(tickers) => {
assert!(!tickers.is_empty(), "AllMiniTickers array is empty");
assert!(
tickers.iter().any(|t| t.symbol == "BTCUSDT"),
"BTCUSDT not in AllMiniTickers"
);
}
other => panic!("expected AllMiniTickers, got: {other:?}"),
}
}
#[tokio::test]
#[ignore]
async fn test_ws_multi_stream_subscription() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec![
"btcusdt@markPrice",
"btcusdt@miniTicker",
"btcusdt@ticker",
])
.await
.expect("multi-stream subscribe failed");
let mut seen_mark_price = false;
let mut seen_mini_ticker = false;
let mut seen_ticker = false;
let deadline = tokio::time::Instant::now() + Duration::from_secs(MSG_TIMEOUT_SECS);
while !seen_mark_price || !seen_mini_ticker || !seen_ticker {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match timeout(remaining, stream.recv()).await {
Ok(Some(Ok(raw))) => {
match serde_json::from_value::<WebSocketMessage>(raw) {
Ok(WebSocketMessage::MarkPrice(_)) => seen_mark_price = true,
Ok(WebSocketMessage::MiniTicker(_)) => seen_mini_ticker = true,
Ok(WebSocketMessage::Ticker(_)) => seen_ticker = true,
Ok(WebSocketMessage::Unknown(_)) => {} Ok(_) => {}
Err(_) => {}
}
}
_ => break,
}
}
assert!(seen_mark_price, "did not receive MarkPrice in multi-stream");
assert!(seen_mini_ticker, "did not receive MiniTicker in multi-stream");
assert!(seen_ticker, "did not receive Ticker in multi-stream");
}
#[tokio::test]
#[ignore]
async fn test_ws_user_data_stream_connects() {
let rest = build_rest_client();
let lk_resp = rest
.create_listen_key()
.await
.expect("create_listen_key failed");
let listen_key = lk_resp.data.listen_key.clone();
let ws = build_ws_client();
let mut stream = ws
.subscribe_user_data(&listen_key)
.await
.expect("subscribe_user_data failed");
let _ = timeout(Duration::from_secs(3), stream.recv()).await;
let _ = rest.delete_listen_key(&listen_key).await;
}
#[tokio::test]
#[ignore]
async fn test_ws_user_data_order_update_event() {
let rest = build_rest_client();
let lk_resp = rest
.create_listen_key()
.await
.expect("create_listen_key failed");
let listen_key = lk_resp.data.listen_key.clone();
let ws = build_ws_client();
let mut user_stream = ws
.subscribe_user_data(&listen_key)
.await
.expect("subscribe_user_data failed");
let order = rest
.place_order(PlaceOrderParams {
symbol: "BTCUSDT".to_string(),
side: "BUY".to_string(),
order_type: "LIMIT".to_string(),
quantity: "0.001".to_string(),
price: Some("10000.0".to_string()),
time_in_force: Some("GTC".to_string()),
..Default::default()
})
.await
.expect("place_order failed");
let order_id = order.data.order_id;
let event_received = {
let deadline = tokio::time::Instant::now() + Duration::from_secs(MSG_TIMEOUT_SECS);
let mut found = false;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
eprintln!("test_ws_user_data_order_update_event: timeout waiting for event");
break;
}
match timeout(remaining, user_stream.next_event()).await {
Ok(Some(Ok(UserDataEvent::OrderUpdate(update)))) => {
if update.order.order_id == order_id {
assert_eq!(update.order.symbol, "BTCUSDT");
assert_eq!(update.order.side, "BUY");
assert_eq!(update.order.order_status, "NEW");
found = true;
break;
}
}
Ok(Some(Ok(UserDataEvent::Unknown(_)))) => continue,
Ok(Some(Ok(_))) => continue,
Ok(Some(Err(e))) => {
eprintln!("next_event error: {e:?}");
break;
}
Ok(None) => break,
Err(_) => break,
}
}
found
};
let _ = rest
.cancel_order(CancelOrderParams {
symbol: "BTCUSDT".to_string(),
order_id: Some(order_id),
..Default::default()
})
.await;
let _ = rest.delete_listen_key(&listen_key).await;
assert!(
event_received,
"ORDER_TRADE_UPDATE for order {order_id} was not received within {MSG_TIMEOUT_SECS}s"
);
}
#[tokio::test]
#[ignore]
async fn test_ws_user_data_cancel_event() {
let rest = build_rest_client();
let lk_resp = rest
.create_listen_key()
.await
.expect("create_listen_key failed");
let listen_key = lk_resp.data.listen_key.clone();
let ws = build_ws_client();
let mut user_stream = ws
.subscribe_user_data(&listen_key)
.await
.expect("subscribe_user_data failed");
let order = rest
.place_order(PlaceOrderParams {
symbol: "BTCUSDT".to_string(),
side: "BUY".to_string(),
order_type: "LIMIT".to_string(),
quantity: "0.001".to_string(),
price: Some("10000.0".to_string()),
time_in_force: Some("GTC".to_string()),
..Default::default()
})
.await
.expect("place_order failed");
let order_id = order.data.order_id;
let _ = rest
.cancel_order(CancelOrderParams {
symbol: "BTCUSDT".to_string(),
order_id: Some(order_id),
..Default::default()
})
.await
.expect("cancel_order failed");
let mut saw_canceled = false;
let deadline = tokio::time::Instant::now() + Duration::from_secs(MSG_TIMEOUT_SECS);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
eprintln!("test_ws_user_data_cancel_event: timeout");
break;
}
match timeout(remaining, user_stream.next_event()).await {
Ok(Some(Ok(UserDataEvent::OrderUpdate(update)))) => {
if update.order.order_id == order_id
&& update.order.order_status == "CANCELED"
{
saw_canceled = true;
break;
}
}
Ok(Some(Ok(_))) | Ok(Some(Err(_))) => continue,
_ => break,
}
}
let _ = rest.delete_listen_key(&listen_key).await;
assert!(
saw_canceled,
"CANCELED ORDER_TRADE_UPDATE was not received within {MSG_TIMEOUT_SECS}s"
);
}
#[tokio::test]
#[ignore]
async fn test_ws_empty_streams_returns_error() {
let client = build_ws_client();
let result = client.subscribe(vec![]).await;
assert!(
matches!(
result,
Err(AsterDexError::WebSocketError { ref message }) if message.contains("at least one stream")
),
"expected WebSocketError about empty streams, got: {result:?}"
);
}
#[tokio::test]
#[ignore]
async fn test_ws_max_streams_exceeded_returns_error() {
let client = build_ws_client();
let streams: Vec<&str> = (0..201).map(|_| "btcusdt@aggTrade").collect();
let result = client.subscribe(streams).await;
assert!(
matches!(
result,
Err(AsterDexError::WebSocketError { ref message }) if message.contains("max 200 streams")
),
"expected WebSocketError about max streams, got: {result:?}"
);
}
#[tokio::test]
#[ignore]
async fn test_ws_invalid_url_returns_config_error() {
let result = WebSocketClient::new("not a url :::");
assert!(
matches!(result, Err(AsterDexError::ConfigError { .. })),
"expected ConfigError, got: {result:?}"
);
}
#[tokio::test]
#[ignore]
async fn test_ws_envelope_unwrapped() {
let client = build_ws_client();
let mut stream = client
.subscribe(vec!["btcusdt@markPrice"])
.await
.expect("subscribe failed");
let raw = recv_raw(&mut stream, MSG_TIMEOUT_SECS).await;
let raw = raw.expect("no raw message from btcusdt@markPrice");
assert!(
raw.get("stream").is_none(),
"stream envelope was NOT unwrapped — outer 'stream' key still present"
);
let event_type = raw.get("e").and_then(|e| e.as_str()).unwrap_or("");
assert_eq!(
event_type, "markPriceUpdate",
"expected event type 'markPriceUpdate', got '{event_type}'"
);
}