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