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