ccxt_core/
test_config.rs

1//! Test configuration management utilities.
2//!
3//! Provides test environment configuration loading and management, supporting:
4//! - Configuration loading from environment variables
5//! - Configuration loading from `.env` files
6//! - Multi-exchange API credential management
7//! - Test data path management
8//! - Performance benchmark configuration
9
10use serde::Deserialize;
11use std::env;
12use std::path::PathBuf;
13
14/// Configuration loading error types.
15#[derive(Debug, thiserror::Error)]
16pub enum ConfigError {
17    /// Environment variable error
18    #[error("Environment variable error: {0}")]
19    EnvError(#[from] env::VarError),
20
21    /// Configuration parsing error
22    #[error("Configuration parsing error: {0}")]
23    ParseError(String),
24
25    /// File not found error
26    #[error("File not found: {0}")]
27    FileNotFound(String),
28}
29
30/// Main test configuration structure.
31#[derive(Debug, Clone, Deserialize)]
32pub struct TestConfig {
33    /// Whether to skip private tests
34    #[serde(default)]
35    pub skip_private_tests: bool,
36
37    /// Whether to enable integration tests
38    #[serde(default)]
39    pub enable_integration_tests: bool,
40
41    /// Test timeout in milliseconds
42    #[serde(default = "default_timeout")]
43    pub test_timeout_ms: u64,
44
45    /// Binance exchange configuration
46    #[serde(default)]
47    pub binance: ExchangeConfig,
48
49    /// OKX exchange configuration
50    #[serde(default)]
51    pub okx: ExchangeConfig,
52
53    /// Bybit exchange configuration
54    #[serde(default)]
55    pub bybit: ExchangeConfig,
56
57    /// Kraken exchange configuration
58    #[serde(default)]
59    pub kraken: ExchangeConfig,
60
61    /// KuCoin exchange configuration
62    #[serde(default)]
63    pub kucoin: ExchangeConfig,
64
65    /// Hyperliquid exchange configuration
66    #[serde(default)]
67    pub hyperliquid: ExchangeConfig,
68
69    /// Test data configuration
70    #[serde(default)]
71    pub test_data: TestDataConfig,
72
73    /// Performance benchmark configuration
74    #[serde(default)]
75    pub benchmark: BenchmarkConfig,
76}
77
78fn default_timeout() -> u64 {
79    30000
80}
81
82/// Exchange-specific configuration.
83#[derive(Debug, Clone, Default, Deserialize)]
84pub struct ExchangeConfig {
85    /// API key for production environment
86    pub api_key: Option<String>,
87    /// API secret for production environment
88    pub api_secret: Option<String>,
89    /// API key for testnet environment
90    pub testnet_api_key: Option<String>,
91    /// API secret for testnet environment
92    pub testnet_api_secret: Option<String>,
93    /// Whether to use testnet
94    #[serde(default)]
95    pub use_testnet: bool,
96}
97
98/// Test data configuration.
99#[derive(Debug, Clone, Deserialize)]
100pub struct TestDataConfig {
101    /// Test fixtures directory path
102    #[serde(default = "default_fixtures_dir")]
103    pub fixtures_dir: String,
104
105    /// Logging level
106    #[serde(default = "default_log_level")]
107    pub log_level: String,
108}
109
110fn default_fixtures_dir() -> String {
111    "tests/fixtures".to_string()
112}
113
114fn default_log_level() -> String {
115    "info".to_string()
116}
117
118impl Default for TestDataConfig {
119    fn default() -> Self {
120        Self {
121            fixtures_dir: default_fixtures_dir(),
122            log_level: default_log_level(),
123        }
124    }
125}
126
127/// Performance benchmark configuration.
128#[derive(Debug, Clone, Deserialize)]
129pub struct BenchmarkConfig {
130    /// Benchmark sample size
131    #[serde(default = "default_sample_size")]
132    pub sample_size: usize,
133
134    /// Number of warmup iterations
135    #[serde(default = "default_warmup_iterations")]
136    pub warmup_iterations: usize,
137}
138
139fn default_sample_size() -> usize {
140    100
141}
142
143fn default_warmup_iterations() -> usize {
144    10
145}
146
147impl Default for BenchmarkConfig {
148    fn default() -> Self {
149        Self {
150            sample_size: default_sample_size(),
151            warmup_iterations: default_warmup_iterations(),
152        }
153    }
154}
155
156impl Default for TestConfig {
157    fn default() -> Self {
158        Self {
159            skip_private_tests: false,
160            enable_integration_tests: false,
161            test_timeout_ms: default_timeout(),
162            binance: ExchangeConfig::default(),
163            okx: ExchangeConfig::default(),
164            bybit: ExchangeConfig::default(),
165            kraken: ExchangeConfig::default(),
166            kucoin: ExchangeConfig::default(),
167            hyperliquid: ExchangeConfig::default(),
168            test_data: TestDataConfig::default(),
169            benchmark: BenchmarkConfig::default(),
170        }
171    }
172}
173
174impl TestConfig {
175    /// Loads configuration from environment variables with `CCXT_` prefix.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`ConfigError`] if exchange configuration loading fails.
180    pub fn from_env() -> Result<Self, ConfigError> {
181        let mut config = TestConfig::default();
182
183        if let Ok(val) = env::var("CCXT_SKIP_PRIVATE_TESTS") {
184            config.skip_private_tests = val.parse().unwrap_or(false);
185        }
186        if let Ok(val) = env::var("CCXT_ENABLE_INTEGRATION_TESTS") {
187            config.enable_integration_tests = val.parse().unwrap_or(false);
188        }
189        if let Ok(val) = env::var("CCXT_TEST_TIMEOUT_MS") {
190            config.test_timeout_ms = val.parse().unwrap_or(default_timeout());
191        }
192
193        config.binance = Self::load_exchange_config("BINANCE")?;
194        config.okx = Self::load_exchange_config("OKX")?;
195        config.bybit = Self::load_exchange_config("BYBIT")?;
196        config.kraken = Self::load_exchange_config("KRAKEN")?;
197        config.kucoin = Self::load_exchange_config("KUCOIN")?;
198        config.hyperliquid = Self::load_exchange_config("HYPERLIQUID")?;
199
200        if let Ok(val) = env::var("CCXT_FIXTURES_DIR") {
201            config.test_data.fixtures_dir = val;
202        }
203        if let Ok(val) = env::var("CCXT_LOG_LEVEL") {
204            config.test_data.log_level = val;
205        }
206
207        if let Ok(val) = env::var("CCXT_BENCH_SAMPLE_SIZE") {
208            config.benchmark.sample_size = val.parse().unwrap_or(default_sample_size());
209        }
210        if let Ok(val) = env::var("CCXT_BENCH_WARMUP_ITERATIONS") {
211            config.benchmark.warmup_iterations = val.parse().unwrap_or(default_warmup_iterations());
212        }
213
214        Ok(config)
215    }
216
217    /// Loads configuration from a specified `.env` file.
218    ///
219    /// # Arguments
220    ///
221    /// * `path` - Path to the `.env` file
222    ///
223    /// # Errors
224    ///
225    /// Returns [`ConfigError::FileNotFound`] if the file does not exist.
226    #[cfg(feature = "test-utils")]
227    pub fn from_dotenv(path: &str) -> Result<Self, ConfigError> {
228        dotenvy::from_filename(path)
229            .map_err(|e| ConfigError::FileNotFound(format!("{}: {}", path, e)))?;
230        Self::from_env()
231    }
232
233    /// Loads configuration from the default `.env` file.
234    ///
235    /// # Errors
236    ///
237    /// Returns [`ConfigError`] if environment variable loading fails.
238    #[cfg(feature = "test-utils")]
239    pub fn from_default_dotenv() -> Result<Self, ConfigError> {
240        dotenvy::dotenv().ok();
241        Self::from_env()
242    }
243
244    /// Loads configuration for a single exchange from environment variables.
245    fn load_exchange_config(exchange: &str) -> Result<ExchangeConfig, ConfigError> {
246        let mut config = ExchangeConfig::default();
247
248        let api_key_var = format!("{}_API_KEY", exchange);
249        let api_secret_var = format!("{}_API_SECRET", exchange);
250        let testnet_key_var = format!("{}_TESTNET_API_KEY", exchange);
251        let testnet_secret_var = format!("{}_TESTNET_API_SECRET", exchange);
252        let use_testnet_var = format!("{}_USE_TESTNET", exchange);
253
254        config.api_key = env::var(&api_key_var).ok();
255        config.api_secret = env::var(&api_secret_var).ok();
256        config.testnet_api_key = env::var(&testnet_key_var).ok();
257        config.testnet_api_secret = env::var(&testnet_secret_var).ok();
258
259        if let Ok(val) = env::var(&use_testnet_var) {
260            config.use_testnet = val.parse().unwrap_or(false);
261        }
262
263        Ok(config)
264    }
265
266    /// Checks whether private tests should be skipped.
267    pub fn should_skip_private_tests(&self) -> bool {
268        self.skip_private_tests
269    }
270
271    /// Checks whether integration tests are enabled.
272    pub fn is_integration_enabled(&self) -> bool {
273        self.enable_integration_tests
274    }
275
276    /// Checks whether Binance credentials are available.
277    pub fn has_binance_credentials(&self) -> bool {
278        self.binance.has_credentials()
279    }
280
281    /// Checks whether OKX credentials are available.
282    pub fn has_okx_credentials(&self) -> bool {
283        self.okx.has_credentials()
284    }
285
286    /// Checks whether Bybit credentials are available.
287    pub fn has_bybit_credentials(&self) -> bool {
288        self.bybit.has_credentials()
289    }
290
291    /// Checks whether Kraken credentials are available.
292    pub fn has_kraken_credentials(&self) -> bool {
293        self.kraken.has_credentials()
294    }
295
296    /// Checks whether KuCoin credentials are available.
297    pub fn has_kucoin_credentials(&self) -> bool {
298        self.kucoin.has_credentials()
299    }
300
301    /// Checks whether Hyperliquid credentials are available.
302    pub fn has_hyperliquid_credentials(&self) -> bool {
303        self.hyperliquid.has_credentials()
304    }
305
306    /// Gets the active API credentials for the specified exchange.
307    ///
308    /// # Arguments
309    ///
310    /// * `exchange` - Exchange name (case-insensitive)
311    ///
312    /// # Returns
313    ///
314    /// Returns `Some((api_key, api_secret))` if credentials exist, `None` otherwise.
315    pub fn get_active_api_key(&self, exchange: &str) -> Option<(String, String)> {
316        let config = match exchange.to_lowercase().as_str() {
317            "binance" => &self.binance,
318            "okx" => &self.okx,
319            "bybit" => &self.bybit,
320            "kraken" => &self.kraken,
321            "kucoin" => &self.kucoin,
322            "hyperliquid" => &self.hyperliquid,
323            _ => return None,
324        };
325
326        config.get_active_credentials()
327    }
328
329    /// Constructs the path to a test fixture file.
330    ///
331    /// # Arguments
332    ///
333    /// * `category` - Fixture category subdirectory
334    /// * `filename` - Fixture filename
335    ///
336    /// # Returns
337    ///
338    /// Returns a [`PathBuf`] to the fixture file.
339    pub fn get_fixture_path(&self, category: &str, filename: &str) -> PathBuf {
340        PathBuf::from(&self.test_data.fixtures_dir)
341            .join(category)
342            .join(filename)
343    }
344}
345
346impl ExchangeConfig {
347    /// Checks whether any credentials are available.
348    pub fn has_credentials(&self) -> bool {
349        if self.use_testnet {
350            self.testnet_api_key.is_some() && self.testnet_api_secret.is_some()
351        } else {
352            self.api_key.is_some() && self.api_secret.is_some()
353        }
354    }
355
356    /// Gets the active credentials based on the `use_testnet` flag.
357    ///
358    /// # Returns
359    ///
360    /// Returns `Some((api_key, api_secret))` if credentials exist, `None` otherwise.
361    pub fn get_active_credentials(&self) -> Option<(String, String)> {
362        if self.use_testnet {
363            match (&self.testnet_api_key, &self.testnet_api_secret) {
364                (Some(key), Some(secret)) => Some((key.clone(), secret.clone())),
365                _ => None,
366            }
367        } else {
368            match (&self.api_key, &self.api_secret) {
369                (Some(key), Some(secret)) => Some((key.clone(), secret.clone())),
370                _ => None,
371            }
372        }
373    }
374}
375
376/// Conditionally skips a test based on a condition.
377///
378/// # Examples
379///
380/// ```ignore
381/// // Version 1: With explicit config and condition
382/// skip_if!(config, config.skip_private_tests, "Private tests disabled");
383///
384/// // Version 2: Simplified version for private tests
385/// skip_if!(private_tests);
386/// ```
387#[macro_export]
388macro_rules! skip_if {
389    ($config:expr, $condition:expr, $reason:expr) => {
390        if $condition {
391            println!("SKIPPED: {}", $reason);
392            return;
393        }
394    };
395
396    (private_tests) => {{
397        #[cfg(feature = "test-utils")]
398        {
399            let config = $crate::test_config::TestConfig::from_default_dotenv().unwrap_or_default();
400            if config.should_skip_private_tests() {
401                println!("SKIPPED: Private tests are disabled");
402                return;
403            }
404        }
405        #[cfg(not(feature = "test-utils"))]
406        {
407            println!("SKIPPED: test-utils feature not enabled");
408            return;
409        }
410    }};
411}
412
413/// Requires exchange credentials to run a test.
414///
415/// Skips the test if credentials are not available for the specified exchange.
416///
417/// # Examples
418///
419/// ```ignore
420/// // Version 1: With explicit config
421/// require_credentials!(config, binance);
422///
423/// // Version 2: Simplified version with auto-loading
424/// require_credentials!(binance);
425/// ```
426#[macro_export]
427macro_rules! require_credentials {
428    ($config:expr, $exchange:ident) => {
429        paste::paste! {
430            if !$config.[<has_ $exchange _credentials>]() {
431                println!("SKIPPED: No {} credentials", stringify!($exchange));
432                return;
433            }
434        }
435    };
436
437    ($exchange:ident) => {{
438        #[cfg(feature = "test-utils")]
439        {
440            let config = $crate::test_config::TestConfig::from_default_dotenv().unwrap_or_default();
441            paste::paste! {
442                if !config.[<has_ $exchange _credentials>]() {
443                    println!("SKIPPED: No {} credentials", stringify!($exchange));
444                    return;
445                }
446            }
447        }
448        #[cfg(not(feature = "test-utils"))]
449        {
450            println!("SKIPPED: test-utils feature not enabled");
451            return;
452        }
453    }};
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn test_default_config() {
462        let config = TestConfig::default();
463        assert!(!config.skip_private_tests);
464        assert!(!config.enable_integration_tests);
465        assert_eq!(config.test_timeout_ms, 30000);
466        assert_eq!(config.test_data.fixtures_dir, "tests/fixtures");
467        assert_eq!(config.benchmark.sample_size, 100);
468    }
469
470    #[test]
471    fn test_exchange_config_no_credentials() {
472        let config = ExchangeConfig::default();
473        assert!(!config.has_credentials());
474        assert!(config.get_active_credentials().is_none());
475    }
476
477    #[test]
478    fn test_exchange_config_with_credentials() {
479        let config = ExchangeConfig {
480            api_key: Some("test_key".to_string()),
481            api_secret: Some("test_secret".to_string()),
482            testnet_api_key: None,
483            testnet_api_secret: None,
484            use_testnet: false,
485        };
486
487        assert!(config.has_credentials());
488        let (key, secret) = config.get_active_credentials().unwrap();
489        assert_eq!(key, "test_key");
490        assert_eq!(secret, "test_secret");
491    }
492
493    #[test]
494    fn test_exchange_config_testnet() {
495        let config = ExchangeConfig {
496            api_key: Some("prod_key".to_string()),
497            api_secret: Some("prod_secret".to_string()),
498            testnet_api_key: Some("test_key".to_string()),
499            testnet_api_secret: Some("test_secret".to_string()),
500            use_testnet: true,
501        };
502
503        assert!(config.has_credentials());
504        let (key, secret) = config.get_active_credentials().unwrap();
505        assert_eq!(key, "test_key");
506        assert_eq!(secret, "test_secret");
507    }
508
509    #[test]
510    fn test_fixture_path() {
511        let config = TestConfig::default();
512        let path = config.get_fixture_path("tickers", "binance_btcusdt.json");
513        assert_eq!(
514            path.to_str().unwrap(),
515            "tests/fixtures/tickers/binance_btcusdt.json"
516        );
517    }
518
519    #[test]
520    fn test_from_env_with_defaults() {
521        // 测试在没有环境变量时使用默认值
522        let config = TestConfig::from_env().unwrap();
523        assert_eq!(config.test_timeout_ms, 30000);
524        assert_eq!(config.test_data.fixtures_dir, "tests/fixtures");
525    }
526}