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
12const 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 #[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 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 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 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 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 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, ¶ms).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 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, ¶ms).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 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", ¶ms).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 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 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, ¶ms).await
390 }
391
392 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 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 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 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 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"), ¶ms)
513 .await
514 }
515
516 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
528fn 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
543fn 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 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 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 let server = MockServer::start().await;
728
729 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 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 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 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 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 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 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 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 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 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 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 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 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 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}