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