#![cfg(not(target_arch = "wasm32"))]
use std::time::Duration;
use digdigdig3_station::{
AccountType, ConsumerQuota, ConsumerWhitelist, ExchangeId, QuotaError, Station, Stream,
SubscriptionSet,
};
use digdigdig3_station::series::Kind;
#[tokio::test]
async fn quota_default_is_unlimited() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let consumer = station.register_consumer(ConsumerQuota::unlimited());
let set = SubscriptionSet::new()
.add(ExchangeId::Binance, "BTC-USDT", AccountType::Spot, [Stream::Trade])
.add(ExchangeId::Binance, "ETH-USDT", AccountType::Spot, [Stream::Trade])
.add(ExchangeId::Binance, "SOL-USDT", AccountType::Spot, [Stream::Trade])
.add(ExchangeId::Bybit, "BTC-USDT", AccountType::FuturesCross, [Stream::Trade])
.add(ExchangeId::Bybit, "ETH-USDT", AccountType::FuturesCross, [Stream::Trade]);
let result = consumer.subscribe(set).await;
match result {
Err(QuotaError::SubsCapExceeded { .. }) => {
panic!("unlimited quota must not return SubsCapExceeded")
}
Err(QuotaError::NotInWhitelist(_)) => {
panic!("unlimited quota must not return NotInWhitelist")
}
_ => {} }
let count = consumer.active_sub_count().await;
assert!(count <= 5, "at most 5 streams were requested; got {count}");
}
#[tokio::test]
async fn quota_subs_cap_rejects_atomic_or_nothing() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let consumer = station.register_consumer(
ConsumerQuota::default().max_active_subs(2),
);
let set = SubscriptionSet::new().add(
ExchangeId::Binance,
"BTC-USDT",
AccountType::Spot,
[Stream::Trade, Stream::Ticker, Stream::Orderbook], );
let err = consumer.subscribe(set).await
.expect_err("3 streams with cap=2 must fail");
match err {
QuotaError::SubsCapExceeded { have, cap } => {
assert_eq!(cap, 2, "cap must be 2");
assert_eq!(have, 3, "have must be 3 (requested)");
}
other => panic!("expected SubsCapExceeded, got {other:?}"),
}
assert_eq!(consumer.active_sub_count().await, 0,
"atomic: zero subs after cap rejection");
assert_eq!(station.active_streams(), 0,
"no mux spawned after cap rejection");
}
#[tokio::test]
async fn quota_cap_zero_rejects_any_sub() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let consumer = station.register_consumer(
ConsumerQuota::default().max_active_subs(0),
);
let set = SubscriptionSet::new().add(
ExchangeId::Binance,
"BTC-USDT",
AccountType::Spot,
[Stream::Trade],
);
let err = consumer.subscribe(set).await
.expect_err("cap=0 must reject any sub");
assert!(
matches!(err, QuotaError::SubsCapExceeded { cap: 0, .. }),
"expected SubsCapExceeded with cap=0"
);
}
#[tokio::test]
async fn quota_whitelist_rejects_wrong_exchange() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let wl = ConsumerWhitelist::new().allow_exchange(ExchangeId::Binance);
let consumer = station.register_consumer(
ConsumerQuota::default().whitelist(wl),
);
let set = SubscriptionSet::new().add(
ExchangeId::Bybit, "BTC-USDT",
AccountType::FuturesCross,
[Stream::Trade],
);
let err = consumer.subscribe(set).await
.expect_err("wrong exchange must be rejected by whitelist");
assert!(
matches!(err, QuotaError::NotInWhitelist(_)),
"expected NotInWhitelist, got {err:?}"
);
assert_eq!(consumer.active_sub_count().await, 0);
}
#[tokio::test]
async fn quota_whitelist_rejects_wrong_kind() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let wl = ConsumerWhitelist::new()
.allow_exchange(ExchangeId::Binance)
.allow_kind(Kind::Trade);
let consumer = station.register_consumer(
ConsumerQuota::default().whitelist(wl),
);
let set = SubscriptionSet::new().add(
ExchangeId::Binance,
"BTC-USDT",
AccountType::Spot,
[Stream::Ticker], );
let err = consumer.subscribe(set).await
.expect_err("wrong kind must be rejected");
assert!(matches!(err, QuotaError::NotInWhitelist(_)));
}
#[tokio::test]
async fn quota_whitelist_rejects_wrong_symbol() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let wl = ConsumerWhitelist::new()
.allow_exchange(ExchangeId::Binance)
.allow_symbol("BTC-USDT");
let consumer = station.register_consumer(
ConsumerQuota::default().whitelist(wl),
);
let set = SubscriptionSet::new().add(
ExchangeId::Binance,
"ETH-USDT", AccountType::Spot,
[Stream::Trade],
);
let err = consumer.subscribe(set).await
.expect_err("symbol not in whitelist must be rejected");
assert!(matches!(err, QuotaError::NotInWhitelist(_)));
}
#[tokio::test]
async fn quota_whitelist_fires_before_cap() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let wl = ConsumerWhitelist::new().allow_exchange(ExchangeId::Binance);
let consumer = station.register_consumer(
ConsumerQuota::default()
.max_active_subs(0)
.whitelist(wl),
);
let set = SubscriptionSet::new().add(
ExchangeId::Bybit, "BTC-USDT",
AccountType::FuturesCross,
[Stream::Trade],
);
let err = consumer.subscribe(set).await
.expect_err("must fail");
assert!(
matches!(err, QuotaError::NotInWhitelist(_)),
"whitelist must fire before cap check, got {err:?}"
);
}
#[tokio::test]
async fn quota_drop_releases_arc_ref() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let consumer = station.register_consumer(ConsumerQuota::unlimited());
drop(consumer);
assert_eq!(station.active_streams(), 0);
}
#[tokio::test(flavor = "multi_thread")]
async fn quota_rest_token_bucket_refills() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let consumer = station.register_consumer(
ConsumerQuota::default().max_rest(2, Duration::from_millis(200)),
);
assert!(consumer.rest_gate().await.is_ok(), "token 1");
assert!(consumer.rest_gate().await.is_ok(), "token 2");
let err = consumer.rest_gate().await.expect_err("bucket empty");
let remaining_ms = match err {
QuotaError::RestRateLimit { remaining_ms } => remaining_ms,
other => panic!("expected RestRateLimit, got {other:?}"),
};
assert!(remaining_ms > 0, "remaining_ms must be positive");
tokio::time::sleep(Duration::from_millis(210)).await;
assert!(consumer.rest_gate().await.is_ok(), "token after refill");
}
#[tokio::test]
async fn quota_rest_gate_rate_limit() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let consumer = station.register_consumer(
ConsumerQuota::default().max_rest(2, Duration::from_millis(500)),
);
assert!(consumer.rest_gate().await.is_ok(), "token 1");
assert!(consumer.rest_gate().await.is_ok(), "token 2");
let err = consumer.rest_gate().await
.expect_err("third gate must be rate-limited");
assert!(
matches!(err, QuotaError::RestRateLimit { .. }),
"expected RestRateLimit, got {err:?}"
);
}
#[tokio::test]
async fn quota_rest_gate_unlimited_never_blocks() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let consumer = station.register_consumer(ConsumerQuota::unlimited());
for i in 0..100 {
consumer.rest_gate().await
.unwrap_or_else(|e| panic!("gate {i} failed: {e}"));
}
assert_eq!(consumer.rest_tokens_available().await, u32::MAX);
}
#[tokio::test]
#[ignore = "requires live Binance connection"]
async fn quota_live_cap_enforced_after_real_subscribe() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let consumer = station.register_consumer(
ConsumerQuota::default().max_active_subs(1),
);
let set1 = SubscriptionSet::new().add(
ExchangeId::Binance,
"BTC-USDT",
AccountType::Spot,
[Stream::Trade],
);
let report1 = consumer.subscribe(set1).await
.expect("first subscribe must succeed");
assert!(report1.is_fully_ok(), "trade subscribe fully ok");
assert_eq!(consumer.active_sub_count().await, 1);
let set2 = SubscriptionSet::new().add(
ExchangeId::Binance,
"BTC-USDT",
AccountType::Spot,
[Stream::Ticker], );
let err = consumer.subscribe(set2).await
.expect_err("second sub must be cap-rejected");
assert!(matches!(err, QuotaError::SubsCapExceeded { have: 2, cap: 1 }));
}
#[tokio::test]
async fn quota_two_consumers_independent_caps() {
let station = Station::builder()
.build()
.await
.expect("Station::build");
let c1 = station.register_consumer(ConsumerQuota::default().max_active_subs(1));
let c2 = station.register_consumer(ConsumerQuota::default().max_active_subs(2));
let set = SubscriptionSet::new().add(
ExchangeId::Binance,
"BTC-USDT",
AccountType::Spot,
[Stream::Trade, Stream::Ticker],
);
assert!(matches!(
c1.subscribe(set.clone()).await,
Err(QuotaError::SubsCapExceeded { have: 2, cap: 1 })
));
let result = c2.subscribe(set).await;
assert!(
!matches!(result, Err(QuotaError::SubsCapExceeded { .. })),
"c2 cap=2 must not reject 2 streams"
);
}