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
7pub trait Paginated: Sized {
12 fn next_page_token(&self) -> Option<&str>;
14 fn append_page(&mut self, next: Self);
16}
17
18#[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#[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#[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 #[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#[derive(Debug, Serialize)]
172pub struct RecordingControlRequest {
173 pub action: String,
174}
175
176#[derive(Debug, Serialize)]
179pub struct MeetingStatusRequest {
180 pub action: String,
181}
182
183#[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 #[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#[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#[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
285impl 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 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#[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 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 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}