1use crate::auth::Signer;
2use crate::errors::{ErrorCategory, IndodaxError};
3use reqwest::{Client, RequestBuilder, Response, StatusCode};
4use serde::de::DeserializeOwned;
5use std::collections::{HashMap, BTreeMap};
6use tokio::sync::Mutex;
7
8use web_time::{Duration, Instant};
9
10#[cfg(target_arch = "wasm32")]
11async fn sleep(duration: Duration) {
12 let mut cb = |resolve: js_sys::Function, _reject: js_sys::Function| {
13 web_sys::window()
14 .unwrap()
15 .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, duration.as_millis() as i32)
16 .unwrap();
17 };
18 let p = js_sys::Promise::new(&mut cb);
19 wasm_bindgen_futures::JsFuture::from(p).await.unwrap();
20}
21
22#[cfg(not(target_arch = "wasm32"))]
23use tokio::time::sleep;
24
25const PRIVATE_V1_URL: &str = "https://indodax.com/tapi";
26const PRIVATE_V2_BASE: &str = "https://tapi.btcapi.net";
27const WS_TOKEN_URL: &str = "https://indodax.com/api/private_ws/v1/generate_token";
28const MAX_RETRIES: u32 = 3;
29
30#[cfg(not(target_arch = "wasm32"))]
31fn public_base_url() -> String {
32 "https://indodax.com".to_owned()
33}
34
35#[cfg(not(target_arch = "wasm32"))]
36fn api_base_url() -> String {
37 "https://api.indodax.com".to_owned()
38}
39
40#[cfg(target_arch = "wasm32")]
41fn public_base_url() -> String {
42 let base = option_env!("INDODAX_PUBLIC_BASE_URL").unwrap_or("/api/indodax");
43 if base.starts_with("http://") || base.starts_with("https://") {
44 return base.to_owned();
45 }
46
47 let origin = web_sys::window()
48 .and_then(|window| window.location().origin().ok())
49 .unwrap_or_default();
50
51 format!("{origin}{base}")
52}
53
54#[cfg(target_arch = "wasm32")]
55fn api_base_url() -> String {
56 public_base_url()
58}
59
60#[derive(Debug)]
61struct RateLimiterState {
62 tokens: u64,
63 last_refill: Instant,
64}
65
66#[derive(Debug)]
68struct RateLimiter {
69 capacity: u64,
70 refill_per_sec: u64,
71 state: Mutex<RateLimiterState>,
72}
73
74impl RateLimiter {
75 fn new(capacity: u64, refill_per_sec: u64) -> Self {
76 Self {
77 capacity,
78 refill_per_sec,
79 state: Mutex::new(RateLimiterState {
80 tokens: capacity,
81 last_refill: Instant::now(),
82 }),
83 }
84 }
85
86 fn from_env() -> Self {
87 #[cfg(not(target_arch = "wasm32"))]
88 let rps = std::env::var("INDODAX_RATE_LIMIT")
89 .ok()
90 .and_then(|v| v.parse::<u64>().ok())
91 .unwrap_or(5)
92 .max(1);
93
94 #[cfg(target_arch = "wasm32")]
95 let rps = 5;
96
97 Self::new(rps, rps)
98 }
99
100 async fn acquire(&self) {
101 loop {
102 let mut state = self.state.lock().await;
103 let elapsed = state.last_refill.elapsed();
104 if elapsed >= Duration::from_secs(1) {
105 let secs = elapsed.as_secs();
106 let capped = secs.min(60); let add = self.refill_per_sec * capped;
108 state.tokens = state.tokens.saturating_add(add).min(self.capacity);
109 state.last_refill += Duration::from_secs(capped);
110 }
111 if state.tokens > 0 {
112 state.tokens -= 1;
113 return;
114 }
115 let elapsed_ms = elapsed.as_millis().min(u128::from(u64::MAX)) as u64;
116 let wait = if elapsed_ms < 1000 {
117 Duration::from_millis(1000 - elapsed_ms)
118 } else {
119 Duration::from_millis(50)
120 };
121 drop(state);
122 sleep(wait.max(Duration::from_millis(10))).await;
123 }
124 }
125}
126
127#[derive(Debug)]
128pub struct IndodaxClient {
129 http: Client,
130 signer: Option<Signer>,
131 rate_limiter: RateLimiter,
132 ws_token: Option<String>,
133}
134
135#[derive(Debug, serde::Deserialize)]
136pub struct IndodaxV1Response<T> {
137 pub success: i32,
138 #[serde(rename = "return")]
139 pub return_data: Option<T>,
140 pub error: Option<String>,
141 pub error_code: Option<String>,
142}
143
144#[derive(Debug, serde::Deserialize)]
145pub struct IndodaxV2Response<T> {
146 pub data: Option<T>,
147 pub code: Option<i64>,
148 pub error: Option<String>,
149}
150
151#[derive(Debug, serde::Deserialize, serde::Serialize)]
152pub struct TvHistoryResponse {
153 #[serde(rename = "t")]
154 pub time: Vec<u64>,
155 #[serde(rename = "o")]
156 pub open: Vec<f64>,
157 #[serde(rename = "h")]
158 pub high: Vec<f64>,
159 #[serde(rename = "l")]
160 pub low: Vec<f64>,
161 #[serde(rename = "c")]
162 pub close: Vec<f64>,
163 #[serde(rename = "v")]
164 pub volume: Vec<f64>,
165 #[serde(rename = "s")]
166 pub status: String,
167 #[serde(rename = "nextTime", skip_serializing_if = "Option::is_none")]
168 pub next_time: Option<u64>,
169}
170
171impl IndodaxClient {
172 pub fn new(signer: Option<Signer>) -> Result<Self, IndodaxError> {
173 let builder = Client::builder();
174
175 #[cfg(not(target_arch = "wasm32"))]
176 let builder = builder
177 .user_agent(format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")))
178 .timeout(Duration::from_secs(30))
179 .pool_max_idle_per_host(2);
180
181 let http = builder
182 .build()
183 .map_err(|e| IndodaxError::Other(format!("Failed to create HTTP client: {}", e)))?;
184
185 Ok(Self {
186 http,
187 signer,
188 rate_limiter: RateLimiter::from_env(),
189 ws_token: None,
190 })
191 }
192
193 pub fn with_ws_token(mut self, token: Option<String>) -> Self {
194 self.ws_token = token;
195 self
196 }
197
198 pub fn signer(&self) -> Option<&Signer> {
199 self.signer.as_ref()
200 }
201
202 pub fn ws_token(&self) -> Option<&str> {
203 self.ws_token.as_deref()
204 }
205
206 pub fn http_client(&self) -> &Client {
207 &self.http
208 }
209
210 pub async fn get_tradingview_history(
211 &self,
212 symbol: &str,
213 timeframe: &str,
214 from: u64,
215 to: u64,
216 ) -> Result<TvHistoryResponse, IndodaxError> {
217 let from_str = from.to_string();
218 let to_str = to.to_string();
219 let params = [
220 ("symbol", symbol),
221 ("tf", timeframe),
222 ("from", &from_str),
223 ("to", &to_str),
224 ];
225 self.public_get_v2("/tradingview/history_v2", ¶ms).await
226 }
227
228 pub async fn get_webdata(&self, pair: &str) -> Result<serde_json::Value, IndodaxError> {
229 let path = format!("/api/webdata/{}", pair);
230 self.public_get_v2(&path, &[("lang", "indonesia")]).await
231 }
232
233 pub async fn get_chatroom_history(&self) -> Result<serde_json::Value, IndodaxError> {
234 self.public_get_v2("/api/v2/chatroom/history", &[]).await
235 }
236
237 pub async fn get_pairs_v2(&self, pair: Option<&str>) -> Result<serde_json::Value, IndodaxError> {
238 if let Some(p) = pair {
239 self.public_get_v2("/api/pairs_v2", &[("pair", p)]).await
240 } else {
241 self.public_get_v2("/api/pairs_v2", &[]).await
242 }
243 }
244
245 pub async fn get_tv_search(&self) -> Result<serde_json::Value, IndodaxError> {
246 self.public_get_v2("/tradingview/search_v2", &[]).await
247 }
248
249 pub async fn get_terminal_trade(&self, pair: &str) -> Result<serde_json::Value, IndodaxError> {
250 let path = format!("/terminal-trading/trade?pair={}", pair);
251 self.public_get_v2(&path, &[]).await
252 }
253
254 pub async fn get_terminal_market_data(&self, pair: &str) -> Result<serde_json::Value, IndodaxError> {
255 let path = format!("/terminal-trading/market/data?pair={}", pair);
256 self.public_get_v2(&path, &[]).await
257 }
258
259 pub async fn get_terminal_market_category(&self) -> Result<serde_json::Value, IndodaxError> {
260 self.public_get_v2("/terminal-trading/market/category", &[]).await
261 }
262
263 pub async fn get_onramp_config(&self, pair: &str) -> Result<serde_json::Value, IndodaxError> {
264 let url = format!("{}/deposit-idr/v1/onramp/config?pair={}", api_base_url(), pair);
265 let resp = self.retry_get(&url).await?;
266 self.handle_response(resp).await
267 }
268
269 pub async fn get_news(&self, asset: &str, page: u32) -> Result<String, IndodaxError> {
270 let url = format!("{}/news?page={}&asset={}", public_base_url(), page, asset);
271 let resp = self.retry_get(&url).await?;
272 Ok(resp.text().await?)
273 }
274
275 pub async fn public_get<T: DeserializeOwned>(
276 &self,
277 path: &str,
278 ) -> Result<T, IndodaxError> {
279 let url = format!("{}{}", public_base_url(), path);
280 let resp = self.retry_get(&url).await?;
281 self.handle_response(resp).await
282 }
283
284 pub async fn countdown_cancel_all(
285 &self,
286 pair: Option<&str>,
287 countdown_time: u64,
288 ) -> Result<serde_json::Value, IndodaxError> {
289 let signer = self.signer.as_ref().ok_or_else(|| {
290 IndodaxError::Config("API credentials required for countdown cancel all".into())
291 })?;
292
293 let mut body_parts: Vec<String> = vec![
294 format!("countdownTime={}", countdown_time),
295 ];
296 if let Some(p) = pair {
297 body_parts.push(format!("pair={}", p));
298 }
299
300 let body = body_parts.join("&");
301 let (payload, signature) = signer.sign_v1(&body)?;
302
303 let url = format!("{}/countdownCancelAll", PRIVATE_V1_URL);
304 let req = self
305 .http
306 .post(&url)
307 .header("Key", signer.api_key())
308 .header("Sign", &signature)
309 .header("Content-Type", "application/x-www-form-urlencoded")
310 .body(payload);
311 let resp = self.send_with_retry(req).await?;
312 self.handle_v1_response(resp).await
313 }
314
315 pub async fn generate_ws_token(&self) -> Result<(String, String), IndodaxError> {
316 let signer = self.signer.as_ref().ok_or_else(|| {
317 IndodaxError::Config("API credentials required for WebSocket token generation".into())
318 })?;
319
320 let nonce = signer.next_nonce_str();
321 let (_, signature) = signer.sign_v1(&nonce)?;
322
323 let req = self
324 .http
325 .post(WS_TOKEN_URL)
326 .header("Key", signer.api_key())
327 .header("Sign", &signature)
328 .header("Content-Type", "application/x-www-form-urlencoded")
329 .body(format!("nonce={}", nonce));
330 let resp = self.send_with_retry(req).await?;
331
332 let body_text = resp.text().await?;
333 let val: serde_json::Value = serde_json::from_str(&body_text)?;
334
335 let token = val.get("token")
336 .and_then(|t| t.as_str())
337 .or_else(|| val.get("data").and_then(|d| d.get("token")).and_then(|t| t.as_str()))
338 .or_else(|| val.get("return").and_then(|r| r.get("connToken")).and_then(|t| t.as_str()))
339 .map(|t| t.to_string());
340
341 let channel = val.get("channel")
342 .and_then(|c| c.as_str())
343 .or_else(|| val.get("data").and_then(|d| d.get("channel")).and_then(|c| c.as_str()))
344 .or_else(|| val.get("return").and_then(|r| r.get("channel")).and_then(|c| c.as_str()))
345 .map(|c| c.to_string());
346
347 match (token, channel) {
348 (Some(t), Some(c)) => Ok((t, c)),
349 (Some(t), None) => Ok((t, "private:orders".to_string())), _ => Err(IndodaxError::WsToken(format!("No token or channel in response: {}", body_text))),
351 }
352 }
353
354 pub async fn public_get_v2<T: DeserializeOwned>(
355 &self,
356 path: &str,
357 params: &[(&str, &str)],
358 ) -> Result<T, IndodaxError> {
359 let url = format!("{}{}", public_base_url(), path);
360 let resp = self.retry_get_with_params(&url, params).await?;
361 self.handle_response(resp).await
362 }
363
364 async fn handle_v1_response<T: DeserializeOwned>(
365 &self,
366 resp: Response,
367 ) -> Result<T, IndodaxError> {
368 let body_text = resp.text().await?;
369 let envelope: IndodaxV1Response<T> = serde_json::from_str(&body_text).map_err(|e| {
370 IndodaxError::Parse(format!(
371 "Failed to parse response: {} (body: {})",
372 e, body_text
373 ))
374 })?;
375
376 if envelope.success == 1 {
377 envelope.return_data.ok_or_else(|| {
378 IndodaxError::Parse("API returned success but no 'return' data".into())
379 })
380 } else {
381 Err(IndodaxError::api(
382 envelope.error.unwrap_or_else(|| "Unknown error".into()),
383 match envelope.error_code.as_deref() {
384 Some("invalid_credentials") => ErrorCategory::Authentication,
385 Some("rate_limit") => ErrorCategory::RateLimit,
386 Some(c) if c.contains("invalid") => ErrorCategory::Validation,
387 _ => ErrorCategory::Unknown,
388 },
389 envelope.error_code,
390 ))
391 }
392 }
393
394 pub async fn private_post_v1<T: DeserializeOwned>(
395 &self,
396 method: &str,
397 params: &HashMap<String, String>,
398 ) -> Result<T, IndodaxError> {
399 let signer = self.signer.as_ref().ok_or_else(|| {
400 IndodaxError::Config("API credentials required for private endpoints".into())
401 })?;
402
403 let mut full_params: BTreeMap<String, String> = params
404 .iter()
405 .map(|(k, v)| (k.clone(), v.clone()))
406 .collect();
407
408 full_params.insert("method".into(), method.to_string());
409 full_params.insert("nonce".into(), signer.next_nonce_str());
410
411 let body = serde_urlencoded_str(&full_params);
412 let (_, signature) = signer.sign_v1(&body)?;
413
414 let resp = self
415 .retry_post(PRIVATE_V1_URL, &body, signer.api_key(), &signature)
416 .await?;
417
418 self.handle_v1_response(resp).await
419 }
420
421 pub async fn private_get_v2<T: DeserializeOwned>(
422 &self,
423 path: &str,
424 params: &HashMap<String, String>,
425 ) -> Result<T, IndodaxError> {
426 let signer = self.signer.as_ref().ok_or_else(|| {
427 IndodaxError::Config("API credentials required for private endpoints".into())
428 })?;
429
430 let mut qs_parts: Vec<String> = params
431 .iter()
432 .map(|(k, v)| format!("{}={}", k, v))
433 .collect();
434 let timestamp = crate::now_millis();
435 qs_parts.push(format!("timestamp={}", timestamp));
436 qs_parts.push("recvWindow=5000".to_string());
437 qs_parts.sort();
438 let query_string = qs_parts.join("&");
439
440 let signature = signer.sign_v2(&query_string)?;
441 let url = format!("{}{}?{}", PRIVATE_V2_BASE, path, query_string);
442
443 let req = self
444 .http
445 .get(&url)
446 .header("X-APIKEY", signer.api_key())
447 .header("Sign", &signature)
448 .header("Accept", "application/json")
449 .header("Content-Type", "application/json");
450 let resp = self.send_with_retry(req).await?;
451
452 let body_text = resp.text().await?;
453 let envelope: IndodaxV2Response<T> = serde_json::from_str(&body_text).map_err(|e| {
454 IndodaxError::Parse(format!(
455 "Failed to parse v2 response: {} (body: {})",
456 e, body_text
457 ))
458 })?;
459
460 if let Some(data) = envelope.data {
461 Ok(data)
462 } else if let Some(error) = envelope.error {
463 Err(IndodaxError::api(error, ErrorCategory::Unknown, None))
464 } else {
465 Ok(serde_json::from_str(&body_text)?)
466 }
467 }
468
469 async fn retry_get(&self, url: &str) -> Result<Response, IndodaxError> {
470 let req = self.http.get(url);
471 self.send_with_retry(req).await
472 }
473
474 async fn retry_get_with_params(
475 &self,
476 url: &str,
477 params: &[(&str, &str)],
478 ) -> Result<Response, IndodaxError> {
479 let req = self.http.get(url).query(params);
480 self.send_with_retry(req).await
481 }
482
483 async fn retry_post(
484 &self,
485 url: &str,
486 body: &str,
487 api_key: &str,
488 signature: &str,
489 ) -> Result<Response, IndodaxError> {
490 let req = self
491 .http
492 .post(url)
493 .header("Key", api_key)
494 .header("Sign", signature)
495 .header("Content-Type", "application/x-www-form-urlencoded")
496 .body(body.to_string());
497 self.send_with_retry(req).await
498 }
499
500 async fn send_with_retry(
501 &self,
502 builder: RequestBuilder,
503 ) -> Result<Response, IndodaxError> {
504 self.rate_limiter.acquire().await;
505 let mut last_err = None;
506 let mut total_retries = 0u32;
507 let mut backoff_count = 0u32;
508 let mut current_builder = Some(builder);
509
510 while total_retries <= MAX_RETRIES {
511 if backoff_count > 0 {
512 sleep(Duration::from_millis(500 * 2u64.pow(backoff_count - 1))).await;
513 }
514
515 let req = if total_retries == 0 {
516 let b = current_builder.take().unwrap();
517 current_builder = b.try_clone();
518 b.build()
519 } else {
520 Ok(match ¤t_builder {
521 Some(b) => {
522 let retry_req = b.try_clone().map(|b| b.build());
523 match retry_req {
524 Some(Ok(r)) => r,
525 _ => return Err(last_err.unwrap_or_else(|| IndodaxError::Other("Request not cloneable for retry".into()))),
526 }
527 }
528 None => return Err(last_err.unwrap_or_else(|| IndodaxError::Other("Request not cloneable for retry".into()))),
529 })
530 }.map_err(|e| IndodaxError::Other(format!("Failed to build request: {}", e)))?;
531
532 match self.http.execute(req).await {
533 Ok(resp) => {
534 let status = resp.status();
535 if status.is_success() {
536 return Ok(resp);
537 }
538
539 if status == StatusCode::TOO_MANY_REQUESTS {
540 let retry_after = resp.headers()
541 .get("Retry-After")
542 .and_then(|v| v.to_str().ok())
543 .and_then(|s| s.parse::<u64>().ok())
544 .map(Duration::from_secs);
545 if let Some(delay) = retry_after {
546 sleep(delay).await;
547 backoff_count = 0;
548 } else {
549 backoff_count += 1;
550 }
551 total_retries += 1;
552 last_err = Some(IndodaxError::api(
553 format!("Rate limited (HTTP {})", status.as_u16()),
554 ErrorCategory::RateLimit,
555 None,
556 ));
557
558 if current_builder.is_none() {
559 return Err(last_err.unwrap());
560 }
561 continue;
562 }
563
564 if status.is_server_error() {
565 total_retries += 1;
566 backoff_count += 1;
567 last_err = Some(IndodaxError::api(
568 format!("Server error (HTTP {})", status.as_u16()),
569 ErrorCategory::Server,
570 None,
571 ));
572
573 if current_builder.is_none() {
574 return Err(last_err.unwrap());
575 }
576 continue;
577 }
578
579 last_err = Some(IndodaxError::api(
580 format!("HTTP {}", status.as_u16()),
581 ErrorCategory::Unknown,
582 None,
583 ));
584 break;
585 }
586 Err(e) => {
587 total_retries += 1;
588 backoff_count += 1;
589
590 let is_retryable = e.is_timeout() || {
591 #[cfg(not(target_arch = "wasm32"))]
592 { e.is_connect() }
593 #[cfg(target_arch = "wasm32")]
594 { false }
595 };
596
597 if is_retryable && current_builder.is_some() {
598 last_err = Some(IndodaxError::Http(e));
599 continue;
600 }
601
602 return Err(IndodaxError::Http(e));
603 }
604 }
605 }
606
607 Err(last_err.unwrap_or_else(|| {
608 IndodaxError::Other("Max retries exceeded".into())
609 }))
610 }
611
612 async fn handle_response<T: DeserializeOwned>(
613 &self,
614 resp: Response,
615 ) -> Result<T, IndodaxError> {
616 let body_text = resp.text().await?;
617 serde_json::from_str(&body_text).map_err(|e| {
618 IndodaxError::Parse(format!(
619 "Failed to parse response: {} (body: {})",
620 e, body_text
621 ))
622 })
623 }
624}
625
626fn serde_urlencoded_str(params: &BTreeMap<String, String>) -> String {
627 params
628 .iter()
629 .map(|(k, v)| {
630 format!(
631 "{}={}",
632 url::form_urlencoded::byte_serialize(k.as_bytes()).collect::<String>(),
633 url::form_urlencoded::byte_serialize(v.as_bytes()).collect::<String>()
634 )
635 })
636 .collect::<Vec<_>>()
637 .join("&")
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 use crate::auth::Signer;
644
645 #[test]
646 fn test_indodax_client_new_with_signer() {
647 let signer = Signer::new("key", "secret");
648 let client = IndodaxClient::new(Some(signer)).unwrap();
649 assert!(client.signer().is_some());
650 }
651
652 #[test]
653 fn test_indodax_client_new_without_signer() {
654 let client = IndodaxClient::new(None).unwrap();
655 assert!(client.signer().is_none());
656 }
657
658 #[test]
659 fn test_indodax_client_signer() {
660 let signer = Signer::new("mykey", "mysecret");
661 let client = IndodaxClient::new(Some(signer)).unwrap();
662 let s = client.signer().unwrap();
663 assert_eq!(s.api_key(), "mykey");
664 }
665
666 #[test]
667 fn test_indodax_v1_response_success() {
668 let json = serde_json::json!({
669 "success": 1,
670 "return": {"balance": {"btc": "1.0"}},
671 "error": null,
672 "error_code": null
673 });
674 let resp: IndodaxV1Response<serde_json::Value> = serde_json::from_value(json).unwrap();
675 assert_eq!(resp.success, 1);
676 assert!(resp.return_data.is_some());
677 assert!(resp.error.is_none());
678 }
679
680 #[test]
681 fn test_indodax_v1_response_failure() {
682 let json = serde_json::json!({
683 "success": 0,
684 "return": null,
685 "error": "Invalid credentials",
686 "error_code": "invalid_credentials"
687 });
688 let resp: IndodaxV1Response<serde_json::Value> = serde_json::from_value(json).unwrap();
689 assert_eq!(resp.success, 0);
690 assert!(resp.return_data.is_none());
691 assert!(resp.error.is_some());
692 assert!(resp.error_code.is_some());
693 }
694
695 #[test]
696 fn test_indodax_v2_response_success() {
697 let json = serde_json::json!({
698 "data": {"name": "test"},
699 "code": null,
700 "error": null
701 });
702 let resp: IndodaxV2Response<serde_json::Value> = serde_json::from_value(json).unwrap();
703 assert!(resp.data.is_some());
704 assert!(resp.error.is_none());
705 }
706
707 #[test]
708 fn test_indodax_v2_response_error() {
709 let json = serde_json::json!({
710 "data": null,
711 "code": 400,
712 "error": "Bad request"
713 });
714 let resp: IndodaxV2Response<serde_json::Value> = serde_json::from_value(json).unwrap();
715 assert!(resp.data.is_none());
716 assert!(resp.error.is_some());
717 assert!(resp.code.is_some());
718 }
719
720 #[test]
721 fn test_serde_urlencoded_str_single() {
722 let mut params = std::collections::BTreeMap::new();
723 params.insert("method".into(), "getInfo".into());
724 params.insert("nonce".into(), "12345".into());
725
726 let result = serde_urlencoded_str(¶ms);
727 assert!(result.contains("method=getInfo"));
728 assert!(result.contains("nonce=12345"));
729 }
730
731 #[test]
732 fn test_serde_urlencoded_str_empty() {
733 let params = std::collections::BTreeMap::new();
734 let result = serde_urlencoded_str(¶ms);
735 assert_eq!(result, "");
736 }
737
738 #[test]
739 fn test_serde_urlencoded_str_special_chars() {
740 let mut params = std::collections::BTreeMap::new();
741 params.insert("key with space".into(), "value&more".into());
742
743 let result = serde_urlencoded_str(¶ms);
744 assert!(result.contains("%20") || result.contains("+"));
746 }
747
748 #[test]
749 fn test_public_base_url() {
750 assert!(!public_base_url().is_empty());
752 }
753
754 #[test]
755 fn test_private_v1_url() {
756 assert!(PRIVATE_V1_URL.contains("indodax.com/tapi"));
757 }
758
759 #[test]
760 fn test_private_v2_base() {
761 assert!(PRIVATE_V2_BASE.contains("tapi.btcapi.net"));
762 }
763
764 #[test]
765 fn test_max_retries_constant() {
766 assert_eq!(MAX_RETRIES, 3);
767 }
768
769 #[test]
770 fn test_indodax_v1_response_debug() {
771 let resp: IndodaxV1Response<serde_json::Value> = IndodaxV1Response {
772 success: 1,
773 return_data: Some(serde_json::json!({})),
774 error: None,
775 error_code: None,
776 };
777 let debug_str = format!("{:?}", resp);
778 assert!(debug_str.contains("success"));
779 }
780
781 #[test]
782 fn test_indodax_v2_response_debug() {
783 let resp: IndodaxV2Response<serde_json::Value> = IndodaxV2Response {
784 data: Some(serde_json::json!({})),
785 code: None,
786 error: None,
787 };
788 let debug_str = format!("{:?}", resp);
789 assert!(debug_str.contains("data"));
790 }
791
792 #[test]
793 fn test_rate_limiter_from_env_default() {
794 let rl = RateLimiter::from_env();
796 assert!(rl.capacity > 0);
799 assert!(rl.refill_per_sec > 0);
800 }
801
802 #[tokio::test]
803 async fn test_rate_limiter_acquire_single() {
804 let rl = RateLimiter::new(5, 5);
805 rl.acquire().await;
806 let state = rl.state.lock().await;
807 assert_eq!(state.tokens, 4);
808 }
809
810 #[tokio::test]
811 async fn test_rate_limiter_token_exhaustion_refills() {
812 let rl = RateLimiter::new(3, 10);
813 for _ in 0..3 {
814 rl.acquire().await;
815 }
816 {
817 let state = rl.state.lock().await;
818 assert_eq!(state.tokens, 0);
819 }
820
821 {
822 let mut state = rl.state.lock().await;
823 state.last_refill = Instant::now() - Duration::from_secs(1);
824 }
825
826 rl.acquire().await;
827 let state = rl.state.lock().await;
828 assert_eq!(state.tokens, 2);
829 }
830
831 #[tokio::test]
832 async fn test_rate_limiter_refill_capped_at_capacity() {
833 let rl = RateLimiter::new(5, 100);
834 for _ in 0..5 {
835 rl.acquire().await;
836 }
837 {
838 let state = rl.state.lock().await;
839 assert_eq!(state.tokens, 0);
840 }
841
842 {
843 let mut state = rl.state.lock().await;
844 state.last_refill = Instant::now() - Duration::from_secs(10);
845 }
846
847 rl.acquire().await;
848 let state = rl.state.lock().await;
849 assert_eq!(state.tokens, 4);
850 }
851
852 #[test]
853 fn test_rate_limiter_new_custom() {
854 let rl = RateLimiter::new(25, 25);
855 assert_eq!(rl.capacity, 25);
856 assert_eq!(rl.refill_per_sec, 25);
857 }
858}