Skip to main content

zoom_cli/api/
client.rs

1use base64::Engine;
2use base64::engine::general_purpose::STANDARD as BASE64;
3use serde::Serialize;
4use serde::de::DeserializeOwned;
5
6use super::ApiError;
7use super::types::{self, *};
8
9const ZOOM_API_BASE: &str = "https://api.zoom.us/v2";
10const ZOOM_OAUTH_BASE: &str = "https://zoom.us";
11
12/// Maximum number of attempts before giving up on a rate-limited request.
13/// 4 attempts = initial + 3 retries.
14const MAX_RETRY_ATTEMPTS: u32 = 4;
15
16pub struct ZoomClient {
17    http: reqwest::Client,
18    base_url: String,
19    oauth_base_url: String,
20    account_id: String,
21    client_id: String,
22    client_secret: String,
23    token: Option<String>,
24}
25
26impl ZoomClient {
27    pub fn new(account_id: String, client_id: String, client_secret: String) -> Self {
28        let http = reqwest::Client::builder()
29            .timeout(std::time::Duration::from_secs(30))
30            .build()
31            .expect("failed to build HTTP client");
32        Self {
33            http,
34            base_url: ZOOM_API_BASE.to_owned(),
35            oauth_base_url: ZOOM_OAUTH_BASE.to_owned(),
36            account_id,
37            client_id,
38            client_secret,
39            token: None,
40        }
41    }
42
43    /// For tests: skip OAuth flow, use a pre-provided token and a mock base URL.
44    #[cfg(test)]
45    pub fn new_for_test(base_url: String, oauth_base_url: String, token: String) -> Self {
46        let http = reqwest::Client::builder()
47            .timeout(std::time::Duration::from_secs(5))
48            .build()
49            .expect("failed to build HTTP client");
50        Self {
51            http,
52            base_url,
53            oauth_base_url,
54            account_id: "test-account".into(),
55            client_id: "test-client".into(),
56            client_secret: "test-secret".into(),
57            token: Some(token),
58        }
59    }
60
61    async fn ensure_token(&mut self) -> Result<&str, ApiError> {
62        if self.token.is_none() {
63            let token = self.fetch_token().await?;
64            self.token = Some(token);
65        }
66        Ok(self.token.as_deref().unwrap())
67    }
68
69    async fn fetch_token(&self) -> Result<String, ApiError> {
70        let creds = BASE64.encode(format!("{}:{}", self.client_id, self.client_secret));
71        let url = format!(
72            "{}/oauth/token?grant_type=account_credentials&account_id={}",
73            self.oauth_base_url, self.account_id
74        );
75        let resp = self
76            .http
77            .post(&url)
78            .header("Authorization", format!("Basic {creds}"))
79            .header("Content-Type", "application/x-www-form-urlencoded")
80            .send()
81            .await?;
82
83        let status = resp.status();
84        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
85            return Err(ApiError::Auth(
86                "Failed to obtain access token. Check account_id, client_id, client_secret.".into(),
87            ));
88        }
89        if !status.is_success() {
90            let body = resp.text().await.unwrap_or_default();
91            return Err(ApiError::Api {
92                status: status.as_u16(),
93                message: body,
94            });
95        }
96
97        let token_resp: TokenResponse = resp.json().await?;
98        Ok(token_resp.access_token)
99    }
100
101    /// Send a request with automatic token refresh on 401 (exactly one refresh).
102    ///
103    /// This is the inner layer: it handles expired tokens but not rate limiting.
104    async fn send_once(
105        &mut self,
106        build: &impl Fn(&reqwest::Client, &str) -> reqwest::RequestBuilder,
107    ) -> Result<reqwest::Response, ApiError> {
108        let token = self.ensure_token().await?.to_owned();
109        let resp = build(&self.http, &token).send().await?;
110        if resp.status().as_u16() == 401 {
111            // Token may have expired — discard it, fetch a fresh one, retry once.
112            self.token = None;
113            let token = self.ensure_token().await?.to_owned();
114            return Ok(build(&self.http, &token).send().await?);
115        }
116        Ok(resp)
117    }
118
119    /// Send a request, retrying on HTTP 429 with exponential backoff and
120    /// refreshing the bearer token transparently on HTTP 401.
121    ///
122    /// The two retry concerns are independent:
123    /// - **Expired token (401):** handled by `send_once`, which refreshes and
124    ///   retries exactly once. This does not consume a rate-limit retry slot.
125    /// - **Rate limiting (429):** retried up to `MAX_RETRY_ATTEMPTS` times with
126    ///   exponential backoff (1 s → 2 s → 4 s, max 60 s). The `Retry-After`
127    ///   response header is honoured when present.
128    async fn send_with_retry(
129        &mut self,
130        build: impl Fn(&reqwest::Client, &str) -> reqwest::RequestBuilder,
131    ) -> Result<reqwest::Response, ApiError> {
132        let mut delay = std::time::Duration::from_secs(1);
133        for attempt in 0..MAX_RETRY_ATTEMPTS {
134            let resp = self.send_once(&build).await?;
135            let is_last = attempt + 1 >= MAX_RETRY_ATTEMPTS;
136            if resp.status().as_u16() != 429 || is_last {
137                return Ok(resp);
138            }
139            let wait = retry_after_duration(&resp).unwrap_or(delay);
140            tokio::time::sleep(wait).await;
141            delay = (delay * 2).min(std::time::Duration::from_secs(60));
142        }
143        // Every iteration either returns or sleeps and loops; the loop always
144        // terminates via the early return on the last attempt.
145        unreachable!()
146    }
147
148    async fn get<T: DeserializeOwned>(&mut self, path: &str) -> Result<T, ApiError> {
149        let url = format!("{}{}", self.base_url, path);
150        let resp = self
151            .send_with_retry(|http, token| http.get(&url).bearer_auth(token))
152            .await?;
153        self.handle_response(resp).await
154    }
155
156    async fn get_with_query<T: DeserializeOwned>(
157        &mut self,
158        path: &str,
159        params: &[(&str, &str)],
160    ) -> Result<T, ApiError> {
161        let url = format!("{}{}", self.base_url, path);
162        let resp = self
163            .send_with_retry(|http, token| http.get(&url).bearer_auth(token).query(params))
164            .await?;
165        self.handle_response(resp).await
166    }
167
168    /// Fetches all pages of a paginated endpoint, merging results into one value.
169    async fn get_all_pages<T>(
170        &mut self,
171        path: &str,
172        base_params: &[(&str, &str)],
173    ) -> Result<T, ApiError>
174    where
175        T: DeserializeOwned + types::Paginated,
176    {
177        let mut result: T = self.get_with_query(path, base_params).await?;
178        loop {
179            let token = match result.next_page_token() {
180                Some(t) if !t.is_empty() => t.to_owned(),
181                _ => break,
182            };
183            let mut params = base_params.to_vec();
184            params.push(("next_page_token", token.as_str()));
185            let next: T = self.get_with_query(path, &params).await?;
186            result.append_page(next);
187        }
188        Ok(result)
189    }
190
191    async fn post<T: DeserializeOwned, B: Serialize>(
192        &mut self,
193        path: &str,
194        body: &B,
195    ) -> Result<T, ApiError> {
196        let url = format!("{}{}", self.base_url, path);
197        let resp = self
198            .send_with_retry(|http, token| http.post(&url).bearer_auth(token).json(body))
199            .await?;
200        self.handle_response(resp).await
201    }
202
203    async fn patch<B: Serialize>(&mut self, path: &str, body: &B) -> Result<(), ApiError> {
204        let url = format!("{}{}", self.base_url, path);
205        let resp = self
206            .send_with_retry(|http, token| http.patch(&url).bearer_auth(token).json(body))
207            .await?;
208        self.handle_empty_response(resp).await
209    }
210
211    async fn put<B: Serialize>(&mut self, path: &str, body: &B) -> Result<(), ApiError> {
212        let url = format!("{}{}", self.base_url, path);
213        let resp = self
214            .send_with_retry(|http, token| http.put(&url).bearer_auth(token).json(body))
215            .await?;
216        self.handle_empty_response(resp).await
217    }
218
219    async fn delete(&mut self, path: &str) -> Result<(), ApiError> {
220        let url = format!("{}{}", self.base_url, path);
221        let resp = self
222            .send_with_retry(|http, token| http.delete(&url).bearer_auth(token))
223            .await?;
224        self.handle_empty_response(resp).await
225    }
226
227    async fn delete_with_query(
228        &mut self,
229        path: &str,
230        params: &[(&str, &str)],
231    ) -> Result<(), ApiError> {
232        let url = format!("{}{}", self.base_url, path);
233        let resp = self
234            .send_with_retry(|http, token| http.delete(&url).bearer_auth(token).query(params))
235            .await?;
236        self.handle_empty_response(resp).await
237    }
238
239    async fn handle_response<T: DeserializeOwned>(
240        &self,
241        resp: reqwest::Response,
242    ) -> Result<T, ApiError> {
243        let status = resp.status();
244        match status.as_u16() {
245            200..=299 => Ok(resp.json::<T>().await?),
246            401 | 403 => {
247                let body = resp.text().await.unwrap_or_default();
248                Err(ApiError::Auth(body))
249            }
250            404 => {
251                let body = resp.text().await.unwrap_or_default();
252                Err(ApiError::NotFound(body))
253            }
254            429 => Err(ApiError::RateLimit),
255            _ => {
256                let body = resp.text().await.unwrap_or_default();
257                Err(ApiError::Api {
258                    status: status.as_u16(),
259                    message: body,
260                })
261            }
262        }
263    }
264
265    async fn handle_empty_response(&self, resp: reqwest::Response) -> Result<(), ApiError> {
266        let status = resp.status();
267        match status.as_u16() {
268            200..=299 => Ok(()),
269            401 | 403 => {
270                let body = resp.text().await.unwrap_or_default();
271                Err(ApiError::Auth(body))
272            }
273            404 => {
274                let body = resp.text().await.unwrap_or_default();
275                Err(ApiError::NotFound(body))
276            }
277            429 => Err(ApiError::RateLimit),
278            _ => {
279                let body = resp.text().await.unwrap_or_default();
280                Err(ApiError::Api {
281                    status: status.as_u16(),
282                    message: body,
283                })
284            }
285        }
286    }
287
288    // ── Meetings ──────────────────────────────────────────────────────────────
289
290    pub async fn list_meetings(
291        &mut self,
292        user_id: &str,
293        meeting_type: Option<&str>,
294    ) -> Result<MeetingList, ApiError> {
295        let path = format!("/users/{user_id}/meetings");
296        let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
297        let mt_owned;
298        if let Some(mt) = meeting_type {
299            mt_owned = mt.to_owned();
300            params.push(("type", mt_owned.as_str()));
301        }
302        self.get_all_pages(&path, &params).await
303    }
304
305    pub async fn get_meeting(&mut self, meeting_id: u64) -> Result<Meeting, ApiError> {
306        self.get(&format!("/meetings/{meeting_id}")).await
307    }
308
309    pub async fn create_meeting(
310        &mut self,
311        user_id: &str,
312        req: CreateMeetingRequest,
313    ) -> Result<Meeting, ApiError> {
314        self.post(&format!("/users/{user_id}/meetings"), &req).await
315    }
316
317    pub async fn update_meeting(
318        &mut self,
319        meeting_id: u64,
320        req: UpdateMeetingRequest,
321    ) -> Result<(), ApiError> {
322        self.patch(&format!("/meetings/{meeting_id}"), &req).await
323    }
324
325    pub async fn delete_meeting(&mut self, meeting_id: u64) -> Result<(), ApiError> {
326        self.delete(&format!("/meetings/{meeting_id}")).await
327    }
328
329    pub async fn end_meeting(&mut self, meeting_id: u64) -> Result<(), ApiError> {
330        self.put(
331            &format!("/meetings/{meeting_id}/status"),
332            &MeetingStatusRequest {
333                action: "end".into(),
334            },
335        )
336        .await
337    }
338
339    // ── Users ─────────────────────────────────────────────────────────────────
340
341    pub async fn list_users(&mut self, status: Option<&str>) -> Result<UserList, ApiError> {
342        let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
343        let st_owned;
344        if let Some(st) = status {
345            st_owned = st.to_owned();
346            params.push(("status", st_owned.as_str()));
347        }
348        self.get_all_pages("/users", &params).await
349    }
350
351    pub async fn get_user(&mut self, user_id: &str) -> Result<User, ApiError> {
352        self.get(&format!("/users/{user_id}")).await
353    }
354
355    // ── Participants ─────────────────────────────────────────────────────────
356
357    pub async fn list_past_meeting_participants(
358        &mut self,
359        meeting_id: &str,
360    ) -> Result<ParticipantList, ApiError> {
361        let encoded_id = encode_meeting_id(meeting_id);
362        self.get_all_pages(
363            &format!("/past_meetings/{encoded_id}/participants"),
364            &[("page_size", "300")],
365        )
366        .await
367    }
368
369    // ── Recordings ───────────────────────────────────────────────────────────
370
371    pub async fn list_recordings(
372        &mut self,
373        user_id: &str,
374        from: Option<&str>,
375        to: Option<&str>,
376    ) -> Result<RecordingList, ApiError> {
377        let path = format!("/users/{user_id}/recordings");
378        let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
379        let from_owned;
380        let to_owned;
381        if let Some(f) = from {
382            from_owned = f.to_owned();
383            params.push(("from", from_owned.as_str()));
384        }
385        if let Some(t) = to {
386            to_owned = t.to_owned();
387            params.push(("to", to_owned.as_str()));
388        }
389        self.get_all_pages(&path, &params).await
390    }
391
392    /// Delete all cloud recording files for a meeting.
393    ///
394    /// `trash`: when `true`, moves files to the trash (recoverable for 30 days);
395    /// when `false`, permanently deletes them immediately.
396    pub async fn delete_recording(
397        &mut self,
398        meeting_id: &str,
399        trash: bool,
400    ) -> Result<(), ApiError> {
401        let encoded_id = encode_meeting_id(meeting_id);
402        let action = if trash { "trash" } else { "delete" };
403        self.delete_with_query(
404            &format!("/meetings/{encoded_id}/recordings"),
405            &[("action", action)],
406        )
407        .await
408    }
409
410    /// Control recording state for a live meeting (start/stop/pause/resume).
411    pub async fn control_recording(
412        &mut self,
413        meeting_id: u64,
414        action: &str,
415    ) -> Result<(), ApiError> {
416        let req = RecordingControlRequest {
417            action: action.to_owned(),
418        };
419        self.patch(&format!("/live_meetings/{meeting_id}/recordings"), &req)
420            .await
421    }
422
423    pub async fn get_recording(&mut self, meeting_id: &str) -> Result<CloudRecording, ApiError> {
424        let encoded_id = encode_meeting_id(meeting_id);
425        self.get(&format!("/meetings/{encoded_id}/recordings"))
426            .await
427    }
428
429    /// Download a recording file to disk.
430    ///
431    /// Uses `send_with_retry` so expired tokens are refreshed and rate-limit
432    /// retries apply, matching all other API calls. Writes to a `.download`
433    /// temp file and renames atomically on success so a failed or interrupted
434    /// download never leaves a partial file at the destination path.
435    pub async fn download_recording_file(
436        &mut self,
437        download_url: &str,
438        dest_path: &std::path::Path,
439    ) -> Result<u64, ApiError> {
440        use futures_util::StreamExt;
441        use tokio::io::AsyncWriteExt;
442
443        let url = download_url.to_owned();
444        let resp = self
445            .send_with_retry(|http, token| http.get(&url).bearer_auth(token))
446            .await?;
447
448        let status = resp.status();
449        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
450            return Err(ApiError::Auth(
451                "Not authorized to download this recording".into(),
452            ));
453        }
454        if !status.is_success() {
455            let body = resp.text().await.unwrap_or_default();
456            return Err(ApiError::Api {
457                status: status.as_u16(),
458                message: body,
459            });
460        }
461
462        // Stream into a temp file; rename to the final path only on success.
463        let tmp_path = dest_path.with_extension("download");
464        let write_result: Result<u64, ApiError> = async {
465            let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
466                ApiError::Other(format!("Cannot create file {}: {e}", tmp_path.display()))
467            })?;
468            let mut bytes_written: u64 = 0;
469            let mut stream = resp.bytes_stream();
470            while let Some(chunk) = stream.next().await {
471                let chunk = chunk?;
472                file.write_all(&chunk)
473                    .await
474                    .map_err(|e| ApiError::Other(format!("Write error: {e}")))?;
475                bytes_written += chunk.len() as u64;
476            }
477            file.flush()
478                .await
479                .map_err(|e| ApiError::Other(format!("Flush error: {e}")))?;
480            Ok(bytes_written)
481        }
482        .await;
483
484        match write_result {
485            Ok(bytes) => {
486                tokio::fs::rename(&tmp_path, dest_path)
487                    .await
488                    .map_err(|e| ApiError::Other(format!("Cannot finalize download: {e}")))?;
489                Ok(bytes)
490            }
491            Err(e) => {
492                let _ = tokio::fs::remove_file(&tmp_path).await;
493                Err(e)
494            }
495        }
496    }
497
498    // ── Reports ───────────────────────────────────────────────────────────────
499
500    pub async fn list_user_meeting_reports(
501        &mut self,
502        user_id: &str,
503        from: &str,
504        to: Option<&str>,
505    ) -> Result<UserMeetingReportList, ApiError> {
506        let mut params: Vec<(&str, &str)> = vec![("from", from), ("page_size", "300")];
507        let to_owned;
508        if let Some(t) = to {
509            to_owned = t.to_owned();
510            params.push(("to", to_owned.as_str()));
511        }
512        self.get_all_pages(&format!("/report/users/{user_id}/meetings"), &params)
513            .await
514    }
515
516    // ── Webinars ──────────────────────────────────────────────────────────────
517
518    pub async fn list_webinars(&mut self, user_id: &str) -> Result<WebinarList, ApiError> {
519        let path = format!("/users/{user_id}/webinars");
520        self.get_all_pages(&path, &[("page_size", "300")]).await
521    }
522
523    pub async fn get_webinar(&mut self, webinar_id: u64) -> Result<Webinar, ApiError> {
524        self.get(&format!("/webinars/{webinar_id}")).await
525    }
526}
527
528/// Percent-encode a Zoom meeting ID for use in URL path segments.
529///
530/// Zoom meeting UUIDs can contain `/` (base64 chars). When a UUID begins with
531/// `/` or contains `//`, the Zoom API gateway decodes the path before routing,
532/// so a single-encoded slash (`%2F`) would be decoded back to `/` and corrupt
533/// the URL. Such UUIDs must be **double-encoded**: `/` → `%252F`, so that after
534/// one decode pass the API handler sees `%2F` and correctly treats it as data.
535fn encode_meeting_id(id: &str) -> String {
536    if id.starts_with('/') || id.contains("//") {
537        id.replace('/', "%252F")
538    } else {
539        id.replace('/', "%2F")
540    }
541}
542
543/// Parse the `Retry-After` header as a delay duration.
544///
545/// Zoom uses integer seconds. Caps at 60 s to avoid extremely long waits from
546/// misconfigured or adversarial responses.
547fn retry_after_duration(resp: &reqwest::Response) -> Option<std::time::Duration> {
548    let secs: u64 = resp
549        .headers()
550        .get("retry-after")?
551        .to_str()
552        .ok()?
553        .parse()
554        .ok()?;
555    Some(std::time::Duration::from_secs(secs.min(60)))
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use wiremock::matchers::{header, method, path, query_param};
562    use wiremock::{Mock, MockServer, ResponseTemplate};
563
564    async fn mock_client(server: &MockServer) -> ZoomClient {
565        ZoomClient::new_for_test(
566            format!("{}/v2", server.uri()),
567            server.uri(),
568            "test-token".into(),
569        )
570    }
571
572    #[tokio::test]
573    async fn fetch_token_returns_access_token_on_success() {
574        let server = MockServer::start().await;
575
576        Mock::given(method("POST"))
577            .and(path("/oauth/token"))
578            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
579                "access_token": "eyJhbGciOiJSUzI1NiJ9.test",
580                "token_type": "bearer",
581                "expires_in": 3599
582            })))
583            .mount(&server)
584            .await;
585
586        let client = ZoomClient {
587            http: reqwest::Client::new(),
588            base_url: format!("{}/v2", server.uri()),
589            oauth_base_url: server.uri(),
590            account_id: "acct123".into(),
591            client_id: "cid".into(),
592            client_secret: "csec".into(),
593            token: None,
594        };
595
596        let token = client.fetch_token().await.unwrap();
597        assert_eq!(token, "eyJhbGciOiJSUzI1NiJ9.test");
598    }
599
600    #[tokio::test]
601    async fn fetch_token_returns_auth_error_on_401() {
602        let server = MockServer::start().await;
603
604        Mock::given(method("POST"))
605            .and(path("/oauth/token"))
606            .respond_with(ResponseTemplate::new(401))
607            .mount(&server)
608            .await;
609
610        let client = ZoomClient {
611            http: reqwest::Client::new(),
612            base_url: format!("{}/v2", server.uri()),
613            oauth_base_url: server.uri(),
614            account_id: "acct".into(),
615            client_id: "cid".into(),
616            client_secret: "csec".into(),
617            token: None,
618        };
619
620        let err = client.fetch_token().await.unwrap_err();
621        assert!(matches!(err, ApiError::Auth(_)));
622    }
623
624    #[tokio::test]
625    async fn list_meetings_returns_meeting_list() {
626        let server = MockServer::start().await;
627
628        Mock::given(method("GET"))
629            .and(path("/v2/users/me/meetings"))
630            .and(header("authorization", "Bearer test-token"))
631            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
632                "meetings": [
633                    {"id": 111111111, "topic": "Standup", "duration": 15}
634                ],
635                "total_records": 1,
636                "page_size": 100
637            })))
638            .mount(&server)
639            .await;
640
641        let mut client = mock_client(&server).await;
642        let list = client.list_meetings("me", None).await.unwrap();
643        assert_eq!(list.meetings.len(), 1);
644        assert_eq!(list.meetings[0].topic, "Standup");
645    }
646
647    #[tokio::test]
648    async fn get_meeting_returns_404_as_not_found() {
649        let server = MockServer::start().await;
650
651        Mock::given(method("GET"))
652            .and(path("/v2/meetings/999999999"))
653            .respond_with(ResponseTemplate::new(404).set_body_string("Meeting not found"))
654            .mount(&server)
655            .await;
656
657        let mut client = mock_client(&server).await;
658        let err = client.get_meeting(999999999).await.unwrap_err();
659        assert!(matches!(err, ApiError::NotFound(_)));
660    }
661
662    #[tokio::test]
663    async fn delete_meeting_returns_ok_on_204() {
664        let server = MockServer::start().await;
665
666        Mock::given(method("DELETE"))
667            .and(path("/v2/meetings/123456789"))
668            .respond_with(ResponseTemplate::new(204))
669            .mount(&server)
670            .await;
671
672        let mut client = mock_client(&server).await;
673        client.delete_meeting(123456789).await.unwrap();
674    }
675
676    #[tokio::test]
677    async fn list_meetings_with_type_filter_sends_query_param() {
678        let server = MockServer::start().await;
679
680        Mock::given(method("GET"))
681            .and(path("/v2/users/me/meetings"))
682            .and(query_param("type", "scheduled"))
683            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
684                "meetings": [],
685                "total_records": 0
686            })))
687            .mount(&server)
688            .await;
689
690        let mut client = mock_client(&server).await;
691        let list = client.list_meetings("me", Some("scheduled")).await.unwrap();
692        assert_eq!(list.meetings.len(), 0);
693    }
694
695    #[tokio::test]
696    async fn rate_limit_response_is_retried_and_succeeds() {
697        let server = MockServer::start().await;
698
699        // First request: 429 with Retry-After: 0 (instant retry in tests).
700        Mock::given(method("GET"))
701            .and(path("/v2/users/me/meetings"))
702            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
703            .up_to_n_times(1)
704            .mount(&server)
705            .await;
706
707        // Second request: 200.
708        Mock::given(method("GET"))
709            .and(path("/v2/users/me/meetings"))
710            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
711                "meetings": [{"id": 1, "topic": "After retry"}],
712                "total_records": 1
713            })))
714            .mount(&server)
715            .await;
716
717        let mut client = mock_client(&server).await;
718        let list = client.list_meetings("me", None).await.unwrap();
719        assert_eq!(list.meetings.len(), 1, "result from the retry attempt");
720        assert_eq!(list.meetings[0].topic, "After retry");
721    }
722
723    #[tokio::test]
724    async fn rate_limit_then_expired_token_does_not_panic() {
725        // Regression test: 429 on first attempts then 401 on the last attempt
726        // previously hit the unreachable!() branch and panicked.
727        let server = MockServer::start().await;
728
729        // OAuth endpoint for token refresh triggered by the 401.
730        Mock::given(method("POST"))
731            .and(path("/oauth/token"))
732            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
733                "access_token": "fresh-token",
734                "token_type": "bearer",
735                "expires_in": 3599
736            })))
737            .mount(&server)
738            .await;
739
740        // First three requests: 429 (rate limited).
741        Mock::given(method("GET"))
742            .and(path("/v2/users/me/meetings"))
743            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
744            .up_to_n_times(3)
745            .mount(&server)
746            .await;
747
748        // After the 429 retries, the next attempt gets a 401 (expired token).
749        // send_once refreshes the token and retries — that retry also returns 401
750        // (genuinely bad credentials), which is returned as ApiError::Auth.
751        Mock::given(method("GET"))
752            .and(path("/v2/users/me/meetings"))
753            .respond_with(ResponseTemplate::new(401).set_body_string("invalid token"))
754            .mount(&server)
755            .await;
756
757        let mut client = mock_client(&server).await;
758        let err = client.list_meetings("me", None).await.unwrap_err();
759        // Must not panic; must surface as an auth error.
760        assert!(matches!(err, ApiError::Auth(_)));
761    }
762
763    #[tokio::test]
764    async fn rate_limit_exhausted_returns_rate_limit_error() {
765        let server = MockServer::start().await;
766
767        // All requests return 429 — retries exhausted.
768        Mock::given(method("GET"))
769            .and(path("/v2/users/me/meetings"))
770            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
771            .mount(&server)
772            .await;
773
774        let mut client = mock_client(&server).await;
775        let err = client.list_meetings("me", None).await.unwrap_err();
776        assert!(matches!(err, ApiError::RateLimit));
777    }
778
779    #[tokio::test]
780    async fn expired_token_is_refreshed_transparently() {
781        let server = MockServer::start().await;
782
783        // OAuth endpoint — called when the cached token is cleared after a 401.
784        Mock::given(method("POST"))
785            .and(path("/oauth/token"))
786            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
787                "access_token": "fresh-token",
788                "token_type": "bearer",
789                "expires_in": 3599
790            })))
791            .mount(&server)
792            .await;
793
794        // First request: 401 (expired token).
795        Mock::given(method("GET"))
796            .and(path("/v2/users/me/meetings"))
797            .and(header("authorization", "Bearer test-token"))
798            .respond_with(ResponseTemplate::new(401).set_body_string("token expired"))
799            .up_to_n_times(1)
800            .mount(&server)
801            .await;
802
803        // Second request: same endpoint, fresh token — succeeds.
804        Mock::given(method("GET"))
805            .and(path("/v2/users/me/meetings"))
806            .and(header("authorization", "Bearer fresh-token"))
807            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
808                "meetings": [{"id": 1, "topic": "After refresh"}],
809                "total_records": 1
810            })))
811            .mount(&server)
812            .await;
813
814        let mut client = mock_client(&server).await;
815        let list = client.list_meetings("me", None).await.unwrap();
816        assert_eq!(list.meetings[0].topic, "After refresh");
817    }
818
819    #[tokio::test]
820    async fn retry_after_header_is_parsed() {
821        let server = MockServer::start().await;
822
823        // Return 429 with a Retry-After header once, then 200.
824        Mock::given(method("GET"))
825            .and(path("/v2/users"))
826            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
827            .up_to_n_times(1)
828            .mount(&server)
829            .await;
830
831        Mock::given(method("GET"))
832            .and(path("/v2/users"))
833            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
834                "users": [], "total_records": 0
835            })))
836            .mount(&server)
837            .await;
838
839        let mut client = mock_client(&server).await;
840        // Should succeed after the retry.
841        client.list_users(None).await.unwrap();
842    }
843
844    #[tokio::test]
845    async fn list_users_sends_correct_request() {
846        let server = MockServer::start().await;
847
848        Mock::given(method("GET"))
849            .and(path("/v2/users"))
850            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
851                "users": [
852                    {
853                        "id": "user-123",
854                        "email": "alice@example.com",
855                        "display_name": "Alice"
856                    }
857                ],
858                "total_records": 1
859            })))
860            .mount(&server)
861            .await;
862
863        let mut client = mock_client(&server).await;
864        let list = client.list_users(None).await.unwrap();
865        assert_eq!(list.users.len(), 1);
866        assert_eq!(list.users[0].email, "alice@example.com");
867    }
868
869    #[tokio::test]
870    async fn end_meeting_sends_put_with_action() {
871        let server = MockServer::start().await;
872        Mock::given(method("PUT"))
873            .and(path("/v2/meetings/123456/status"))
874            .respond_with(ResponseTemplate::new(204))
875            .mount(&server)
876            .await;
877        let mut client = mock_client(&server).await;
878        client.end_meeting(123456).await.unwrap();
879    }
880
881    #[tokio::test]
882    async fn list_past_meeting_participants_returns_list() {
883        let server = MockServer::start().await;
884        Mock::given(method("GET"))
885            .and(path("/v2/past_meetings/abc123/participants"))
886            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
887                "participants": [
888                    {"name": "Alice", "user_email": "alice@example.com", "duration": 1800}
889                ],
890                "total_records": 1
891            })))
892            .mount(&server)
893            .await;
894        let mut client = mock_client(&server).await;
895        let list = client
896            .list_past_meeting_participants("abc123")
897            .await
898            .unwrap();
899        assert_eq!(list.participants.len(), 1);
900        assert_eq!(list.participants[0].name, Some("Alice".into()));
901    }
902
903    #[tokio::test]
904    async fn list_user_meeting_reports_sends_from_param() {
905        let server = MockServer::start().await;
906        Mock::given(method("GET"))
907            .and(path("/v2/report/users/me/meetings"))
908            .and(query_param("from", "2026-04-01"))
909            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
910                "meetings": [],
911                "total_records": 0,
912                "from": "2026-04-01",
913                "to": "2026-04-30"
914            })))
915            .mount(&server)
916            .await;
917        let mut client = mock_client(&server).await;
918        let list = client
919            .list_user_meeting_reports("me", "2026-04-01", None)
920            .await
921            .unwrap();
922        assert_eq!(list.meetings.len(), 0);
923    }
924
925    #[tokio::test]
926    async fn list_meetings_follows_next_page_token() {
927        let server = MockServer::start().await;
928
929        // First page returns a next_page_token.
930        Mock::given(method("GET"))
931            .and(path("/v2/users/me/meetings"))
932            .and(query_param("page_size", "300"))
933            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
934                "meetings": [{"id": 1, "topic": "Page 1 Meeting"}],
935                "total_records": 2,
936                "next_page_token": "token-abc"
937            })))
938            .up_to_n_times(1)
939            .mount(&server)
940            .await;
941
942        // Second page (identified by next_page_token) returns no further token.
943        Mock::given(method("GET"))
944            .and(path("/v2/users/me/meetings"))
945            .and(query_param("next_page_token", "token-abc"))
946            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
947                "meetings": [{"id": 2, "topic": "Page 2 Meeting"}],
948                "total_records": 2,
949                "next_page_token": ""
950            })))
951            .mount(&server)
952            .await;
953
954        let mut client = mock_client(&server).await;
955        let list = client.list_meetings("me", None).await.unwrap();
956
957        assert_eq!(list.meetings.len(), 2, "both pages must be merged");
958        assert_eq!(list.meetings[0].topic, "Page 1 Meeting");
959        assert_eq!(list.meetings[1].topic, "Page 2 Meeting");
960        assert!(
961            list.next_page_token.is_none(),
962            "exhausted token must be absent"
963        );
964    }
965
966    #[test]
967    fn encode_meeting_id_single_encodes_plain_uuids() {
968        assert_eq!(encode_meeting_id("abc123"), "abc123");
969        assert_eq!(encode_meeting_id("abc/def"), "abc%2Fdef");
970    }
971
972    #[test]
973    fn encode_meeting_id_double_encodes_leading_slash() {
974        // UUID starting with '/' must be double-encoded so the API gateway
975        // does not consume the slash during path decoding.
976        assert_eq!(encode_meeting_id("/abc"), "%252Fabc");
977        assert_eq!(encode_meeting_id("/abc/def"), "%252Fabc%252Fdef");
978    }
979
980    #[test]
981    fn encode_meeting_id_double_encodes_double_slash() {
982        assert_eq!(encode_meeting_id("abc//def"), "abc%252F%252Fdef");
983        assert_eq!(
984            encode_meeting_id("4444AAAiAAAAAiAA//AA=="),
985            "4444AAAiAAAAAiAA%252F%252FAA=="
986        );
987    }
988
989    #[tokio::test]
990    async fn get_recording_double_encodes_uuid_with_double_slash() {
991        let server = MockServer::start().await;
992        // The path must contain %252F%252F (double-encoded), not %2F%2F.
993        Mock::given(method("GET"))
994            .and(path("/v2/meetings/abc%252F%252Fdef/recordings"))
995            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
996                "id": 123,
997                "topic": "Double-slash UUID meeting",
998                "start_time": "2026-04-01T10:00:00Z",
999                "duration": 30,
1000                "recording_files": []
1001            })))
1002            .mount(&server)
1003            .await;
1004
1005        let mut client = mock_client(&server).await;
1006        let rec = client.get_recording("abc//def").await.unwrap();
1007        assert_eq!(rec.topic, "Double-slash UUID meeting");
1008    }
1009
1010    #[tokio::test]
1011    async fn list_users_follows_next_page_token() {
1012        let server = MockServer::start().await;
1013
1014        Mock::given(method("GET"))
1015            .and(path("/v2/users"))
1016            .and(query_param("page_size", "300"))
1017            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1018                "users": [{"id": "u1", "email": "a@example.com"}],
1019                "total_records": 2,
1020                "next_page_token": "page2-token"
1021            })))
1022            .up_to_n_times(1)
1023            .mount(&server)
1024            .await;
1025
1026        Mock::given(method("GET"))
1027            .and(path("/v2/users"))
1028            .and(query_param("next_page_token", "page2-token"))
1029            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1030                "users": [{"id": "u2", "email": "b@example.com"}],
1031                "total_records": 2,
1032                "next_page_token": ""
1033            })))
1034            .mount(&server)
1035            .await;
1036
1037        let mut client = mock_client(&server).await;
1038        let list = client.list_users(None).await.unwrap();
1039
1040        assert_eq!(list.users.len(), 2, "both pages must be merged");
1041        assert_eq!(list.users[0].email, "a@example.com");
1042        assert_eq!(list.users[1].email, "b@example.com");
1043    }
1044
1045    #[tokio::test]
1046    async fn list_participants_follows_next_page_token() {
1047        let server = MockServer::start().await;
1048
1049        Mock::given(method("GET"))
1050            .and(path("/v2/past_meetings/mtg123/participants"))
1051            .and(query_param("page_size", "300"))
1052            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1053                "participants": [{"name": "Alice"}],
1054                "total_records": 2,
1055                "next_page_token": "p2"
1056            })))
1057            .up_to_n_times(1)
1058            .mount(&server)
1059            .await;
1060
1061        Mock::given(method("GET"))
1062            .and(path("/v2/past_meetings/mtg123/participants"))
1063            .and(query_param("next_page_token", "p2"))
1064            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1065                "participants": [{"name": "Bob"}],
1066                "total_records": 2,
1067                "next_page_token": ""
1068            })))
1069            .mount(&server)
1070            .await;
1071
1072        let mut client = mock_client(&server).await;
1073        let list = client
1074            .list_past_meeting_participants("mtg123")
1075            .await
1076            .unwrap();
1077
1078        assert_eq!(list.participants.len(), 2);
1079        assert_eq!(list.participants[0].name, Some("Alice".into()));
1080        assert_eq!(list.participants[1].name, Some("Bob".into()));
1081    }
1082
1083    #[tokio::test]
1084    async fn delete_recording_sends_delete_with_action_trash() {
1085        let server = MockServer::start().await;
1086        Mock::given(method("DELETE"))
1087            .and(path("/v2/meetings/abc123/recordings"))
1088            .and(query_param("action", "trash"))
1089            .respond_with(ResponseTemplate::new(204))
1090            .mount(&server)
1091            .await;
1092
1093        let mut client = mock_client(&server).await;
1094        client.delete_recording("abc123", true).await.unwrap();
1095    }
1096
1097    #[tokio::test]
1098    async fn delete_recording_sends_delete_with_action_delete() {
1099        let server = MockServer::start().await;
1100        Mock::given(method("DELETE"))
1101            .and(path("/v2/meetings/abc123/recordings"))
1102            .and(query_param("action", "delete"))
1103            .respond_with(ResponseTemplate::new(204))
1104            .mount(&server)
1105            .await;
1106
1107        let mut client = mock_client(&server).await;
1108        client.delete_recording("abc123", false).await.unwrap();
1109    }
1110
1111    #[tokio::test]
1112    async fn list_webinars_returns_list() {
1113        let server = MockServer::start().await;
1114        Mock::given(method("GET"))
1115            .and(path("/v2/users/me/webinars"))
1116            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1117                "webinars": [
1118                    {
1119                        "id": 12345678,
1120                        "topic": "Product Launch",
1121                        "start_time": "2026-05-01T14:00:00Z",
1122                        "duration": 60,
1123                        "type": 5
1124                    }
1125                ],
1126                "total_records": 1
1127            })))
1128            .mount(&server)
1129            .await;
1130
1131        let mut client = mock_client(&server).await;
1132        let list = client.list_webinars("me").await.unwrap();
1133        assert_eq!(list.webinars.len(), 1);
1134        assert_eq!(list.webinars[0].topic, "Product Launch");
1135    }
1136
1137    #[tokio::test]
1138    async fn get_webinar_returns_404_as_not_found() {
1139        let server = MockServer::start().await;
1140        Mock::given(method("GET"))
1141            .and(path("/v2/webinars/99999999"))
1142            .respond_with(ResponseTemplate::new(404).set_body_string("Webinar not found"))
1143            .mount(&server)
1144            .await;
1145
1146        let mut client = mock_client(&server).await;
1147        let err = client.get_webinar(99999999).await.unwrap_err();
1148        assert!(matches!(err, ApiError::NotFound(_)));
1149    }
1150}