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