Abin Simon 9c8ac96aed
Skip emails with incorrect email when export ics file (#5190)
<!-- 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: --->
- [ ] 🌻 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
2024-02-06 17:03:58 +00:00

1396 lines
34 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: "not cancelled event",
event: func() *models.Event {
e := baseEvent()
e.SetIsCancelled(ptr.To(false))
return e
},
check: func(out string) {
assert.NotContains(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: "attendee with internal exchange representation for email",
att: [][]string{{
"/o=ExchangeLabs/ou=ExchangeAdministrative Group(FY...LT)/cn=Recipients/cn=883...4a-John Doe",
"required",
"declined",
}},
check: func(out string) {
assert.NotContains(t, out, "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)
})
}
}