<!-- 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>
1372 lines
33 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|