Skip to main content

wechat_mp_sdk/api/
openapi.rs

1//! OpenAPI Management API
2//!
3//! Endpoints for managing API quotas, rate limits, and diagnostic information.
4//!
5//! # Endpoints
6//!
7//! - [`OpenApiApi::clear_quota`] - Reset all API call quotas
8//! - [`OpenApiApi::get_api_quota`] - Query API call quota for a specific endpoint
9//! - [`OpenApiApi::clear_api_quota`] - Reset quota for a specific endpoint
10//! - [`OpenApiApi::clear_quota_by_app_secret`] - Reset quota using AppSecret (no token)
11//! - [`OpenApiApi::get_rid_info`] - Get request debug information by rid
12//! - [`OpenApiApi::callback_check`] - Check callback URL connectivity
13//! - [`OpenApiApi::get_api_domain_ip`] - Get WeChat API server IP addresses
14//! - [`OpenApiApi::get_callback_ip`] - Get WeChat callback server IP addresses
15
16use std::fmt;
17use std::sync::Arc;
18
19use serde::{Deserialize, Serialize};
20
21use super::{WechatApi, WechatContext};
22use crate::error::WechatError;
23
24// ============================================================================
25// Request Types (internal)
26// ============================================================================
27
28#[derive(Debug, Clone, Serialize)]
29struct ClearQuotaRequest {
30    appid: String,
31}
32
33#[derive(Debug, Clone, Serialize)]
34struct GetApiQuotaRequest {
35    cgi_path: String,
36}
37
38#[derive(Debug, Clone, Serialize)]
39struct ClearApiQuotaRequest {
40    cgi_path: String,
41}
42
43#[derive(Clone, Serialize)]
44struct ClearQuotaByAppSecretRequest {
45    appid: String,
46    appsecret: String,
47}
48
49impl fmt::Debug for ClearQuotaByAppSecretRequest {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.debug_struct("ClearQuotaByAppSecretRequest")
52            .field("appid", &self.appid)
53            .field("appsecret", &"[REDACTED]")
54            .finish()
55    }
56}
57
58#[derive(Debug, Clone, Serialize)]
59struct GetRidInfoRequest {
60    rid: String,
61}
62
63#[derive(Debug, Clone, Serialize)]
64struct CallbackCheckRequest {
65    action: String,
66    check_operator: String,
67}
68
69// ============================================================================
70// Internal Response Types
71// ============================================================================
72
73/// Internal response for endpoints that only return errcode/errmsg
74#[derive(Debug, Clone, Deserialize)]
75struct BaseApiResponse {
76    #[serde(default)]
77    errcode: i32,
78    #[serde(default)]
79    errmsg: String,
80}
81
82// ============================================================================
83// Public Response Types
84// ============================================================================
85
86/// API quota details
87#[non_exhaustive]
88#[derive(Debug, Clone, Default, Deserialize, Serialize)]
89pub struct QuotaInfo {
90    /// Daily API call limit
91    #[serde(default)]
92    pub daily_limit: i64,
93    /// Number of calls used today
94    #[serde(default)]
95    pub used: i64,
96    /// Remaining calls today
97    #[serde(default)]
98    pub remain: i64,
99}
100
101/// Response from getApiQuota
102#[non_exhaustive]
103#[derive(Debug, Clone, Deserialize, Serialize)]
104pub struct ApiQuotaResponse {
105    /// Quota details
106    #[serde(default)]
107    pub quota: QuotaInfo,
108    /// Error code (0 means success)
109    #[serde(default)]
110    pub(crate) errcode: i32,
111    /// Error message
112    #[serde(default)]
113    pub(crate) errmsg: String,
114}
115
116/// Request debug information
117#[non_exhaustive]
118#[derive(Debug, Clone, Default, Deserialize, Serialize)]
119pub struct RidRequestInfo {
120    /// Request invocation timestamp (Unix epoch seconds)
121    #[serde(default)]
122    pub invoke_time: i64,
123    /// Request cost in milliseconds
124    #[serde(default)]
125    pub cost_in_ms: i64,
126    /// Request URL
127    #[serde(default)]
128    pub request_url: String,
129    /// Request body
130    #[serde(default)]
131    pub request_body: String,
132    /// Response body
133    #[serde(default)]
134    pub response_body: String,
135    /// Client IP address
136    #[serde(default)]
137    pub client_ip: String,
138}
139
140/// Response from getRidInfo
141#[non_exhaustive]
142#[derive(Debug, Clone, Deserialize, Serialize)]
143pub struct RidInfoResponse {
144    /// Request debug information
145    #[serde(default)]
146    pub request: RidRequestInfo,
147    /// Error code (0 means success)
148    #[serde(default)]
149    pub(crate) errcode: i32,
150    /// Error message
151    #[serde(default)]
152    pub(crate) errmsg: String,
153}
154
155/// DNS check result entry
156#[non_exhaustive]
157#[derive(Debug, Clone, Default, Deserialize, Serialize)]
158pub struct DnsInfo {
159    /// IP address
160    #[serde(default)]
161    pub ip: String,
162    /// Real network operator
163    #[serde(default)]
164    pub real_operator: String,
165}
166
167/// Ping check result entry
168#[non_exhaustive]
169#[derive(Debug, Clone, Default, Deserialize, Serialize)]
170pub struct PingInfo {
171    /// IP address
172    #[serde(default)]
173    pub ip: String,
174    /// Source operator
175    #[serde(default)]
176    pub from_operator: String,
177    /// Packet loss rate
178    #[serde(default)]
179    pub package_loss: String,
180    /// Response time
181    #[serde(default)]
182    pub time: String,
183}
184
185/// Response from callbackCheck
186#[non_exhaustive]
187#[derive(Debug, Clone, Deserialize, Serialize)]
188pub struct CallbackCheckResponse {
189    /// DNS check results
190    #[serde(default)]
191    pub dns: Vec<DnsInfo>,
192    /// Ping check results
193    #[serde(default)]
194    pub ping: Vec<PingInfo>,
195    /// Error code (0 means success)
196    #[serde(default)]
197    pub(crate) errcode: i32,
198    /// Error message
199    #[serde(default)]
200    pub(crate) errmsg: String,
201}
202
203/// Response from getApiDomainIp and getCallbackIp
204#[non_exhaustive]
205#[derive(Debug, Clone, Deserialize, Serialize)]
206pub struct IpListResponse {
207    /// List of IP addresses
208    #[serde(default)]
209    pub ip_list: Vec<String>,
210    /// Error code (0 means success)
211    #[serde(default)]
212    pub(crate) errcode: i32,
213    /// Error message
214    #[serde(default)]
215    pub(crate) errmsg: String,
216}
217
218// ============================================================================
219// OpenApiApi
220// ============================================================================
221
222/// OpenAPI Management API
223///
224/// Provides methods for managing API quotas, debugging, and server info.
225pub struct OpenApiApi {
226    context: Arc<WechatContext>,
227}
228
229impl OpenApiApi {
230    /// Create a new OpenApiApi instance
231    pub fn new(context: Arc<WechatContext>) -> Self {
232        Self { context }
233    }
234
235    /// Clear all API call quotas for this appid
236    ///
237    /// POST /cgi-bin/clear_quota?access_token=ACCESS_TOKEN
238    ///
239    /// # Returns
240    /// `Ok(())` on success
241    pub async fn clear_quota(&self) -> Result<(), WechatError> {
242        let body = ClearQuotaRequest {
243            appid: self.context.client.appid().to_string(),
244        };
245        let response: BaseApiResponse = self
246            .context
247            .authed_post("/cgi-bin/clear_quota", &body)
248            .await?;
249        WechatError::check_api(response.errcode, &response.errmsg)?;
250        Ok(())
251    }
252
253    /// Get API call quota for a specific endpoint
254    ///
255    /// POST /cgi-bin/openapi/quota/get?access_token=ACCESS_TOKEN
256    ///
257    /// # Arguments
258    /// * `cgi_path` - The API path to query (e.g., "/cgi-bin/message/custom/send")
259    pub async fn get_api_quota(&self, cgi_path: &str) -> Result<ApiQuotaResponse, WechatError> {
260        let body = GetApiQuotaRequest {
261            cgi_path: cgi_path.to_string(),
262        };
263        let response: ApiQuotaResponse = self
264            .context
265            .authed_post("/cgi-bin/openapi/quota/get", &body)
266            .await?;
267        WechatError::check_api(response.errcode, &response.errmsg)?;
268        Ok(response)
269    }
270
271    /// Clear API call quota for a specific endpoint
272    ///
273    /// POST /cgi-bin/openapi/quota/clear?access_token=ACCESS_TOKEN
274    ///
275    /// # Arguments
276    /// * `cgi_path` - The API path to clear quota for
277    pub async fn clear_api_quota(&self, cgi_path: &str) -> Result<(), WechatError> {
278        let body = ClearApiQuotaRequest {
279            cgi_path: cgi_path.to_string(),
280        };
281        let response: BaseApiResponse = self
282            .context
283            .authed_post("/cgi-bin/openapi/quota/clear", &body)
284            .await?;
285        WechatError::check_api(response.errcode, &response.errmsg)?;
286        Ok(())
287    }
288
289    /// Clear all API call quotas using AppSecret (no access_token required)
290    ///
291    /// POST /cgi-bin/clear_quota/v2
292    ///
293    /// This endpoint authenticates with appid + appsecret directly,
294    /// bypassing the access_token mechanism.
295    pub async fn clear_quota_by_app_secret(&self) -> Result<(), WechatError> {
296        let path = "/cgi-bin/clear_quota/v2";
297        let body = ClearQuotaByAppSecretRequest {
298            appid: self.context.client.appid().to_string(),
299            appsecret: self.context.client.secret().to_string(),
300        };
301        let response: BaseApiResponse = self.context.client.post(path, &body).await?;
302        WechatError::check_api(response.errcode, &response.errmsg)?;
303        Ok(())
304    }
305
306    /// Get request debug information by rid
307    ///
308    /// POST /cgi-bin/openapi/rid/get?access_token=ACCESS_TOKEN
309    ///
310    /// # Arguments
311    /// * `rid` - The request ID to look up
312    pub async fn get_rid_info(&self, rid: &str) -> Result<RidInfoResponse, WechatError> {
313        let body = GetRidInfoRequest {
314            rid: rid.to_string(),
315        };
316        let response: RidInfoResponse = self
317            .context
318            .authed_post("/cgi-bin/openapi/rid/get", &body)
319            .await?;
320        WechatError::check_api(response.errcode, &response.errmsg)?;
321        Ok(response)
322    }
323
324    /// Check callback URL connectivity
325    ///
326    /// POST /cgi-bin/callback/check?access_token=ACCESS_TOKEN
327    ///
328    /// # Arguments
329    /// * `action` - Check action type (e.g., "all", "dns", "ping")
330    /// * `check_operator` - Operator to check (e.g., "DEFAULT", "CHINANET", "UNICOM", "CAP")
331    pub async fn callback_check(
332        &self,
333        action: &str,
334        check_operator: &str,
335    ) -> Result<CallbackCheckResponse, WechatError> {
336        let body = CallbackCheckRequest {
337            action: action.to_string(),
338            check_operator: check_operator.to_string(),
339        };
340        let response: CallbackCheckResponse = self
341            .context
342            .authed_post("/cgi-bin/callback/check", &body)
343            .await?;
344        WechatError::check_api(response.errcode, &response.errmsg)?;
345        Ok(response)
346    }
347
348    /// Get WeChat API server IP addresses
349    ///
350    /// GET /cgi-bin/get_api_domain_ip?access_token=ACCESS_TOKEN
351    pub async fn get_api_domain_ip(&self) -> Result<IpListResponse, WechatError> {
352        let response: IpListResponse = self
353            .context
354            .authed_get("/cgi-bin/get_api_domain_ip", &[])
355            .await?;
356        WechatError::check_api(response.errcode, &response.errmsg)?;
357        Ok(response)
358    }
359
360    /// Get WeChat callback server IP addresses
361    ///
362    /// GET /cgi-bin/getcallbackip?access_token=ACCESS_TOKEN
363    pub async fn get_callback_ip(&self) -> Result<IpListResponse, WechatError> {
364        let response: IpListResponse = self
365            .context
366            .authed_get("/cgi-bin/getcallbackip", &[])
367            .await?;
368        WechatError::check_api(response.errcode, &response.errmsg)?;
369        Ok(response)
370    }
371}
372
373impl WechatApi for OpenApiApi {
374    fn context(&self) -> &WechatContext {
375        &self.context
376    }
377
378    fn api_name(&self) -> &'static str {
379        "openapi"
380    }
381}
382
383// ============================================================================
384// Tests
385// ============================================================================
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::client::WechatClient;
391    use crate::token::TokenManager;
392    use crate::types::{AppId, AppSecret};
393
394    fn create_test_context(base_url: &str) -> Arc<WechatContext> {
395        let appid = AppId::new("wx1234567890abcdef").unwrap();
396        let secret = AppSecret::new("secret1234567890ab").unwrap();
397        let client = Arc::new(
398            WechatClient::builder()
399                .appid(appid)
400                .secret(secret)
401                .base_url(base_url)
402                .build()
403                .unwrap(),
404        );
405        let token_manager = Arc::new(TokenManager::new((*client).clone()));
406        Arc::new(WechatContext::new(client, token_manager))
407    }
408
409    // ---- Deserialization Tests ----
410
411    #[test]
412    fn test_api_quota_response_parse() {
413        let json = r#"{
414            "quota": {
415                "daily_limit": 10000000,
416                "used": 500,
417                "remain": 9999500
418            },
419            "errcode": 0,
420            "errmsg": "ok"
421        }"#;
422
423        let response: ApiQuotaResponse = serde_json::from_str(json).unwrap();
424        assert_eq!(response.quota.daily_limit, 10_000_000);
425        assert_eq!(response.quota.used, 500);
426        assert_eq!(response.quota.remain, 9_999_500);
427        assert_eq!(response.errcode, 0);
428    }
429
430    #[test]
431    fn test_api_quota_response_missing_quota() {
432        let json = r#"{"errcode": 0, "errmsg": "ok"}"#;
433        let response: ApiQuotaResponse = serde_json::from_str(json).unwrap();
434        assert_eq!(response.quota.daily_limit, 0);
435        assert_eq!(response.quota.used, 0);
436        assert_eq!(response.quota.remain, 0);
437    }
438
439    #[test]
440    fn test_rid_info_response_parse() {
441        let json = r#"{
442            "request": {
443                "invoke_time": 1635927298,
444                "cost_in_ms": 100,
445                "request_url": "access_token=xxx",
446                "request_body": "{\"appid\":\"wx1234\"}",
447                "response_body": "{\"errcode\":0}",
448                "client_ip": "1.2.3.4"
449            },
450            "errcode": 0,
451            "errmsg": "ok"
452        }"#;
453
454        let response: RidInfoResponse = serde_json::from_str(json).unwrap();
455        assert_eq!(response.request.invoke_time, 1_635_927_298);
456        assert_eq!(response.request.cost_in_ms, 100);
457        assert_eq!(response.request.client_ip, "1.2.3.4");
458        assert_eq!(response.errcode, 0);
459    }
460
461    #[test]
462    fn test_rid_info_response_missing_request() {
463        let json = r#"{"errcode": 0, "errmsg": "ok"}"#;
464        let response: RidInfoResponse = serde_json::from_str(json).unwrap();
465        assert_eq!(response.request.invoke_time, 0);
466        assert!(response.request.client_ip.is_empty());
467    }
468
469    #[test]
470    fn test_callback_check_response_parse() {
471        let json = r#"{
472            "dns": [
473                {"ip": "1.2.3.4", "real_operator": "unicom"}
474            ],
475            "ping": [
476                {"ip": "1.2.3.4", "from_operator": "cap", "package_loss": "0%", "time": "20.536ms"}
477            ],
478            "errcode": 0,
479            "errmsg": "ok"
480        }"#;
481
482        let response: CallbackCheckResponse = serde_json::from_str(json).unwrap();
483        assert_eq!(response.dns.len(), 1);
484        assert_eq!(response.dns[0].ip, "1.2.3.4");
485        assert_eq!(response.dns[0].real_operator, "unicom");
486        assert_eq!(response.ping.len(), 1);
487        assert_eq!(response.ping[0].from_operator, "cap");
488        assert_eq!(response.ping[0].package_loss, "0%");
489        assert_eq!(response.ping[0].time, "20.536ms");
490    }
491
492    #[test]
493    fn test_callback_check_response_empty_arrays() {
494        let json = r#"{"errcode": 0, "errmsg": "ok"}"#;
495        let response: CallbackCheckResponse = serde_json::from_str(json).unwrap();
496        assert!(response.dns.is_empty());
497        assert!(response.ping.is_empty());
498    }
499
500    #[test]
501    fn test_ip_list_response_parse() {
502        let json = r#"{
503            "ip_list": ["101.226.62.77", "101.226.62.78", "101.226.62.79"],
504            "errcode": 0,
505            "errmsg": "ok"
506        }"#;
507
508        let response: IpListResponse = serde_json::from_str(json).unwrap();
509        assert_eq!(response.ip_list.len(), 3);
510        assert_eq!(response.ip_list[0], "101.226.62.77");
511    }
512
513    #[test]
514    fn test_ip_list_response_empty() {
515        let json = r#"{"errcode": 0, "errmsg": "ok"}"#;
516        let response: IpListResponse = serde_json::from_str(json).unwrap();
517        assert!(response.ip_list.is_empty());
518    }
519
520    #[test]
521    fn test_api_name() {
522        let context = create_test_context("http://localhost:0");
523        let api = OpenApiApi::new(context);
524        assert_eq!(api.api_name(), "openapi");
525    }
526
527    // ---- Wiremock Integration Tests ----
528
529    async fn setup_token_mock(mock_server: &wiremock::MockServer) {
530        use wiremock::matchers::{method, path};
531        use wiremock::{Mock, ResponseTemplate};
532
533        Mock::given(method("GET"))
534            .and(path("/cgi-bin/token"))
535            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
536                "access_token": "test_token",
537                "expires_in": 7200,
538                "errcode": 0,
539                "errmsg": ""
540            })))
541            .mount(mock_server)
542            .await;
543    }
544
545    #[tokio::test]
546    async fn test_clear_quota_success() {
547        use wiremock::matchers::{method, path, query_param};
548        use wiremock::{Mock, MockServer, ResponseTemplate};
549
550        let mock_server = MockServer::start().await;
551        setup_token_mock(&mock_server).await;
552
553        Mock::given(method("POST"))
554            .and(path("/cgi-bin/clear_quota"))
555            .and(query_param("access_token", "test_token"))
556            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
557                "errcode": 0,
558                "errmsg": "ok"
559            })))
560            .mount(&mock_server)
561            .await;
562
563        let context = create_test_context(&mock_server.uri());
564        let api = OpenApiApi::new(context);
565        let result = api.clear_quota().await;
566        assert!(result.is_ok());
567    }
568
569    #[tokio::test]
570    async fn test_clear_quota_api_error() {
571        use wiremock::matchers::{method, path};
572        use wiremock::{Mock, MockServer, ResponseTemplate};
573
574        let mock_server = MockServer::start().await;
575        setup_token_mock(&mock_server).await;
576
577        Mock::given(method("POST"))
578            .and(path("/cgi-bin/clear_quota"))
579            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
580                "errcode": 40013,
581                "errmsg": "invalid appid"
582            })))
583            .mount(&mock_server)
584            .await;
585
586        let context = create_test_context(&mock_server.uri());
587        let api = OpenApiApi::new(context);
588        let result = api.clear_quota().await;
589        assert!(result.is_err());
590        if let Err(WechatError::Api { code, message }) = result {
591            assert_eq!(code, 40013);
592            assert_eq!(message, "invalid appid");
593        } else {
594            panic!("Expected WechatError::Api");
595        }
596    }
597
598    #[tokio::test]
599    async fn test_get_api_quota_success() {
600        use wiremock::matchers::{method, path, query_param};
601        use wiremock::{Mock, MockServer, ResponseTemplate};
602
603        let mock_server = MockServer::start().await;
604        setup_token_mock(&mock_server).await;
605
606        Mock::given(method("POST"))
607            .and(path("/cgi-bin/openapi/quota/get"))
608            .and(query_param("access_token", "test_token"))
609            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
610                "quota": {
611                    "daily_limit": 10000000,
612                    "used": 500,
613                    "remain": 9999500
614                },
615                "errcode": 0,
616                "errmsg": "ok"
617            })))
618            .mount(&mock_server)
619            .await;
620
621        let context = create_test_context(&mock_server.uri());
622        let api = OpenApiApi::new(context);
623        let result = api.get_api_quota("/cgi-bin/message/custom/send").await;
624        assert!(result.is_ok());
625        let response = result.unwrap();
626        assert_eq!(response.quota.daily_limit, 10_000_000);
627        assert_eq!(response.quota.used, 500);
628    }
629
630    #[tokio::test]
631    async fn test_clear_quota_by_app_secret_success() {
632        use wiremock::matchers::{method, path};
633        use wiremock::{Mock, MockServer, ResponseTemplate};
634
635        let mock_server = MockServer::start().await;
636        // No token mock needed — this endpoint doesn't use access_token
637
638        Mock::given(method("POST"))
639            .and(path("/cgi-bin/clear_quota/v2"))
640            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
641                "errcode": 0,
642                "errmsg": "ok"
643            })))
644            .mount(&mock_server)
645            .await;
646
647        let context = create_test_context(&mock_server.uri());
648        let api = OpenApiApi::new(context);
649        let result = api.clear_quota_by_app_secret().await;
650        assert!(result.is_ok());
651    }
652
653    #[tokio::test]
654    async fn test_get_rid_info_success() {
655        use wiremock::matchers::{method, path, query_param};
656        use wiremock::{Mock, MockServer, ResponseTemplate};
657
658        let mock_server = MockServer::start().await;
659        setup_token_mock(&mock_server).await;
660
661        Mock::given(method("POST"))
662            .and(path("/cgi-bin/openapi/rid/get"))
663            .and(query_param("access_token", "test_token"))
664            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
665                "request": {
666                    "invoke_time": 1635927298,
667                    "cost_in_ms": 100,
668                    "request_url": "/cgi-bin/clear_quota",
669                    "request_body": "",
670                    "response_body": "{\"errcode\":0}",
671                    "client_ip": "1.2.3.4"
672                },
673                "errcode": 0,
674                "errmsg": "ok"
675            })))
676            .mount(&mock_server)
677            .await;
678
679        let context = create_test_context(&mock_server.uri());
680        let api = OpenApiApi::new(context);
681        let result = api
682            .get_rid_info("61234567-abcd-1234-abcd-123456789012")
683            .await;
684        assert!(result.is_ok());
685        let response = result.unwrap();
686        assert_eq!(response.request.invoke_time, 1_635_927_298);
687        assert_eq!(response.request.cost_in_ms, 100);
688        assert_eq!(response.request.client_ip, "1.2.3.4");
689    }
690
691    #[tokio::test]
692    async fn test_callback_check_success() {
693        use wiremock::matchers::{method, path, query_param};
694        use wiremock::{Mock, MockServer, ResponseTemplate};
695
696        let mock_server = MockServer::start().await;
697        setup_token_mock(&mock_server).await;
698
699        Mock::given(method("POST"))
700            .and(path("/cgi-bin/callback/check"))
701            .and(query_param("access_token", "test_token"))
702            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
703                "dns": [{"ip": "1.2.3.4", "real_operator": "unicom"}],
704                "ping": [{"ip": "1.2.3.4", "from_operator": "cap", "package_loss": "0%", "time": "20.536ms"}],
705                "errcode": 0,
706                "errmsg": "ok"
707            })))
708            .mount(&mock_server)
709            .await;
710
711        let context = create_test_context(&mock_server.uri());
712        let api = OpenApiApi::new(context);
713        let result = api.callback_check("all", "DEFAULT").await;
714        assert!(result.is_ok());
715        let response = result.unwrap();
716        assert_eq!(response.dns.len(), 1);
717        assert_eq!(response.ping.len(), 1);
718    }
719
720    #[tokio::test]
721    async fn test_get_api_domain_ip_success() {
722        use wiremock::matchers::{method, path, query_param};
723        use wiremock::{Mock, MockServer, ResponseTemplate};
724
725        let mock_server = MockServer::start().await;
726        setup_token_mock(&mock_server).await;
727
728        Mock::given(method("GET"))
729            .and(path("/cgi-bin/get_api_domain_ip"))
730            .and(query_param("access_token", "test_token"))
731            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
732                "ip_list": ["101.226.62.77", "101.226.62.78"],
733                "errcode": 0,
734                "errmsg": "ok"
735            })))
736            .mount(&mock_server)
737            .await;
738
739        let context = create_test_context(&mock_server.uri());
740        let api = OpenApiApi::new(context);
741        let result = api.get_api_domain_ip().await;
742        assert!(result.is_ok());
743        let response = result.unwrap();
744        assert_eq!(response.ip_list.len(), 2);
745        assert_eq!(response.ip_list[0], "101.226.62.77");
746    }
747
748    #[tokio::test]
749    async fn test_get_callback_ip_success() {
750        use wiremock::matchers::{method, path, query_param};
751        use wiremock::{Mock, MockServer, ResponseTemplate};
752
753        let mock_server = MockServer::start().await;
754        setup_token_mock(&mock_server).await;
755
756        Mock::given(method("GET"))
757            .and(path("/cgi-bin/getcallbackip"))
758            .and(query_param("access_token", "test_token"))
759            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
760                "ip_list": ["101.226.103.61", "101.226.103.62"],
761                "errcode": 0,
762                "errmsg": "ok"
763            })))
764            .mount(&mock_server)
765            .await;
766
767        let context = create_test_context(&mock_server.uri());
768        let api = OpenApiApi::new(context);
769        let result = api.get_callback_ip().await;
770        assert!(result.is_ok());
771        let response = result.unwrap();
772        assert_eq!(response.ip_list.len(), 2);
773        assert_eq!(response.ip_list[0], "101.226.103.61");
774    }
775
776    #[test]
777    fn test_clear_quota_by_app_secret_request_debug_redacts_secret() {
778        let request = ClearQuotaByAppSecretRequest {
779            appid: "wx1234567890abcdef".to_string(),
780            appsecret: "top-secret-appsecret".to_string(),
781        };
782
783        let output = format!("{:?}", request);
784        assert!(output.contains("appsecret"));
785        assert!(output.contains("[REDACTED]"));
786        assert!(!output.contains("top-secret-appsecret"));
787    }
788}