Skip to main content

zoom_cli/api/
types.rs

1use serde::{Deserialize, Serialize};
2
3fn is_none_or_empty(s: &Option<String>) -> bool {
4    s.as_deref().is_none_or(str::is_empty)
5}
6
7// ── Pagination ────────────────────────────────────────────────────────────────
8
9/// Implemented by every paginated list type so `ZoomClient::get_all_pages` can
10/// collect results across multiple API requests transparently.
11pub trait Paginated: Sized {
12    /// Returns the token for the next page, or `None` / empty when exhausted.
13    fn next_page_token(&self) -> Option<&str>;
14    /// Merge `next` page's items into `self`, discarding `next`'s metadata.
15    fn append_page(&mut self, next: Self);
16}
17
18// ── Meeting ──────────────────────────────────────────────────────────────────
19
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct Meeting {
22    pub id: u64,
23    pub topic: String,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub start_time: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub duration: Option<u32>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub join_url: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub start_url: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub status: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub created_at: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub password: Option<String>,
38    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
39    pub meeting_type: Option<u8>,
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize)]
43pub struct MeetingList {
44    pub meetings: Vec<Meeting>,
45    #[serde(skip_serializing_if = "is_none_or_empty")]
46    pub next_page_token: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub total_records: Option<u64>,
49    #[serde(skip_serializing)]
50    pub page_count: Option<u32>,
51    #[serde(skip_serializing)]
52    pub page_size: Option<u32>,
53}
54
55#[derive(Debug, Serialize)]
56pub struct CreateMeetingRequest {
57    pub topic: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub start_time: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub duration: Option<u32>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub password: Option<String>,
64    #[serde(rename = "type")]
65    pub meeting_type: u8,
66}
67
68#[derive(Debug, Serialize, Default)]
69pub struct UpdateMeetingRequest {
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub topic: Option<String>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub start_time: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub duration: Option<u32>,
76}
77
78// ── User ─────────────────────────────────────────────────────────────────────
79
80#[derive(Debug, Clone, Deserialize, Serialize)]
81pub struct User {
82    pub id: String,
83    pub email: String,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub display_name: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub first_name: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub last_name: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub status: Option<String>,
92    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
93    pub user_type: Option<u8>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub created_at: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub last_login_time: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub pic_url: Option<String>,
100}
101
102#[derive(Debug, Deserialize, Serialize)]
103pub struct UserList {
104    pub users: Vec<User>,
105    #[serde(skip_serializing_if = "is_none_or_empty")]
106    pub next_page_token: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub total_records: Option<u64>,
109    #[serde(skip_serializing)]
110    pub page_count: Option<u32>,
111    #[serde(skip_serializing)]
112    pub page_size: Option<u32>,
113}
114
115// ── User management ──────────────────────────────────────────────────────────
116
117#[derive(Debug, Serialize)]
118pub struct CreateUserInfo {
119    pub email: String,
120    #[serde(rename = "type")]
121    pub user_type: u8,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub first_name: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub last_name: Option<String>,
126}
127
128#[derive(Debug, Serialize)]
129pub struct CreateUserRequest {
130    pub action: String,
131    pub user_info: CreateUserInfo,
132}
133
134#[derive(Debug, Serialize)]
135pub struct UserStatusRequest {
136    pub action: String,
137}
138
139// ── Recording ─────────────────────────────────────────────────────────────────
140
141#[derive(Debug, Clone, Deserialize, Serialize)]
142pub struct CloudRecording {
143    pub id: u64,
144    pub topic: String,
145    pub start_time: String,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub duration: Option<u32>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub total_size: Option<u64>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub recording_count: Option<u32>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub recording_files: Option<Vec<RecordingFile>>,
154}
155
156#[derive(Debug, Clone, Deserialize, Serialize)]
157pub struct RecordingFile {
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub id: Option<String>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub file_type: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub file_extension: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub file_size: Option<u64>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub play_url: Option<String>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub download_url: Option<String>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub status: Option<String>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub recording_type: Option<String>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub recording_start: Option<String>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub recording_end: Option<String>,
178}
179
180#[derive(Debug, Deserialize, Serialize)]
181pub struct RecordingList {
182    // Zoom names this field "meetings" internally; we expose it as "recordings".
183    #[serde(rename(deserialize = "meetings", serialize = "recordings"))]
184    pub recordings: Option<Vec<CloudRecording>>,
185    #[serde(skip_serializing_if = "is_none_or_empty")]
186    pub next_page_token: Option<String>,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub total_records: Option<u64>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub from: Option<String>,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub to: Option<String>,
193}
194
195// ── Recording control ────────────────────────────────────────────────────────
196
197#[derive(Debug, Serialize)]
198pub struct RecordingControlRequest {
199    pub action: String,
200}
201
202// ── Meeting status ────────────────────────────────────────────────────────────
203
204#[derive(Debug, Serialize)]
205pub struct MeetingStatusRequest {
206    pub action: String,
207}
208
209// ── Meeting invitation ────────────────────────────────────────────────────────
210
211#[derive(Debug, Clone, Deserialize, Serialize)]
212pub struct MeetingInvitation {
213    pub invitation: String,
214}
215
216// ── Participants ──────────────────────────────────────────────────────────────
217
218#[derive(Debug, Clone, Deserialize, Serialize)]
219pub struct Participant {
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub id: Option<String>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub name: Option<String>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub user_email: Option<String>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub join_time: Option<String>,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub leave_time: Option<String>,
230    // Duration in seconds
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub duration: Option<u32>,
233}
234
235#[derive(Debug, Deserialize, Serialize)]
236pub struct ParticipantList {
237    pub participants: Vec<Participant>,
238    #[serde(skip_serializing_if = "is_none_or_empty")]
239    pub next_page_token: Option<String>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub total_records: Option<u64>,
242    #[serde(skip_serializing)]
243    pub page_count: Option<u32>,
244    #[serde(skip_serializing)]
245    pub page_size: Option<u32>,
246}
247
248// ── Reports ───────────────────────────────────────────────────────────────────
249
250#[derive(Debug, Clone, Deserialize, Serialize)]
251pub struct MeetingReportItem {
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub uuid: Option<String>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub id: Option<u64>,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub topic: Option<String>,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub start_time: Option<String>,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub end_time: Option<String>,
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub duration: Option<u32>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub total_minutes: Option<u32>,
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub participants_count: Option<u32>,
268}
269
270#[derive(Debug, Deserialize, Serialize)]
271pub struct UserMeetingReportList {
272    pub meetings: Vec<MeetingReportItem>,
273    #[serde(skip_serializing_if = "is_none_or_empty")]
274    pub next_page_token: Option<String>,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub total_records: Option<u64>,
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub from: Option<String>,
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub to: Option<String>,
281    #[serde(skip_serializing)]
282    pub page_count: Option<u32>,
283    #[serde(skip_serializing)]
284    pub page_size: Option<u32>,
285}
286
287// ── Meeting participant report ────────────────────────────────────────────────
288
289#[derive(Debug, Clone, Deserialize, Serialize)]
290pub struct MeetingParticipantReport {
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub id: Option<String>,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub user_id: Option<String>,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub name: Option<String>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub user_email: Option<String>,
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub join_time: Option<String>,
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub leave_time: Option<String>,
303    /// Participation duration in seconds.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub duration: Option<u64>,
306}
307
308#[derive(Debug, Deserialize, Serialize)]
309pub struct MeetingParticipantReportList {
310    pub participants: Vec<MeetingParticipantReport>,
311    #[serde(skip_serializing_if = "is_none_or_empty")]
312    pub next_page_token: Option<String>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub total_records: Option<u64>,
315    #[serde(skip_serializing)]
316    pub page_size: Option<u32>,
317}
318
319// ── Webinar ───────────────────────────────────────────────────────────────────
320
321#[derive(Debug, Clone, Deserialize, Serialize)]
322pub struct Webinar {
323    pub id: u64,
324    pub topic: String,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub start_time: Option<String>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub duration: Option<u32>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub join_url: Option<String>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub agenda: Option<String>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub status: Option<String>,
335    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
336    pub webinar_type: Option<u8>,
337}
338
339#[derive(Debug, Deserialize, Serialize)]
340pub struct WebinarList {
341    pub webinars: Vec<Webinar>,
342    #[serde(skip_serializing_if = "is_none_or_empty")]
343    pub next_page_token: Option<String>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub total_records: Option<u64>,
346    #[serde(skip_serializing)]
347    pub page_size: Option<u32>,
348}
349
350// ── Paginated impls ───────────────────────────────────────────────────────────
351
352impl Paginated for MeetingList {
353    fn next_page_token(&self) -> Option<&str> {
354        self.next_page_token.as_deref().filter(|s| !s.is_empty())
355    }
356
357    fn append_page(&mut self, next: Self) {
358        self.meetings.extend(next.meetings);
359        // Normalize empty string to None — empty token means no more pages.
360        self.next_page_token = next.next_page_token.filter(|t| !t.is_empty());
361    }
362}
363
364impl Paginated for UserList {
365    fn next_page_token(&self) -> Option<&str> {
366        self.next_page_token.as_deref().filter(|s| !s.is_empty())
367    }
368
369    fn append_page(&mut self, next: Self) {
370        self.users.extend(next.users);
371        self.next_page_token = next.next_page_token.filter(|t| !t.is_empty());
372    }
373}
374
375impl Paginated for RecordingList {
376    fn next_page_token(&self) -> Option<&str> {
377        self.next_page_token.as_deref().filter(|s| !s.is_empty())
378    }
379
380    fn append_page(&mut self, next: Self) {
381        match (self.recordings.as_mut(), next.recordings) {
382            (Some(dst), Some(src)) => dst.extend(src),
383            (None, Some(src)) => self.recordings = Some(src),
384            _ => {}
385        }
386        self.next_page_token = next.next_page_token.filter(|t| !t.is_empty());
387    }
388}
389
390impl Paginated for ParticipantList {
391    fn next_page_token(&self) -> Option<&str> {
392        self.next_page_token.as_deref().filter(|s| !s.is_empty())
393    }
394
395    fn append_page(&mut self, next: Self) {
396        self.participants.extend(next.participants);
397        self.next_page_token = next.next_page_token.filter(|t| !t.is_empty());
398    }
399}
400
401impl Paginated for UserMeetingReportList {
402    fn next_page_token(&self) -> Option<&str> {
403        self.next_page_token.as_deref().filter(|s| !s.is_empty())
404    }
405
406    fn append_page(&mut self, next: Self) {
407        self.meetings.extend(next.meetings);
408        self.next_page_token = next.next_page_token.filter(|t| !t.is_empty());
409    }
410}
411
412impl Paginated for MeetingParticipantReportList {
413    fn next_page_token(&self) -> Option<&str> {
414        self.next_page_token.as_deref().filter(|s| !s.is_empty())
415    }
416    fn append_page(&mut self, next: Self) {
417        self.participants.extend(next.participants);
418        self.next_page_token = next.next_page_token.filter(|t| !t.is_empty());
419    }
420}
421
422impl Paginated for WebinarList {
423    fn next_page_token(&self) -> Option<&str> {
424        self.next_page_token.as_deref().filter(|s| !s.is_empty())
425    }
426
427    fn append_page(&mut self, next: Self) {
428        self.webinars.extend(next.webinars);
429        self.next_page_token = next.next_page_token.filter(|t| !t.is_empty());
430    }
431}
432
433// ── Auth ──────────────────────────────────────────────────────────────────────
434
435#[derive(Debug, Deserialize)]
436pub struct TokenResponse {
437    pub access_token: String,
438    pub token_type: String,
439    pub expires_in: u64,
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn meeting_deserializes_from_zoom_api_shape() {
448        let json = r#"{
449            "id": 123456789,
450            "topic": "Weekly Standup",
451            "start_time": "2026-04-03T09:00:00Z",
452            "duration": 30,
453            "join_url": "https://zoom.us/j/123456789",
454            "start_url": "https://zoom.us/s/123456789",
455            "status": "waiting",
456            "created_at": "2026-04-02T10:00:00Z",
457            "type": 2
458        }"#;
459        let m: Meeting = serde_json::from_str(json).unwrap();
460        assert_eq!(m.id, 123456789);
461        assert_eq!(m.topic, "Weekly Standup");
462        assert_eq!(m.duration, Some(30));
463        assert_eq!(m.meeting_type, Some(2));
464    }
465
466    #[test]
467    fn meeting_serializes_skipping_null_fields() {
468        let m = Meeting {
469            id: 1,
470            topic: "Test".into(),
471            start_time: None,
472            duration: Some(30),
473            join_url: None,
474            start_url: None,
475            status: None,
476            created_at: None,
477            password: None,
478            meeting_type: Some(2),
479        };
480        let v: serde_json::Value = serde_json::to_value(&m).unwrap();
481        assert!(v.get("start_time").is_none(), "null fields must be omitted");
482        assert!(v.get("join_url").is_none(), "null fields must be omitted");
483        assert_eq!(v["duration"], 30);
484    }
485
486    #[test]
487    fn meeting_list_deserializes_with_pagination() {
488        let json = r#"{
489            "meetings": [
490                {"id": 111, "topic": "A"},
491                {"id": 222, "topic": "B"}
492            ],
493            "total_records": 2,
494            "page_count": 1,
495            "page_size": 30
496        }"#;
497        let list: MeetingList = serde_json::from_str(json).unwrap();
498        assert_eq!(list.meetings.len(), 2);
499        assert_eq!(list.total_records, Some(2));
500        assert!(list.next_page_token.is_none());
501    }
502
503    #[test]
504    fn meeting_list_serializes_without_page_internals() {
505        let list = MeetingList {
506            meetings: vec![],
507            next_page_token: Some("".into()),
508            total_records: Some(0),
509            page_count: Some(1),
510            page_size: Some(100),
511        };
512        let v: serde_json::Value = serde_json::to_value(&list).unwrap();
513        assert!(
514            v.get("page_count").is_none(),
515            "page internals must be hidden"
516        );
517        assert!(
518            v.get("page_size").is_none(),
519            "page internals must be hidden"
520        );
521        assert!(
522            v.get("next_page_token").is_none(),
523            "empty token must be omitted"
524        );
525        assert_eq!(v["total_records"], 0);
526    }
527
528    #[test]
529    fn user_deserializes_from_zoom_api_shape() {
530        let json = r#"{
531            "id": "user-abc-123",
532            "email": "alice@example.com",
533            "display_name": "Alice Smith",
534            "first_name": "Alice",
535            "last_name": "Smith",
536            "status": "active",
537            "type": 2
538        }"#;
539        let u: User = serde_json::from_str(json).unwrap();
540        assert_eq!(u.id, "user-abc-123");
541        assert_eq!(u.email, "alice@example.com");
542        assert_eq!(u.display_name, Some("Alice Smith".into()));
543        assert_eq!(u.user_type, Some(2));
544    }
545
546    #[test]
547    fn user_serializes_skipping_null_fields() {
548        let u = User {
549            id: "u1".into(),
550            email: "a@b.com".into(),
551            display_name: Some("Alice".into()),
552            first_name: None,
553            last_name: None,
554            status: Some("active".into()),
555            user_type: Some(2),
556            created_at: None,
557            last_login_time: None,
558            pic_url: None,
559        };
560        let v: serde_json::Value = serde_json::to_value(&u).unwrap();
561        assert!(v.get("first_name").is_none());
562        assert!(v.get("pic_url").is_none());
563        assert_eq!(v["display_name"], "Alice");
564    }
565
566    #[test]
567    fn recording_file_deserializes_with_optional_fields() {
568        let json = r#"{
569            "id": "rec-file-001",
570            "file_type": "MP4",
571            "file_size": 104857600,
572            "download_url": "https://zoom.us/rec/download/abc123",
573            "status": "completed",
574            "recording_type": "shared_screen_with_speaker_view"
575        }"#;
576        let f: RecordingFile = serde_json::from_str(json).unwrap();
577        assert_eq!(f.file_type, Some("MP4".into()));
578        assert_eq!(f.file_size, Some(104857600));
579    }
580
581    #[test]
582    fn recording_list_deserializes_meetings_key() {
583        let json = r#"{
584            "meetings": [
585                {"id": 1, "topic": "Test", "start_time": "2026-01-01T10:00:00Z"}
586            ],
587            "total_records": 1,
588            "from": "2026-01-01",
589            "to": "2026-04-01",
590            "next_page_token": ""
591        }"#;
592        let list: RecordingList = serde_json::from_str(json).unwrap();
593        assert_eq!(list.recordings.as_ref().unwrap().len(), 1);
594        assert_eq!(list.total_records, Some(1));
595    }
596
597    #[test]
598    fn recording_list_serializes_recordings_key() {
599        let list = RecordingList {
600            recordings: Some(vec![]),
601            next_page_token: Some("".into()),
602            total_records: Some(0),
603            from: Some("2026-01-01".into()),
604            to: Some("2026-04-01".into()),
605        };
606        let v: serde_json::Value = serde_json::to_value(&list).unwrap();
607        assert!(
608            v.get("meetings").is_none(),
609            "must serialize as 'recordings', not 'meetings'"
610        );
611        assert!(v.get("recordings").is_some());
612        assert!(
613            v.get("next_page_token").is_none(),
614            "empty token must be omitted"
615        );
616    }
617
618    #[test]
619    fn token_response_deserializes() {
620        let json = r#"{
621            "access_token": "eyJhbGc...",
622            "token_type": "bearer",
623            "expires_in": 3599
624        }"#;
625        let t: TokenResponse = serde_json::from_str(json).unwrap();
626        assert_eq!(t.access_token, "eyJhbGc...");
627        assert_eq!(t.expires_in, 3599);
628    }
629
630    #[test]
631    fn create_meeting_request_serializes_skipping_nones() {
632        let req = CreateMeetingRequest {
633            topic: "Demo".into(),
634            start_time: None,
635            duration: Some(45),
636            password: None,
637            meeting_type: 2,
638        };
639        let json: serde_json::Value = serde_json::to_value(&req).unwrap();
640        assert_eq!(json["topic"], "Demo");
641        assert_eq!(json["duration"], 45);
642        assert_eq!(json["type"], 2);
643        assert!(
644            json.get("start_time").is_none(),
645            "None fields must be omitted"
646        );
647        assert!(
648            json.get("password").is_none(),
649            "None fields must be omitted"
650        );
651    }
652
653    #[test]
654    fn participant_list_deserializes() {
655        let json = r#"{
656            "participants": [
657                {
658                    "id": "u1",
659                    "name": "Alice",
660                    "user_email": "alice@example.com",
661                    "join_time": "2026-04-01T10:00:00Z",
662                    "leave_time": "2026-04-01T11:00:00Z",
663                    "duration": 3600
664                }
665            ],
666            "total_records": 1,
667            "page_size": 300,
668            "next_page_token": ""
669        }"#;
670        let list: ParticipantList = serde_json::from_str(json).unwrap();
671        assert_eq!(list.participants.len(), 1);
672        assert_eq!(list.participants[0].name, Some("Alice".into()));
673        assert_eq!(list.participants[0].duration, Some(3600));
674        // page internals and empty token must not serialize
675        let v: serde_json::Value = serde_json::to_value(&list).unwrap();
676        assert!(v.get("page_size").is_none());
677        assert!(v.get("next_page_token").is_none());
678    }
679
680    #[test]
681    fn meeting_list_paginated_append_merges_items() {
682        let mut first = MeetingList {
683            meetings: vec![Meeting {
684                id: 1,
685                topic: "First".into(),
686                start_time: None,
687                duration: None,
688                join_url: None,
689                start_url: None,
690                status: None,
691                created_at: None,
692                password: None,
693                meeting_type: None,
694            }],
695            next_page_token: Some("tok".into()),
696            total_records: Some(2),
697            page_count: None,
698            page_size: None,
699        };
700        let second = MeetingList {
701            meetings: vec![Meeting {
702                id: 2,
703                topic: "Second".into(),
704                start_time: None,
705                duration: None,
706                join_url: None,
707                start_url: None,
708                status: None,
709                created_at: None,
710                password: None,
711                meeting_type: None,
712            }],
713            next_page_token: Some("".into()),
714            total_records: Some(2),
715            page_count: None,
716            page_size: None,
717        };
718        first.append_page(second);
719        assert_eq!(first.meetings.len(), 2);
720        assert_eq!(first.next_page_token(), None, "empty token is exhausted");
721        assert_eq!(
722            first.total_records,
723            Some(2),
724            "total_records preserved from first page"
725        );
726    }
727
728    #[test]
729    fn recording_list_paginated_append_merges_option_vec() {
730        let mut first = RecordingList {
731            recordings: Some(vec![CloudRecording {
732                id: 1,
733                topic: "Rec 1".into(),
734                start_time: "2026-01-01T10:00:00Z".into(),
735                duration: None,
736                total_size: None,
737                recording_count: None,
738                recording_files: None,
739            }]),
740            next_page_token: Some("t".into()),
741            total_records: Some(2),
742            from: None,
743            to: None,
744        };
745        let second = RecordingList {
746            recordings: Some(vec![CloudRecording {
747                id: 2,
748                topic: "Rec 2".into(),
749                start_time: "2026-01-02T10:00:00Z".into(),
750                duration: None,
751                total_size: None,
752                recording_count: None,
753                recording_files: None,
754            }]),
755            next_page_token: None,
756            total_records: Some(2),
757            from: None,
758            to: None,
759        };
760        first.append_page(second);
761        assert_eq!(first.recordings.as_ref().unwrap().len(), 2);
762        assert_eq!(first.next_page_token(), None);
763    }
764
765    #[test]
766    fn webinar_list_deserializes_and_serializes() {
767        let json = r#"{
768            "webinars": [
769                {
770                    "id": 12345678,
771                    "topic": "Product Launch",
772                    "start_time": "2026-05-01T14:00:00Z",
773                    "duration": 60,
774                    "type": 5
775                }
776            ],
777            "total_records": 1,
778            "page_size": 300
779        }"#;
780        let list: WebinarList = serde_json::from_str(json).unwrap();
781        assert_eq!(list.webinars.len(), 1);
782        assert_eq!(list.webinars[0].id, 12345678);
783        assert_eq!(list.webinars[0].topic, "Product Launch");
784        assert_eq!(list.webinars[0].webinar_type, Some(5));
785        // page_size must not appear in output
786        let v: serde_json::Value = serde_json::to_value(&list).unwrap();
787        assert!(v.get("page_size").is_none());
788    }
789
790    #[test]
791    fn webinar_list_paginated_append_merges_items() {
792        let mut first = WebinarList {
793            webinars: vec![Webinar {
794                id: 1,
795                topic: "First".into(),
796                start_time: None,
797                duration: None,
798                join_url: None,
799                agenda: None,
800                status: None,
801                webinar_type: None,
802            }],
803            next_page_token: Some("tok".into()),
804            total_records: Some(2),
805            page_size: None,
806        };
807        let second = WebinarList {
808            webinars: vec![Webinar {
809                id: 2,
810                topic: "Second".into(),
811                start_time: None,
812                duration: None,
813                join_url: None,
814                agenda: None,
815                status: None,
816                webinar_type: None,
817            }],
818            next_page_token: Some("".into()),
819            total_records: Some(2),
820            page_size: None,
821        };
822        first.append_page(second);
823        assert_eq!(first.webinars.len(), 2);
824        assert_eq!(first.next_page_token(), None);
825    }
826
827    #[test]
828    fn meeting_report_list_deserializes() {
829        let json = r#"{
830            "meetings": [
831                {
832                    "uuid": "abc==",
833                    "id": 123,
834                    "topic": "Standup",
835                    "start_time": "2026-04-01T09:00:00Z",
836                    "duration": 30,
837                    "participants_count": 5
838                }
839            ],
840            "total_records": 1,
841            "from": "2026-04-01",
842            "to": "2026-04-30"
843        }"#;
844        let list: UserMeetingReportList = serde_json::from_str(json).unwrap();
845        assert_eq!(list.meetings.len(), 1);
846        assert_eq!(list.meetings[0].participants_count, Some(5));
847    }
848}