groupware: feature test improvements and upgrade to Stalwart 0.14.1

* upgrade Stalwart image for devtools/full to 0.14.1

 * re-assert which features are implemented or not in 0.14.1

 * refactor the integration tests yet again to make it clearer and
   easier to see those "features-or-not"

 * get rid of old tests that are now better covered by integration tests

 * rewrite how we compare expected and actual objects in integration
   tests, finally having found a way to ignore the @type attribute
   properly instead of having to mutate all objects to remove it
This commit is contained in:
Pascal Bleser
2025-11-24 09:32:53 +01:00
parent 71dad79b36
commit d87b3dd708
9 changed files with 240 additions and 1423 deletions

View File

@@ -21,6 +21,11 @@ import (
"github.com/opencloud-eu/opencloud/pkg/structs"
)
const (
// currently not supported, reported as https://github.com/stalwartlabs/stalwart/issues/2431
EnableMediaWithBlobId = false
)
func TestContacts(t *testing.T) {
if skip(t) {
return
@@ -39,8 +44,6 @@ func TestContacts(t *testing.T) {
require.NotEmpty(accountId)
require.NotEmpty(addressbookId)
allTrue(t, boxes, "mediaWithBlobId")
filter := ContactCardFilterCondition{
InAddressBook: addressbookId,
}
@@ -61,10 +64,17 @@ func TestContacts(t *testing.T) {
require.True(ok, "failed to find created contact by its id")
matchContact(t, actual, expected)
}
exceptions := []string{}
if !EnableMediaWithBlobId {
exceptions = append(exceptions, "mediaWithBlobId")
}
allBoxesAreTicked(t, boxes, exceptions...)
}
func matchContact(t *testing.T, actual jscontact.ContactCard, expected jscontact.ContactCard) {
require.Equal(t, expected, actual)
// require.Equal(t, expected, actual)
deepEqual(t, expected, actual)
}
type ContactsBoxes struct {
@@ -116,33 +126,32 @@ func (s *StalwartTest) fillContacts(
}
require.NotEmpty(addressbookId)
u := true
filled := map[string]jscontact.ContactCard{}
for i := range count {
person := gofakeit.Person()
nameMap, nameObj := createName(person, u)
nameMap, nameObj := createName(person)
language := pickLanguage()
contact := map[string]any{
"@type": "Card",
"version": "1.0",
"addressBookIds": toBoolMap([]string{addressbookId}),
"prodId": productName,
"language": pickLanguage(),
"language": language,
"kind": "individual",
"name": nameMap,
}
card := jscontact.ContactCard{
//Type: jscontact.ContactCardType,
Type: jscontact.ContactCardType,
Version: "1.0",
AddressBookIds: toBoolMap([]string{addressbookId}),
ProdId: productName,
Language: contact["language"].(string),
Language: language,
Kind: jscontact.ContactCardKindIndividual,
Name: &nameObj,
}
if i%3 == 0 {
nicknameMap, nicknameObj := createNickName(person, u)
nicknameMap, nicknameObj := createNickName(person)
id := id()
contact["nicknames"] = map[string]map[string]any{id: nicknameMap}
card.Nicknames = map[string]jscontact.Nickname{id: nicknameObj}
@@ -153,13 +162,13 @@ func (s *StalwartTest) fillContacts(
emailMaps := map[string]map[string]any{}
emailObjs := map[string]jscontact.EmailAddress{}
emailId := id()
emailMap, emailObj := createEmail(person, 10, u)
emailMap, emailObj := createEmail(person, 10)
emailMaps[emailId] = emailMap
emailObjs[emailId] = emailObj
for i := range rand.Intn(3) {
id := id()
m, o := createSecondaryEmail(gofakeit.Email(), i*100, u)
m, o := createSecondaryEmail(gofakeit.Email(), i*100)
emailMaps[id] = m
emailObjs[id] = o
boxes.secondaryEmails = true
@@ -192,12 +201,12 @@ func (s *StalwartTest) fillContacts(
"number": tel,
"features": structs.MapKeys(features, func(f jscontact.PhoneFeature) string { return string(f) }),
"contexts": structs.MapKeys(contexts, func(c jscontact.PhoneContext) string { return string(c) }),
}, untype(jscontact.Phone{
}, jscontact.Phone{
Type: jscontact.PhoneType,
Number: tel,
Features: features,
Contexts: contexts,
}, u), nil
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
@@ -212,16 +221,16 @@ func (s *StalwartTest) fillContacts(
components := []jscontact.AddressComponent{}
m := streetNumberRegex.FindAllStringSubmatch(source.Street, -1)
if m != nil {
components = append(components, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindName, Value: m[0][2]})
components = append(components, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindNumber, Value: m[0][1]})
components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindName, Value: m[0][2]})
components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindNumber, Value: m[0][1]})
} else {
components = append(components, jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindName, Value: source.Street})
components = append(components, jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindName, Value: source.Street})
}
components = append(components,
jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindLocality, Value: source.City},
jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindCountry, Value: source.Country},
jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindRegion, Value: source.State},
jscontact.AddressComponent{ /*Type: jscontact.AddressComponentType,*/ Kind: jscontact.AddressComponentKindPostcode, Value: source.Zip},
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindLocality, Value: source.City},
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindCountry, Value: source.Country},
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindRegion, Value: source.State},
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindPostcode, Value: source.Zip},
)
tz := pickRandom(timezones...)
return map[string]any{
@@ -232,13 +241,13 @@ func (s *StalwartTest) fillContacts(
"defaultSeparator": ", ",
"isOrdered": true,
"timeZone": tz,
}, untype(jscontact.Address{
}, jscontact.Address{
Type: jscontact.AddressType,
Components: components,
DefaultSeparator: ", ",
IsOrdered: true,
TimeZone: tz,
}, u), nil
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
@@ -251,32 +260,32 @@ func (s *StalwartTest) fillContacts(
"service": "Mastodon",
"user": "@" + person.Contact.Email,
"uri": "https://mastodon.example.com/@" + strings.ToLower(person.FirstName),
}, untype(jscontact.OnlineService{
}, jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Service: "Mastodon",
User: "@" + person.Contact.Email,
Uri: "https://mastodon.example.com/@" + strings.ToLower(person.FirstName),
}, u), nil
}, nil
case 1:
return map[string]any{
"@type": "OnlineService",
"uri": "xmpp:" + person.Contact.Email,
}, untype(jscontact.OnlineService{
}, jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Uri: "xmpp:" + person.Contact.Email,
}, u), nil
}, nil
default:
return map[string]any{
"@type": "OnlineService",
"service": "Discord",
"user": person.Contact.Email,
"uri": "https://discord.example.com/user/" + person.Contact.Email,
}, untype(jscontact.OnlineService{
}, jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Service: "Discord",
User: person.Contact.Email,
Uri: "https://discord.example.com/user/" + person.Contact.Email,
}, u), nil
}, nil
}
}); err != nil {
return "", "", nil, boxes, err
@@ -291,12 +300,12 @@ func (s *StalwartTest) fillContacts(
"language": lang,
"contexts": toBoolMap(contexts),
"pref": i + 1,
}, untype(jscontact.LanguagePref{
}, jscontact.LanguagePref{
Type: jscontact.LanguagePrefType,
Language: lang,
Contexts: toBoolMap(structs.Map(contexts, func(s string) jscontact.LanguagePrefContext { return jscontact.LanguagePrefContext(s) })),
Pref: uint(i + 1),
}, u), nil
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
@@ -315,23 +324,23 @@ func (s *StalwartTest) fillContacts(
"name": person.Job.Company,
"contexts": toBoolMapS("work"),
}
organizationObjs[orgId] = untype(jscontact.Organization{
organizationObjs[orgId] = jscontact.Organization{
Type: jscontact.OrganizationType,
Name: person.Job.Company,
Contexts: toBoolMapS(jscontact.OrganizationContextWork),
}, u)
}
titleMaps[titleId] = map[string]any{
"@type": "Title",
"kind": "title",
"name": person.Job.Title,
"organizationId": orgId,
}
titleObjs[titleId] = untype(jscontact.Title{
titleObjs[titleId] = jscontact.Title{
Type: jscontact.TitleType,
Kind: jscontact.TitleKindTitle,
Name: person.Job.Title,
OrganizationId: orgId,
}, u)
}
}
contact["organizations"] = organizationMaps
contact["titles"] = titleMaps
@@ -352,19 +361,29 @@ func (s *StalwartTest) fillContacts(
}
encoded := base64.RawStdEncoding.EncodeToString(b.Bytes())
return map[string]any{
"@type": "CryptoKey",
"uri": "data:application/pgp-keys;base64," + encoded,
}, untype(jscontact.CryptoKey{
Type: jscontact.CryptoKeyType,
Uri: "data:application/pgp-keys;base64," + encoded,
}, u), nil
"@type": "CryptoKey",
"uri": "data:application/pgp-keys;base64," + encoded,
"mediaType": "application/pgp-keys",
}, jscontact.CryptoKey{
Type: jscontact.CryptoKeyType,
Uri: "data:application/pgp-keys;base64," + encoded,
MediaType: "application/pgp-keys",
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
if err := propmap(i%2 == 0, 1, 2, contact, "media", &card.Media, func(i int, id string) (map[string]any, jscontact.Media, error) {
label := fmt.Sprintf("photo-%d", 1000+rand.Intn(9000))
switch rand.Intn(3) {
r := 0
if EnableMediaWithBlobId {
r = rand.Intn(3)
} else {
r = rand.Intn(2)
}
switch r {
case 0:
boxes.mediaWithDataUri = true
// use data uri
@@ -381,16 +400,35 @@ func (s *StalwartTest) fillContacts(
"mediaType": mime,
"contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
"label": label,
}, untype(jscontact.Media{
}, jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
Uri: uri,
MediaType: mime,
Contexts: contexts,
Label: label,
}, u), nil
// currently not supported, reported as https://github.com/stalwartlabs/stalwart/issues/2431
case -1: // change this to 1 to enable it again
}, nil
case 1:
boxes.mediaWithExternalUri = true
// use external uri
uri := externalImageUri()
contexts := toBoolMapS(jscontact.MediaContextWork)
return map[string]any{
"@type": "Media",
"kind": string(jscontact.MediaKindPhoto),
"uri": uri,
"contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
"label": label,
}, jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
Uri: uri,
Contexts: contexts,
Label: label,
}, nil
default:
boxes.mediaWithBlobId = true
size := pickRandom(16, 24, 32, 48, 64)
img := gofakeit.ImageJpeg(size, size)
@@ -405,33 +443,15 @@ func (s *StalwartTest) fillContacts(
"blobId": blob.BlobId,
"contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
"label": label,
}, untype(jscontact.Media{
}, jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
BlobId: blob.BlobId,
MediaType: blob.Type,
Contexts: contexts,
Label: label,
}, u), nil
}, nil
default:
boxes.mediaWithExternalUri = true
// use external uri
uri := picsum(128, 128)
contexts := toBoolMapS(jscontact.MediaContextWork)
return map[string]any{
"@type": "Media",
"kind": string(jscontact.MediaKindPhoto),
"uri": uri,
"contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
"label": label,
}, untype(jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
Uri: uri,
Contexts: contexts,
Label: label,
}, u), nil
}
}); err != nil {
return "", "", nil, boxes, err
@@ -443,12 +463,12 @@ func (s *StalwartTest) fillContacts(
"kind": "contact",
"uri": "mailto:" + person.Contact.Email,
"pref": (i + 1) * 10,
}, untype(jscontact.Link{
}, jscontact.Link{
Type: jscontact.LinkType,
Kind: jscontact.LinkKindContact,
Uri: "mailto:" + person.Contact.Email,
Pref: uint((i + 1) * 10),
}, u), nil
}, nil
}); err != nil {
return "", "", nil, boxes, err
}

View File

@@ -19,6 +19,12 @@ import (
"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
@@ -37,8 +43,6 @@ func TestEvents(t *testing.T) {
require.NotEmpty(accountId)
require.NotEmpty(calendarId)
allTrue(t, boxes)
filter := CalendarEventFilterCondition{
InCalendar: calendarId,
}
@@ -59,13 +63,23 @@ func TestEvents(t *testing.T) {
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)
//require.Equal(t, expected, actual)
deepEqual(t, expected, actual)
}
type EventsBoxes struct {
categories bool
keywords bool
mayInvite bool
}
func (s *StalwartTest) fillEvents(
@@ -100,8 +114,6 @@ func (s *StalwartTest) fillEvents(
}
require.NotEmpty(calendarId)
u := true
filled := map[string]CalendarEvent{}
for i := range count {
uid := gofakeit.UUID()
@@ -119,7 +131,7 @@ func (s *StalwartTest) fillEvents(
for range n {
locationId, locationMap, locationObj := pickLocation()
locationMaps[locationId] = locationMap
locationObjs[locationId] = untype(locationObj, u)
locationObjs[locationId] = locationObj
locationIds = append(locationIds, locationId)
if n > 0 && mainLocationId == "" {
mainLocationId = locationId
@@ -127,7 +139,7 @@ func (s *StalwartTest) fillEvents(
}
}
virtualLocationId, virtualLocationMap, virtualLocationObj := pickVirtualLocation()
participantMaps, participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId}, u)
participantMaps, participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId})
duration := pickRandom("PT30M", "PT45M", "PT1H", "PT90M")
tz := pickRandom(timezones...)
daysDiff := rand.Intn(31) - 15
@@ -148,9 +160,9 @@ func (s *StalwartTest) fillEvents(
privacy := pickRandom(jscalendar.Privacies...)
color := pickRandom(basicColors...)
locale := pickLocale()
keywords := keywords()
categories := categories()
var _ = categories // currently not used because it's unsupported in Stalwart
keywords := pickKeywords()
categories := pickCategories()
sequence := 0
alertId := id()
@@ -169,18 +181,15 @@ func (s *StalwartTest) fillEvents(
"description": description,
"descriptionContentType": descriptionFormat,
"locale": locale,
// "categories": categories, // currently unsupported in Stalwart
"color": color,
"sequence": sequence,
"showWithoutTime": false,
"freeBusyStatus": string(freeBusy),
"privacy": string(privacy),
"sentBy": organizerEmail,
"participants": participantMaps,
"timeZone": tz,
// "mayInviteSelf": true, // currently unsupported in Stalwart
// "mayInviteOthers": true, // currently unsupported in Stalwart
"hideAttendees": false,
"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,
},
@@ -199,12 +208,13 @@ func (s *StalwartTest) fillEvents(
},
},
}
obj := CalendarEvent{
Id: "",
CalendarIds: toBoolMapS(calendarId),
IsDraft: isDraft,
IsOrigin: true,
Event: untype(jscalendar.Event{
Event: jscalendar.Event{
Type: jscalendar.EventType,
Start: jscalendar.LocalDateTime(start),
Duration: jscalendar.Duration(duration),
@@ -217,8 +227,7 @@ func (s *StalwartTest) fillEvents(
Description: description,
DescriptionContentType: descriptionFormat,
Locale: locale,
// Categories: categories, // currently unsupported in Stalwart
Color: color,
Color: color,
},
Sequence: uint(sequence),
ShowWithoutTime: false,
@@ -227,33 +236,46 @@ func (s *StalwartTest) fillEvents(
SentBy: organizerEmail,
Participants: participantObjs,
TimeZone: tz,
// MayInviteSelf: true, // currently unsupported in Stalwart
// MayInviteOthers: true, // currently unsupported in Stalwart
HideAttendees: false,
HideAttendees: false,
ReplyTo: map[jscalendar.ReplyMethod]string{
jscalendar.ReplyMethodImip: "mailto:" + organizerEmail,
},
Locations: locationObjs,
VirtualLocations: map[string]jscalendar.VirtualLocation{
virtualLocationId: untype(virtualLocationObj, u),
virtualLocationId: virtualLocationObj,
},
Alerts: map[string]jscalendar.Alert{
alertId: untype(jscalendar.Alert{
alertId: jscalendar.Alert{
Type: jscalendar.AlertType,
Trigger: jscalendar.OffsetTrigger{
Type: jscalendar.OffsetTriggerType,
Offset: jscalendar.SignedDuration(alertOffset),
RelativeTo: jscalendar.RelativeToStart,
},
}, u),
},
},
},
}, u),
},
}
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 != "" {
@@ -273,19 +295,19 @@ func (s *StalwartTest) fillEvents(
uri = "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img)
default:
mime = "image/jpeg"
uri = "https://picsum.photos/id/" + strconv.Itoa(1+rand.Intn(200)) + "/200/300"
uri = externalImageUri()
}
return map[string]any{
"@type": "Link",
"href": uri,
"contentType": mime,
"rel": string(rel),
}, untype(jscalendar.Link{
}, jscalendar.Link{
Type: jscalendar.LinkType,
Href: uri,
ContentType: mime,
Rel: rel,
}, u), nil
}, nil
})
if rand.Intn(10) > 7 {
@@ -306,7 +328,7 @@ func (s *StalwartTest) fillEvents(
"firstDayOfWeek": string(jscalendar.DayOfWeekMonday),
"count": count,
}
rr := untype(jscalendar.RecurrenceRule{
rr := jscalendar.RecurrenceRule{
Type: jscalendar.RecurrenceRuleType,
Frequency: frequency,
Interval: uint(interval),
@@ -314,7 +336,7 @@ func (s *StalwartTest) fillEvents(
Skip: jscalendar.SkipOmit,
FirstDayOfWeek: jscalendar.DayOfWeekMonday,
Count: uint(count),
}, u)
}
obj.RecurrenceRule = &rr
}
@@ -400,7 +422,6 @@ var virtualRooms = []jscalendar.VirtualLocation{
func pickLocation() (string, map[string]any, jscalendar.Location) {
locationId := id()
room := rooms[rand.Intn(len(rooms))]
room.Links = nil // currently unsupported in Stalwart
b, err := json.Marshal(room)
if err != nil {
panic(err)
@@ -431,23 +452,23 @@ func pickVirtualLocation() (string, map[string]any, jscalendar.VirtualLocation)
var ChairRoles = toBoolMapS(jscalendar.RoleChair, jscalendar.RoleOwner)
var RegularRoles = toBoolMapS(jscalendar.RoleOptional)
func createParticipants(uid string, locationIds []string, virtualLocationIds []string, u bool) (map[string]map[string]any, map[string]jscalendar.Participant, string) {
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...), "", "", u)
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, u)
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, u bool) (string, string, map[string]any, jscalendar.Participant) {
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
@@ -500,11 +521,9 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri
}
m := map[string]any{
"@type": "Participant",
"name": name,
"email": email,
// "description": description, // currently not supported in Stalwart
// "descriptionContentType": descriptionContentType, // currently not supported in Stalwart
"@type": "Participant",
"name": name,
"email": email,
"calendarAddress": calendarAddress,
"kind": "individual",
"roles": structs.MapKeys(roles, func(r jscalendar.Role) string { return string(r) }),
@@ -522,11 +541,9 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri
"scheduleId": "mailto:" + email,
}
o := jscalendar.Participant{
Type: jscalendar.ParticipantType,
Name: name,
Email: email,
// Description: description, // currently not supported in Stalwart
// DescriptionContentType: descriptionContentType, // currently not supported in Stalwart
Type: jscalendar.ParticipantType,
Name: name,
Email: email,
Kind: jscalendar.ParticipantKindIndividual,
CalendarAddress: calendarAddress,
Roles: roles,
@@ -544,8 +561,15 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri
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 := "https://picsum.photos/id/" + strconv.Itoa(1+rand.Intn(200)) + "/200/300"
href := externalImageUri()
title := person.FirstName + "'s Cake Day pick"
return map[string]any{
"@type": "Link",
@@ -554,20 +578,20 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri
"rel": "icon",
"display": "badge",
"title": title,
}, untype(jscalendar.Link{
}, jscalendar.Link{
Type: jscalendar.LinkType,
Href: href,
ContentType: "image/jpeg",
Rel: jscalendar.RelIcon,
Display: jscalendar.DisplayBadge,
Title: title,
}, u), nil
}, nil
})
if err != nil {
panic(err)
}
return participantId, person.Contact.Email, m, untype(o, u)
return participantId, person.Contact.Email, m, o
}
var Keywords = []string{
@@ -578,7 +602,7 @@ var Keywords = []string{
"decision",
}
func keywords() map[string]bool {
func pickKeywords() map[string]bool {
return toBoolMap(pickRandoms(Keywords...))
}
@@ -587,6 +611,6 @@ var Categories = []string{
"http://opencloud.eu/categories/internal",
}
func categories() map[string]bool {
func pickCategories() map[string]bool {
return toBoolMap(pickRandoms(Categories...))
}

View File

@@ -22,6 +22,7 @@ import (
"text/template"
"time"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"github.com/gorilla/websocket"
@@ -42,6 +43,10 @@ import (
"github.com/go-crypt/crypt/algorithm/shacrypt"
)
const (
EnableTypes = false
)
var (
domains = [...]string{"earth.gov", "mars.mil", "opa.org", "acme.com"}
people = [...]string{
@@ -58,7 +63,7 @@ var (
)
const (
stalwartImage = "ghcr.io/stalwartlabs/stalwart:v0.14.0-alpine"
stalwartImage = "ghcr.io/stalwartlabs/stalwart:v0.14.1-alpine"
httpPort = "8080"
imapsPort = "993"
configTemplate = `
@@ -105,6 +110,7 @@ tracer.log.level = "trace"
tracer.log.lossy = false
tracer.log.multiline = false
tracer.log.type = "stdout"
sharing.allow-directory-query = false
`
)
@@ -393,13 +399,6 @@ func pickRandomlyFromMap[K comparable, V any](m map[K]V, min int, max int) map[K
var productName = "jmaptest"
func untype[S any](s S, t bool) S {
if t {
reflect.ValueOf(&s).Elem().FieldByName("Type").SetString("")
}
return s
}
type TestJmapClient struct {
h *http.Client
username string
@@ -645,7 +644,7 @@ func (j *TestJmapClient) objectsById(accountId string, objectType ObjectType, sc
return m, nil
}
func createName(person *gofakeit.PersonInfo, u bool) (map[string]any, jscontact.Name) {
func createName(person *gofakeit.PersonInfo) (map[string]any, jscontact.Name) {
o := jscontact.Name{
Type: jscontact.NameType,
}
@@ -658,20 +657,20 @@ func createName(person *gofakeit.PersonInfo, u bool) (map[string]any, jscontact.
"kind": "given",
"value": person.FirstName,
}
oComps[0] = untype(jscontact.NameComponent{
oComps[0] = jscontact.NameComponent{
Type: jscontact.NameComponentType,
Kind: jscontact.NameComponentKindGiven,
Value: person.FirstName,
}, u)
}
mComps[1] = map[string]string{
"kind": "surname",
"value": person.LastName,
}
oComps[1] = untype(jscontact.NameComponent{
oComps[1] = jscontact.NameComponent{
Type: jscontact.NameComponentType,
Kind: jscontact.NameComponentKindSurname,
Value: person.LastName,
}, u)
}
m["components"] = mComps
o.Components = oComps
m["isOrdered"] = true
@@ -681,24 +680,24 @@ func createName(person *gofakeit.PersonInfo, u bool) (map[string]any, jscontact.
full := fmt.Sprintf("%s %s", person.FirstName, person.LastName)
m["full"] = full
o.Full = full
return m, untype(o, u)
return m, o
}
func createNickName(_ *gofakeit.PersonInfo, u bool) (map[string]any, jscontact.Nickname) {
func createNickName(_ *gofakeit.PersonInfo) (map[string]any, jscontact.Nickname) {
name := gofakeit.PetName()
contexts := pickRandoms(jscontact.NicknameContextPrivate, jscontact.NicknameContextWork)
return map[string]any{
"@type": "Nickname",
"name": name,
"contexts": toBoolMap(structs.Map(contexts, func(s jscontact.NicknameContext) string { return string(s) })),
}, untype(jscontact.Nickname{
}, jscontact.Nickname{
Type: jscontact.NicknameType,
Name: name,
Contexts: orNilMap(toBoolMap(contexts)),
}, u)
}
}
func createEmail(person *gofakeit.PersonInfo, pref int, u bool) (map[string]any, jscontact.EmailAddress) {
func createEmail(person *gofakeit.PersonInfo, pref int) (map[string]any, jscontact.EmailAddress) {
email := person.Contact.Email
contexts := pickRandoms1(jscontact.EmailAddressContextWork, jscontact.EmailAddressContextPrivate)
label := strings.ToLower(person.FirstName)
@@ -708,28 +707,28 @@ func createEmail(person *gofakeit.PersonInfo, pref int, u bool) (map[string]any,
"contexts": toBoolMap(structs.Map(contexts, func(s jscontact.EmailAddressContext) string { return string(s) })),
"label": label,
"pref": pref,
}, untype(jscontact.EmailAddress{
}, jscontact.EmailAddress{
Type: jscontact.EmailAddressType,
Address: email,
Contexts: orNilMap(toBoolMap(contexts)),
Label: label,
Pref: uint(pref),
}, u)
}
}
func createSecondaryEmail(email string, pref int, u bool) (map[string]any, jscontact.EmailAddress) {
func createSecondaryEmail(email string, pref int) (map[string]any, jscontact.EmailAddress) {
contexts := pickRandoms(jscontact.EmailAddressContextWork, jscontact.EmailAddressContextPrivate)
return map[string]any{
"@type": "EmailAddress",
"address": email,
"contexts": toBoolMap(structs.Map(contexts, func(s jscontact.EmailAddressContext) string { return string(s) })),
"pref": pref,
}, untype(jscontact.EmailAddress{
}, jscontact.EmailAddress{
Type: jscontact.EmailAddressType,
Address: email,
Contexts: orNilMap(toBoolMap(contexts)),
Pref: uint(pref),
}, u)
}
}
var idFirstLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
@@ -971,8 +970,8 @@ func propmap[T any](enabled bool, min int, max int, container map[string]any, na
return nil
}
func picsum(w, h int) string {
return fmt.Sprintf("https://picsum.photos/id/%d/%d/%d", 1+rand.Intn(200), h, w)
func externalImageUri() string {
return fmt.Sprintf("https://picsum.photos/id/%d/%d/%d", 1+rand.Intn(200), 200, 300)
}
func orNilMap[K comparable, V any](m map[K]V) map[K]V {
@@ -983,14 +982,6 @@ func orNilMap[K comparable, V any](m map[K]V) map[K]V {
}
}
func orNilSlice[E any](s []E) []E {
if len(s) < 1 {
return nil
} else {
return s
}
}
func toBoolMap[K comparable](s []K) map[K]bool {
m := make(map[K]bool, len(s))
for _, e := range s {
@@ -1046,15 +1037,37 @@ func pickLocale() string {
return pickRandom("en", "fr", "de")
}
func allTrue[S any](t *testing.T, s S, exceptions ...string) {
func allBoxesAreTicked[S any](t *testing.T, s S, exceptions ...string) {
v := reflect.ValueOf(s)
typ := v.Type()
for i := range v.NumField() {
name := typ.Field(i).Name
if slices.Contains(exceptions, name) {
log.Printf("(/) %s\n", name)
continue
}
value := v.Field(i).Bool()
if value {
log.Printf("(X) %s\n", name)
} else {
log.Printf("( ) %s\n", name)
}
require.True(t, value, "should be true: %v", name)
}
}
func deepEqual[T any](t *testing.T, expected, actual T) {
diff := ""
if EnableTypes {
diff = cmp.Diff(expected, actual)
} else {
diff = cmp.Diff(expected, actual, cmp.FilterPath(func(p cmp.Path) bool {
switch sf := p.Last().(type) {
case cmp.StructField:
return sf.String() == ".Type"
}
return false
}, cmp.Ignore()))
}
require.Empty(t, diff)
}

View File

@@ -1,772 +0,0 @@
package jmap
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/opencloud-eu/opencloud/pkg/jscalendar"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/stretchr/testify/require"
)
func jsoneq[X any](t *testing.T, expected string, object X) {
data, err := json.MarshalIndent(object, "", "")
require.NoError(t, err)
require.JSONEq(t, expected, string(data))
var rec X
err = json.Unmarshal(data, &rec)
require.NoError(t, err)
require.Equal(t, object, rec)
}
func TestEmptySessionCapabilitiesMarshalling(t *testing.T) {
jsoneq(t, `{}`, SessionCapabilities{})
}
func TestSessionCapabilitiesMarshalling(t *testing.T) {
jsoneq(t, `{
"urn:ietf:params:jmap:core": {
"maxSizeUpload": 123,
"maxConcurrentUpload": 4,
"maxSizeRequest": 1000,
"maxConcurrentRequests": 8,
"maxCallsInRequest": 16,
"maxObjectsInGet": 32,
"maxObjectsInSet": 8
},
"urn:ietf:params:jmap:tasks": {
}
}`, SessionCapabilities{
Core: &SessionCoreCapabilities{
MaxSizeUpload: 123,
MaxConcurrentUpload: 4,
MaxSizeRequest: 1000,
MaxConcurrentRequests: 8,
MaxCallsInRequest: 16,
MaxObjectsInGet: 32,
MaxObjectsInSet: 8,
},
Tasks: &SessionTasksCapabilities{},
})
}
type TestJmapWellKnownClient struct {
t *testing.T
}
func NewTestJmapWellKnownClient(t *testing.T) SessionClient {
return &TestJmapWellKnownClient{t: t}
}
func (t *TestJmapWellKnownClient) Close() error {
return nil
}
func (t *TestJmapWellKnownClient) GetSession(sessionUrl *url.URL, username string, logger *log.Logger) (SessionResponse, Error) {
pa := generateRandomString(2 + seededRand.Intn(10))
return SessionResponse{
Username: generateRandomString(8),
ApiUrl: "test://",
PrimaryAccounts: SessionPrimaryAccounts{
Core: pa,
Mail: pa,
Submission: pa,
VacationResponse: pa,
Sieve: pa,
Blob: pa,
Quota: pa,
Websocket: pa,
},
Capabilities: SessionCapabilities{
Core: &SessionCoreCapabilities{
MaxCallsInRequest: 64,
},
},
}, nil
}
type TestJmapApiClient struct {
t *testing.T
}
func NewTestJmapApiClient(t *testing.T) ApiClient {
return &TestJmapApiClient{t: t}
}
func (t TestJmapApiClient) Close() error {
return nil
}
type TestJmapBlobClient struct {
t *testing.T
}
func NewTestJmapBlobClient(t *testing.T) BlobClient {
return &TestJmapBlobClient{t: t}
}
func (t TestJmapBlobClient) UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, endpoint string, contentType string, acceptLanguage string, body io.Reader) (UploadedBlob, Language, Error) {
bytes, err := io.ReadAll(body)
if err != nil {
return UploadedBlob{}, "", SimpleError{code: 0, err: err}
}
return UploadedBlob{
BlobId: uuid.NewString(),
Size: len(bytes),
Type: contentType,
}, "", nil
}
func (h *TestJmapBlobClient) DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string, endpoint string, acceptLanguage string) (*BlobDownload, Language, Error) {
return &BlobDownload{
Body: io.NopCloser(strings.NewReader("")),
Size: -1,
Type: "text/plain",
ContentDisposition: "attachment; filename=\"file.txt\"",
CacheControl: "",
}, "", nil
}
func (t TestJmapBlobClient) Close() error {
return nil
}
type TestWsClientFactory struct {
WsClientFactory
}
var _ WsClientFactory = &TestWsClientFactory{}
func NewTestWsClientFactory(t *testing.T) WsClientFactory {
return TestWsClientFactory{}
}
func (t TestWsClientFactory) EnableNotifications(pushState string, sessionProvider func() (*Session, error), listener WsPushListener) (WsClient, Error) {
return nil, nil // TODO
}
func (t TestWsClientFactory) Close() error {
return nil
}
func serveTestFile(t *testing.T, name string) ([]byte, Language, Error) {
cwd, _ := os.Getwd()
p := filepath.Join(cwd, "testdata", name)
bytes, err := os.ReadFile(p)
if err != nil {
return bytes, "", SimpleError{code: 0, err: err}
}
// try to parse it first to avoid any deeper issues that are caused by the test tools
var target map[string]any
err = json.Unmarshal(bytes, &target)
if err != nil {
t.Errorf("failed to parse JSON test data file '%v': %v", p, err)
return nil, "", SimpleError{code: 0, err: err}
}
return bytes, "", nil
}
func (t *TestJmapApiClient) Command(ctx context.Context, logger *log.Logger, session *Session, request Request, acceptLanguage string) ([]byte, Language, Error) {
command := request.MethodCalls[0].Command
switch command {
case CommandMailboxGet:
return serveTestFile(t.t, "mailboxes1.json")
case CommandEmailQuery:
return serveTestFile(t.t, "mails1.json")
default:
require.Fail(t.t, "TestJmapApiClient: unsupported jmap command: %v", command)
return nil, "", SimpleError{code: 0, err: fmt.Errorf("TestJmapApiClient: unsupported jmap command: %v", command)}
}
}
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano()))
func generateRandomString(length int) string {
b := make([]byte, length)
for i := range b {
b[i] = charset[seededRand.Intn(len(charset))]
}
return string(b)
}
func TestRequests(t *testing.T) {
require := require.New(t)
apiClient := NewTestJmapApiClient(t)
wkClient := NewTestJmapWellKnownClient(t)
blobClient := NewTestJmapBlobClient(t)
wsClientFactory := NewTestWsClientFactory(t)
logger := log.NopLogger()
ctx := context.Background()
client := NewClient(wkClient, apiClient, blobClient, wsClientFactory)
jmapUrl, err := url.Parse("http://localhost/jmap")
require.NoError(err)
session := Session{
Username: "user123",
JmapUrl: *jmapUrl,
SessionResponse: SessionResponse{
Capabilities: SessionCapabilities{
Core: &SessionCoreCapabilities{
MaxCallsInRequest: 10,
},
},
},
}
foldersByAccountId, sessionState, _, _, err := client.GetAllMailboxes([]string{"a"}, &session, ctx, &logger, "")
require.NoError(err)
require.Len(foldersByAccountId, 1)
require.Contains(foldersByAccountId, "a")
folders := foldersByAccountId["a"]
require.Len(folders, 5)
require.NotEmpty(sessionState)
emails, sessionState, _, _, err := client.GetAllEmailsInMailbox("a", &session, ctx, &logger, "", "Inbox", 0, 0, false, true, 0, true)
require.NoError(err)
require.Len(emails.Emails, 3)
require.NotEmpty(sessionState)
{
email := emails.Emails[0]
require.Equal("Ornare Senectus Ultrices Elit", email.Subject)
require.Equal(false, email.HasAttachment)
}
{
email := emails.Emails[1]
require.Equal("Lorem Tortor Eros Blandit Adipiscing Scelerisque Fermentum", email.Subject)
require.Equal(false, email.HasAttachment)
}
}
func TestEmailFilterSerialization(t *testing.T) {
expectedFilterJson := `
{"operator":"AND","conditions":[{"hasKeyword":"seen","text":"sample"},{"hasKeyword":"draft"}]}
`
require := require.New(t)
text := "sample"
mailboxId := ""
notInMailboxIds := []string{}
from := ""
to := ""
cc := ""
bcc := ""
subject := ""
body := ""
before := time.Time{}
after := time.Time{}
minSize := 0
maxSize := 0
keywords := []string{"seen", "draft"}
var filter EmailFilterElement
firstFilter := EmailFilterCondition{
Text: text,
InMailbox: mailboxId,
InMailboxOtherThan: notInMailboxIds,
From: from,
To: to,
Cc: cc,
Bcc: bcc,
Subject: subject,
Body: body,
Before: before,
After: after,
MinSize: minSize,
MaxSize: maxSize,
}
filter = &firstFilter
if len(keywords) > 0 {
firstFilter.HasKeyword = keywords[0]
if len(keywords) > 1 {
firstFilter.HasKeyword = keywords[0]
filters := make([]EmailFilterElement, len(keywords))
filters[0] = firstFilter
for i, keyword := range keywords[1:] {
filters[i+1] = EmailFilterCondition{
HasKeyword: keyword,
}
}
filter = &EmailFilterOperator{
Operator: And,
Conditions: filters,
}
}
}
b, err := json.Marshal(filter)
require.NoError(err)
json := string(b)
require.Equal(strings.TrimSpace(expectedFilterJson), json)
}
func TestUnmarshallingCalendarEvent(t *testing.T) {
payload := `
{
"locale" : "en-US",
"description" : "Internal meeting about the grand strategy for the future",
"locations" : {
"ux1uokie" : {
"links" : {
"eefe2pax" : {
"@type" : "Link",
"href" : "https://example.com/office"
}
},
"iCalComponent" : {
"name" : "vlocation"
},
"@type" : "Location",
"description" : "Office meeting room upstairs",
"name" : "Office",
"locationTypes" : {
"office" : true
},
"coordinates" : "geo:52.5334956,13.4079872",
"relativeTo" : "start",
"timeZone" : "CEST"
}
},
"replyTo" : {
"imip" : "mailto:organizer@example.com"
},
"links" : {
"cai0thoh" : {
"href" : "https://example.com/9a7ab91a-edca-4988-886f-25e00743430d",
"rel" : "about",
"contentType" : "text/html",
"@type" : "Link"
}
},
"prodId" : "Mock 0.0",
"@type" : "Event",
"keywords" : {
"secret" : true,
"meeting" : true
},
"status" : "confirmed",
"freeBusyStatus" : "busy",
"categories" : {
"internal" : true,
"secret" : true
},
"duration" : "PT30M",
"calendarIds" : {
"b" : true
},
"alerts" : {
"ahqu4xi0" : {
"@type" : "Alert"
}
},
"start" : "2025-09-30T12:00:00",
"privacy" : "public",
"isDraft" : false,
"id" : "c",
"isOrigin" : true,
"sentBy" : "organizer@example.com",
"descriptionContentType" : "text/plain",
"updated" : "2025-09-29T16:17:18Z",
"created" : "2025-09-29T16:17:18Z",
"color" : "purple",
"recurrenceRule" : {
"skip" : "omit",
"count" : 4,
"firstDayOfWeek" : "monday",
"rscale" : "iso8601",
"interval" : 1,
"frequency" : "weekly"
},
"timeZone" : "Etc/UTC",
"title" : "Meeting of the Minds",
"participants" : {
"xeikie9p" : {
"name" : "Klaes Ashford",
"locationId" : "em4eal0o",
"language" : "en-GB",
"description" : "As the first officer on the Behemoth",
"invitedBy" : "eegh7uph",
"@type" : "Participant",
"links" : {
"oifooj6g" : {
"@type" : "Link",
"contentType" : "image/png",
"display" : "badge",
"href" : "https://static.wikia.nocookie.net/expanse/images/0/02/Klaes_Ashford_-_Expanse_season_4_promotional_2.png/revision/latest?cb=20191206012007",
"rel" : "icon",
"title" : "Ashford on Medina Station"
}
},
"iCalComponent" : {
"name" : "participant"
},
"scheduleAgent" : "server",
"scheduleId" : "mailto:ashford@opa.org"
},
"eegh7uph" : {
"description" : "Called the meeting",
"language" : "en-GB",
"locationId" : "ux1uokie",
"name" : "Anderson Dawes",
"scheduleAgent" : "server",
"scheduleUpdated" : "2025-10-01T11:59:12Z",
"links" : {
"ieni5eiw" : {
"href" : "https://static.wikia.nocookie.net/expanse/images/1/1e/OPA_leader.png/revision/latest?cb=20250121103410",
"display" : "badge",
"rel" : "icon",
"title" : "Anderson Dawes",
"contentType" : "image/png",
"@type" : "Link"
}
},
"iCalComponent" : {
"name" : "participant"
},
"@type" : "Participant",
"invitedBy" : "eegh7uph",
"scheduleSequence" : 1,
"sendTo" : {
"imip" : "mailto:adawes@opa.org"
},
"scheduleStatus" : [
"1.0"
],
"scheduleId" : "mailto:adawes@opa.org"
}
},
"uid" : "9a7ab91a-edca-4988-886f-25e00743430d",
"virtualLocations" : {
"em4eal0o" : {
"@type" : "VirtualLocation",
"description" : "The opentalk Conference Room",
"uri" : "https://meet.opentalk.eu",
"features" : {
"audio" : true,
"screen" : true,
"chat" : true,
"video" : true
},
"name" : "opentalk"
}
}
}
`
require := require.New(t)
var result CalendarEvent
err := json.Unmarshal([]byte(payload), &result)
require.NoError(err)
require.Len(result.VirtualLocations, 1)
require.Len(result.Locations, 1)
require.Equal("9a7ab91a-edca-4988-886f-25e00743430d", result.Uid)
require.Equal(jscalendar.PrivacyPublic, result.Privacy)
}
func TestUnmarshallingCalendarEventGetResponse(t *testing.T) {
payload := `
{
"sessionState" : "7d3cae5b",
"methodResponses" : [
[
"CalendarEvent/query",
{
"position" : 0,
"queryState" : "s2yba",
"accountId" : "b",
"canCalculateChanges" : true,
"ids" : [
"c"
]
},
"b:0"
],
[
"CalendarEvent/get",
{
"state" : "s2yba",
"list" : [
{
"links" : {
"cai0thoh" : {
"contentType" : "text/html",
"href" : "https://example.com/9a7ab91a-edca-4988-886f-25e00743430d",
"@type" : "Link",
"rel" : "about"
}
},
"freeBusyStatus" : "busy",
"color" : "purple",
"isDraft" : false,
"calendarIds" : {
"b" : true
},
"updated" : "2025-09-29T16:17:18Z",
"locations" : {
"ux1uokie" : {
"relativeTo" : "start",
"description" : "Office meeting room upstairs",
"coordinates" : "geo:52.5334956,13.4079872",
"name" : "Office",
"locationTypes" : {
"office" : true
},
"links" : {
"eefe2pax" : {
"href" : "https://example.com/office",
"@type" : "Link"
}
},
"iCalComponent" : {
"name" : "vlocation"
},
"@type" : "Location",
"timeZone" : "CEST"
}
},
"virtualLocations" : {
"em4eal0o" : {
"name" : "opentalk",
"@type" : "VirtualLocation",
"features" : {
"screen" : true,
"chat" : true,
"audio" : true,
"video" : true
},
"uri" : "https://meet.opentalk.eu",
"description" : "The opentalk Conference Room"
}
},
"uid" : "9a7ab91a-edca-4988-886f-25e00743430d",
"categories" : {
"secret" : true,
"internal" : true
},
"keywords" : {
"secret" : true,
"meeting" : true
},
"replyTo" : {
"imip" : "mailto:organizer@example.com"
},
"duration" : "PT30M",
"created" : "2025-09-29T16:17:18Z",
"start" : "2025-09-30T12:00:00",
"id" : "c",
"sentBy" : "organizer@example.com",
"timeZone" : "Etc/UTC",
"@type" : "Event",
"title" : "Meeting of the Minds",
"alerts" : {
"ahqu4xi0" : {
"@type" : "Alert"
}
},
"participants" : {
"xeikie9p" : {
"@type" : "Participant",
"scheduleId" : "mailto:ashford@opa.org",
"invitedBy" : "eegh7uph",
"language" : "en-GB",
"links" : {
"oifooj6g" : {
"contentType" : "image/png",
"display" : "badge",
"title" : "Ashford on Medina Station",
"@type" : "Link",
"href" : "https://static.wikia.nocookie.net/expanse/images/0/02/Klaes_Ashford_-_Expanse_season_4_promotional_2.png/revision/latest?cb=20191206012007",
"rel" : "icon"
}
},
"iCalComponent" : {
"name" : "participant"
},
"scheduleAgent" : "server",
"name" : "Klaes Ashford",
"locationId" : "em4eal0o",
"description" : "As the first officer on the Behemoth"
},
"eegh7uph" : {
"description" : "Called the meeting",
"locationId" : "ux1uokie",
"scheduleUpdated" : "2025-10-01T11:59:12Z",
"sendTo" : {
"imip" : "mailto:adawes@opa.org"
},
"scheduleAgent" : "server",
"scheduleStatus" : [
"1.0"
],
"name" : "Anderson Dawes",
"invitedBy" : "eegh7uph",
"language" : "en-GB",
"links" : {
"ieni5eiw" : {
"rel" : "icon",
"display" : "badge",
"@type" : "Link",
"title" : "Anderson Dawes",
"href" : "https://static.wikia.nocookie.net/expanse/images/1/1e/OPA_leader.png/revision/latest?cb=20250121103410",
"contentType" : "image/png"
}
},
"iCalComponent" : {
"name" : "participant"
},
"scheduleSequence" : 1,
"scheduleId" : "mailto:adawes@opa.org",
"@type" : "Participant"
}
},
"status" : "confirmed",
"description" : "Internal meeting about the grand strategy for the future",
"locale" : "en-US",
"recurrenceRule" : {
"count" : 4,
"rscale" : "iso8601",
"frequency" : "weekly",
"interval" : 1,
"firstDayOfWeek" : "monday",
"skip" : "omit"
},
"descriptionContentType" : "text/plain",
"isOrigin" : true,
"prodId" : "Mock 0.0",
"privacy" : "public"
}
],
"accountId" : "b",
"notFound" : []
},
"b:1"
]
]
}
`
require := require.New(t)
var response Response
err := json.Unmarshal([]byte(payload), &response)
require.NoError(err)
r1 := response.MethodResponses[1]
require.Equal(CommandCalendarEventGet, r1.Command)
get := r1.Parameters.(CalendarEventGetResponse)
require.Len(get.List, 1)
result := get.List[0]
require.Len(result.VirtualLocations, 1)
require.Len(result.Locations, 1)
require.Equal("9a7ab91a-edca-4988-886f-25e00743430d", result.Uid)
require.Equal(jscalendar.PrivacyPublic, result.Privacy)
}
func TestAlertWithOffsetTriggerInResponse(t *testing.T) {
require := require.New(t)
text := `{
"methodResponses":[
["CalendarEvent/get",{
"accountId":"b",
"state":"ssecq",
"list":[{
"@type": "Event",
"start":"2025-11-01T14:30:00",
"alerts": {
"M87fT82": {
"@type": "Alert",
"trigger": {
"@type": "OffsetTrigger",
"offset": "-PT15M",
"relativeTo": "start"
}
}
}
}],
"notFound":[]
},"1"]
],"sessionState":"7d3cae5b"
}`
var response Response
err := json.Unmarshal([]byte(text), &response)
require.NoError(err)
resp := response.MethodResponses[0]
require.Equal(CommandCalendarEventGet, resp.Command)
require.IsType(CalendarEventGetResponse{}, resp.Parameters)
params := resp.Parameters.(CalendarEventGetResponse)
require.Len(params.List, 1)
event := params.List[0]
require.Contains(event.Alerts, "M87fT82")
alert := event.Alerts["M87fT82"]
require.NotNil(alert)
trigger := alert.Trigger
require.NotNil(trigger)
require.IsType(jscalendar.OffsetTrigger{}, trigger)
offsetTrigger := trigger.(jscalendar.OffsetTrigger)
require.Equal(jscalendar.SignedDuration("-PT15M"), offsetTrigger.Offset)
require.Equal(jscalendar.RelativeToStart, offsetTrigger.RelativeTo)
}
func TestParseContactCardMedia(t *testing.T) {
require := require.New(t)
text := `{
"methodResponses":[
["ContactCard/get",{
"accountId":"b",
"state":"ssecq",
"list":[{
"prodId": "jmaptest",
"@type": "Card",
"kind": "individual",
"media": {
"aT8afd59": {
"uri": "https://picsum.photos/id/96/128/128",
"kind": "photo",
"@type": "Media",
"contexts": {
"work": true
}
}
}
}],
"notFound":[]
},"1"]
],"sessionState":"7d3cae5b"
}`
var response Response
err := json.Unmarshal([]byte(text), &response)
require.NoError(err)
resp := response.MethodResponses[0]
require.Equal(CommandContactCardGet, resp.Command)
require.IsType(ContactCardGetResponse{}, resp.Parameters)
params := resp.Parameters.(ContactCardGetResponse)
require.Len(params.List, 1)
contact := params.List[0]
require.Contains(contact.Media, "aT8afd59")
media := contact.Media["aT8afd59"]
require.NotNil(media)
require.Equal("https://picsum.photos/id/96/128/128", media.Uri)
require.Equal(jscontact.MediaKindPhoto, media.Kind)
}

View File

@@ -7,87 +7,6 @@ import (
"github.com/stretchr/testify/require"
)
func TestDeserializeMailboxGetResponse(t *testing.T) {
require := require.New(t)
jsonBytes, _, jmapErr := serveTestFile(t, "mailboxes1.json")
require.NoError(jmapErr)
var data Response
err := json.Unmarshal(jsonBytes, &data)
require.NoError(err)
require.Empty(data.CreatedIds)
require.Equal("3e25b2a0", data.SessionState)
require.Len(data.MethodResponses, 1)
resp := data.MethodResponses[0]
require.Equal(CommandMailboxGet, resp.Command)
require.Equal("0", resp.Tag)
require.IsType(MailboxGetResponse{}, resp.Parameters)
mgr := resp.Parameters.(MailboxGetResponse)
require.Equal("cs", mgr.AccountId)
require.Len(mgr.List, 5)
require.Equal("n", mgr.State)
require.Empty(mgr.NotFound)
var folders = []struct {
id string
name string
role string
total int
unread int
}{
{"a", "Inbox", "inbox", 10, 8},
{"b", "Deleted Items", "trash", 20, 0},
{"c", "Junk Mail", "junk", 0, 0},
{"d", "Drafts", "drafts", 0, 0},
{"e", "Sent Items", "sent", 0, 0},
}
for i, expected := range folders {
folder := mgr.List[i]
require.Equal(expected.id, folder.Id)
require.Equal(expected.name, folder.Name)
require.Equal(expected.role, folder.Role)
require.Equal(expected.total, folder.TotalEmails)
require.Equal(expected.total, folder.TotalThreads)
require.Equal(expected.unread, folder.UnreadEmails)
require.Equal(expected.unread, folder.UnreadThreads)
require.Empty(folder.ParentId)
require.Zero(folder.SortOrder)
require.Nil(folder.IsSubscribed)
require.True(folder.MyRights.MayReadItems)
require.True(folder.MyRights.MayAddItems)
require.True(folder.MyRights.MayRemoveItems)
require.True(folder.MyRights.MaySetSeen)
require.True(folder.MyRights.MaySetKeywords)
require.True(folder.MyRights.MayCreateChild)
require.True(folder.MyRights.MayRename)
require.True(folder.MyRights.MayDelete)
require.True(folder.MyRights.MaySubmit)
}
}
func TestDeserializeEmailGetResponse(t *testing.T) {
require := require.New(t)
jsonBytes, _, jmapErr := serveTestFile(t, "mails1.json")
require.NoError(jmapErr)
var data Response
err := json.Unmarshal(jsonBytes, &data)
require.NoError(err)
require.Empty(data.CreatedIds)
require.Equal("3e25b2a0", data.SessionState)
require.Len(data.MethodResponses, 2)
resp := data.MethodResponses[1]
require.Equal(CommandEmailGet, resp.Command)
require.Equal("1", resp.Tag)
require.IsType(EmailGetResponse{}, resp.Parameters)
egr := resp.Parameters.(EmailGetResponse)
require.Equal("d", egr.AccountId)
require.Len(egr.List, 3)
require.Equal("suqmq", egr.State)
require.Empty(egr.NotFound)
email := egr.List[0]
require.Equal("moyaaaddw", email.Id)
require.Equal("cbejozsk1fgcviw7thwzsvtgmf1ep0a3izjoimj02jmtsunpeuwmsaya1yma", email.BlobId)
}
func TestUnmarshallingError(t *testing.T) {
require := require.New(t)

View File

@@ -1,121 +0,0 @@
{"methodResponses": [
["Mailbox/get", {
"accountId":"cs",
"state":"n",
"list": [
{
"id":"a",
"name":"Inbox",
"parentId":null,
"role":"inbox",
"sortOrder":0,
"isSubscribed":true,
"totalEmails":10,
"unreadEmails":8,
"totalThreads":10,
"unreadThreads":8,
"myRights":{
"mayReadItems":true,
"mayAddItems":true,
"mayRemoveItems":true,
"maySetSeen":true,
"maySetKeywords":true,
"mayCreateChild":true,
"mayRename":true,
"mayDelete":true,
"maySubmit":true
}
},{
"id":"b",
"name":"Deleted Items",
"parentId":null,
"role":"trash",
"sortOrder":0,
"isSubscribed":true,
"totalEmails":20,
"unreadEmails":0,
"totalThreads":20,
"unreadThreads":0,
"myRights":{
"mayReadItems":true,
"mayAddItems":true,
"mayRemoveItems":true,
"maySetSeen":true,
"maySetKeywords":true,
"mayCreateChild":true,
"mayRename":true,
"mayDelete":true,
"maySubmit":true
}
},{
"id":"c",
"name":"Junk Mail",
"parentId":null,
"role":"junk",
"sortOrder":0,
"isSubscribed":true,
"totalEmails":0,
"unreadEmails":0,
"totalThreads":0,
"unreadThreads":0,
"myRights":{
"mayReadItems":true,
"mayAddItems":true,
"mayRemoveItems":true,
"maySetSeen":true,
"maySetKeywords":true,
"mayCreateChild":true,
"mayRename":true,
"mayDelete":true,
"maySubmit":true
}
},{
"id":"d",
"name":"Drafts",
"parentId":null,
"role":"drafts",
"sortOrder":0,
"isSubscribed":true,
"totalEmails":0,
"unreadEmails":0,
"totalThreads":0,
"unreadThreads":0,
"myRights":{
"mayReadItems":true,
"mayAddItems":true,
"mayRemoveItems":true,
"maySetSeen":true,
"maySetKeywords":true,
"mayCreateChild":true,
"mayRename":true,
"mayDelete":true,
"maySubmit":true
}
},{
"id":"e",
"name":"Sent Items",
"parentId":null,
"role":"sent",
"sortOrder":0,
"isSubscribed":true,
"totalEmails":0,
"unreadEmails":0,
"totalThreads":0,
"unreadThreads":0,
"myRights":{
"mayReadItems":true,
"mayAddItems":true,
"mayRemoveItems":true,
"maySetSeen":true,
"maySetKeywords":true,
"mayCreateChild":true,
"mayRename":true,
"mayDelete":true,
"maySubmit":true
}
}
],
"notFound":[]
},"a:0"]
], "sessionState":"3e25b2a0"
}

View File

File diff suppressed because one or more lines are too long

View File

@@ -1410,8 +1410,8 @@ func MapstructTriggerHook() mapstructure.DecodeHookFunc {
return data, nil
}
m := data.(map[string]any)
if typ, ok := m["@type"]; ok {
switch typ {
if t, ok := m["@type"]; ok {
switch t {
case string(OffsetTriggerType):
return mapOffsetTrigger(m)
case string(AbsoluteTriggerType):
@@ -1517,25 +1517,37 @@ type Alert struct {
}
func (a *Alert) UnmarshalJSON(b []byte) error {
var typ struct {
// use a simplified Trigger structure to only deserialize some of
// its fields, only in order to detect which Trigger type it actually
// is (Offset, Absolute, or Unknown)
var peekAlert struct {
Trigger struct {
Type string `json:"@type"`
Type string `json:"@type"`
Offset string `json:"offset"`
When string `json:"when"`
} `json:"trigger"`
}
if err := json.Unmarshal(b, &typ); err != nil {
if err := json.Unmarshal(b, &peekAlert); err != nil {
return err
}
switch typ.Trigger.Type {
switch peekAlert.Trigger.Type {
case string(OffsetTriggerType):
a.Trigger = new(OffsetTrigger)
case string(AbsoluteTriggerType):
a.Trigger = new(AbsoluteTrigger)
default:
a.Trigger = new(UnknownTrigger)
// it could still be a Trigger without its optional @type field
if peekAlert.Trigger.Offset != "" { // if "offset" is set, it's an OffsetTrigger
a.Trigger = new(OffsetTrigger)
} else if peekAlert.Trigger.When != "" { // if "when" is set, it's an AbsoluteTrigger
a.Trigger = new(AbsoluteTrigger)
} else { // it's neither -> UnknownTrigger
a.Trigger = new(UnknownTrigger)
}
}
type tmp Alert
return json.Unmarshal(b, (*tmp)(a))
type tmpAlert Alert // alias Alert to avoid infinite recursion into this func
return json.Unmarshal(b, (*tmpAlert)(a))
}
// A `TimeZoneRule` object maps a `STANDARD` or `DAYLIGHT` sub-component from iCalendar,

View File

@@ -113,9 +113,8 @@ var T1 = jmap.Task{
ShowWithoutTime: false,
Locations: map[string]jscalendar.Location{
"ruoth5uu": {
Type: jscalendar.LocationType,
Name: "Sol Gate",
Description: "We meet at the Sol gate",
Type: jscalendar.LocationType,
Name: "Sol Gate",
LocationTypes: map[jscalendar.LocationTypeOption]bool{
jscalendar.LocationTypeOptionLandmarkAddress: true,
},