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