use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::io::IsTerminal;
use super::ApiError;
use super::types::{self, *};
const ZOOM_API_BASE: &str = "https://api.zoom.us/v2";
const ZOOM_OAUTH_BASE: &str = "https://zoom.us";
const MAX_RETRY_ATTEMPTS: u32 = 4;
fn parse_zoom_error(body: &str) -> String {
let Ok(val) = serde_json::from_str::<serde_json::Value>(body) else {
return body.trim().to_owned();
};
let message = match val["message"].as_str() {
Some(m) => m.to_owned(),
None => return body.to_owned(),
};
if val["code"].as_u64() == Some(4711) || message.contains("does not contain scopes") {
let scope = message
.find('[')
.and_then(|s| message.find(']').map(|e| &message[s + 1..e]))
.unwrap_or("");
let what = if scope.is_empty() {
message.clone()
} else {
format!("Missing required OAuth scope: {scope}")
};
let link = crate::output::hyperlink("https://marketplace.zoom.us/user/build");
return format!(
"{what}\nAdd this scope to your app at {link}, then run `zoom init` to update credentials."
);
}
message
}
pub struct ZoomClient {
http: reqwest::Client,
base_url: String,
oauth_base_url: String,
account_id: String,
client_id: String,
client_secret: String,
token: Option<String>,
}
impl ZoomClient {
pub fn new(account_id: String, client_id: String, client_secret: String) -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to build HTTP client");
Self {
http,
base_url: ZOOM_API_BASE.to_owned(),
oauth_base_url: ZOOM_OAUTH_BASE.to_owned(),
account_id,
client_id,
client_secret,
token: None,
}
}
#[cfg(test)]
pub fn new_for_test(base_url: String, oauth_base_url: String, token: String) -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.expect("failed to build HTTP client");
Self {
http,
base_url,
oauth_base_url,
account_id: "test-account".into(),
client_id: "test-client".into(),
client_secret: "test-secret".into(),
token: Some(token),
}
}
async fn ensure_token(&mut self) -> Result<&str, ApiError> {
if self.token.is_none() {
let token = self.fetch_token().await?;
self.token = Some(token);
}
Ok(self.token.as_deref().unwrap())
}
async fn fetch_token(&self) -> Result<String, ApiError> {
let creds = BASE64.encode(format!("{}:{}", self.client_id, self.client_secret));
let url = format!(
"{}/oauth/token?grant_type=account_credentials&account_id={}",
self.oauth_base_url, self.account_id
);
let resp = self
.http
.post(&url)
.header("Authorization", format!("Basic {creds}"))
.header("Content-Type", "application/x-www-form-urlencoded")
.send()
.await?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(ApiError::Auth(
"Failed to obtain access token. Check account_id, client_id, client_secret.".into(),
));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(ApiError::Api {
status: status.as_u16(),
message: parse_zoom_error(&body),
});
}
let token_resp: TokenResponse = resp.json().await?;
Ok(token_resp.access_token)
}
async fn send_once(
&mut self,
build: &impl Fn(&reqwest::Client, &str) -> reqwest::RequestBuilder,
) -> Result<reqwest::Response, ApiError> {
let token = self.ensure_token().await?.to_owned();
let resp = build(&self.http, &token).send().await?;
if resp.status().as_u16() == 401 {
self.token = None;
let token = self.ensure_token().await?.to_owned();
return Ok(build(&self.http, &token).send().await?);
}
Ok(resp)
}
async fn send_with_retry(
&mut self,
build: impl Fn(&reqwest::Client, &str) -> reqwest::RequestBuilder,
) -> Result<reqwest::Response, ApiError> {
let mut delay = std::time::Duration::from_secs(1);
for attempt in 0..MAX_RETRY_ATTEMPTS {
let resp = self.send_once(&build).await?;
let is_last = attempt + 1 >= MAX_RETRY_ATTEMPTS;
if resp.status().as_u16() != 429 || is_last {
return Ok(resp);
}
let wait = retry_after_duration(&resp).unwrap_or(delay);
tokio::time::sleep(wait).await;
delay = (delay * 2).min(std::time::Duration::from_secs(60));
}
unreachable!()
}
async fn get<T: DeserializeOwned>(&mut self, path: &str) -> Result<T, ApiError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.send_with_retry(|http, token| http.get(&url).bearer_auth(token))
.await?;
self.handle_response(resp).await
}
async fn get_with_query<T: DeserializeOwned>(
&mut self,
path: &str,
params: &[(&str, &str)],
) -> Result<T, ApiError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.send_with_retry(|http, token| http.get(&url).bearer_auth(token).query(params))
.await?;
self.handle_response(resp).await
}
async fn get_all_pages<T>(
&mut self,
path: &str,
base_params: &[(&str, &str)],
) -> Result<T, ApiError>
where
T: DeserializeOwned + types::Paginated,
{
let mut result: T = self.get_with_query(path, base_params).await?;
let mut page_num: u32 = 1;
loop {
let token = match result.next_page_token() {
Some(t) if !t.is_empty() => t.to_owned(),
_ => break,
};
page_num += 1;
if std::io::stderr().is_terminal() {
eprint!("\r Fetching page {page_num}...");
let _ = std::io::Write::flush(&mut std::io::stderr());
}
let mut params = base_params.to_vec();
params.push(("next_page_token", token.as_str()));
let next: T = self.get_with_query(path, ¶ms).await?;
result.append_page(next);
}
if page_num > 1 && std::io::stderr().is_terminal() {
eprint!("\r \r");
}
Ok(result)
}
async fn post<T: DeserializeOwned, B: Serialize>(
&mut self,
path: &str,
body: &B,
) -> Result<T, ApiError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.send_with_retry(|http, token| http.post(&url).bearer_auth(token).json(body))
.await?;
self.handle_response(resp).await
}
async fn patch<B: Serialize>(&mut self, path: &str, body: &B) -> Result<(), ApiError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.send_with_retry(|http, token| http.patch(&url).bearer_auth(token).json(body))
.await?;
self.handle_empty_response(resp).await
}
async fn put<B: Serialize>(&mut self, path: &str, body: &B) -> Result<(), ApiError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.send_with_retry(|http, token| http.put(&url).bearer_auth(token).json(body))
.await?;
self.handle_empty_response(resp).await
}
async fn delete(&mut self, path: &str) -> Result<(), ApiError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.send_with_retry(|http, token| http.delete(&url).bearer_auth(token))
.await?;
self.handle_empty_response(resp).await
}
async fn delete_with_query(
&mut self,
path: &str,
params: &[(&str, &str)],
) -> Result<(), ApiError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.send_with_retry(|http, token| http.delete(&url).bearer_auth(token).query(params))
.await?;
self.handle_empty_response(resp).await
}
async fn handle_response<T: DeserializeOwned>(
&self,
resp: reqwest::Response,
) -> Result<T, ApiError> {
let status = resp.status();
match status.as_u16() {
200..=299 => Ok(resp.json::<T>().await?),
401 | 403 => {
let body = resp.text().await.unwrap_or_default();
Err(ApiError::Auth(parse_zoom_error(&body)))
}
404 => {
let body = resp.text().await.unwrap_or_default();
Err(ApiError::NotFound(parse_zoom_error(&body)))
}
429 => Err(ApiError::RateLimit),
_ => {
let body = resp.text().await.unwrap_or_default();
Err(ApiError::Api {
status: status.as_u16(),
message: parse_zoom_error(&body),
})
}
}
}
async fn handle_empty_response(&self, resp: reqwest::Response) -> Result<(), ApiError> {
let status = resp.status();
match status.as_u16() {
200..=299 => Ok(()),
401 | 403 => {
let body = resp.text().await.unwrap_or_default();
Err(ApiError::Auth(parse_zoom_error(&body)))
}
404 => {
let body = resp.text().await.unwrap_or_default();
Err(ApiError::NotFound(parse_zoom_error(&body)))
}
429 => Err(ApiError::RateLimit),
_ => {
let body = resp.text().await.unwrap_or_default();
Err(ApiError::Api {
status: status.as_u16(),
message: parse_zoom_error(&body),
})
}
}
}
pub async fn list_meetings(
&mut self,
user_id: &str,
meeting_type: Option<&str>,
) -> Result<MeetingList, ApiError> {
let path = format!("/users/{user_id}/meetings");
let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
if let Some(mt) = meeting_type {
params.push(("type", mt));
}
self.get_all_pages(&path, ¶ms).await
}
pub async fn get_meeting(&mut self, meeting_id: u64) -> Result<Meeting, ApiError> {
self.get(&format!("/meetings/{meeting_id}")).await
}
pub async fn get_meeting_invitation(
&mut self,
meeting_id: u64,
) -> Result<MeetingInvitation, ApiError> {
self.get(&format!("/meetings/{meeting_id}/invitation"))
.await
}
pub async fn create_meeting(
&mut self,
user_id: &str,
req: CreateMeetingRequest,
) -> Result<Meeting, ApiError> {
self.post(&format!("/users/{user_id}/meetings"), &req).await
}
pub async fn update_meeting(
&mut self,
meeting_id: u64,
req: UpdateMeetingRequest,
) -> Result<(), ApiError> {
self.patch(&format!("/meetings/{meeting_id}"), &req).await
}
pub async fn delete_meeting(&mut self, meeting_id: u64) -> Result<(), ApiError> {
self.delete(&format!("/meetings/{meeting_id}")).await
}
pub async fn end_meeting(&mut self, meeting_id: u64) -> Result<(), ApiError> {
self.put(
&format!("/meetings/{meeting_id}/status"),
&MeetingStatusRequest {
action: "end".into(),
},
)
.await
}
pub async fn list_users(&mut self, status: Option<&str>) -> Result<UserList, ApiError> {
let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
if let Some(st) = status {
params.push(("status", st));
}
self.get_all_pages("/users", ¶ms).await
}
pub async fn get_user(&mut self, user_id: &str) -> Result<User, ApiError> {
self.get(&format!("/users/{user_id}")).await
}
pub async fn create_user(&mut self, req: CreateUserRequest) -> Result<User, ApiError> {
self.post("/users", &req).await
}
pub async fn set_user_status(&mut self, user_id: &str, action: &str) -> Result<(), ApiError> {
let req = UserStatusRequest {
action: action.into(),
};
self.put(&format!("/users/{user_id}/status"), &req).await
}
pub async fn list_past_meeting_participants(
&mut self,
meeting_id: &str,
) -> Result<ParticipantList, ApiError> {
let encoded_id = encode_meeting_id(meeting_id);
self.get_all_pages(
&format!("/past_meetings/{encoded_id}/participants"),
&[("page_size", "300")],
)
.await
}
pub async fn list_recordings(
&mut self,
user_id: &str,
from: Option<&str>,
to: Option<&str>,
) -> Result<RecordingList, ApiError> {
let path = format!("/users/{user_id}/recordings");
let mut params: Vec<(&str, &str)> = vec![("page_size", "300")];
if let Some(f) = from {
params.push(("from", f));
}
if let Some(t) = to {
params.push(("to", t));
}
self.get_all_pages(&path, ¶ms).await
}
pub async fn delete_recording(
&mut self,
meeting_id: &str,
trash: bool,
) -> Result<(), ApiError> {
let encoded_id = encode_meeting_id(meeting_id);
let action = if trash { "trash" } else { "delete" };
self.delete_with_query(
&format!("/meetings/{encoded_id}/recordings"),
&[("action", action)],
)
.await
}
pub async fn control_recording(
&mut self,
meeting_id: u64,
action: &str,
) -> Result<(), ApiError> {
let req = RecordingControlRequest {
action: action.to_owned(),
};
self.patch(&format!("/live_meetings/{meeting_id}/recordings"), &req)
.await
}
pub async fn get_recording(&mut self, meeting_id: &str) -> Result<CloudRecording, ApiError> {
let encoded_id = encode_meeting_id(meeting_id);
self.get(&format!("/meetings/{encoded_id}/recordings"))
.await
}
pub async fn download_recording_file(
&mut self,
download_url: &str,
dest_path: &std::path::Path,
) -> Result<u64, ApiError> {
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
let url = download_url.to_owned();
let resp = self
.send_with_retry(|http, token| http.get(&url).bearer_auth(token))
.await?;
let status = resp.status();
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
return Err(ApiError::Auth(
"Not authorized to download this recording".into(),
));
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(ApiError::Api {
status: status.as_u16(),
message: parse_zoom_error(&body),
});
}
let tmp_path = dest_path.with_extension("download");
let write_result: Result<u64, ApiError> = async {
let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
ApiError::Other(format!("Cannot create file {}: {e}", tmp_path.display()))
})?;
let mut bytes_written: u64 = 0;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk)
.await
.map_err(|e| ApiError::Other(format!("Write error: {e}")))?;
bytes_written += chunk.len() as u64;
}
file.flush()
.await
.map_err(|e| ApiError::Other(format!("Flush error: {e}")))?;
Ok(bytes_written)
}
.await;
match write_result {
Ok(bytes) => {
tokio::fs::rename(&tmp_path, dest_path)
.await
.map_err(|e| ApiError::Other(format!("Cannot finalize download: {e}")))?;
Ok(bytes)
}
Err(e) => {
let _ = tokio::fs::remove_file(&tmp_path).await;
Err(e)
}
}
}
pub async fn list_user_meeting_reports(
&mut self,
user_id: &str,
from: &str,
to: Option<&str>,
) -> Result<UserMeetingReportList, ApiError> {
let mut params: Vec<(&str, &str)> = vec![("from", from), ("page_size", "300")];
let to_owned;
if let Some(t) = to {
to_owned = t.to_owned();
params.push(("to", to_owned.as_str()));
}
self.get_all_pages(&format!("/report/users/{user_id}/meetings"), ¶ms)
.await
}
pub async fn list_meeting_participant_reports(
&mut self,
meeting_id: &str,
) -> Result<MeetingParticipantReportList, ApiError> {
let encoded_id = encode_meeting_id(meeting_id);
self.get_all_pages(
&format!("/report/meetings/{encoded_id}/participants"),
&[("page_size", "300")],
)
.await
}
pub async fn list_webinars(&mut self, user_id: &str) -> Result<WebinarList, ApiError> {
let path = format!("/users/{user_id}/webinars");
self.get_all_pages(&path, &[("page_size", "300")]).await
}
pub async fn get_webinar(&mut self, webinar_id: u64) -> Result<Webinar, ApiError> {
self.get(&format!("/webinars/{webinar_id}")).await
}
}
fn encode_meeting_id(id: &str) -> String {
if id.starts_with('/') || id.contains("//") {
id.replace('/', "%252F")
} else {
id.replace('/', "%2F")
}
}
fn retry_after_duration(resp: &reqwest::Response) -> Option<std::time::Duration> {
let secs: u64 = resp
.headers()
.get("retry-after")?
.to_str()
.ok()?
.parse()
.ok()?;
Some(std::time::Duration::from_secs(secs.min(60)))
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn parse_zoom_error_extracts_message_from_json() {
assert_eq!(
parse_zoom_error(r#"{"code":3001,"message":"Meeting does not exist"}"#),
"Meeting does not exist"
);
}
#[test]
fn parse_zoom_error_scope_4711_gives_actionable_message() {
let body = r#"{"code":4711,"message":"Invalid access token, does not contain scopes:[report:read:user:admin]."}"#;
let msg = parse_zoom_error(body);
assert!(
msg.contains("report:read:user:admin"),
"must name the missing scope"
);
assert!(msg.contains("zoom init"), "must tell user how to fix it");
assert!(
!msg.contains("Invalid access token"),
"must not echo the raw API message"
);
}
#[test]
fn parse_zoom_error_falls_back_to_raw_body_for_non_json() {
assert_eq!(parse_zoom_error("plain text error"), "plain text error");
}
async fn mock_client(server: &MockServer) -> ZoomClient {
ZoomClient::new_for_test(
format!("{}/v2", server.uri()),
server.uri(),
"test-token".into(),
)
}
#[tokio::test]
async fn fetch_token_returns_access_token_on_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "eyJhbGciOiJSUzI1NiJ9.test",
"token_type": "bearer",
"expires_in": 3599
})))
.mount(&server)
.await;
let client = ZoomClient {
http: reqwest::Client::new(),
base_url: format!("{}/v2", server.uri()),
oauth_base_url: server.uri(),
account_id: "acct123".into(),
client_id: "cid".into(),
client_secret: "csec".into(),
token: None,
};
let token = client.fetch_token().await.unwrap();
assert_eq!(token, "eyJhbGciOiJSUzI1NiJ9.test");
}
#[tokio::test]
async fn fetch_token_returns_auth_error_on_401() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let client = ZoomClient {
http: reqwest::Client::new(),
base_url: format!("{}/v2", server.uri()),
oauth_base_url: server.uri(),
account_id: "acct".into(),
client_id: "cid".into(),
client_secret: "csec".into(),
token: None,
};
let err = client.fetch_token().await.unwrap_err();
assert!(matches!(err, ApiError::Auth(_)));
}
#[tokio::test]
async fn list_meetings_returns_meeting_list() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/users/me/meetings"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meetings": [
{"id": 111111111, "topic": "Standup", "duration": 15}
],
"total_records": 1,
"page_size": 100
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let list = client.list_meetings("me", None).await.unwrap();
assert_eq!(list.meetings.len(), 1);
assert_eq!(list.meetings[0].topic, "Standup");
}
#[tokio::test]
async fn get_meeting_returns_404_as_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/meetings/999999999"))
.respond_with(ResponseTemplate::new(404).set_body_string("Meeting not found"))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let err = client.get_meeting(999999999).await.unwrap_err();
assert!(matches!(err, ApiError::NotFound(_)));
}
#[tokio::test]
async fn delete_meeting_returns_ok_on_204() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/v2/meetings/123456789"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
client.delete_meeting(123456789).await.unwrap();
}
#[tokio::test]
async fn list_meetings_with_type_filter_sends_query_param() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/users/me/meetings"))
.and(query_param("type", "scheduled"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meetings": [],
"total_records": 0
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let list = client.list_meetings("me", Some("scheduled")).await.unwrap();
assert_eq!(list.meetings.len(), 0);
}
#[tokio::test]
async fn rate_limit_response_is_retried_and_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/users/me/meetings"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/users/me/meetings"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meetings": [{"id": 1, "topic": "After retry"}],
"total_records": 1
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let list = client.list_meetings("me", None).await.unwrap();
assert_eq!(list.meetings.len(), 1, "result from the retry attempt");
assert_eq!(list.meetings[0].topic, "After retry");
}
#[tokio::test]
async fn rate_limit_then_expired_token_does_not_panic() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "fresh-token",
"token_type": "bearer",
"expires_in": 3599
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/users/me/meetings"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.up_to_n_times(3)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/users/me/meetings"))
.respond_with(ResponseTemplate::new(401).set_body_string("invalid token"))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let err = client.list_meetings("me", None).await.unwrap_err();
assert!(matches!(err, ApiError::Auth(_)));
}
#[tokio::test]
async fn rate_limit_exhausted_returns_rate_limit_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/users/me/meetings"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let err = client.list_meetings("me", None).await.unwrap_err();
assert!(matches!(err, ApiError::RateLimit));
}
#[tokio::test]
async fn expired_token_is_refreshed_transparently() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "fresh-token",
"token_type": "bearer",
"expires_in": 3599
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/users/me/meetings"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(401).set_body_string("token expired"))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/users/me/meetings"))
.and(header("authorization", "Bearer fresh-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meetings": [{"id": 1, "topic": "After refresh"}],
"total_records": 1
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let list = client.list_meetings("me", None).await.unwrap();
assert_eq!(list.meetings[0].topic, "After refresh");
}
#[tokio::test]
async fn retry_after_header_is_parsed() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/users"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/users"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"users": [], "total_records": 0
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
client.list_users(None).await.unwrap();
}
#[tokio::test]
async fn list_users_sends_correct_request() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/users"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"users": [
{
"id": "user-123",
"email": "alice@example.com",
"display_name": "Alice"
}
],
"total_records": 1
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let list = client.list_users(None).await.unwrap();
assert_eq!(list.users.len(), 1);
assert_eq!(list.users[0].email, "alice@example.com");
}
#[tokio::test]
async fn end_meeting_sends_put_with_action() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/v2/meetings/123456/status"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
client.end_meeting(123456).await.unwrap();
}
#[tokio::test]
async fn list_past_meeting_participants_returns_list() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/past_meetings/abc123/participants"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"participants": [
{"name": "Alice", "user_email": "alice@example.com", "duration": 1800}
],
"total_records": 1
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let list = client
.list_past_meeting_participants("abc123")
.await
.unwrap();
assert_eq!(list.participants.len(), 1);
assert_eq!(list.participants[0].name, Some("Alice".into()));
}
#[tokio::test]
async fn list_user_meeting_reports_sends_from_param() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/report/users/me/meetings"))
.and(query_param("from", "2026-04-01"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meetings": [],
"total_records": 0,
"from": "2026-04-01",
"to": "2026-04-30"
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let list = client
.list_user_meeting_reports("me", "2026-04-01", None)
.await
.unwrap();
assert_eq!(list.meetings.len(), 0);
}
#[tokio::test]
async fn list_meetings_follows_next_page_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/users/me/meetings"))
.and(query_param("page_size", "300"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meetings": [{"id": 1, "topic": "Page 1 Meeting"}],
"total_records": 2,
"next_page_token": "token-abc"
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/users/me/meetings"))
.and(query_param("next_page_token", "token-abc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"meetings": [{"id": 2, "topic": "Page 2 Meeting"}],
"total_records": 2,
"next_page_token": ""
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let list = client.list_meetings("me", None).await.unwrap();
assert_eq!(list.meetings.len(), 2, "both pages must be merged");
assert_eq!(list.meetings[0].topic, "Page 1 Meeting");
assert_eq!(list.meetings[1].topic, "Page 2 Meeting");
assert!(
list.next_page_token.is_none(),
"exhausted token must be absent"
);
}
#[test]
fn encode_meeting_id_single_encodes_plain_uuids() {
assert_eq!(encode_meeting_id("abc123"), "abc123");
assert_eq!(encode_meeting_id("abc/def"), "abc%2Fdef");
}
#[test]
fn encode_meeting_id_double_encodes_leading_slash() {
assert_eq!(encode_meeting_id("/abc"), "%252Fabc");
assert_eq!(encode_meeting_id("/abc/def"), "%252Fabc%252Fdef");
}
#[test]
fn encode_meeting_id_double_encodes_double_slash() {
assert_eq!(encode_meeting_id("abc//def"), "abc%252F%252Fdef");
assert_eq!(
encode_meeting_id("4444AAAiAAAAAiAA//AA=="),
"4444AAAiAAAAAiAA%252F%252FAA=="
);
}
#[tokio::test]
async fn get_recording_double_encodes_uuid_with_double_slash() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/meetings/abc%252F%252Fdef/recordings"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 123,
"topic": "Double-slash UUID meeting",
"start_time": "2026-04-01T10:00:00Z",
"duration": 30,
"recording_files": []
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let rec = client.get_recording("abc//def").await.unwrap();
assert_eq!(rec.topic, "Double-slash UUID meeting");
}
#[tokio::test]
async fn list_users_follows_next_page_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/users"))
.and(query_param("page_size", "300"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"users": [{"id": "u1", "email": "a@example.com"}],
"total_records": 2,
"next_page_token": "page2-token"
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/users"))
.and(query_param("next_page_token", "page2-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"users": [{"id": "u2", "email": "b@example.com"}],
"total_records": 2,
"next_page_token": ""
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let list = client.list_users(None).await.unwrap();
assert_eq!(list.users.len(), 2, "both pages must be merged");
assert_eq!(list.users[0].email, "a@example.com");
assert_eq!(list.users[1].email, "b@example.com");
}
#[tokio::test]
async fn list_participants_follows_next_page_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/past_meetings/mtg123/participants"))
.and(query_param("page_size", "300"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"participants": [{"name": "Alice"}],
"total_records": 2,
"next_page_token": "p2"
})))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/past_meetings/mtg123/participants"))
.and(query_param("next_page_token", "p2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"participants": [{"name": "Bob"}],
"total_records": 2,
"next_page_token": ""
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let list = client
.list_past_meeting_participants("mtg123")
.await
.unwrap();
assert_eq!(list.participants.len(), 2);
assert_eq!(list.participants[0].name, Some("Alice".into()));
assert_eq!(list.participants[1].name, Some("Bob".into()));
}
#[tokio::test]
async fn delete_recording_sends_delete_with_action_trash() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/v2/meetings/abc123/recordings"))
.and(query_param("action", "trash"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
client.delete_recording("abc123", true).await.unwrap();
}
#[tokio::test]
async fn delete_recording_sends_delete_with_action_delete() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/v2/meetings/abc123/recordings"))
.and(query_param("action", "delete"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
client.delete_recording("abc123", false).await.unwrap();
}
#[tokio::test]
async fn list_webinars_returns_list() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/users/me/webinars"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"webinars": [
{
"id": 12345678,
"topic": "Product Launch",
"start_time": "2026-05-01T14:00:00Z",
"duration": 60,
"type": 5
}
],
"total_records": 1
})))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let list = client.list_webinars("me").await.unwrap();
assert_eq!(list.webinars.len(), 1);
assert_eq!(list.webinars[0].topic, "Product Launch");
}
#[tokio::test]
async fn get_webinar_returns_404_as_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/webinars/99999999"))
.respond_with(ResponseTemplate::new(404).set_body_string("Webinar not found"))
.mount(&server)
.await;
let mut client = mock_client(&server).await;
let err = client.get_webinar(99999999).await.unwrap_err();
assert!(matches!(err, ApiError::NotFound(_)));
}
}