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!({
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#[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
324pub 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
351pub 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
388pub 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
406pub 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
430pub 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
442pub 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
461pub 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
487pub 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
508pub 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 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}