Skip to main content

indodax_cli/
client.rs

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    // For WASM, we usually proxy through the same origin or a specific path
60    public_base_url()
61}
62
63#[derive(Debug)]
64struct RateLimiterState {
65    tokens: u64,
66    last_refill: Instant,
67}
68
69/// Token-bucket rate limiter for proactive 429 avoidance.
70#[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); // cap to 60s to guard against clock jumps
110                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", &params).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())), // Fallback channel
381            _ => 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 &current_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(&params);
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(&params);
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(&params);
783        // Should be URL encoded
784        assert!(result.contains("%20") || result.contains("+"));
785    }
786
787    #[test]
788    fn test_public_base_url() {
789        // In WASM mode, it might be a relative path like /api/indodax
790        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        // Without env var, should default to 10
834        let rl = RateLimiter::from_env();
835        // If INDODAX_RATE_LIMIT is set in environment, test may fail
836        // so we just verify it doesn't panic
837        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}