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