Cancelled and modified events in ics export (#4996)

<!-- PR description-->

---

#### Does this PR need a docs update or release note?

- [ ]  Yes, it's included
- [ ] 🕐 Yes, but in a later PR
- [x]  No

#### Type of change

<!--- Please check the type of change your PR introduces: --->
- [x] 🌻 Feature
- [ ] 🐛 Bugfix
- [ ] 🗺️ Documentation
- [ ] 🤖 Supportability/Tests
- [ ] 💻 CI/Deployment
- [ ] 🧹 Tech Debt/Cleanup

#### Issue(s)

<!-- Can reference multiple issues. Use one of the following "magic words" - "closes, fixes" to auto-close the Github issue. -->
* https://github.com/alcionai/corso/issues/3890

#### Test Plan

<!-- How will this be tested prior to merging.-->
- [ ] 💪 Manual
- [x]  Unit test
- [ ] 💚 E2E
This commit is contained in:
Abin Simon 2024-01-12 16:11:10 +05:30 committed by GitHub
parent 7f0b5ebf34
commit 303b8c31ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 406 additions and 115 deletions

View File

@ -3,6 +3,7 @@ package ics
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@ -33,6 +34,11 @@ import (
// originalStartTimeZone, reminderMinutesBeforeStart, responseRequested, // originalStartTimeZone, reminderMinutesBeforeStart, responseRequested,
// responseStatus, sensitivity // responseStatus, sensitivity
const (
iCalDateTimeFormat = "20060102T150405Z"
iCalDateFormat = "20060102"
)
func keyValues(key, value string) *ics.KeyValues { func keyValues(key, value string) *ics.KeyValues {
return &ics.KeyValues{ return &ics.KeyValues{
Key: key, Key: key,
@ -100,6 +106,7 @@ func getUTCTime(ts, tz string) (time.Time, error) {
// https://www.rfc-editor.org/rfc/rfc5545#section-3.3.10 // https://www.rfc-editor.org/rfc/rfc5545#section-3.3.10
// https://learn.microsoft.com/en-us/graph/api/resources/patternedrecurrence?view=graph-rest-1.0 // https://learn.microsoft.com/en-us/graph/api/resources/patternedrecurrence?view=graph-rest-1.0
// Ref: https://github.com/closeio/sync-engine/pull/381/files // Ref: https://github.com/closeio/sync-engine/pull/381/files
// FIXME: When we have daily repeating task the last one is not getting added (due to timezone differences)
func getRecurrencePattern( func getRecurrencePattern(
ctx context.Context, ctx context.Context,
recurrence models.PatternedRecurrenceable, recurrence models.PatternedRecurrenceable,
@ -173,10 +180,10 @@ func getRecurrencePattern(
// a date anymore. // a date anymore.
endTime, err := getUTCTime(end.String(), ptr.Val(rrange.GetRecurrenceTimeZone())) endTime, err := getUTCTime(end.String(), ptr.Val(rrange.GetRecurrenceTimeZone()))
if err != nil { if err != nil {
return "", clues.Wrap(err, "parsing end time") return "", clues.WrapWC(ctx, err, "parsing end time")
} }
recurComponents = append(recurComponents, "UNTIL="+endTime.Format("20060102T150405Z")) recurComponents = append(recurComponents, "UNTIL="+endTime.Format(iCalDateTimeFormat))
} }
case models.NOEND_RECURRENCERANGETYPE: case models.NOEND_RECURRENCERANGETYPE:
// Nothing to do // Nothing to do
@ -192,108 +199,150 @@ func getRecurrencePattern(
} }
func FromJSON(ctx context.Context, body []byte) (string, error) { func FromJSON(ctx context.Context, body []byte) (string, error) {
data, err := api.BytesToEventable(body) event, err := api.BytesToEventable(body)
if err != nil { if err != nil {
return "", clues.Wrap(err, "converting to eventable") return "", clues.WrapWC(ctx, err, "converting to eventable")
} }
cal := ics.NewCalendar() cal := ics.NewCalendar()
cal.SetProductId("-//Alcion//Corso") // Does this have to be customizable? cal.SetProductId("-//Alcion//Corso") // Does this have to be customizable?
id := data.GetId() // XXX: iCalUId? id := ptr.Val(event.GetId())
event := cal.AddEvent(ptr.Val(id)) iCalEvent := cal.AddEvent(id)
created := data.GetCreatedDateTime() err = updateEventProperties(ctx, event, iCalEvent)
if err != nil {
return "", clues.Wrap(err, "updating event properties")
}
exceptionOcurrances := event.GetAdditionalData()["exceptionOccurrences"]
if exceptionOcurrances == nil {
return cal.Serialize(), nil
}
for _, occ := range exceptionOcurrances.([]any) {
instance, ok := occ.(map[string]any)
if !ok {
return "", clues.NewWC(ctx, "converting exception instance to map[string]any").
With("interface_type", fmt.Sprintf("%T", instance))
}
exBody, err := json.Marshal(instance)
if err != nil {
return "", clues.WrapWC(ctx, err, "marshalling exception instance")
}
exception, err := api.BytesToEventable(exBody)
if err != nil {
return "", clues.WrapWC(ctx, err, "converting to eventable")
}
exICalEvent := cal.AddEvent(id)
start := exception.GetOriginalStart() // will always be in UTC
exICalEvent.AddProperty(ics.ComponentProperty(ics.PropertyRecurrenceId), start.Format(iCalDateTimeFormat))
err = updateEventProperties(ctx, exception, exICalEvent)
if err != nil {
return "", clues.Wrap(err, "updating exception event properties")
}
}
return cal.Serialize(), nil
}
func updateEventProperties(ctx context.Context, event models.Eventable, iCalEvent *ics.VEvent) error {
created := event.GetCreatedDateTime()
if created != nil { if created != nil {
event.SetCreatedTime(ptr.Val(created)) iCalEvent.SetCreatedTime(ptr.Val(created))
} }
modified := data.GetLastModifiedDateTime() modified := event.GetLastModifiedDateTime()
if modified != nil { if modified != nil {
event.SetModifiedAt(ptr.Val(modified)) iCalEvent.SetModifiedAt(ptr.Val(modified))
} }
allDay := ptr.Val(data.GetIsAllDay()) allDay := ptr.Val(event.GetIsAllDay())
startString := data.GetStart().GetDateTime() startString := event.GetStart().GetDateTime()
timeZone := data.GetStart().GetTimeZone() startTimezone := event.GetStart().GetTimeZone()
if startString != nil { if startString != nil {
start, err := getUTCTime(ptr.Val(startString), ptr.Val(timeZone)) start, err := getUTCTime(ptr.Val(startString), ptr.Val(startTimezone))
if err != nil { if err != nil {
return "", clues.Wrap(err, "parsing start time") return clues.WrapWC(ctx, err, "parsing start time")
} }
if allDay { if allDay {
event.SetStartAt(start, ics.WithValue(string(ics.ValueDataTypeDate))) iCalEvent.SetStartAt(start, ics.WithValue(string(ics.ValueDataTypeDate)))
} else { } else {
event.SetStartAt(start) iCalEvent.SetStartAt(start)
} }
} }
endString := data.GetEnd().GetDateTime() endString := event.GetEnd().GetDateTime()
timeZone = data.GetEnd().GetTimeZone() endTimezone := event.GetEnd().GetTimeZone()
if endString != nil { if endString != nil {
end, err := getUTCTime(ptr.Val(endString), ptr.Val(timeZone)) end, err := getUTCTime(ptr.Val(endString), ptr.Val(endTimezone))
if err != nil { if err != nil {
return "", clues.Wrap(err, "parsing end time") return clues.WrapWC(ctx, err, "parsing end time")
} }
if allDay { if allDay {
event.SetEndAt(end, ics.WithValue(string(ics.ValueDataTypeDate))) iCalEvent.SetEndAt(end, ics.WithValue(string(ics.ValueDataTypeDate)))
} else { } else {
event.SetEndAt(end) iCalEvent.SetEndAt(end)
} }
} }
recurrence := data.GetRecurrence() recurrence := event.GetRecurrence()
if recurrence != nil { if recurrence != nil {
pattern, err := getRecurrencePattern(ctx, recurrence) pattern, err := getRecurrencePattern(ctx, recurrence)
if err != nil { if err != nil {
return "", clues.WrapWC(ctx, err, "generating RRULE") return clues.Wrap(err, "generating RRULE")
} }
event.AddRrule(pattern) iCalEvent.AddRrule(pattern)
} }
cancelled := data.GetIsCancelled() cancelled := event.GetIsCancelled()
if cancelled != nil { if cancelled != nil {
event.SetStatus(ics.ObjectStatusCancelled) iCalEvent.SetStatus(ics.ObjectStatusCancelled)
} }
draft := data.GetIsDraft() draft := event.GetIsDraft()
if draft != nil { if draft != nil {
event.SetStatus(ics.ObjectStatusDraft) iCalEvent.SetStatus(ics.ObjectStatusDraft)
} }
summary := data.GetSubject() summary := event.GetSubject()
if summary != nil { if summary != nil {
event.SetSummary(ptr.Val(summary)) iCalEvent.SetSummary(ptr.Val(summary))
} }
// TODO: Emojies currently don't seem to be read properly by Outlook // TODO: Emojies currently don't seem to be read properly by Outlook
bodyPreview := ptr.Val(data.GetBodyPreview()) bodyPreview := ptr.Val(event.GetBodyPreview())
if data.GetBody() != nil { if event.GetBody() != nil {
description := ptr.Val(data.GetBody().GetContent()) description := ptr.Val(event.GetBody().GetContent())
contentType := data.GetBody().GetContentType().String() contentType := event.GetBody().GetContentType().String()
if len(description) > 0 && contentType == "text" { if len(description) > 0 && contentType == "text" {
event.SetDescription(description) iCalEvent.SetDescription(description)
} else { } else if len(description) > 0 {
// https://stackoverflow.com/a/859475 // https://stackoverflow.com/a/859475
event.SetDescription(bodyPreview) iCalEvent.SetDescription(bodyPreview)
if contentType == "html" { if contentType == "html" {
desc := strings.ReplaceAll(description, "\r\n", "") desc := strings.ReplaceAll(description, "\r\n", "")
desc = strings.ReplaceAll(desc, "\n", "") desc = strings.ReplaceAll(desc, "\n", "")
event.AddProperty("X-ALT-DESC", desc, ics.WithFmtType("text/html")) iCalEvent.AddProperty("X-ALT-DESC", desc, ics.WithFmtType("text/html"))
} }
} }
} }
showAs := ptr.Val(data.GetShowAs()).String() showAs := ptr.Val(event.GetShowAs()).String()
if len(showAs) > 0 && showAs != "unknown" { if len(showAs) > 0 && showAs != "unknown" {
var status ics.FreeBusyTimeType var status ics.FreeBusyTimeType
@ -308,37 +357,37 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
status = ics.FreeBusyTimeTypeBusyUnavailable status = ics.FreeBusyTimeTypeBusyUnavailable
} }
event.AddProperty(ics.ComponentPropertyFreebusy, string(status)) iCalEvent.AddProperty(ics.ComponentPropertyFreebusy, string(status))
} }
categories := data.GetCategories() categories := event.GetCategories()
for _, category := range categories { for _, category := range categories {
event.AddProperty(ics.ComponentPropertyCategories, category) iCalEvent.AddProperty(ics.ComponentPropertyCategories, category)
} }
// According to the RFC, this property may be used in a calendar // According to the RFC, this property may be used in a calendar
// component to convey a location where a more dynamic rendition of // component to convey a location where a more dynamic rendition of
// the calendar information associated with the calendar component // the calendar information associated with the calendar component
// can be found. // can be found.
url := ptr.Val(data.GetWebLink()) url := ptr.Val(event.GetWebLink())
if len(url) > 0 { if len(url) > 0 {
event.SetURL(url) iCalEvent.SetURL(url)
} }
organizer := data.GetOrganizer() organizer := event.GetOrganizer()
if organizer != nil { if organizer != nil {
name := ptr.Val(organizer.GetEmailAddress().GetName()) name := ptr.Val(organizer.GetEmailAddress().GetName())
addr := ptr.Val(organizer.GetEmailAddress().GetAddress()) addr := ptr.Val(organizer.GetEmailAddress().GetAddress())
// TODO: What to do if we only have a name? // TODO: What to do if we only have a name?
if len(name) > 0 && len(addr) > 0 { if len(name) > 0 && len(addr) > 0 {
event.SetOrganizer(addr, ics.WithCN(name)) iCalEvent.SetOrganizer(addr, ics.WithCN(name))
} else if len(addr) > 0 { } else if len(addr) > 0 {
event.SetOrganizer(addr) iCalEvent.SetOrganizer(addr)
} }
} }
attendees := data.GetAttendees() attendees := event.GetAttendees()
for _, attendee := range attendees { for _, attendee := range attendees {
props := []ics.PropertyParameter{} props := []ics.PropertyParameter{}
@ -385,16 +434,16 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
} }
addr := ptr.Val(attendee.GetEmailAddress().GetAddress()) addr := ptr.Val(attendee.GetEmailAddress().GetAddress())
event.AddAttendee(addr, props...) iCalEvent.AddAttendee(addr, props...)
} }
location := getLocationString(data.GetLocation()) location := getLocationString(event.GetLocation())
if len(location) > 0 { if len(location) > 0 {
event.SetLocation(location) iCalEvent.SetLocation(location)
} }
// TODO Handle different attachment type (file, item and reference) // TODO Handle different attachment type (file, item and reference)
attachments := data.GetAttachments() attachments := event.GetAttachments()
for _, attachment := range attachments { for _, attachment := range attachments {
props := []ics.PropertyParameter{} props := []ics.PropertyParameter{}
contentType := ptr.Val(attachment.GetContentType()) contentType := ptr.Val(attachment.GetContentType())
@ -411,12 +460,12 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
cb, err := attachment.GetBackingStore().Get("contentBytes") cb, err := attachment.GetBackingStore().Get("contentBytes")
if err != nil { if err != nil {
return "", clues.Wrap(err, "getting attachment content") return clues.WrapWC(ctx, err, "getting attachment content")
} }
content, ok := cb.([]uint8) content, ok := cb.([]uint8)
if !ok { if !ok {
return "", clues.NewWC(ctx, "getting attachment content string") return clues.NewWC(ctx, "getting attachment content string")
} }
props = append(props, ics.WithEncoding("base64"), ics.WithValue("BINARY")) props = append(props, ics.WithEncoding("base64"), ics.WithValue("BINARY"))
@ -429,19 +478,55 @@ func FromJSON(ctx context.Context, body []byte) (string, error) {
if inline { if inline {
cidv, err := attachment.GetBackingStore().Get("contentId") cidv, err := attachment.GetBackingStore().Get("contentId")
if err != nil { if err != nil {
return "", clues.Wrap(err, "getting attachment content id") return clues.WrapWC(ctx, err, "getting attachment content id")
} }
cid, err := str.AnyToString(cidv) cid, err := str.AnyToString(cidv)
if err != nil { if err != nil {
return "", clues.Wrap(err, "getting attachment content id string") return clues.WrapWC(ctx, err, "getting attachment content id string")
} }
props = append(props, keyValues("CID", cid)) props = append(props, keyValues("CID", cid))
} }
event.AddAttachment(base64.StdEncoding.EncodeToString(content), props...) iCalEvent.AddAttachment(base64.StdEncoding.EncodeToString(content), props...)
} }
return cal.Serialize(), nil cancelledDates, err := getCancelledDates(ctx, event)
if err != nil {
return clues.Wrap(err, "getting cancelled dates")
}
dateStrings := []string{}
for _, date := range cancelledDates {
dateStrings = append(dateStrings, date.Format(iCalDateFormat))
}
if len(dateStrings) > 0 {
iCalEvent.AddProperty(ics.ComponentPropertyExdate, strings.Join(dateStrings, ","))
}
return nil
}
func getCancelledDates(ctx context.Context, event models.Eventable) ([]time.Time, error) {
dateStrings, err := api.GetCancelledEventDateStrings(event)
if err != nil {
return nil, clues.WrapWC(ctx, err, "getting cancelled event date strings")
}
dates := []time.Time{}
tz := ptr.Val(event.GetStart().GetTimeZone())
for _, ds := range dateStrings {
// the data just contains date and no time which seems to work
start, err := getUTCTime(ds, tz)
if err != nil {
return nil, clues.WrapWC(ctx, err, "parsing cancelled event date")
}
dates = append(dates, start)
}
return dates, nil
} }

View File

@ -734,14 +734,7 @@ func (suite *ICSUnitSuite) TestEventConversion() {
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
defer flush() defer flush()
// convert event to bytes bts, err := eventToJSON(tt.event())
writer := kjson.NewJsonSerializationWriter()
defer writer.Close()
err := writer.WriteObjectValue("", tt.event())
require.NoError(t, err, "serializing contact")
bts, err := writer.GetSerializedContent()
require.NoError(t, err, "getting serialized content") require.NoError(t, err, "getting serialized content")
e, err := FromJSON(ctx, bts) e, err := FromJSON(ctx, bts)
@ -849,10 +842,6 @@ func (suite *ICSUnitSuite) TestAttendees() {
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
defer flush() defer flush()
// convert event to bytes
writer := kjson.NewJsonSerializationWriter()
defer writer.Close()
e := baseEvent() e := baseEvent()
atts := make([]models.Attendeeable, len(tt.att)) atts := make([]models.Attendeeable, len(tt.att))
@ -889,10 +878,7 @@ func (suite *ICSUnitSuite) TestAttendees() {
e.SetAttendees(atts) e.SetAttendees(atts)
err := writer.WriteObjectValue("", e) bts, err := eventToJSON(e)
require.NoError(t, err, "serializing contact")
bts, err := writer.GetSerializedContent()
require.NoError(t, err, "getting serialized content") require.NoError(t, err, "getting serialized content")
out, err := FromJSON(ctx, bts) out, err := FromJSON(ctx, bts)
@ -1035,16 +1021,9 @@ func (suite *ICSUnitSuite) TestAttachments() {
ctx, flush := tester.NewContext(t) ctx, flush := tester.NewContext(t)
defer flush() defer flush()
// convert event to bytes
writer := kjson.NewJsonSerializationWriter()
defer writer.Close()
e := baseEvent() e := baseEvent()
err := writer.WriteObjectValue("", e) bts, err := eventToJSON(e)
require.NoError(t, err, "serializing contact")
bts, err := writer.GetSerializedContent()
require.NoError(t, err, "getting serialized content") require.NoError(t, err, "getting serialized content")
parsed := map[string]any{} parsed := map[string]any{}
@ -1079,3 +1058,225 @@ func (suite *ICSUnitSuite) TestAttachments() {
}) })
} }
} }
func (suite *ICSUnitSuite) TestCancellations() {
table := []struct {
name string
cancelledIds []string
expected string
}{
{
name: "single",
cancelledIds: []string{
"OID.DEADBEEF=.2024-01-25",
},
expected: "EXDATE:20240125",
},
{
name: "multiple",
cancelledIds: []string{
"OID.DEADBEEF=.2024-01-25",
"OID.LIVEBEEF=.2024-02-26",
},
expected: "EXDATE:20240125,20240226",
},
}
for _, tt := range table {
suite.Run(tt.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
e := baseEvent()
e.SetIsCancelled(ptr.To(true))
e.SetAdditionalData(map[string]any{
"cancelledOccurrences": tt.cancelledIds,
})
bts, err := eventToJSON(e)
require.NoError(t, err, "getting serialized content")
out, err := FromJSON(ctx, bts)
require.NoError(t, err, "converting to ics")
assert.Contains(t, out, tt.expected, "cancellation exrule")
})
}
}
func getDateTimeZone(t time.Time, tz string) *models.DateTimeTimeZone {
dt := models.NewDateTimeTimeZone()
dt.SetDateTime(ptr.To(t.Format(time.RFC3339)))
dt.SetTimeZone(ptr.To(tz))
return dt
}
func eventToMap(e *models.Event) (map[string]any, error) {
bts, err := eventToJSON(e)
if err != nil {
return nil, err
}
parsed := map[string]any{}
err = json.Unmarshal(bts, &parsed)
if err != nil {
return nil, err
}
return parsed, nil
}
func eventToJSON(e *models.Event) ([]byte, error) {
writer := kjson.NewJsonSerializationWriter()
defer writer.Close()
err := writer.WriteObjectValue("", e)
if err != nil {
return nil, err
}
bts, err := writer.GetSerializedContent()
if err != nil {
return nil, err
}
return bts, err
}
func (suite *ICSUnitSuite) TestEventExceptions() {
table := []struct {
name string
event func() *models.Event
check func(string)
}{
{
name: "single exception",
event: func() *models.Event {
e := baseEvent()
exception := baseEvent()
exception.SetSubject(ptr.To("Exception"))
exception.SetOriginalStart(ptr.To(time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC)))
newStart := getDateTimeZone(time.Date(2021, 1, 1, 13, 0, 0, 0, time.UTC), "UTC")
newEnd := getDateTimeZone(time.Date(2021, 1, 1, 14, 0, 0, 0, time.UTC), "UTC")
exception.SetStart(newStart)
exception.SetEnd(newEnd)
parsed, err := eventToMap(exception)
require.NoError(suite.T(), err, "parsing exception")
// add exception event to additional data
e.SetAdditionalData(map[string]any{
"exceptionOccurrences": []map[string]any{parsed},
})
return e
},
check: func(out string) {
lines := strings.Split(out, "\r\n")
events := 0
for _, l := range lines {
if strings.HasPrefix(l, "BEGIN:VEVENT") {
events++
}
}
assert.Equal(suite.T(), 2, events, "number of events")
assert.Contains(suite.T(), out, "RECURRENCE-ID:20210101T120000Z", "recurrence id")
assert.Contains(suite.T(), out, "SUMMARY:Subject", "original event")
assert.Contains(suite.T(), out, "SUMMARY:Exception", "exception event")
assert.Contains(suite.T(), out, "DTSTART:20210101T130000Z", "new start time")
assert.Contains(suite.T(), out, "DTEND:20210101T140000Z", "new end time")
},
},
{
name: "multiple exceptions",
event: func() *models.Event {
e := baseEvent()
exception1 := baseEvent()
exception1.SetSubject(ptr.To("Exception 1"))
exception1.SetOriginalStart(ptr.To(time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC)))
newStart := getDateTimeZone(time.Date(2021, 1, 1, 13, 0, 0, 0, time.UTC), "UTC")
newEnd := getDateTimeZone(time.Date(2021, 1, 1, 14, 0, 0, 0, time.UTC), "UTC")
exception1.SetStart(newStart)
exception1.SetEnd(newEnd)
exception2 := baseEvent()
exception2.SetSubject(ptr.To("Exception 2"))
exception2.SetOriginalStart(ptr.To(time.Date(2021, 1, 2, 12, 0, 0, 0, time.UTC)))
newStart = getDateTimeZone(time.Date(2021, 1, 2, 13, 0, 0, 0, time.UTC), "UTC")
newEnd = getDateTimeZone(time.Date(2021, 1, 2, 14, 0, 0, 0, time.UTC), "UTC")
exception2.SetStart(newStart)
exception2.SetEnd(newEnd)
parsed1, err := eventToMap(exception1)
require.NoError(suite.T(), err, "parsing exception 1")
parsed2, err := eventToMap(exception2)
require.NoError(suite.T(), err, "parsing exception 2")
// add exception event to additional data
e.SetAdditionalData(map[string]any{
"exceptionOccurrences": []map[string]any{parsed1, parsed2},
})
return e
},
check: func(out string) {
lines := strings.Split(out, "\r\n")
events := 0
for _, l := range lines {
if strings.HasPrefix(l, "BEGIN:VEVENT") {
events++
}
}
assert.Equal(suite.T(), 3, events, "number of events")
assert.Contains(suite.T(), out, "RECURRENCE-ID:20210101T120000Z", "recurrence id 1")
assert.Contains(suite.T(), out, "RECURRENCE-ID:20210102T120000Z", "recurrence id 2")
assert.Contains(suite.T(), out, "SUMMARY:Subject", "original event")
assert.Contains(suite.T(), out, "SUMMARY:Exception 1", "exception event 1")
assert.Contains(suite.T(), out, "SUMMARY:Exception 2", "exception event 2")
assert.Contains(suite.T(), out, "DTSTART:20210101T130000Z", "new start time 1")
assert.Contains(suite.T(), out, "DTEND:20210101T140000Z", "new end time 1")
assert.Contains(suite.T(), out, "DTSTART:20210102T130000Z", "new start time 2")
assert.Contains(suite.T(), out, "DTEND:20210102T140000Z", "new end time 2")
},
},
}
for _, tt := range table {
suite.Run(tt.name, func() {
ctx, flush := tester.NewContext(suite.T())
defer flush()
bts, err := eventToJSON(tt.event())
require.NoError(suite.T(), err, "getting serialized content")
out, err := FromJSON(ctx, bts)
require.NoError(suite.T(), err, "converting to ics")
tt.check(out)
})
}
}

View File

@ -221,7 +221,7 @@ func (c Events) GetItem(
return nil, nil, graph.Stack(ctx, err) return nil, nil, graph.Stack(ctx, err)
} }
err = validateCancelledOccurrences(event) _, err = GetCancelledEventDateStrings(event)
if err != nil { if err != nil {
return nil, nil, clues.Wrap(err, "verify cancelled occurrences") return nil, nil, clues.Wrap(err, "verify cancelled occurrences")
} }
@ -309,26 +309,30 @@ func fixupExceptionOccurrences(
return nil return nil
} }
// Adding checks to ensure that the data is in the format that we expect M365 to return func GetCancelledEventDateStrings(event models.Eventable) ([]string, error) {
func validateCancelledOccurrences(event models.Eventable) error {
cancelledOccurrences := event.GetAdditionalData()["cancelledOccurrences"] cancelledOccurrences := event.GetAdditionalData()["cancelledOccurrences"]
if cancelledOccurrences != nil { if cancelledOccurrences == nil {
return nil, nil
}
co, ok := cancelledOccurrences.([]any) co, ok := cancelledOccurrences.([]any)
if !ok { if !ok {
return clues.New("converting cancelledOccurrences to []any"). return nil, clues.New("converting cancelledOccurrences to []any").
With("type", fmt.Sprintf("%T", cancelledOccurrences)) With("type", fmt.Sprintf("%T", cancelledOccurrences))
} }
dates := []string{}
for _, instance := range co { for _, instance := range co {
instance, err := str.AnyToString(instance) instance, err := str.AnyToString(instance)
if err != nil { if err != nil {
return err return nil, err
} }
// There might be multiple `.` in the ID and hence >2 // There might be multiple `.` in the ID and hence >2
splits := strings.Split(instance, ".") splits := strings.Split(instance, ".")
if len(splits) < 2 { if len(splits) < 2 {
return clues.New("unexpected cancelled event format"). return nil, clues.New("unexpected cancelled event format").
With("instance", instance) With("instance", instance)
} }
@ -336,12 +340,13 @@ func validateCancelledOccurrences(event models.Eventable) error {
_, err = dttm.ParseTime(startStr) _, err = dttm.ParseTime(startStr)
if err != nil { if err != nil {
return clues.Wrap(err, "parsing cancelled event date") return nil, clues.Wrap(err, "parsing cancelled event date")
}
}
} }
return nil dates = append(dates, startStr)
}
return dates, nil
} }
func parseableToMap(att serialization.Parsable) (map[string]any, error) { func parseableToMap(att serialization.Parsable) (map[string]any, error) {