Skip to main content

threads_rs/
client.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::time::Duration;
5
6use chrono::{DateTime, Utc};
7use tokio::sync::RwLock;
8
9use crate::constants::{BASE_API_URL, DEFAULT_HTTP_TIMEOUT, VERSION};
10use crate::error;
11use crate::http::{HttpClient, RetryConfig};
12use crate::rate_limit::{RateLimitStatus, RateLimiter, RateLimiterConfig};
13
14// ---------------------------------------------------------------------------
15// Config
16// ---------------------------------------------------------------------------
17
18/// Valid OAuth scopes for the Threads API.
19pub const VALID_SCOPES: &[&str] = &[
20    "threads_basic",
21    "threads_content_publish",
22    "threads_manage_insights",
23    "threads_manage_replies",
24    "threads_read_replies",
25    "threads_manage_mentions",
26    "threads_keyword_search",
27    "threads_delete",
28    "threads_location_tagging",
29    "threads_profile_discovery",
30];
31
32/// Configuration for the Threads API client.
33#[derive(Debug, Clone)]
34pub struct Config {
35    /// OAuth application client ID.
36    pub client_id: String,
37    /// OAuth application client secret.
38    pub client_secret: String,
39    /// OAuth redirect URI.
40    pub redirect_uri: String,
41    /// OAuth scopes to request.
42    pub scopes: Vec<String>,
43    /// HTTP request timeout.
44    pub http_timeout: Duration,
45    /// Retry configuration for failed requests.
46    pub retry_config: RetryConfig,
47    /// Base URL for the Threads API.
48    pub base_url: String,
49    /// User-Agent header value.
50    pub user_agent: String,
51    /// Enable debug logging.
52    pub debug: bool,
53}
54
55impl Config {
56    /// Create a new config with required fields and sensible defaults.
57    pub fn new(
58        client_id: impl Into<String>,
59        client_secret: impl Into<String>,
60        redirect_uri: impl Into<String>,
61    ) -> Self {
62        Self {
63            client_id: client_id.into(),
64            client_secret: client_secret.into(),
65            redirect_uri: redirect_uri.into(),
66            scopes: vec![
67                "threads_basic".into(),
68                "threads_content_publish".into(),
69                "threads_manage_replies".into(),
70                "threads_manage_insights".into(),
71                "threads_read_replies".into(),
72                "threads_manage_mentions".into(),
73                "threads_keyword_search".into(),
74                "threads_delete".into(),
75                "threads_location_tagging".into(),
76                "threads_profile_discovery".into(),
77            ],
78            http_timeout: DEFAULT_HTTP_TIMEOUT,
79            retry_config: RetryConfig::default(),
80            base_url: BASE_API_URL.to_owned(),
81            user_agent: format!("threads-rs/{}", VERSION),
82            debug: false,
83        }
84    }
85
86    /// Create a config from environment variables.
87    ///
88    /// Required: `THREADS_CLIENT_ID`, `THREADS_CLIENT_SECRET`, `THREADS_REDIRECT_URI`.
89    pub fn from_env() -> crate::Result<Self> {
90        let client_id = std::env::var("THREADS_CLIENT_ID").map_err(|_| {
91            error::new_validation_error(
92                0,
93                "THREADS_CLIENT_ID environment variable is required",
94                "",
95                "client_id",
96            )
97        })?;
98
99        let client_secret = std::env::var("THREADS_CLIENT_SECRET").map_err(|_| {
100            error::new_validation_error(
101                0,
102                "THREADS_CLIENT_SECRET environment variable is required",
103                "",
104                "client_secret",
105            )
106        })?;
107
108        let redirect_uri = std::env::var("THREADS_REDIRECT_URI").map_err(|_| {
109            error::new_validation_error(
110                0,
111                "THREADS_REDIRECT_URI environment variable is required",
112                "",
113                "redirect_uri",
114            )
115        })?;
116
117        let mut config = Self::new(client_id, client_secret, redirect_uri);
118
119        if let Ok(scopes) = std::env::var("THREADS_SCOPES") {
120            config.scopes = scopes.split(',').map(|s| s.trim().to_owned()).collect();
121        }
122
123        if let Ok(timeout) = std::env::var("THREADS_HTTP_TIMEOUT") {
124            if let Ok(secs) = timeout.parse::<u64>() {
125                config.http_timeout = Duration::from_secs(secs);
126            }
127        }
128
129        if let Ok(base_url) = std::env::var("THREADS_BASE_URL") {
130            config.base_url = base_url;
131        }
132
133        if let Ok(ua) = std::env::var("THREADS_USER_AGENT") {
134            config.user_agent = ua;
135        }
136
137        if let Ok(debug) = std::env::var("THREADS_DEBUG") {
138            config.debug = debug.parse().unwrap_or(false);
139        }
140
141        if let Ok(retries) = std::env::var("THREADS_MAX_RETRIES") {
142            if let Ok(n) = retries.parse::<u32>() {
143                config.retry_config.max_retries = n;
144            }
145        }
146
147        Ok(config)
148    }
149
150    /// Set defaults for any unset/zero values.
151    pub fn set_defaults(&mut self) {
152        if self.scopes.is_empty() {
153            self.scopes = vec![
154                "threads_basic".into(),
155                "threads_content_publish".into(),
156                "threads_manage_insights".into(),
157                "threads_manage_replies".into(),
158                "threads_read_replies".into(),
159            ];
160        }
161        if self.http_timeout.is_zero() {
162            self.http_timeout = DEFAULT_HTTP_TIMEOUT;
163        }
164        if self.base_url.is_empty() {
165            self.base_url = BASE_API_URL.to_owned();
166        }
167        if self.user_agent.is_empty() {
168            self.user_agent = format!("threads-rs/{}", VERSION);
169        }
170    }
171
172    /// Validate configuration and return an error if invalid.
173    pub fn validate(&self) -> crate::Result<()> {
174        if self.client_id.is_empty() {
175            return Err(error::new_validation_error(
176                0,
177                "ClientID is required",
178                "",
179                "client_id",
180            ));
181        }
182        if self.client_secret.is_empty() {
183            return Err(error::new_validation_error(
184                0,
185                "ClientSecret is required",
186                "",
187                "client_secret",
188            ));
189        }
190        if self.redirect_uri.is_empty() {
191            return Err(error::new_validation_error(
192                0,
193                "RedirectURI is required",
194                "",
195                "redirect_uri",
196            ));
197        }
198        if !self.redirect_uri.starts_with("http://") && !self.redirect_uri.starts_with("https://") {
199            return Err(error::new_validation_error(
200                0,
201                "RedirectURI must be a valid HTTP or HTTPS URL",
202                "",
203                "redirect_uri",
204            ));
205        }
206        if self.scopes.is_empty() {
207            return Err(error::new_validation_error(
208                0,
209                "At least one scope is required",
210                "",
211                "scopes",
212            ));
213        }
214        for scope in &self.scopes {
215            if !VALID_SCOPES.contains(&scope.as_str()) {
216                return Err(error::new_validation_error(
217                    0,
218                    &format!("Invalid scope: {}", scope),
219                    "",
220                    "scopes",
221                ));
222            }
223        }
224        if self.http_timeout.is_zero() {
225            return Err(error::new_validation_error(
226                0,
227                "HTTPTimeout must be positive",
228                "",
229                "http_timeout",
230            ));
231        }
232        if !self.base_url.starts_with("http://") && !self.base_url.starts_with("https://") {
233            return Err(error::new_validation_error(
234                0,
235                "BaseURL must be a valid HTTP or HTTPS URL",
236                "",
237                "base_url",
238            ));
239        }
240        Ok(())
241    }
242}
243
244// ---------------------------------------------------------------------------
245// Token types
246// ---------------------------------------------------------------------------
247
248/// Information about the current access token.
249#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
250pub struct TokenInfo {
251    /// The OAuth access token.
252    pub access_token: String,
253    /// Token type (usually "bearer").
254    pub token_type: String,
255    /// When the token expires.
256    pub expires_at: DateTime<Utc>,
257    /// App-scoped user ID.
258    pub user_id: String,
259    /// When the token was created.
260    pub created_at: DateTime<Utc>,
261}
262
263/// Trait for persistent token storage.
264///
265/// All methods are async so implementations can perform I/O (file, database,
266/// remote store) without blocking the Tokio executor.
267pub trait TokenStorage: Send + Sync {
268    /// Store a token.
269    fn store(
270        &self,
271        token: &TokenInfo,
272    ) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + '_>>;
273    /// Load the stored token.
274    fn load(&self) -> Pin<Box<dyn Future<Output = crate::Result<TokenInfo>> + Send + '_>>;
275    /// Delete the stored token.
276    fn delete(&self) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + '_>>;
277}
278
279/// In-memory token storage (default).
280pub struct MemoryTokenStorage {
281    token: std::sync::Mutex<Option<TokenInfo>>,
282}
283
284impl MemoryTokenStorage {
285    /// Create a new empty in-memory token store.
286    pub fn new() -> Self {
287        Self {
288            token: std::sync::Mutex::new(None),
289        }
290    }
291}
292
293impl Default for MemoryTokenStorage {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299impl TokenStorage for MemoryTokenStorage {
300    fn store(
301        &self,
302        token: &TokenInfo,
303    ) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + '_>> {
304        let token = token.clone();
305        Box::pin(async move {
306            let mut guard = self.token.lock().map_err(|_| {
307                error::new_authentication_error(500, "Token storage lock poisoned", "")
308            })?;
309            *guard = Some(token);
310            Ok(())
311        })
312    }
313
314    fn load(&self) -> Pin<Box<dyn Future<Output = crate::Result<TokenInfo>> + Send + '_>> {
315        Box::pin(async move {
316            let guard = self.token.lock().map_err(|_| {
317                error::new_authentication_error(500, "Token storage lock poisoned", "")
318            })?;
319            guard.clone().ok_or_else(|| {
320                error::new_authentication_error(
321                    401,
322                    "No token stored",
323                    "Token not found in memory storage",
324                )
325            })
326        })
327    }
328
329    fn delete(&self) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + '_>> {
330        Box::pin(async move {
331            let mut guard = self.token.lock().map_err(|_| {
332                error::new_authentication_error(500, "Token storage lock poisoned", "")
333            })?;
334            *guard = None;
335            Ok(())
336        })
337    }
338}
339
340// ---------------------------------------------------------------------------
341// Client
342// ---------------------------------------------------------------------------
343
344/// Thread-safe state behind RwLock.
345struct TokenState {
346    access_token: String,
347    token_info: Option<TokenInfo>,
348}
349
350/// Threads API client. Thread-safe; wrap in `Arc<Client>` to share across tasks.
351pub struct Client {
352    config: Config,
353    pub(crate) http_client: HttpClient,
354    rate_limiter: Option<Arc<RateLimiter>>,
355    pub(crate) token_storage: Box<dyn TokenStorage>,
356    token_state: RwLock<TokenState>,
357}
358
359impl Client {
360    /// Create a new client with the given configuration.
361    pub async fn new(mut config: Config) -> crate::Result<Self> {
362        config.set_defaults();
363        config.validate()?;
364
365        let rate_limiter = Arc::new(RateLimiter::new(&RateLimiterConfig::default()));
366
367        let http_client = HttpClient::new(
368            config.http_timeout,
369            config.retry_config.clone(),
370            Some(Arc::clone(&rate_limiter)),
371            Some(&config.base_url),
372            Some(&config.user_agent),
373        )?;
374
375        let token_storage: Box<dyn TokenStorage> = Box::new(MemoryTokenStorage::new());
376
377        // Try to load existing token
378        let (access_token, token_info) = if let Ok(info) = token_storage.load().await {
379            let at = info.access_token.clone();
380            (at, Some(info))
381        } else {
382            (String::new(), None)
383        };
384
385        Ok(Self {
386            config,
387            http_client,
388            rate_limiter: Some(rate_limiter),
389            token_storage,
390            token_state: RwLock::new(TokenState {
391                access_token,
392                token_info,
393            }),
394        })
395    }
396
397    /// Create a new client with custom token storage.
398    pub async fn with_token_storage(
399        mut config: Config,
400        token_storage: Box<dyn TokenStorage>,
401    ) -> crate::Result<Self> {
402        config.set_defaults();
403        config.validate()?;
404
405        let rate_limiter = Arc::new(RateLimiter::new(&RateLimiterConfig::default()));
406
407        let http_client = HttpClient::new(
408            config.http_timeout,
409            config.retry_config.clone(),
410            Some(Arc::clone(&rate_limiter)),
411            Some(&config.base_url),
412            Some(&config.user_agent),
413        )?;
414
415        let (access_token, token_info) = if let Ok(info) = token_storage.load().await {
416            let at = info.access_token.clone();
417            (at, Some(info))
418        } else {
419            (String::new(), None)
420        };
421
422        Ok(Self {
423            config,
424            http_client,
425            rate_limiter: Some(rate_limiter),
426            token_storage,
427            token_state: RwLock::new(TokenState {
428                access_token,
429                token_info,
430            }),
431        })
432    }
433
434    /// Create a client with a pre-existing access token.
435    ///
436    /// Calls the debug_token endpoint to resolve the user ID and exact
437    /// expiry from the token. Useful for scripts and tests where the
438    /// token is already known.
439    pub async fn with_token(mut config: Config, access_token: &str) -> crate::Result<Self> {
440        config.set_defaults();
441        config.validate()?;
442
443        let rate_limiter = Arc::new(RateLimiter::new(&RateLimiterConfig::default()));
444
445        let http_client = HttpClient::new(
446            config.http_timeout,
447            config.retry_config.clone(),
448            Some(Arc::clone(&rate_limiter)),
449            Some(&config.base_url),
450            Some(&config.user_agent),
451        )?;
452
453        let token_storage: Box<dyn TokenStorage> = Box::new(MemoryTokenStorage::new());
454
455        // Set a temporary token so we can call debug_token
456        let temp_info = TokenInfo {
457            access_token: access_token.to_owned(),
458            token_type: "bearer".into(),
459            expires_at: Utc::now() + chrono::Duration::hours(1),
460            user_id: String::new(),
461            created_at: Utc::now(),
462        };
463
464        let client = Self {
465            config,
466            http_client,
467            rate_limiter: Some(rate_limiter),
468            token_storage,
469            token_state: RwLock::new(TokenState {
470                access_token: access_token.to_owned(),
471                token_info: Some(temp_info),
472            }),
473        };
474
475        // Validate and resolve accurate token info via debug_token
476        let debug_resp = client.debug_token(access_token).await?;
477        client
478            .set_token_from_debug_info(access_token, &debug_resp)
479            .await?;
480
481        Ok(client)
482    }
483
484    /// Create a client from environment variables.
485    pub async fn from_env() -> crate::Result<Self> {
486        let config = Config::from_env()?;
487        Self::new(config).await
488    }
489
490    // ---- Token management ----
491
492    /// Set token information (thread-safe).
493    pub async fn set_token_info(&self, token_info: TokenInfo) -> crate::Result<()> {
494        self.token_storage.store(&token_info).await?;
495        let mut state = self.token_state.write().await;
496        state.access_token = token_info.access_token.clone();
497        state.token_info = Some(token_info);
498        Ok(())
499    }
500
501    /// Get a copy of the current token info.
502    pub async fn get_token_info(&self) -> Option<TokenInfo> {
503        self.token_state.read().await.token_info.clone()
504    }
505
506    /// Returns `true` if the client has a valid access token.
507    pub async fn is_authenticated(&self) -> bool {
508        let state = self.token_state.read().await;
509        !state.access_token.is_empty() && state.token_info.is_some()
510    }
511
512    /// Returns `true` if the current token has expired.
513    pub async fn is_token_expired(&self) -> bool {
514        let state = self.token_state.read().await;
515        match &state.token_info {
516            Some(info) => Utc::now() > info.expires_at,
517            None => true,
518        }
519    }
520
521    /// Returns `true` if the token expires within the given duration.
522    pub async fn is_token_expiring_soon(&self, within: Duration) -> bool {
523        let state = self.token_state.read().await;
524        match &state.token_info {
525            Some(info) => {
526                let threshold = Utc::now()
527                    + chrono::Duration::from_std(within).unwrap_or(chrono::Duration::zero());
528                threshold > info.expires_at
529            }
530            None => true,
531        }
532    }
533
534    /// Clear the current token from the client and storage.
535    pub async fn clear_token(&self) -> crate::Result<()> {
536        self.token_storage.delete().await?;
537        let mut state = self.token_state.write().await;
538        state.access_token.clear();
539        state.token_info = None;
540        Ok(())
541    }
542
543    /// Get the current access token.
544    pub async fn access_token(&self) -> String {
545        self.token_state.read().await.access_token.clone()
546    }
547
548    /// Get the user ID from the current token info.
549    pub(crate) async fn user_id(&self) -> String {
550        self.token_state
551            .read()
552            .await
553            .token_info
554            .as_ref()
555            .map(|t| t.user_id.clone())
556            .unwrap_or_default()
557    }
558
559    // ---- Config access ----
560
561    /// Returns a reference to the client configuration.
562    pub fn config(&self) -> &Config {
563        &self.config
564    }
565
566    /// Consume this client and create a new one with the given config,
567    /// preserving the current token state.
568    pub async fn update_config(self, mut new_config: Config) -> crate::Result<Client> {
569        new_config.set_defaults();
570        new_config.validate()?;
571
572        let rate_limiter = Arc::new(RateLimiter::new(&RateLimiterConfig::default()));
573
574        let http_client = HttpClient::new(
575            new_config.http_timeout,
576            new_config.retry_config.clone(),
577            Some(Arc::clone(&rate_limiter)),
578            Some(&new_config.base_url),
579            Some(&new_config.user_agent),
580        )?;
581
582        let state = self.token_state.read().await;
583        let access_token = state.access_token.clone();
584        let token_info = state.token_info.clone();
585        drop(state);
586
587        Ok(Client {
588            config: new_config,
589            http_client,
590            rate_limiter: Some(rate_limiter),
591            token_storage: self.token_storage,
592            token_state: RwLock::new(TokenState {
593                access_token,
594                token_info,
595            }),
596        })
597    }
598
599    // ---- Rate limit access ----
600
601    /// Returns the current rate limit status.
602    pub async fn rate_limit_status(&self) -> Option<RateLimitStatus> {
603        if let Some(ref rl) = self.rate_limiter {
604            Some(rl.get_status().await)
605        } else {
606            None
607        }
608    }
609
610    /// Returns `true` if we're close to the rate limit.
611    pub async fn is_near_rate_limit(&self, threshold: f64) -> bool {
612        if let Some(ref rl) = self.rate_limiter {
613            rl.is_near_limit(threshold).await
614        } else {
615            false
616        }
617    }
618
619    /// Returns `true` if we're currently rate-limited by the API.
620    pub async fn is_rate_limited(&self) -> bool {
621        if let Some(ref rl) = self.rate_limiter {
622            rl.is_rate_limited().await
623        } else {
624            false
625        }
626    }
627
628    /// Disable rate limiting. Requests will not be throttled.
629    pub async fn disable_rate_limiting(&self) {
630        if let Some(ref rl) = self.rate_limiter {
631            rl.disable().await;
632        }
633    }
634
635    /// Enable rate limiting.
636    pub async fn enable_rate_limiting(&self) {
637        if let Some(ref rl) = self.rate_limiter {
638            rl.enable().await;
639        }
640    }
641
642    /// Wait until the rate limiter allows a request.
643    pub async fn wait_for_rate_limit(&self) -> crate::Result<()> {
644        if let Some(ref rl) = self.rate_limiter {
645            if rl.should_wait().await {
646                rl.wait().await?;
647            }
648        }
649        Ok(())
650    }
651}
652
653// ---------------------------------------------------------------------------
654// Tests
655// ---------------------------------------------------------------------------
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    fn test_config() -> Config {
662        Config::new(
663            "test-client-id",
664            "test-secret",
665            "https://example.com/callback",
666        )
667    }
668
669    #[test]
670    fn test_config_new_defaults() {
671        let cfg = test_config();
672        assert_eq!(cfg.client_id, "test-client-id");
673        assert_eq!(cfg.base_url, BASE_API_URL);
674        assert_eq!(cfg.http_timeout, DEFAULT_HTTP_TIMEOUT);
675        assert!(!cfg.scopes.is_empty());
676    }
677
678    #[test]
679    fn test_config_validate_ok() {
680        let cfg = test_config();
681        cfg.validate().unwrap();
682    }
683
684    #[test]
685    fn test_config_validate_empty_client_id() {
686        let cfg = Config::new("", "secret", "https://example.com/cb");
687        assert!(cfg.validate().is_err());
688    }
689
690    #[test]
691    fn test_config_validate_bad_redirect_uri() {
692        let cfg = Config::new("id", "secret", "not-a-url");
693        assert!(cfg.validate().is_err());
694    }
695
696    #[test]
697    fn test_config_validate_invalid_scope() {
698        let mut cfg = test_config();
699        cfg.scopes.push("invalid_scope".into());
700        assert!(cfg.validate().is_err());
701    }
702
703    #[test]
704    fn test_config_validate_empty_scopes() {
705        let mut cfg = test_config();
706        cfg.scopes.clear();
707        assert!(cfg.validate().is_err());
708    }
709
710    #[tokio::test]
711    async fn test_memory_token_storage() {
712        let storage = MemoryTokenStorage::new();
713        assert!(storage.load().await.is_err());
714
715        let token = TokenInfo {
716            access_token: "test-token".into(),
717            token_type: "Bearer".into(),
718            expires_at: Utc::now() + chrono::Duration::hours(1),
719            user_id: "user-1".into(),
720            created_at: Utc::now(),
721        };
722
723        storage.store(&token).await.unwrap();
724        let loaded = storage.load().await.unwrap();
725        assert_eq!(loaded.access_token, "test-token");
726
727        storage.delete().await.unwrap();
728        assert!(storage.load().await.is_err());
729    }
730
731    #[tokio::test]
732    async fn test_client_new() {
733        let client = Client::new(test_config()).await.unwrap();
734        assert!(!client.is_authenticated().await);
735        assert!(client.is_token_expired().await);
736    }
737
738    #[tokio::test]
739    async fn test_client_set_and_get_token() {
740        let client = Client::new(test_config()).await.unwrap();
741        let token = TokenInfo {
742            access_token: "my-token".into(),
743            token_type: "Bearer".into(),
744            expires_at: Utc::now() + chrono::Duration::hours(1),
745            user_id: "u-123".into(),
746            created_at: Utc::now(),
747        };
748
749        client.set_token_info(token).await.unwrap();
750        assert!(client.is_authenticated().await);
751        assert!(!client.is_token_expired().await);
752        assert_eq!(client.access_token().await, "my-token");
753        assert_eq!(client.user_id().await, "u-123");
754    }
755
756    #[tokio::test]
757    async fn test_client_clear_token() {
758        let client = Client::new(test_config()).await.unwrap();
759        let token = TokenInfo {
760            access_token: "tok".into(),
761            token_type: "Bearer".into(),
762            expires_at: Utc::now() + chrono::Duration::hours(1),
763            user_id: "u-1".into(),
764            created_at: Utc::now(),
765        };
766
767        client.set_token_info(token).await.unwrap();
768        assert!(client.is_authenticated().await);
769
770        client.clear_token().await.unwrap();
771        assert!(!client.is_authenticated().await);
772    }
773
774    #[tokio::test]
775    async fn test_client_token_expiring_soon() {
776        let client = Client::new(test_config()).await.unwrap();
777        let token = TokenInfo {
778            access_token: "tok".into(),
779            token_type: "Bearer".into(),
780            expires_at: Utc::now() + chrono::Duration::minutes(30),
781            user_id: "u-1".into(),
782            created_at: Utc::now(),
783        };
784
785        client.set_token_info(token).await.unwrap();
786        assert!(
787            client
788                .is_token_expiring_soon(Duration::from_secs(3600))
789                .await
790        );
791        assert!(!client.is_token_expiring_soon(Duration::from_secs(60)).await);
792    }
793
794    #[tokio::test]
795    async fn test_client_rate_limit_status() {
796        let client = Client::new(test_config()).await.unwrap();
797        let status = client.rate_limit_status().await;
798        assert!(status.is_some());
799        assert_eq!(status.unwrap().limit, 100);
800    }
801
802    // with_token requires a live API call (debug_token), so it is tested
803    // in examples/validate.rs rather than here.
804
805    #[tokio::test]
806    async fn test_client_update_config() {
807        let client = Client::new(test_config()).await.unwrap();
808        let token = TokenInfo {
809            access_token: "keep-me".into(),
810            token_type: "Bearer".into(),
811            expires_at: Utc::now() + chrono::Duration::hours(1),
812            user_id: "u-1".into(),
813            created_at: Utc::now(),
814        };
815        client.set_token_info(token).await.unwrap();
816
817        let mut new_config = test_config();
818        new_config.debug = true;
819        let new_client = client.update_config(new_config).await.unwrap();
820
821        assert!(new_client.config().debug);
822        assert_eq!(new_client.access_token().await, "keep-me");
823    }
824
825    #[tokio::test]
826    async fn test_client_disable_enable_rate_limiting() {
827        let client = Client::new(test_config()).await.unwrap();
828        assert!(!client.is_rate_limited().await);
829
830        client.disable_rate_limiting().await;
831        // Even if marked rate limited, should not report as limited when disabled
832        // (tested at the rate_limiter level)
833
834        client.enable_rate_limiting().await;
835    }
836
837    #[tokio::test]
838    async fn test_client_wait_for_rate_limit() {
839        let client = Client::new(test_config()).await.unwrap();
840        // Should return immediately when not rate-limited
841        client.wait_for_rate_limit().await.unwrap();
842    }
843}