Skip to main content

pidge_client/graph/
events.rs

1//! Microsoft Graph calendar event endpoints.
2//!
3//! All requests send `Prefer: outlook.timezone="UTC"` so Graph normalizes
4//! timestamps regardless of the mailbox's preferred TZ; pidge owns display
5//! formatting via the IANA name carried alongside each `EventTime`.
6
7use 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    /// Graph continuation URL for the next page (`@odata.nextLink`).
147    pub next_link: Option<String>,
148}
149
150/// What the caller is sending — pre-Graph-serialization shape for create/update.
151#[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    /// Send `body_text` as `contentType: html` instead of `text`. Set when
161    /// copying a body Graph returned as HTML, so tags are not escaped.
162    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/// Outlook reminder setting carried on a create/update payload.
171///
172/// `Default` sends nothing: on create Graph applies the mailbox default
173/// (typically 15 minutes), on update the existing setting is preserved.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
175pub enum Reminder {
176    #[default]
177    Default,
178    /// `isReminderOn: false`.
179    Off,
180    /// `isReminderOn: true` with this many minutes before start.
181    MinutesBefore(u32),
182}
183
184impl Reminder {
185    /// The setting an existing event carries, for copy/preserve flows.
186    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        // `format_graph_dt` emits the *UTC* clock for `start`/`end`, so the
197        // accompanying `timeZone` MUST be "UTC". Labelling a UTC clock with a
198        // local zone (e.g. self.tz = "Europe/Stockholm") makes Graph
199        // re-interpret it as local time and shift it again — a double
200        // conversion that stored events hours off. `self.tz` records the
201        // zone the event was scheduled in but must not relabel the clock.
202        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/// GET /me/calendarView?startDateTime=..&endDateTime=..
326///
327/// Expands recurrence instances within the window. Pass `calendar_id` to
328/// scope to a non-default calendar.
329#[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
376/// Parse one raw delta item into an [`Event`].
377pub(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
385/// Fetch a page of calendar-view events at an absolute Graph URL (an
386/// `@odata.nextLink` carried in a pidge cursor).
387pub 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
419/// GET /me/events/{id}
420pub 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
446/// POST /me/calendar/events (or /me/calendars/{id}/events). Returns the
447/// new event ID. Attendees in the payload trigger Graph to send invitations.
448pub 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
483/// PATCH /me/events/{id} — overwrite editable fields.
484pub 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
501/// PATCH /me/events/{id} — change only start + end.
502pub 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
521/// DELETE /me/events/{id} — silent removal (no attendee notification).
522pub 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
533/// POST /me/events/{id}/cancel — organizer-only; sends cancellation notices.
534pub 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
548/// POST /me/events/{id}/accept | /tentativelyAccept | /decline
549pub 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
570/// PATCH /me/events/{id} — move event to a different calendar by binding
571/// the navigation property to the destination calendar's URL.
572pub 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    // With Prefer: outlook.timezone="UTC", Graph returns the dateTime
658    // already in UTC; timeZone is the IANA zone the event was scheduled
659    // in (e.g. "Europe/Stockholm").
660    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        // Regression: a 10:00 Europe/Stockholm booking parses to 08:00Z.
854        // `format_graph_dt` emits the UTC clock ("08:00:00"), so the payload
855        // MUST label it "UTC". Labelling it "Europe/Stockholm" makes Graph
856        // re-interpret 08:00 as local time = 06:00Z — a double conversion
857        // that stored the Fotografiska course two hours early.
858        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}