1use chrono::{DateTime, Datelike, Utc};
8use pidge_core::{
9 Attendee, AttendeeKind, BodyContentType, Event, EventTime, RecurrenceFreq, RecurrencePattern,
10 RecurrenceRange, ResponseStatus, Weekday,
11};
12use serde::Deserialize;
13
14use crate::error::ClientError;
15
16const PREFER_UTC: &str = "outlook.timezone=\"UTC\"";
17
18#[derive(Deserialize)]
19struct GraphEventList {
20 value: Vec<GraphEvent>,
21 #[serde(rename = "@odata.nextLink", default)]
22 next_link: Option<String>,
23}
24
25#[derive(Deserialize)]
26struct GraphEvent {
27 id: String,
28 subject: Option<String>,
29 #[serde(rename = "bodyPreview", default)]
30 body_preview: Option<String>,
31 #[serde(default)]
32 body: Option<GraphBody>,
33 start: GraphDateTime,
34 end: GraphDateTime,
35 #[serde(rename = "isAllDay", default)]
36 is_all_day: Option<bool>,
37 #[serde(default)]
38 location: Option<GraphLocation>,
39 #[serde(default)]
40 organizer: Option<GraphAttendee>,
41 #[serde(default)]
42 attendees: Vec<GraphAttendee>,
43 #[serde(default)]
44 recurrence: Option<GraphRecurrence>,
45 #[serde(rename = "isOrganizer", default)]
46 is_organizer: Option<bool>,
47 #[serde(rename = "responseStatus", default)]
48 response_status: Option<GraphResponseStatus>,
49 #[serde(rename = "onlineMeeting", default)]
50 online_meeting: Option<GraphOnlineMeeting>,
51 #[serde(rename = "seriesMasterId", default)]
52 series_master_id: Option<String>,
53 #[serde(rename = "calendarId", default)]
54 calendar_id: Option<String>,
55}
56
57#[derive(Deserialize)]
58struct GraphBody {
59 #[serde(rename = "contentType")]
60 content_type: String,
61 content: String,
62}
63
64#[derive(Deserialize)]
65struct GraphDateTime {
66 #[serde(rename = "dateTime")]
67 date_time: String,
68 #[serde(rename = "timeZone")]
69 time_zone: String,
70}
71
72#[derive(Deserialize)]
73struct GraphLocation {
74 #[serde(rename = "displayName", default)]
75 display_name: Option<String>,
76}
77
78#[derive(Deserialize)]
79struct GraphAttendee {
80 #[serde(rename = "emailAddress")]
81 email_address: GraphEmailAddress,
82 #[serde(rename = "type", default)]
83 kind: Option<String>,
84 #[serde(default)]
85 status: Option<GraphAttendeeStatus>,
86}
87
88#[derive(Deserialize)]
89struct GraphEmailAddress {
90 #[serde(default)]
91 name: Option<String>,
92 #[serde(default)]
93 address: Option<String>,
94}
95
96#[derive(Deserialize)]
97struct GraphAttendeeStatus {
98 #[serde(default)]
99 response: Option<String>,
100}
101
102#[derive(Deserialize)]
103struct GraphResponseStatus {
104 #[serde(default)]
105 response: Option<String>,
106}
107
108#[derive(Deserialize)]
109struct GraphOnlineMeeting {
110 #[serde(rename = "joinUrl", default)]
111 join_url: Option<String>,
112}
113
114#[derive(Deserialize)]
115struct GraphRecurrence {
116 pattern: GraphRecurrencePattern,
117 range: GraphRecurrenceRange,
118}
119
120#[derive(Deserialize)]
121struct GraphRecurrencePattern {
122 #[serde(rename = "type")]
123 kind: String,
124 interval: u32,
125 #[serde(rename = "daysOfWeek", default)]
126 days_of_week: Vec<String>,
127}
128
129#[derive(Deserialize)]
130struct GraphRecurrenceRange {
131 #[serde(rename = "type")]
132 kind: String,
133 #[serde(rename = "endDate", default)]
134 end_date: Option<String>,
135 #[serde(rename = "numberOfOccurrences", default)]
136 number_of_occurrences: Option<u32>,
137}
138
139pub struct EventsPage {
140 pub events: Vec<Event>,
141 pub has_more: bool,
142 pub next_link: Option<String>,
144}
145
146#[derive(Debug, Clone)]
148pub struct NewEvent {
149 pub subject: String,
150 pub start: DateTime<Utc>,
151 pub end: DateTime<Utc>,
152 pub tz: String,
153 pub all_day: bool,
154 pub location: Option<String>,
155 pub body_text: Option<String>,
156 pub required_attendees: Vec<String>,
157 pub optional_attendees: Vec<String>,
158 pub recurrence: Option<RecurrencePattern>,
159 pub online_meeting: bool,
160}
161
162impl NewEvent {
163 pub(crate) fn to_graph_json(&self) -> serde_json::Value {
164 let mut v = serde_json::json!({
171 "subject": self.subject,
172 "isAllDay": self.all_day,
173 "start": {
174 "dateTime": format_graph_dt(self.start),
175 "timeZone": "UTC",
176 },
177 "end": {
178 "dateTime": format_graph_dt(self.end),
179 "timeZone": "UTC",
180 },
181 });
182 if let Some(loc) = &self.location {
183 v["location"] = serde_json::json!({ "displayName": loc });
184 }
185 if let Some(b) = &self.body_text {
186 v["body"] = serde_json::json!({ "contentType": "text", "content": b });
187 }
188 let mut atts: Vec<serde_json::Value> = self
189 .required_attendees
190 .iter()
191 .map(|a| attendee_json(a, "required"))
192 .collect();
193 atts.extend(
194 self.optional_attendees
195 .iter()
196 .map(|a| attendee_json(a, "optional")),
197 );
198 if !atts.is_empty() {
199 v["attendees"] = serde_json::Value::Array(atts);
200 }
201 if let Some(r) = &self.recurrence {
202 v["recurrence"] = recurrence_to_json(r, self.start);
203 }
204 if self.online_meeting {
205 v["isOnlineMeeting"] = serde_json::Value::Bool(true);
206 v["onlineMeetingProvider"] = serde_json::Value::String("teamsForBusiness".into());
207 }
208 v
209 }
210}
211
212fn format_graph_dt(dt: DateTime<Utc>) -> String {
213 dt.format("%Y-%m-%dT%H:%M:%S").to_string()
214}
215
216fn attendee_json(addr: &str, kind: &str) -> serde_json::Value {
217 serde_json::json!({
218 "emailAddress": { "address": addr },
219 "type": kind,
220 })
221}
222
223fn recurrence_to_json(r: &RecurrencePattern, start: DateTime<Utc>) -> serde_json::Value {
224 let pattern_type = match r.freq {
225 RecurrenceFreq::Daily => "daily",
226 RecurrenceFreq::Weekly => "weekly",
227 RecurrenceFreq::Monthly => "absoluteMonthly",
228 RecurrenceFreq::Yearly => "absoluteYearly",
229 };
230 let mut pattern = serde_json::json!({
231 "type": pattern_type,
232 "interval": r.interval,
233 });
234 if r.freq == RecurrenceFreq::Weekly && !r.by_weekday.is_empty() {
235 pattern["daysOfWeek"] = serde_json::Value::Array(
236 r.by_weekday
237 .iter()
238 .map(|w| serde_json::Value::String(weekday_name(*w).into()))
239 .collect(),
240 );
241 }
242 if matches!(r.freq, RecurrenceFreq::Monthly | RecurrenceFreq::Yearly) {
243 pattern["dayOfMonth"] = serde_json::Value::Number(start.day().into());
244 }
245 if r.freq == RecurrenceFreq::Yearly {
246 pattern["month"] = serde_json::Value::Number(start.month().into());
247 }
248
249 let start_date = start.format("%Y-%m-%d").to_string();
250 let range = match &r.range {
251 RecurrenceRange::NoEnd => serde_json::json!({ "type": "noEnd", "startDate": start_date }),
252 RecurrenceRange::Count(n) => serde_json::json!({
253 "type": "numbered", "startDate": start_date, "numberOfOccurrences": n
254 }),
255 RecurrenceRange::EndDate(d) => serde_json::json!({
256 "type": "endDate", "startDate": start_date, "endDate": d.format("%Y-%m-%d").to_string()
257 }),
258 };
259
260 serde_json::json!({ "pattern": pattern, "range": range })
261}
262
263fn weekday_name(w: Weekday) -> &'static str {
264 match w {
265 Weekday::Monday => "monday",
266 Weekday::Tuesday => "tuesday",
267 Weekday::Wednesday => "wednesday",
268 Weekday::Thursday => "thursday",
269 Weekday::Friday => "friday",
270 Weekday::Saturday => "saturday",
271 Weekday::Sunday => "sunday",
272 }
273}
274
275#[derive(Debug, Clone, Copy)]
276pub enum RsvpKind {
277 Accept,
278 Tentative,
279 Decline,
280}
281
282#[allow(clippy::too_many_arguments)]
287pub async fn list_calendar_view(
288 http: &reqwest::Client,
289 base_url: &str,
290 access_token: &str,
291 account: &str,
292 calendar_id: Option<&str>,
293 start: DateTime<Utc>,
294 end: DateTime<Utc>,
295 limit: usize,
296) -> Result<EventsPage, ClientError> {
297 let url = match calendar_id {
298 Some(id) => format!("{base_url}/me/calendars/{id}/calendarView"),
299 None => format!("{base_url}/me/calendarView"),
300 };
301 let resp = super::send_with_retry(
302 http.get(&url)
303 .bearer_auth(access_token)
304 .header("Prefer", PREFER_UTC)
305 .query(&[
306 ("startDateTime", start.to_rfc3339()),
307 ("endDateTime", end.to_rfc3339()),
308 ("$orderby", "start/dateTime".into()),
309 ("$top", limit.to_string()),
310 ]),
311 )
312 .await?;
313 let status = resp.status();
314 if !status.is_success() {
315 let text = resp.text().await.unwrap_or_default();
316 return Err(ClientError::Graph {
317 status: status.as_u16(),
318 message: text,
319 });
320 }
321 let list: GraphEventList = resp.json().await?;
322 Ok(EventsPage {
323 has_more: list.next_link.is_some(),
324 next_link: list.next_link,
325 events: list
326 .value
327 .into_iter()
328 .map(|g| to_event(g, account, calendar_id))
329 .collect(),
330 })
331}
332
333pub(crate) fn event_from_delta_value(
335 value: serde_json::Value,
336 account: &str,
337) -> Option<pidge_core::Event> {
338 let g: GraphEvent = serde_json::from_value(value).ok()?;
339 Some(to_event(g, account, None))
340}
341
342pub async fn list_events_at(
345 http: &reqwest::Client,
346 access_token: &str,
347 account: &str,
348 url: &str,
349) -> Result<EventsPage, ClientError> {
350 let resp = super::send_with_retry(
351 http.get(url)
352 .bearer_auth(access_token)
353 .header("Prefer", PREFER_UTC),
354 )
355 .await?;
356 let status = resp.status();
357 if !status.is_success() {
358 let text = resp.text().await.unwrap_or_default();
359 return Err(ClientError::Graph {
360 status: status.as_u16(),
361 message: text,
362 });
363 }
364 let list: GraphEventList = resp.json().await?;
365 Ok(EventsPage {
366 has_more: list.next_link.is_some(),
367 next_link: list.next_link,
368 events: list
369 .value
370 .into_iter()
371 .map(|g| to_event(g, account, None))
372 .collect(),
373 })
374}
375
376pub async fn get_event(
378 http: &reqwest::Client,
379 base_url: &str,
380 access_token: &str,
381 account: &str,
382 event_id: &str,
383) -> Result<Event, ClientError> {
384 let url = format!("{base_url}/me/events/{event_id}");
385 let resp = super::send_with_retry(
386 http.get(&url)
387 .bearer_auth(access_token)
388 .header("Prefer", PREFER_UTC),
389 )
390 .await?;
391 let status = resp.status();
392 if !status.is_success() {
393 let text = resp.text().await.unwrap_or_default();
394 return Err(ClientError::Graph {
395 status: status.as_u16(),
396 message: text,
397 });
398 }
399 let g: GraphEvent = resp.json().await?;
400 Ok(to_event(g, account, None))
401}
402
403pub async fn create_event(
406 http: &reqwest::Client,
407 base_url: &str,
408 access_token: &str,
409 calendar_id: Option<&str>,
410 event: &NewEvent,
411) -> Result<String, ClientError> {
412 let url = match calendar_id {
413 Some(id) => format!("{base_url}/me/calendars/{id}/events"),
414 None => format!("{base_url}/me/calendar/events"),
415 };
416 let resp = super::send_with_retry(
417 http.post(&url)
418 .bearer_auth(access_token)
419 .json(&event.to_graph_json()),
420 )
421 .await?;
422 let status = resp.status();
423 if !status.is_success() {
424 let text = resp.text().await.unwrap_or_default();
425 return Err(ClientError::Graph {
426 status: status.as_u16(),
427 message: text,
428 });
429 }
430 let v: serde_json::Value = resp.json().await?;
431 v["id"]
432 .as_str()
433 .map(|s| s.to_string())
434 .ok_or_else(|| ClientError::Graph {
435 status: 200,
436 message: "create event response missing 'id'".to_string(),
437 })
438}
439
440pub async fn update_event(
442 http: &reqwest::Client,
443 base_url: &str,
444 access_token: &str,
445 event_id: &str,
446 event: &NewEvent,
447) -> Result<(), ClientError> {
448 let url = format!("{base_url}/me/events/{event_id}");
449 let resp = super::send_with_retry(
450 http.patch(&url)
451 .bearer_auth(access_token)
452 .json(&event.to_graph_json()),
453 )
454 .await?;
455 bubble_no_body(resp).await
456}
457
458pub async fn move_time(
460 http: &reqwest::Client,
461 base_url: &str,
462 access_token: &str,
463 event_id: &str,
464 start: DateTime<Utc>,
465 end: DateTime<Utc>,
466 tz: &str,
467) -> Result<(), ClientError> {
468 let url = format!("{base_url}/me/events/{event_id}");
469 let body = serde_json::json!({
470 "start": { "dateTime": format_graph_dt(start), "timeZone": tz },
471 "end": { "dateTime": format_graph_dt(end), "timeZone": tz },
472 });
473 let resp =
474 super::send_with_retry(http.patch(&url).bearer_auth(access_token).json(&body)).await?;
475 bubble_no_body(resp).await
476}
477
478pub async fn delete_event(
480 http: &reqwest::Client,
481 base_url: &str,
482 access_token: &str,
483 event_id: &str,
484) -> Result<(), ClientError> {
485 let url = format!("{base_url}/me/events/{event_id}");
486 let resp = super::send_with_retry(http.delete(&url).bearer_auth(access_token)).await?;
487 bubble_no_body(resp).await
488}
489
490pub async fn cancel_event(
492 http: &reqwest::Client,
493 base_url: &str,
494 access_token: &str,
495 event_id: &str,
496 comment: &str,
497) -> Result<(), ClientError> {
498 let url = format!("{base_url}/me/events/{event_id}/cancel");
499 let body = serde_json::json!({ "Comment": comment });
500 let resp =
501 super::send_with_retry(http.post(&url).bearer_auth(access_token).json(&body)).await?;
502 bubble_no_body(resp).await
503}
504
505pub async fn rsvp_event(
507 http: &reqwest::Client,
508 base_url: &str,
509 access_token: &str,
510 event_id: &str,
511 kind: RsvpKind,
512 comment: &str,
513 send_response: bool,
514) -> Result<(), ClientError> {
515 let verb = match kind {
516 RsvpKind::Accept => "accept",
517 RsvpKind::Tentative => "tentativelyAccept",
518 RsvpKind::Decline => "decline",
519 };
520 let url = format!("{base_url}/me/events/{event_id}/{verb}");
521 let body = serde_json::json!({ "Comment": comment, "SendResponse": send_response });
522 let resp =
523 super::send_with_retry(http.post(&url).bearer_auth(access_token).json(&body)).await?;
524 bubble_no_body(resp).await
525}
526
527pub async fn move_event_to_calendar(
530 http: &reqwest::Client,
531 base_url: &str,
532 access_token: &str,
533 event_id: &str,
534 destination_calendar_id: &str,
535) -> Result<(), ClientError> {
536 let url = format!("{base_url}/me/events/{event_id}");
537 let bind = format!("{base_url}/me/calendars/{destination_calendar_id}");
538 let body = serde_json::json!({ "calendar@odata.bind": bind });
539 let resp =
540 super::send_with_retry(http.patch(&url).bearer_auth(access_token).json(&body)).await?;
541 bubble_no_body(resp).await
542}
543
544async fn bubble_no_body(resp: reqwest::Response) -> Result<(), ClientError> {
545 let status = resp.status();
546 if !status.is_success() {
547 let text = resp.text().await.unwrap_or_default();
548 return Err(ClientError::Graph {
549 status: status.as_u16(),
550 message: text,
551 });
552 }
553 Ok(())
554}
555
556fn to_event(g: GraphEvent, account: &str, calendar_hint: Option<&str>) -> Event {
557 let (body_content, body_content_type) = match g.body {
558 Some(b) => {
559 let kind = if b.content_type.eq_ignore_ascii_case("html") {
560 BodyContentType::Html
561 } else {
562 BodyContentType::Text
563 };
564 (b.content, kind)
565 }
566 None => (String::new(), BodyContentType::Text),
567 };
568 let organizer = g
569 .organizer
570 .map(graph_attendee_to_attendee)
571 .unwrap_or_else(|| Attendee {
572 name: String::new(),
573 address: String::new(),
574 kind: AttendeeKind::Required,
575 response: ResponseStatus::Organizer,
576 });
577
578 Event {
579 account: account.to_string(),
580 calendar_id: g
581 .calendar_id
582 .unwrap_or_else(|| calendar_hint.unwrap_or("").to_string()),
583 id: g.id,
584 subject: g.subject.unwrap_or_default(),
585 start: parse_event_time(g.start),
586 end: parse_event_time(g.end),
587 all_day: g.is_all_day.unwrap_or(false),
588 location: g
589 .location
590 .and_then(|l| l.display_name)
591 .filter(|s| !s.is_empty()),
592 organizer,
593 attendees: g
594 .attendees
595 .into_iter()
596 .map(graph_attendee_to_attendee)
597 .collect(),
598 body_preview: g.body_preview.unwrap_or_default(),
599 body_content,
600 body_content_type,
601 recurrence: g.recurrence.map(parse_recurrence),
602 is_organizer: g.is_organizer.unwrap_or(false),
603 response_status: parse_response(g.response_status.and_then(|r| r.response).as_deref()),
604 online_meeting_url: g.online_meeting.and_then(|m| m.join_url),
605 series_master_id: g.series_master_id,
606 }
607}
608
609fn parse_event_time(g: GraphDateTime) -> EventTime {
610 let at = chrono::NaiveDateTime::parse_from_str(&g.date_time, "%Y-%m-%dT%H:%M:%S%.f")
614 .or_else(|_| chrono::NaiveDateTime::parse_from_str(&g.date_time, "%Y-%m-%dT%H:%M:%S"))
615 .map(|n| DateTime::<Utc>::from_naive_utc_and_offset(n, Utc))
616 .unwrap_or_else(|_| Utc::now());
617 EventTime {
618 at,
619 tz: g.time_zone,
620 }
621}
622
623fn graph_attendee_to_attendee(g: GraphAttendee) -> Attendee {
624 Attendee {
625 name: g.email_address.name.unwrap_or_default(),
626 address: g.email_address.address.unwrap_or_default(),
627 kind: match g.kind.as_deref() {
628 Some("optional") => AttendeeKind::Optional,
629 Some("resource") => AttendeeKind::Resource,
630 _ => AttendeeKind::Required,
631 },
632 response: parse_response(g.status.and_then(|s| s.response).as_deref()),
633 }
634}
635
636fn parse_response(s: Option<&str>) -> ResponseStatus {
637 match s {
638 Some("organizer") => ResponseStatus::Organizer,
639 Some("accepted") => ResponseStatus::Accepted,
640 Some("tentativelyAccepted") => ResponseStatus::Tentative,
641 Some("declined") => ResponseStatus::Declined,
642 Some("notResponded") => ResponseStatus::NotResponded,
643 _ => ResponseStatus::None,
644 }
645}
646
647fn parse_recurrence(g: GraphRecurrence) -> RecurrencePattern {
648 let freq = match g.pattern.kind.as_str() {
649 "daily" => RecurrenceFreq::Daily,
650 "weekly" => RecurrenceFreq::Weekly,
651 "absoluteMonthly" | "relativeMonthly" => RecurrenceFreq::Monthly,
652 "absoluteYearly" | "relativeYearly" => RecurrenceFreq::Yearly,
653 _ => RecurrenceFreq::Daily,
654 };
655 let by_weekday = g
656 .pattern
657 .days_of_week
658 .into_iter()
659 .filter_map(|d| match d.as_str() {
660 "monday" => Some(Weekday::Monday),
661 "tuesday" => Some(Weekday::Tuesday),
662 "wednesday" => Some(Weekday::Wednesday),
663 "thursday" => Some(Weekday::Thursday),
664 "friday" => Some(Weekday::Friday),
665 "saturday" => Some(Weekday::Saturday),
666 "sunday" => Some(Weekday::Sunday),
667 _ => None,
668 })
669 .collect();
670 let range = match g.range.kind.as_str() {
671 "noEnd" => RecurrenceRange::NoEnd,
672 "numbered" => RecurrenceRange::Count(g.range.number_of_occurrences.unwrap_or(1)),
673 _ => g
674 .range
675 .end_date
676 .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok())
677 .map(RecurrenceRange::EndDate)
678 .unwrap_or(RecurrenceRange::NoEnd),
679 };
680 RecurrencePattern {
681 freq,
682 interval: g.pattern.interval,
683 by_weekday,
684 range,
685 }
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use wiremock::matchers::{body_partial_json, header, method, path, path_regex, query_param};
692 use wiremock::{Mock, MockServer, ResponseTemplate};
693
694 fn sample_event(tz: &str) -> NewEvent {
695 NewEvent {
696 subject: "Course".into(),
697 start: DateTime::parse_from_rfc3339("2026-07-01T08:00:00Z")
698 .unwrap()
699 .to_utc(),
700 end: DateTime::parse_from_rfc3339("2026-07-01T09:15:00Z")
701 .unwrap()
702 .to_utc(),
703 tz: tz.into(),
704 all_day: false,
705 location: None,
706 body_text: None,
707 required_attendees: vec![],
708 optional_attendees: vec![],
709 recurrence: None,
710 online_meeting: false,
711 }
712 }
713
714 #[test]
715 fn to_graph_json_labels_utc_clock_as_utc_not_local_zone() {
716 let v = sample_event("Europe/Stockholm").to_graph_json();
722 assert_eq!(v["start"]["dateTime"], "2026-07-01T08:00:00");
723 assert_eq!(v["start"]["timeZone"], "UTC");
724 assert_eq!(v["end"]["dateTime"], "2026-07-01T09:15:00");
725 assert_eq!(v["end"]["timeZone"], "UTC");
726 }
727
728 #[tokio::test]
729 async fn list_calendar_view_passes_window_and_orderby() {
730 let server = MockServer::start().await;
731 Mock::given(method("GET"))
732 .and(path("/me/calendarView"))
733 .and(header("authorization", "Bearer AT"))
734 .and(header("prefer", "outlook.timezone=\"UTC\""))
735 .and(query_param("$orderby", "start/dateTime"))
736 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
737 "value": [
738 {
739 "id": "evt-1",
740 "subject": "Sync",
741 "bodyPreview": "weekly",
742 "start": { "dateTime": "2026-05-22T13:00:00.000", "timeZone": "UTC" },
743 "end": { "dateTime": "2026-05-22T14:00:00.000", "timeZone": "UTC" },
744 "isAllDay": false,
745 "organizer": { "emailAddress": { "name": "K", "address": "k@x.com" } },
746 "attendees": [
747 {
748 "emailAddress": { "name": "A", "address": "a@x.com" },
749 "type": "required",
750 "status": { "response": "accepted" }
751 }
752 ],
753 "isOrganizer": true,
754 "responseStatus": { "response": "organizer" }
755 }
756 ]
757 })))
758 .mount(&server)
759 .await;
760 let http = reqwest::Client::new();
761 let start = DateTime::parse_from_rfc3339("2026-05-20T00:00:00Z")
762 .unwrap()
763 .to_utc();
764 let end = DateTime::parse_from_rfc3339("2026-05-27T00:00:00Z")
765 .unwrap()
766 .to_utc();
767 let page = list_calendar_view(&http, &server.uri(), "AT", "u@e.com", None, start, end, 50)
768 .await
769 .unwrap();
770 assert_eq!(page.events.len(), 1);
771 assert_eq!(page.events[0].subject, "Sync");
772 assert!(page.events[0].is_organizer);
773 assert_eq!(page.events[0].attendees.len(), 1);
774 assert_eq!(page.events[0].attendees[0].address, "a@x.com");
775 }
776
777 #[tokio::test]
778 async fn list_calendar_view_supports_calendar_scope() {
779 let server = MockServer::start().await;
780 Mock::given(method("GET"))
781 .and(path("/me/calendars/CAL-2/calendarView"))
782 .respond_with(
783 ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": []})),
784 )
785 .mount(&server)
786 .await;
787 let http = reqwest::Client::new();
788 let s = DateTime::parse_from_rfc3339("2026-05-20T00:00:00Z")
789 .unwrap()
790 .to_utc();
791 let e = DateTime::parse_from_rfc3339("2026-05-21T00:00:00Z")
792 .unwrap()
793 .to_utc();
794 let p = list_calendar_view(
795 &http,
796 &server.uri(),
797 "AT",
798 "u@e.com",
799 Some("CAL-2"),
800 s,
801 e,
802 50,
803 )
804 .await
805 .unwrap();
806 assert!(p.events.is_empty());
807 }
808
809 #[tokio::test]
810 async fn get_event_parses_recurrence() {
811 let server = MockServer::start().await;
812 Mock::given(method("GET"))
813 .and(path("/me/events/EVT-1"))
814 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
815 "id": "EVT-1",
816 "subject": "Daily standup",
817 "start": { "dateTime": "2026-05-22T07:00:00.000", "timeZone": "UTC" },
818 "end": { "dateTime": "2026-05-22T07:15:00.000", "timeZone": "UTC" },
819 "isAllDay": false,
820 "organizer": { "emailAddress": { "name": "K", "address": "k@x.com" } },
821 "recurrence": {
822 "pattern": { "type": "weekly", "interval": 1, "daysOfWeek": ["monday","wednesday","friday"] },
823 "range": { "type": "noEnd", "startDate": "2026-05-22" }
824 }
825 })))
826 .mount(&server)
827 .await;
828 let http = reqwest::Client::new();
829 let e = get_event(&http, &server.uri(), "AT", "u@e.com", "EVT-1")
830 .await
831 .unwrap();
832 let r = e.recurrence.unwrap();
833 assert_eq!(r.freq, RecurrenceFreq::Weekly);
834 assert_eq!(r.by_weekday.len(), 3);
835 assert!(matches!(r.range, RecurrenceRange::NoEnd));
836 }
837
838 #[tokio::test]
839 async fn create_event_serializes_attendees_recurrence_online_meeting() {
840 let server = MockServer::start().await;
841 Mock::given(method("POST"))
842 .and(path("/me/calendar/events"))
843 .and(body_partial_json(serde_json::json!({
844 "subject": "Weekly sync",
845 "isAllDay": false,
846 "isOnlineMeeting": true,
847 "onlineMeetingProvider": "teamsForBusiness",
848 "attendees": [
849 { "emailAddress": { "address": "a@x.com" }, "type": "required" },
850 { "emailAddress": { "address": "b@x.com" }, "type": "optional" }
851 ],
852 "recurrence": {
853 "pattern": { "type": "weekly", "interval": 1, "daysOfWeek": ["monday"] }
854 }
855 })))
856 .respond_with(
857 ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": "NEW"})),
858 )
859 .mount(&server)
860 .await;
861 let http = reqwest::Client::new();
862 let new = NewEvent {
863 subject: "Weekly sync".into(),
864 start: DateTime::parse_from_rfc3339("2026-05-25T13:00:00Z")
865 .unwrap()
866 .to_utc(),
867 end: DateTime::parse_from_rfc3339("2026-05-25T14:00:00Z")
868 .unwrap()
869 .to_utc(),
870 tz: "Europe/Stockholm".into(),
871 all_day: false,
872 location: None,
873 body_text: None,
874 required_attendees: vec!["a@x.com".into()],
875 optional_attendees: vec!["b@x.com".into()],
876 recurrence: Some(RecurrencePattern {
877 freq: RecurrenceFreq::Weekly,
878 interval: 1,
879 by_weekday: vec![Weekday::Monday],
880 range: RecurrenceRange::NoEnd,
881 }),
882 online_meeting: true,
883 };
884 let id = create_event(&http, &server.uri(), "AT", None, &new)
885 .await
886 .unwrap();
887 assert_eq!(id, "NEW");
888 }
889
890 #[tokio::test]
891 async fn move_time_patches_start_and_end() {
892 let server = MockServer::start().await;
893 Mock::given(method("PATCH"))
894 .and(path_regex("/me/events/[A-Za-z0-9]+"))
895 .and(body_partial_json(serde_json::json!({
896 "start": { "dateTime": "2026-05-22T15:00:00", "timeZone": "Europe/Stockholm" },
897 "end": { "dateTime": "2026-05-22T16:00:00", "timeZone": "Europe/Stockholm" }
898 })))
899 .respond_with(ResponseTemplate::new(200))
900 .mount(&server)
901 .await;
902 let http = reqwest::Client::new();
903 let s = DateTime::parse_from_rfc3339("2026-05-22T15:00:00Z")
904 .unwrap()
905 .to_utc();
906 let e = DateTime::parse_from_rfc3339("2026-05-22T16:00:00Z")
907 .unwrap()
908 .to_utc();
909 move_time(&http, &server.uri(), "AT", "EVT", s, e, "Europe/Stockholm")
910 .await
911 .unwrap();
912 }
913
914 #[tokio::test]
915 async fn delete_event_calls_delete() {
916 let server = MockServer::start().await;
917 Mock::given(method("DELETE"))
918 .and(path_regex("/me/events/[A-Za-z0-9]+"))
919 .respond_with(ResponseTemplate::new(204))
920 .mount(&server)
921 .await;
922 let http = reqwest::Client::new();
923 delete_event(&http, &server.uri(), "AT", "EVT")
924 .await
925 .unwrap();
926 }
927
928 #[tokio::test]
929 async fn cancel_event_posts_comment() {
930 let server = MockServer::start().await;
931 Mock::given(method("POST"))
932 .and(path_regex("/me/events/[A-Za-z0-9]+/cancel"))
933 .and(body_partial_json(
934 serde_json::json!({ "Comment": "Sorry, can't make it" }),
935 ))
936 .respond_with(ResponseTemplate::new(202))
937 .mount(&server)
938 .await;
939 let http = reqwest::Client::new();
940 cancel_event(&http, &server.uri(), "AT", "EVT", "Sorry, can't make it")
941 .await
942 .unwrap();
943 }
944
945 #[tokio::test]
946 async fn rsvp_event_posts_to_correct_verb() {
947 let server = MockServer::start().await;
948 Mock::given(method("POST"))
949 .and(path_regex("/me/events/[A-Za-z0-9]+/tentativelyAccept"))
950 .and(body_partial_json(
951 serde_json::json!({ "SendResponse": false }),
952 ))
953 .respond_with(ResponseTemplate::new(202))
954 .mount(&server)
955 .await;
956 let http = reqwest::Client::new();
957 rsvp_event(
958 &http,
959 &server.uri(),
960 "AT",
961 "EVT",
962 RsvpKind::Tentative,
963 "",
964 false,
965 )
966 .await
967 .unwrap();
968 }
969
970 #[tokio::test]
971 async fn move_event_patches_calendar_bind() {
972 let server = MockServer::start().await;
973 Mock::given(method("PATCH"))
974 .and(path_regex("/me/events/[A-Za-z0-9]+"))
975 .respond_with(ResponseTemplate::new(200))
976 .mount(&server)
977 .await;
978 let http = reqwest::Client::new();
979 move_event_to_calendar(&http, &server.uri(), "AT", "EVT-1", "CAL-2")
980 .await
981 .unwrap();
982 let reqs = server.received_requests().await.unwrap();
983 assert_eq!(reqs.len(), 1);
984 let body: serde_json::Value = serde_json::from_slice(&reqs[0].body).unwrap();
985 let expected = format!("{}/me/calendars/CAL-2", server.uri());
986 assert_eq!(body["calendar@odata.bind"], expected);
987 }
988}