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