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