1use chrono::{DateTime, Datelike, Utc};
8use pidge_core::{
9 Attendee, AttendeeKind, BodyContentType, Event, EventTime, RecurrenceFreq, RecurrencePattern,
10 RecurrenceRange, ResponseStatus, Weekday,
11};
12use serde::Deserialize;
13
14use 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#[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!({
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#[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
330pub 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
357pub 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
394pub 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
412pub 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
436pub 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
448pub 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
467pub 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
493pub 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 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 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}