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
13const MAX_RETRY_ATTEMPTS: u32 = 4;
16
17fn 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 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 #[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 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 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 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 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 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, ¶ms).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 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, ¶ms).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 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", ¶ms).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 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 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, ¶ms).await
446 }
447
448 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 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 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 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 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"), ¶ms)
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 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
596fn 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
611fn 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 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 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 let server = MockServer::start().await;
824
825 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 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 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 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 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 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 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 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 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 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 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 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 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 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}