Abin Simon deead262ae
Allow for timezone from TZ database (#5078)
<!-- PR description-->

Graph sometimes just send timezone value which are available in TZ
database. This PR makes sure that we don't always try to convert, but
check if the timezone is already in the format that we need first.

---

#### 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: --->
- [ ] 🌻 Feature
- [x] 🐛 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. -->
* #<issue>

#### Test Plan

<!-- How will this be tested prior to merging.-->
- [ ] 💪 Manual
- [x]  Unit test
- [ ] 💚 E2E

---------

Co-authored-by: zackrossman <zrossman@alcion.ai>
2024-01-22 14:11:39 -08:00

1372 lines
33 KiB
Go

package ics
// Useful tools
// https://icalendar.org/validator.html
// https://icalendar.org/rrule-tool.html
// https://balsoftware.net/rrule/
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/microsoft/kiota-abstractions-go/serialization"
kjson "github.com/microsoft/kiota-serialization-json-go"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/alcionai/corso/src/internal/common/ptr"
"github.com/alcionai/corso/src/internal/tester"
)
type ICSUnitSuite struct {
tester.Suite
}
func TestICSUnitSuite(t *testing.T) {
suite.Run(t, &ICSUnitSuite{Suite: tester.NewUnitSuite(t)})
}
func (suite *ICSUnitSuite) TestGetLocationString() {
table := []struct {
name string
loc func() models.Locationable
expect string
}{
{
name: "only displayname",
loc: func() models.Locationable {
loc := models.NewLocation()
loc.SetDisplayName(ptr.To("DisplayName"))
return loc
},
expect: "DisplayName",
},
{
name: "full address",
loc: func() models.Locationable {
loc := models.NewLocation()
loc.SetDisplayName(ptr.To("DisplayName"))
addr := models.NewPhysicalAddress()
addr.SetStreet(ptr.To("Street"))
addr.SetCity(ptr.To("City"))
addr.SetState(ptr.To("State"))
addr.SetCountryOrRegion(ptr.To("Country"))
addr.SetPostalCode(ptr.To("PostalCode"))
loc.SetAddress(addr)
return loc
},
expect: "DisplayName, Street, City, State, Country, PostalCode",
},
{
name: "displayname and street",
loc: func() models.Locationable {
loc := models.NewLocation()
loc.SetDisplayName(ptr.To("DisplayName"))
addr := models.NewPhysicalAddress()
addr.SetStreet(ptr.To("Street"))
loc.SetAddress(addr)
return loc
},
expect: "DisplayName, Street",
},
{
name: "only street",
loc: func() models.Locationable {
loc := models.NewLocation()
addr := models.NewPhysicalAddress()
addr.SetStreet(ptr.To("Street"))
loc.SetAddress(addr)
return loc
},
expect: "Street",
},
{
name: "displayname, city, country",
loc: func() models.Locationable {
loc := models.NewLocation()
loc.SetDisplayName(ptr.To("DisplayName"))
addr := models.NewPhysicalAddress()
addr.SetCity(ptr.To("City"))
addr.SetCountryOrRegion(ptr.To("Country"))
loc.SetAddress(addr)
return loc
},
expect: "DisplayName, City, Country",
},
}
for _, tt := range table {
suite.Run(tt.name, func() {
assert.Equal(suite.T(), tt.expect, getLocationString(tt.loc()))
})
}
}
func (suite *ICSUnitSuite) TestGetUTCTime() {
table := []struct {
name string
timestamp string
timezone string
time time.Time
errCheck require.ErrorAssertionFunc
}{
{
name: "valid time in UTC",
timestamp: "2021-01-01T12:00:00Z",
timezone: "UTC",
time: time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC),
errCheck: require.NoError,
},
{
name: "valid time in IST",
timestamp: "2021-01-01T12:00:00Z",
timezone: "India Standard Time",
time: time.Date(2021, 1, 1, 6, 30, 0, 0, time.UTC),
errCheck: require.NoError,
},
{
name: "timezone from TZ database",
timestamp: "2021-01-01T12:00:00Z",
timezone: "America/Los_Angeles",
time: time.Date(2021, 1, 1, 20, 0, 0, 0, time.UTC),
errCheck: require.NoError,
},
{
name: "invalid time",
timestamp: "invalid",
timezone: "UTC",
time: time.Time{},
errCheck: require.Error,
},
{
name: "invalid timezone",
timestamp: "2021-01-01T12:00:00Z",
timezone: "invalid",
time: time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC),
errCheck: require.Error,
},
}
for _, tt := range table {
suite.Run(tt.name, func() {
t, err := GetUTCTime(tt.timestamp, tt.timezone)
tt.errCheck(suite.T(), err)
if !tt.time.Equal(time.Time{}) {
assert.Equal(suite.T(), tt.time, t)
}
})
}
}
func (suite *ICSUnitSuite) TestGetRecurrencePattern() {
table := []struct {
name string
recurrence func() models.PatternedRecurrenceable
expect string
errCheck require.ErrorAssertionFunc
}{
{
name: "daily",
recurrence: func() models.PatternedRecurrenceable {
rec := models.NewPatternedRecurrence()
pat := models.NewRecurrencePattern()
typ, err := models.ParseRecurrencePatternType("daily")
require.NoError(suite.T(), err)
pat.SetTypeEscaped(typ.(*models.RecurrencePatternType))
pat.SetInterval(ptr.To(int32(1)))
rec.SetPattern(pat)
return rec
},
expect: "FREQ=DAILY;INTERVAL=1",
errCheck: require.NoError,
},
{
name: "daily with end date in different timezone",
recurrence: func() models.PatternedRecurrenceable {
rec := models.NewPatternedRecurrence()
pat := models.NewRecurrencePattern()
typ, err := models.ParseRecurrencePatternType("daily")
require.NoError(suite.T(), err)
pat.SetTypeEscaped(typ.(*models.RecurrencePatternType))
pat.SetInterval(ptr.To(int32(1)))
rng := models.NewRecurrenceRange()
rrtype, err := models.ParseRecurrenceRangeType("endDate")
require.NoError(suite.T(), err)
rng.SetTypeEscaped(rrtype.(*models.RecurrenceRangeType))
edate := serialization.NewDateOnly(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
rng.SetEndDate(edate)
rng.SetRecurrenceTimeZone(ptr.To("India Standard Time"))
rec.SetPattern(pat)
rec.SetRangeEscaped(rng)
return rec
},
expect: "FREQ=DAILY;INTERVAL=1;UNTIL=20210101T182959Z",
errCheck: require.NoError,
},
{
name: "weekly",
recurrence: func() models.PatternedRecurrenceable {
rec := models.NewPatternedRecurrence()
pat := models.NewRecurrencePattern()
typ, err := models.ParseRecurrencePatternType("weekly")
require.NoError(suite.T(), err)
pat.SetTypeEscaped(typ.(*models.RecurrencePatternType))
pat.SetInterval(ptr.To(int32(1)))
rec.SetPattern(pat)
return rec
},
expect: "FREQ=WEEKLY;INTERVAL=1",
errCheck: require.NoError,
},
{
name: "weekly with end date",
recurrence: func() models.PatternedRecurrenceable {
rec := models.NewPatternedRecurrence()
pat := models.NewRecurrencePattern()
typ, err := models.ParseRecurrencePatternType("weekly")
require.NoError(suite.T(), err)
pat.SetTypeEscaped(typ.(*models.RecurrencePatternType))
pat.SetInterval(ptr.To(int32(1)))
rng := models.NewRecurrenceRange()
rrtype, err := models.ParseRecurrenceRangeType("endDate")
require.NoError(suite.T(), err)
rng.SetTypeEscaped(rrtype.(*models.RecurrenceRangeType))
edate := serialization.NewDateOnly(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC))
rng.SetEndDate(edate)
rng.SetRecurrenceTimeZone(ptr.To("UTC"))
rec.SetPattern(pat)
rec.SetRangeEscaped(rng)
return rec
},
expect: "FREQ=WEEKLY;INTERVAL=1;UNTIL=20210101T235959Z",
errCheck: require.NoError,
},
{
name: "weekly with end count",
recurrence: func() models.PatternedRecurrenceable {
rec := models.NewPatternedRecurrence()
pat := models.NewRecurrencePattern()
typ, err := models.ParseRecurrencePatternType("weekly")
require.NoError(suite.T(), err)
pat.SetTypeEscaped(typ.(*models.RecurrencePatternType))
pat.SetInterval(ptr.To(int32(1)))
rng := models.NewRecurrenceRange()
rrtype, err := models.ParseRecurrenceRangeType("numbered")
require.NoError(suite.T(), err)
rng.SetTypeEscaped(rrtype.(*models.RecurrenceRangeType))
rng.SetNumberOfOccurrences(ptr.To(int32(10)))
rng.SetRecurrenceTimeZone(ptr.To("UTC"))
rec.SetPattern(pat)
rec.SetRangeEscaped(rng)
return rec
},
expect: "FREQ=WEEKLY;INTERVAL=1;COUNT=10",
errCheck: require.NoError,
},
{
name: "days of week",
recurrence: func() models.PatternedRecurrenceable {
rec := models.NewPatternedRecurrence()
pat := models.NewRecurrencePattern()
typ, err := models.ParseRecurrencePatternType("weekly")
require.NoError(suite.T(), err)
pat.SetTypeEscaped(typ.(*models.RecurrencePatternType))
pat.SetInterval(ptr.To(int32(1)))
days := []models.DayOfWeek{
models.MONDAY_DAYOFWEEK,
models.WEDNESDAY_DAYOFWEEK,
models.THURSDAY_DAYOFWEEK,
}
pat.SetDaysOfWeek(days)
rec.SetPattern(pat)
return rec
},
expect: "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,TH",
errCheck: require.NoError,
},
{
name: "daily with custom interval",
recurrence: func() models.PatternedRecurrenceable {
rec := models.NewPatternedRecurrence()
pat := models.NewRecurrencePattern()
typ, err := models.ParseRecurrencePatternType("daily")
require.NoError(suite.T(), err)
pat.SetTypeEscaped(typ.(*models.RecurrencePatternType))
pat.SetInterval(ptr.To(int32(2)))
rec.SetPattern(pat)
return rec
},
expect: "FREQ=DAILY;INTERVAL=2",
errCheck: require.NoError,
},
{
name: "day of month",
recurrence: func() models.PatternedRecurrenceable {
rec := models.NewPatternedRecurrence()
pat := models.NewRecurrencePattern()
typ, err := models.ParseRecurrencePatternType("absoluteMonthly")
require.NoError(suite.T(), err)
pat.SetTypeEscaped(typ.(*models.RecurrencePatternType))
pat.SetInterval(ptr.To(int32(1)))
pat.SetDayOfMonth(ptr.To(int32(5)))
rec.SetPattern(pat)
return rec
},
expect: "FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=5",
errCheck: require.NoError,
},
{
name: "every 3rd august",
recurrence: func() models.PatternedRecurrenceable {
rec := models.NewPatternedRecurrence()
pat := models.NewRecurrencePattern()
typ, err := models.ParseRecurrencePatternType("absoluteYearly")
require.NoError(suite.T(), err)
pat.SetTypeEscaped(typ.(*models.RecurrencePatternType))
pat.SetInterval(ptr.To(int32(3)))
pat.SetMonth(ptr.To(int32(8)))
rec.SetPattern(pat)
return rec
},
expect: "FREQ=YEARLY;INTERVAL=3;BYMONTH=8",
errCheck: require.NoError,
},
{
name: "first friday of august every year",
recurrence: func() models.PatternedRecurrenceable {
rec := models.NewPatternedRecurrence()
pat := models.NewRecurrencePattern()
typ, err := models.ParseRecurrencePatternType("relativeYearly")
require.NoError(suite.T(), err)
pat.SetTypeEscaped(typ.(*models.RecurrencePatternType))
pat.SetInterval(ptr.To(int32(1)))
pat.SetMonth(ptr.To(int32(8)))
pat.SetDaysOfWeek([]models.DayOfWeek{models.FRIDAY_DAYOFWEEK})
wi, err := models.ParseWeekIndex("first")
require.NoError(suite.T(), err)
pat.SetIndex(wi.(*models.WeekIndex))
rec.SetPattern(pat)
return rec
},
expect: "FREQ=YEARLY;INTERVAL=1;BYMONTH=8;BYDAY=1FR",
errCheck: require.NoError,
},
// TODO(meain): could still use more tests for edge cases of time
}
for _, tt := range table {
suite.Run(tt.name, func() {
ctx, flush := tester.NewContext(suite.T())
defer flush()
rec, err := getRecurrencePattern(ctx, tt.recurrence())
tt.errCheck(suite.T(), err)
assert.Equal(suite.T(), tt.expect, rec)
})
}
}
func baseEvent() *models.Event {
e := models.NewEvent()
e.SetId(ptr.To("mango"))
e.SetSubject(ptr.To("Subject"))
start := models.NewDateTimeTimeZone()
start.SetDateTime(ptr.To(time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC).Format(time.RFC3339)))
start.SetTimeZone(ptr.To("UTC"))
end := models.NewDateTimeTimeZone()
end.SetDateTime(ptr.To(time.Date(2021, 1, 2, 13, 0, 0, 0, time.UTC).Format(time.RFC3339)))
end.SetTimeZone(ptr.To("UTC"))
e.SetStart(start)
e.SetEnd(end)
return e
}
func (suite *ICSUnitSuite) TestEventConversion() {
t := suite.T()
table := []struct {
name string
event func() *models.Event
check func(string)
}{
{
name: "simple event",
event: func() *models.Event {
return baseEvent()
},
check: func(out string) {
assert.Contains(t, out, "BEGIN:VCALENDAR", "beginning of calendar")
assert.Contains(t, out, "VERSION:2.0", "version")
assert.Contains(t, out, "PRODID:-//Alcion//Corso", "prodid")
assert.Contains(t, out, "BEGIN:VEVENT", "beginning of event")
assert.Contains(t, out, "UID:mango", "uid")
assert.Contains(t, out, "SUMMARY:Subject", "summary")
assert.Contains(t, out, "DTSTART:20210101T120000Z", "start time")
assert.Contains(t, out, "DTEND:20210102T130000Z", "end time")
assert.Contains(t, out, "END:VEVENT", "end of event")
assert.Contains(t, out, "END:VCALENDAR", "end of calendar")
},
},
{
name: "event with created and modified time",
event: func() *models.Event {
e := baseEvent()
e.SetCreatedDateTime(ptr.To(time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC)))
e.SetLastModifiedDateTime(ptr.To(time.Date(2021, 1, 2, 13, 0, 0, 0, time.UTC)))
return e
},
check: func(out string) {
assert.Contains(t, out, "CREATED:20210101T120000Z", "created time")
assert.Contains(t, out, "LAST-MODIFIED:20210102T130000Z", "modified time")
},
},
{
name: "all day events",
event: func() *models.Event {
e := baseEvent()
e.SetIsAllDay(ptr.To(true))
return e
},
check: func(out string) {
assert.Contains(t, out, "DTSTART;VALUE=DATE:20210101", "start time")
assert.Contains(t, out, "DTEND;VALUE=DATE:20210102", "end time")
},
},
{
// All time values should get converted to UTC
name: "start and end with different timezone",
event: func() *models.Event {
e := baseEvent()
start := models.NewDateTimeTimeZone()
start.SetDateTime(ptr.To(time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC).Format(time.RFC3339)))
start.SetTimeZone(ptr.To("India Standard Time"))
end := models.NewDateTimeTimeZone()
end.SetDateTime(ptr.To(time.Date(2021, 1, 2, 13, 0, 0, 0, time.UTC).Format(time.RFC3339)))
end.SetTimeZone(ptr.To("Korea Standard Time"))
e.SetStart(start)
e.SetEnd(end)
return e
},
check: func(out string) {
assert.Contains(t, out, "DTSTART:20210101T063000Z", "start time")
assert.Contains(t, out, "DTEND:20210102T040000Z", "end time")
},
},
{
name: "daily event",
event: func() *models.Event {
e := baseEvent()
rec := models.NewPatternedRecurrence()
pat := models.NewRecurrencePattern()
typ, err := models.ParseRecurrencePatternType("daily")
require.NoError(t, err)
pat.SetTypeEscaped(typ.(*models.RecurrencePatternType))
pat.SetInterval(ptr.To(int32(1)))
rec.SetPattern(pat)
e.SetRecurrence(rec)
return e
},
check: func(out string) {
assert.Contains(t, out, "RRULE:FREQ=DAILY;INTERVAL=1", "recurrence rule")
},
},
{
name: "cancelled event",
event: func() *models.Event {
e := baseEvent()
e.SetIsCancelled(ptr.To(true))
return e
},
check: func(out string) {
assert.Contains(t, out, "STATUS:CANCELLED", "cancelled status")
},
},
{
name: "text body",
event: func() *models.Event {
e := baseEvent()
body := models.NewItemBody()
btype, err := models.ParseBodyType("text")
require.NoError(t, err, "parse body type")
body.SetContentType(btype.(*models.BodyType))
body.SetContent(ptr.To("body"))
e.SetBody(body)
return e
},
check: func(out string) {
assert.Contains(t, out, "DESCRIPTION:body", "body")
},
},
{
name: "html body",
event: func() *models.Event {
e := baseEvent()
body := models.NewItemBody()
btype, err := models.ParseBodyType("html")
require.NoError(t, err, "parse body type")
body.SetContentType(btype.(*models.BodyType))
body.SetContent(ptr.To("<html><body>body</body></html>"))
e.SetBodyPreview(ptr.To("body preview"))
e.SetBody(body)
return e
},
check: func(out string) {
assert.Contains(t, out, "X-ALT-DESC;FMTTYPE=text/html:<html><body>body</body></html>", "body")
},
},
{
name: "html body with utf8",
event: func() *models.Event {
e := baseEvent()
body := models.NewItemBody()
btype, err := models.ParseBodyType("html")
require.NoError(t, err, "parse body type")
body.SetContentType(btype.(*models.BodyType))
body.SetContent(ptr.To("<html><body>മലയാളം</body></html>"))
e.SetBodyPreview(ptr.To("body preview"))
e.SetBody(body)
return e
},
check: func(out string) {
assert.Contains(t, out, "DESCRIPTION:മലയാളം", "body")
},
},
{
name: "showas free",
event: func() *models.Event {
e := baseEvent()
fbs, err := models.ParseFreeBusyStatus("free")
require.NoError(t, err, "parse free busy status")
e.SetShowAs(fbs.(*models.FreeBusyStatus))
return e
},
check: func(out string) {
assert.Contains(t, out, "TRANSP:TRANSPARENT", "free busy status")
},
},
{
name: "showas oof",
event: func() *models.Event {
e := baseEvent()
fbs, err := models.ParseFreeBusyStatus("oof")
require.NoError(t, err, "parse free busy status")
e.SetShowAs(fbs.(*models.FreeBusyStatus))
return e
},
check: func(out string) {
assert.Contains(t, out, "TRANSP:OPAQUE", "free busy status")
},
},
{
name: "categories",
event: func() *models.Event {
e := baseEvent()
e.SetCategories([]string{"cat1", "cat2"})
return e
},
check: func(out string) {
assert.Contains(t, out, "CATEGORIES:cat1", "categories")
assert.Contains(t, out, "CATEGORIES:cat2", "categories")
},
},
{
name: "weblink",
event: func() *models.Event {
e := baseEvent()
e.SetWebLink(ptr.To("https://example.com"))
return e
},
check: func(out string) {
assert.Contains(t, out, "URL:https://example.com", "weblink")
},
},
{
name: "organizer just email",
event: func() *models.Event {
e := baseEvent()
org := models.NewRecipient()
addr := models.NewEmailAddress()
addr.SetAddress(ptr.To("user@provider.co"))
org.SetEmailAddress(addr)
e.SetOrganizer(org)
return e
},
check: func(out string) {
assert.Contains(t, out, "ORGANIZER:user@provider.co", "organizer")
},
},
{
name: "organizer name and email",
event: func() *models.Event {
e := baseEvent()
org := models.NewRecipient()
addr := models.NewEmailAddress()
addr.SetAddress(ptr.To("user@provider.co"))
addr.SetName(ptr.To("User"))
org.SetEmailAddress(addr)
e.SetOrganizer(org)
return e
},
check: func(out string) {
assert.Contains(t, out, "ORGANIZER;CN=User:user@provider.co", "organizer")
},
},
{
name: "location",
event: func() *models.Event {
e := baseEvent()
// full test is done separately
loc := models.NewLocation()
loc.SetDisplayName(ptr.To("DisplayName"))
e.SetLocation(loc)
return e
},
check: func(out string) {
assert.Contains(t, out, "LOCATION:DisplayName", "location")
},
},
{
name: "teams url",
event: func() *models.Event {
e := baseEvent()
mi := models.NewOnlineMeetingInfo()
mi.SetJoinUrl(ptr.To("https://team.microsoft.com/meeting-url"))
e.SetOnlineMeeting(mi)
return e
},
check: func(out string) {
assert.Contains(t, out, "X-MICROSOFT-SKYPETEAMSMEETINGURL:https://team.microsoft.com/meeting-url", "teams url")
},
},
{
name: "X-MICROSOFT-LOCATIONDISPLAYNAME",
event: func() *models.Event {
e := baseEvent()
loc := models.NewLocation()
loc.SetDisplayName(ptr.To("DisplayName"))
e.SetLocation(loc)
return e
},
check: func(out string) {
assert.Contains(t, out, "X-MICROSOFT-LOCATIONDISPLAYNAME:DisplayName", "location display name")
},
},
{
name: "class",
event: func() *models.Event {
e := baseEvent()
sen := models.CONFIDENTIAL_SENSITIVITY
e.SetSensitivity(&sen)
return e
},
check: func(out string) {
assert.Contains(t, out, "CLASS:CONFIDENTIAL", "class")
},
},
{
name: "priority",
event: func() *models.Event {
e := baseEvent()
pri := models.HIGH_IMPORTANCE
e.SetImportance(&pri)
return e
},
check: func(out string) {
assert.Contains(t, out, "PRIORITY:1", "priority")
},
},
}
for _, tt := range table {
suite.Run(tt.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
bts, err := eventToJSON(tt.event())
require.NoError(t, err, "getting serialized content")
e, err := FromJSON(ctx, bts)
require.NoError(t, err, "converting to ics")
tt.check(e)
})
}
}
// checkAttendee checks the ATTENDEE field
// This is required instead of a string check as the fields might
// not always be in the same order
func checkAttendee(t *testing.T, out, check, msg string) {
splitFunc := func(c rune) bool {
return c == ';' || c == ':'
}
line := ""
for _, l := range strings.Split(out, "\r\n") {
if !strings.HasPrefix(l, "ATTENDEE") {
continue
}
splits := strings.Split(check, ":")
if strings.Contains(l, splits[len(splits)-1]) {
line = l
break
}
}
if len(line) == 0 {
assert.Fail(t, fmt.Sprintf("line not found %s", msg))
return
}
as := strings.FieldsFunc(line, splitFunc)
bs := strings.FieldsFunc(check, splitFunc)
assert.Equal(t, len(as), len(bs), fmt.Sprintf("length of fields of %s", msg))
assert.ElementsMatch(t, as, bs, fmt.Sprintf("fields %s", msg))
}
func (suite *ICSUnitSuite) TestAttendees() {
t := suite.T()
table := []struct {
name string
att [][]string
check func(string)
}{
{
name: "single attendee",
// email, role, participation
att: [][]string{{"one@att.co", "", ""}},
check: func(out string) {
checkAttendee(t, out, "ATTENDEE;CN=one:mailto:one@att.co", "attendee")
},
},
{
name: "single attendee with role and participation",
att: [][]string{{"one@att.co", "required", "declined"}},
check: func(out string) {
checkAttendee(
t,
out,
"ATTENDEE;ROLE=REQ-PARTICIPANT;CN=one;PARTSTAT=DECLINED:mailto:one@att.co",
"attendee")
},
},
{
name: "multiple attendees",
att: [][]string{
{"one@att.co", "", ""},
{"two@att.co", "optional", "accepted"},
{"th@att.co", "resource", "notResponded"}, // th instead of three to prevent split
{"four@att.co", "required", "tentativelyAccepted"},
},
check: func(out string) {
checkAttendee(t, out, "ATTENDEE;CN=one:mailto:one@att.co", "attendee one")
checkAttendee(
t,
out,
"ATTENDEE;ROLE=OPT-PARTICIPANT;CN=two;PARTSTAT=ACCEPTED:mailto:two@att.co",
"attendee two")
checkAttendee(
t,
out,
"ATTENDEE;ROLE=NON-PARTICIPANT;CN=th;PARTSTAT=NEEDS-ACTION:mailto:th@att.co",
"attendee th")
checkAttendee(
t,
out,
"ATTENDEE;ROLE=REQ-PARTICIPANT;CN=four;PARTSTAT=TENTATIVE:mailto:four@att.co",
"attendee four")
},
},
}
for _, tt := range table {
suite.Run(tt.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
e := baseEvent()
atts := make([]models.Attendeeable, len(tt.att))
for i, a := range tt.att {
att := models.NewAttendee()
addr := models.NewEmailAddress()
addr.SetAddress(ptr.To(a[0]))
name := strings.Split(a[0], "@")[0]
addr.SetName(ptr.To(name))
att.SetEmailAddress(addr)
if len(a[1]) > 0 {
atype, err := models.ParseAttendeeType(a[1])
require.NoError(t, err, "parse attendee type")
att.SetTypeEscaped(atype.(*models.AttendeeType))
}
if len(a[2]) > 0 {
stat := models.NewResponseStatus()
resp, err := models.ParseResponseType(a[2])
require.NoError(t, err, "parse response type")
stat.SetResponse(resp.(*models.ResponseType))
att.SetStatus(stat)
}
atts[i] = att
}
e.SetAttendees(atts)
bts, err := eventToJSON(e)
require.NoError(t, err, "getting serialized content")
out, err := FromJSON(ctx, bts)
require.NoError(t, err, "converting to ics")
tt.check(out)
})
}
}
func checkAttachment(t *testing.T, out, check, msg string) {
var (
attachments = []string{}
inAttachment = false
attachment = ""
checkSplits = strings.Split(check, ":")[0]
filenameSegment = ""
attachmentToCheck = ""
)
for _, l := range strings.Split(out, "\r\n") {
if strings.HasPrefix(l, "ATTACH") {
inAttachment = true
attachment = l
} else if inAttachment {
if len(l) > 1 || l[0] == ' ' {
attachment += l[1:]
}
inAttachment = false
attachments = append(attachments, attachment)
}
}
if inAttachment {
attachments = append(attachments, attachment)
}
if len(attachments) == 0 {
assert.Fail(t, fmt.Sprintf("no attachments found: %s", msg))
return
}
for _, s := range strings.Split(checkSplits, ";") {
if strings.HasPrefix(s, "FILENAME") {
filenameSegment = s
break
}
}
if len(filenameSegment) == 0 {
assert.Fail(t, fmt.Sprintf("filename not found %s", msg))
return
}
for _, a := range attachments {
if strings.Contains(a, filenameSegment) {
attachmentToCheck = a
break
}
}
if len(attachmentToCheck) == 0 {
assert.Fail(t, fmt.Sprintf("attachment not found: %s", msg))
return
}
splitFunc := func(c rune) bool {
return c == ';' || c == ':'
}
as := strings.FieldsFunc(attachmentToCheck, splitFunc)
bs := strings.FieldsFunc(check, splitFunc)
assert.Equal(t, len(as), len(bs), fmt.Sprintf("length of fields of %s", msg))
assert.ElementsMatch(t, as, bs, fmt.Sprintf("fields %s", msg))
}
func (suite *ICSUnitSuite) TestAttachments() {
t := suite.T()
type attachment struct {
cid string // contentid
name string
ctype string
content string
inline bool
}
table := []struct {
name string
att []attachment
check func(string)
}{
{
name: "single attachment",
att: []attachment{
{"1", "one", "text/plain", "content", false},
},
check: func(out string) {
checkAttachment(
t,
out,
"ATTACH;VALUE=BINARY;FMTTYPE=text/plain;FILENAME=one;ENCODING=base64:Y29udGVudA==",
"attachment")
},
},
{
name: "multiple attachments",
att: []attachment{
{"1", "one", "text/plain", "content", false},
{"2", "two", "text/html", "<html><body>content</body></html>", false},
},
check: func(out string) {
base := "ATTACH;FILENAME=one;ENCODING=base64;VALUE=BINARY;FMTTYPE=text/plain:"
content := base64.StdEncoding.EncodeToString([]byte("content"))
checkAttachment(
t,
out,
base+content,
"attachment one")
base = "ATTACH;FILENAME=two;ENCODING=base64;VALUE=BINARY;FMTTYPE=text/html:"
content = base64.StdEncoding.EncodeToString([]byte("<html><body>content</body></html>"))
checkAttachment(
t,
out,
base+content,
"attachment two")
},
},
}
for _, tt := range table {
suite.Run(tt.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
e := baseEvent()
bts, err := eventToJSON(e)
require.NoError(t, err, "getting serialized content")
parsed := map[string]any{}
err = json.Unmarshal(bts, &parsed)
require.NoError(t, err, "unmarshalling json")
// could not add attachment content without doing this
atts := make([]map[string]any, len(tt.att))
for i, a := range tt.att {
att := map[string]any{
"@odata.type": "#microsoft.graph.fileAttachment",
"name": a.name,
"contentType": a.ctype,
"contentBytes": base64.StdEncoding.EncodeToString([]byte(a.content)),
"contentId": a.cid,
"isInline": a.inline,
}
atts[i] = att
}
parsed["attachments"] = atts
bts, err = json.Marshal(parsed)
require.NoError(t, err, "marshalling json")
out, err := FromJSON(ctx, bts)
require.NoError(t, err, "converting to ics")
tt.check(out)
})
}
}
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)
})
}
}