Files
opencloud/pkg/jmap/jmap_integration_event_test.go
2025-12-17 09:34:22 +01:00

622 lines
18 KiB
Go

package jmap
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"math"
"math/rand"
"strconv"
"strings"
"testing"
"time"
"github.com/brianvoe/gofakeit/v7"
"github.com/stretchr/testify/require"
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
// fields that are currently unsupported in Stalwart
const (
EnableEventMayInviteFields = false
EnableEventParticipantDescriptionFields = false
)
func TestEvents(t *testing.T) {
if skip(t) {
return
}
count := uint(20 + rand.Intn(30))
require := require.New(t)
s, err := newStalwartTest(t)
require.NoError(err)
defer s.Close()
user := pickUser()
session := s.Session(user.name)
accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count, session, user)
require.NoError(err)
require.NotEmpty(accountId)
require.NotEmpty(calendarId)
filter := CalendarEventFilterCondition{
InCalendar: calendarId,
}
sortBy := []CalendarEventComparator{
{Property: CalendarEventPropertyStart, IsAscending: true},
}
contactsByAccount, _, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, session, t.Context(), s.logger, "", filter, sortBy, 0, 0)
require.NoError(err)
require.Len(contactsByAccount, 1)
require.Contains(contactsByAccount, accountId)
contacts := contactsByAccount[accountId]
require.Len(contacts, int(count))
for _, actual := range contacts {
expected, ok := expectedEventsById[actual.Id]
require.True(ok, "failed to find created contact by its id")
matchEvent(t, actual, expected)
}
exceptions := []string{}
if !EnableEventMayInviteFields {
exceptions = append(exceptions, "mayInvite")
}
allBoxesAreTicked(t, boxes, exceptions...)
}
func matchEvent(t *testing.T, actual CalendarEvent, expected CalendarEvent) {
//require.Equal(t, expected, actual)
deepEqual(t, expected, actual)
}
type EventsBoxes struct {
categories bool
keywords bool
mayInvite bool
}
func (s *StalwartTest) fillEvents(
t *testing.T,
count uint,
session *Session,
user User,
) (string, string, map[string]CalendarEvent, EventsBoxes, error) {
require := require.New(t)
c, err := NewTestJmapClient(session, user.name, user.password, true, true)
require.NoError(err)
defer c.Close()
boxes := EventsBoxes{}
printer := func(s string) { log.Println(s) }
accountId := c.session.PrimaryAccounts.Calendars
require.NotEmpty(accountId, "no primary account for calendars in session")
calendarId := ""
{
calendarsById, err := c.objectsById(accountId, CalendarType, JmapCalendars)
require.NoError(err)
for id, calendar := range calendarsById {
if isDefault, ok := calendar["isDefault"]; ok {
if isDefault.(bool) {
calendarId = id
break
}
}
}
}
require.NotEmpty(calendarId)
filled := map[string]CalendarEvent{}
for i := range count {
uid := gofakeit.UUID()
isDraft := false
mainLocationId := ""
locationIds := []string{}
locationMaps := map[string]map[string]any{}
locationObjs := map[string]jscalendar.Location{}
{
n := 1
if i%4 == 0 {
n++
}
for range n {
locationId, locationMap, locationObj := pickLocation()
locationMaps[locationId] = locationMap
locationObjs[locationId] = locationObj
locationIds = append(locationIds, locationId)
if n > 0 && mainLocationId == "" {
mainLocationId = locationId
}
}
}
virtualLocationId, virtualLocationMap, virtualLocationObj := pickVirtualLocation()
participantMaps, participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId})
duration := pickRandom("PT30M", "PT45M", "PT1H", "PT90M")
tz := pickRandom(timezones...)
daysDiff := rand.Intn(31) - 15
t := time.Now().Add(time.Duration(daysDiff) * time.Hour * 24)
h := pickRandom(9, 10, 11, 14, 15, 16, 18)
m := pickRandom(0, 30)
t = time.Date(t.Year(), t.Month(), t.Day(), h, m, 0, 0, t.Location())
start := strings.ReplaceAll(t.Format(time.DateTime), " ", "T")
title := gofakeit.Sentence(1)
description := gofakeit.Paragraph(1+rand.Intn(3), 1+rand.Intn(4), 1+rand.Intn(32), "\n")
descriptionFormat := pickRandom("text/plain", "text/html")
if descriptionFormat == "text/html" {
description = toHtml(description)
}
status := pickRandom(jscalendar.Statuses...)
freeBusy := pickRandom(jscalendar.FreeBusyStatuses...)
privacy := pickRandom(jscalendar.Privacies...)
color := pickRandom(basicColors...)
locale := pickLocale()
keywords := pickKeywords()
categories := pickCategories()
sequence := 0
alertId := id()
alertOffset := pickRandom("-PT5M", "-PT10M", "-PT15M")
event := map[string]any{
"@type": "Event",
"calendarIds": toBoolMapS(calendarId),
"isDraft": isDraft,
"start": start,
"duration": duration,
"status": string(status),
"uid": uid,
"prodId": productName,
"title": title,
"description": description,
"descriptionContentType": descriptionFormat,
"locale": locale,
"color": color,
"sequence": sequence,
"showWithoutTime": false,
"freeBusyStatus": string(freeBusy),
"privacy": string(privacy),
"sentBy": organizerEmail,
"participants": participantMaps,
"timeZone": tz,
"hideAttendees": false,
"replyTo": map[string]string{
"imip": "mailto:" + organizerEmail,
},
"locations": locationMaps,
"virtualLocations": map[string]any{
virtualLocationId: virtualLocationMap,
},
"alerts": map[string]map[string]any{
alertId: {
"@type": "Alert",
"trigger": map[string]any{
"@type": "OffsetTrigger",
"offset": alertOffset,
"relativeTo": "start",
},
},
},
}
obj := CalendarEvent{
Id: "",
CalendarIds: toBoolMapS(calendarId),
IsDraft: isDraft,
IsOrigin: true,
Event: jscalendar.Event{
Type: jscalendar.EventType,
Start: jscalendar.LocalDateTime(start),
Duration: jscalendar.Duration(duration),
Status: status,
Object: jscalendar.Object{
CommonObject: jscalendar.CommonObject{
Uid: uid,
ProdId: productName,
Title: title,
Description: description,
DescriptionContentType: descriptionFormat,
Locale: locale,
Color: color,
},
Sequence: uint(sequence),
ShowWithoutTime: false,
FreeBusyStatus: freeBusy,
Privacy: privacy,
SentBy: organizerEmail,
Participants: participantObjs,
TimeZone: tz,
HideAttendees: false,
ReplyTo: map[jscalendar.ReplyMethod]string{
jscalendar.ReplyMethodImip: "mailto:" + organizerEmail,
},
Locations: locationObjs,
VirtualLocations: map[string]jscalendar.VirtualLocation{
virtualLocationId: virtualLocationObj,
},
Alerts: map[string]jscalendar.Alert{
alertId: {
Type: jscalendar.AlertType,
Trigger: jscalendar.OffsetTrigger{
Type: jscalendar.OffsetTriggerType,
Offset: jscalendar.SignedDuration(alertOffset),
RelativeTo: jscalendar.RelativeToStart,
},
},
},
},
},
}
if EnableEventMayInviteFields {
event["mayInviteSelf"] = true
event["mayInviteOthers"] = true
obj.MayInviteSelf = true
obj.MayInviteOthers = true
boxes.mayInvite = true
}
if len(keywords) > 0 {
event["keywords"] = keywords
obj.Keywords = keywords
boxes.keywords = true
}
if len(categories) > 0 {
event["categories"] = categories
obj.Categories = categories
boxes.categories = true
}
if mainLocationId != "" {
event["mainLocationId"] = mainLocationId
obj.MainLocationId = mainLocationId
}
err = propmap(i%2 == 0, 1, 1, event, "links", &obj.Links, func(int, string) (map[string]any, jscalendar.Link, error) {
mime := ""
uri := ""
rel := jscalendar.RelAbout
switch rand.Intn(2) {
case 0:
size := pickRandom(16, 24, 32, 48, 64)
img := gofakeit.ImagePng(size, size)
mime = "image/png"
uri = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img)
default:
mime = "image/jpeg"
uri = externalImageUri()
}
return map[string]any{
"@type": "Link",
"href": uri,
"contentType": mime,
"rel": string(rel),
}, jscalendar.Link{
Type: jscalendar.LinkType,
Href: uri,
ContentType: mime,
Rel: rel,
}, nil
})
if rand.Intn(10) > 7 {
frequency := pickRandom(jscalendar.FrequencyWeekly, jscalendar.FrequencyDaily)
interval := pickRandom(1, 2)
count := 1
if frequency == jscalendar.FrequencyWeekly {
count = 1 + rand.Intn(8)
} else {
count = 1 + rand.Intn(4)
}
event["recurrenceRule"] = map[string]any{
"@type": "RecurrenceRule",
"frequency": string(frequency),
"interval": interval,
"rscale": string(jscalendar.RscaleIso8601),
"skip": string(jscalendar.SkipOmit),
"firstDayOfWeek": string(jscalendar.DayOfWeekMonday),
"count": count,
}
rr := jscalendar.RecurrenceRule{
Type: jscalendar.RecurrenceRuleType,
Frequency: frequency,
Interval: uint(interval),
Rscale: jscalendar.RscaleIso8601,
Skip: jscalendar.SkipOmit,
FirstDayOfWeek: jscalendar.DayOfWeekMonday,
Count: uint(count),
}
obj.RecurrenceRule = &rr
}
id, err := s.CreateEvent(c, accountId, event)
if err != nil {
return accountId, calendarId, nil, boxes, err
}
obj.Id = id
filled[id] = obj
printer(fmt.Sprintf("📅 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, uid))
}
return accountId, calendarId, filled, boxes, nil
}
func (s *StalwartTest) CreateEvent(j *TestJmapClient, accountId string, event map[string]any) (string, error) {
return j.create1(accountId, CalendarEventType, JmapCalendars, event)
}
var rooms = []jscalendar.Location{
{
Type: jscalendar.LocationType,
Name: "Office meeting room upstairs",
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice),
Coordinates: "geo:52.5335389,13.4103296",
Links: map[string]jscalendar.Link{
"l1": {Href: "https://www.heinlein-support.de/"},
},
},
{
Type: jscalendar.LocationType,
Name: "office-nue",
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice),
Coordinates: "geo:49.4723337,11.1042282",
Links: map[string]jscalendar.Link{
"l2": {Href: "https://www.workandpepper.de/"},
},
},
{
Type: jscalendar.LocationType,
Name: "Meetingraum Prenzlauer Berg",
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice, jscalendar.LocationTypeOptionPublic),
Coordinates: "geo:52.554222,13.4142387",
Links: map[string]jscalendar.Link{
"l3": {Href: "https://www.spacebase.com/en/venue/meeting-room-prenzlauer-be-11499/"},
},
},
{
Type: jscalendar.LocationType,
Name: "Meetingraum LIANE 1",
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice, jscalendar.LocationTypeOptionLibrary),
Coordinates: "geo:52.4854301,13.4224763",
Links: map[string]jscalendar.Link{
"l4": {Href: "https://www.spacebase.com/en/venue/rent-a-jungle-8372/"},
},
},
{
Type: jscalendar.LocationType,
Name: "Dark Horse",
LocationTypes: toBoolMapS(jscalendar.LocationTypeOptionOffice),
Coordinates: "geo:52.4942254,13.4346015",
Links: map[string]jscalendar.Link{
"l5": {Href: "https://www.spacebase.com/en/event-venue/workshop-white-space-2667/"},
},
},
}
var virtualRooms = []jscalendar.VirtualLocation{
{
Type: jscalendar.VirtualLocationType,
Name: "opentalk",
Uri: "https://meet.opentalk.eu/fake/room/06fb8f7d-42eb-4212-8112-769fac2cb111",
Features: toBoolMapS(
jscalendar.VirtualLocationFeatureAudio,
jscalendar.VirtualLocationFeatureChat,
jscalendar.VirtualLocationFeatureVideo,
jscalendar.VirtualLocationFeatureScreen,
),
},
}
func pickLocation() (string, map[string]any, jscalendar.Location) {
locationId := id()
room := rooms[rand.Intn(len(rooms))]
b, err := json.Marshal(room)
if err != nil {
panic(err)
}
var m map[string]any
err = json.Unmarshal(b, &m)
if err != nil {
panic(err)
}
return locationId, m, room
}
func pickVirtualLocation() (string, map[string]any, jscalendar.VirtualLocation) {
locationId := id()
vroom := virtualRooms[rand.Intn(len(virtualRooms))]
b, err := json.Marshal(vroom)
if err != nil {
panic(err)
}
var m map[string]any
err = json.Unmarshal(b, &m)
if err != nil {
panic(err)
}
return locationId, m, vroom
}
var ChairRoles = toBoolMapS(jscalendar.RoleChair, jscalendar.RoleOwner)
var RegularRoles = toBoolMapS(jscalendar.RoleOptional)
func createParticipants(uid string, locationIds []string, virtualLocationIds []string) (map[string]map[string]any, map[string]jscalendar.Participant, string) {
options := structs.Concat(locationIds, virtualLocationIds)
n := 1 + rand.Intn(4)
maps := map[string]map[string]any{}
objs := map[string]jscalendar.Participant{}
organizerId, organizerEmail, organizerMap, organizerObj := createParticipant(0, uid, pickRandom(options...), "", "")
maps[organizerId] = organizerMap
objs[organizerId] = organizerObj
for i := 1; i < n; i++ {
id, _, participantMap, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail)
maps[id] = participantMap
objs[id] = participantObj
}
return maps, objs, organizerEmail
}
func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string) (string, string, map[string]any, jscalendar.Participant) {
participantId := id()
person := gofakeit.Person()
roles := RegularRoles
if i == 0 {
roles = ChairRoles
}
status := jscalendar.ParticipationStatusAccepted
if i != 0 {
status = pickRandom(
jscalendar.ParticipationStatusNeedsAction,
jscalendar.ParticipationStatusAccepted,
jscalendar.ParticipationStatusDeclined,
jscalendar.ParticipationStatusTentative,
)
//, delegated + set "delegatedTo"
}
statusComment := ""
if rand.Intn(5) >= 3 {
statusComment = gofakeit.HipsterSentence(1 + rand.Intn(5))
}
if i == 0 {
organizerEmail = person.Contact.Email
organizerId = participantId
}
name := person.FirstName + " " + person.LastName
email := person.Contact.Email
description := gofakeit.SentenceSimple()
descriptionContentType := pickRandom("text/html", "text/plain")
if descriptionContentType == "text/html" {
description = toHtml(description)
}
language := pickLanguage()
updated := "2025-10-01T01:59:12Z"
updatedTime, err := time.Parse(time.RFC3339, updated)
if err != nil {
panic(err)
}
var calendarAddress string
{
pos := strings.LastIndex(email, "@")
if pos < 0 {
calendarAddress = email
} else {
local := email[0:pos]
domain := email[pos+1:]
calendarAddress = local + "+itip+" + uid + "@" + "itip." + domain
}
}
m := map[string]any{
"@type": "Participant",
"name": name,
"email": email,
"calendarAddress": calendarAddress,
"kind": "individual",
"roles": structs.MapKeys(roles, func(r jscalendar.Role) string { return string(r) }),
"locationId": locationId,
"language": language,
"participationStatus": string(status),
"participationComment": statusComment,
"expectReply": true,
"scheduleAgent": "server",
"scheduleSequence": 1,
"scheduleStatus": []string{"1.0"},
"scheduleUpdated": updated,
"sentBy": organizerEmail,
"invitedBy": organizerId,
"scheduleId": "mailto:" + email,
}
o := jscalendar.Participant{
Type: jscalendar.ParticipantType,
Name: name,
Email: email,
Kind: jscalendar.ParticipantKindIndividual,
CalendarAddress: calendarAddress,
Roles: roles,
LocationId: locationId,
Language: language,
ParticipationStatus: status,
ParticipationComment: statusComment,
ExpectReply: true,
ScheduleAgent: jscalendar.ScheduleAgentServer,
ScheduleSequence: uint(1),
ScheduleStatus: []string{"1.0"},
ScheduleUpdated: updatedTime,
SentBy: organizerEmail,
InvitedBy: organizerId,
ScheduleId: "mailto:" + email,
}
if EnableEventParticipantDescriptionFields {
m["description"] = description
m["descriptionContentType"] = descriptionContentType
o.Description = description
o.DescriptionContentType = descriptionContentType
}
err = propmap(i%2 == 0, 1, 2, m, "links", &o.Links, func(int, string) (map[string]any, jscalendar.Link, error) {
href := externalImageUri()
title := person.FirstName + "'s Cake Day pick"
return map[string]any{
"@type": "Link",
"href": href,
"contentType": "image/jpeg",
"rel": "icon",
"display": "badge",
"title": title,
}, jscalendar.Link{
Type: jscalendar.LinkType,
Href: href,
ContentType: "image/jpeg",
Rel: jscalendar.RelIcon,
Display: jscalendar.DisplayBadge,
Title: title,
}, nil
})
if err != nil {
panic(err)
}
return participantId, person.Contact.Email, m, o
}
var Keywords = []string{
"office",
"important",
"sales",
"coordination",
"decision",
}
func pickKeywords() map[string]bool {
return toBoolMap(pickRandoms(Keywords...))
}
var Categories = []string{
"http://opencloud.eu/categories/secret",
"http://opencloud.eu/categories/internal",
}
func pickCategories() map[string]bool {
return toBoolMap(pickRandoms(Categories...))
}