groupware: improve tests

* jmap: add UpdateContactCard and UpdateCalendarEvent funcs
 * use JSON marshalling and unmarshalling into a map for toPatch()
   implementations
 * add updating ContactCards and CalendarEvents to tests
 * add deleting ContactCards and CalendarEvents to tests
 * make query response totals work when the value is 0
 * tests: use CreateContactCard and CreateCalendarEvent funcs to create
   objects in tests instead of using a different JMAP stack that works
   with untyped maps
This commit is contained in:
Pascal Bleser
2026-04-13 16:36:17 +02:00
parent 8f5fbb00a8
commit 0efbebe8be
11 changed files with 805 additions and 569 deletions

View File

@@ -76,7 +76,7 @@ func (j *Client) QueryContactCards(accountIds []string,
Results: get.List,
CanCalculateChanges: query.CanCalculateChanges,
Position: query.Position,
Total: uintPtrIf(query.Total, calculateTotal),
Total: uintPtrIfPtr(query.Total, calculateTotal),
Limit: query.Limit,
}
},
@@ -114,3 +114,18 @@ func (j *Client) DeleteContactCard(accountId string, destroyIds []string, ctx Co
ctx,
)
}
func (j *Client) UpdateContactCard(accountId string, id string, changes ContactCardChange, ctx Context) (ContactCard, SessionState, State, Language, Error) {
return update(j, "UpdateContactCard", ContactCardType,
func(update map[string]PatchObject) ContactCardSetCommand {
return ContactCardSetCommand{AccountId: accountId, Update: update}
},
func(id string) ContactCardGetCommand {
return ContactCardGetCommand{AccountId: accountId, Ids: []string{id}}
},
func(resp ContactCardSetResponse) map[string]SetError { return resp.NotUpdated },
func(resp ContactCardGetResponse) ContactCard { return resp.List[0] },
id, changes,
ctx,
)
}

View File

@@ -29,7 +29,7 @@ func (j *Client) QueryCalendarEvents(accountIds []string, //NOSONAR
Results: get.List,
CanCalculateChanges: query.CanCalculateChanges,
Position: query.Position,
Total: uintPtrIf(query.Total, calculateTotal),
Total: uintPtrIfPtr(query.Total, calculateTotal),
Limit: query.Limit,
}
},
@@ -103,3 +103,18 @@ func (j *Client) DeleteCalendarEvent(accountId string, destroyIds []string, ctx
ctx,
)
}
func (j *Client) UpdateCalendarEvent(accountId string, id string, changes CalendarEventChange, ctx Context) (CalendarEvent, SessionState, State, Language, Error) {
return update(j, "UpdateCalendarEvent", CalendarEventType,
func(update map[string]PatchObject) CalendarEventSetCommand {
return CalendarEventSetCommand{AccountId: accountId, Update: update}
},
func(id string) CalendarEventGetCommand {
return CalendarEventGetCommand{AccountId: accountId, Ids: []string{id}}
},
func(resp CalendarEventSetResponse) map[string]SetError { return resp.NotUpdated },
func(resp CalendarEventGetResponse) CalendarEvent { return resp.List[0] },
id, changes,
ctx,
)
}

View File

@@ -39,6 +39,7 @@ const (
JmapErrorSocketPushUnsupported
JmapErrorMissingCreatedObject
JmapInvalidObjectState
JmapPatchObjectSerialization
)
var (

View File

@@ -2,11 +2,12 @@ package jmap
import (
"encoding/base64"
"encoding/json"
"fmt"
golog "log"
"maps"
"math"
"math/rand"
"slices"
"strconv"
"strings"
"testing"
@@ -77,7 +78,7 @@ func TestEvents(t *testing.T) {
session := s.Session(user.name)
ctx := s.Context(session)
accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count, session, user)
accountId, calendarId, expectedEventsById, boxes, err := s.fillEvents(t, count, ctx, user)
require.NoError(err)
require.NotEmpty(accountId)
require.NotEmpty(calendarId)
@@ -89,8 +90,10 @@ func TestEvents(t *testing.T) {
{Property: CalendarEventPropertyStart, IsAscending: true},
}
ss := EmptySessionState
os := EmptyState
{
resultsByAccount, _, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
resultsByAccount, sessionState, state, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
require.NoError(err)
require.Len(resultsByAccount, 1)
@@ -108,6 +111,9 @@ func TestEvents(t *testing.T) {
require.True(ok, "failed to find created contact by its id")
matchEvent(t, actual, expected)
}
ss = sessionState
os = state
}
{
@@ -118,8 +124,7 @@ func TestEvents(t *testing.T) {
for i := range slices {
position := int(i * limit)
page := min(remainder, limit)
m, _, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, position, limit, true, ctx)
fmt.Printf("=== i=%d | limit=%d | remainder=%d | position=%d | limit=%d | results=%d\n", i, limit, remainder, position, limit, len(m[accountId].Results))
m, sessionState, _, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, position, limit, true, ctx)
require.NoError(err)
require.Len(m, 1)
require.Contains(m, accountId)
@@ -131,9 +136,54 @@ func TestEvents(t *testing.T) {
require.NotNil(results.Total)
require.Equal(count, *results.Total)
remainder -= uint(len(results.Results))
require.Equal(ss, sessionState)
}
}
for _, event := range expectedEventsById {
change := CalendarEventChange{
EventChange: jscalendar.EventChange{
Status: ptr(jscalendar.StatusCancelled),
ObjectChange: jscalendar.ObjectChange{
Sequence: uintPtr(99),
ShowWithoutTime: boolPtr(true),
},
},
}
changed, sessionState, state, _, err := s.client.UpdateCalendarEvent(accountId, event.Id, change, ctx)
require.NoError(err)
require.Equal(jscalendar.StatusCancelled, changed.Status)
require.Equal(uint(99), changed.Sequence)
require.Equal(true, changed.ShowWithoutTime)
require.Equal(ss, sessionState)
require.NotEqual(os, state)
os = state
}
{
ids := structs.Map(slices.Collect(maps.Values(expectedEventsById)), func(e CalendarEvent) string { return e.Id })
errMap, sessionState, state, _, err := s.client.DeleteCalendarEvent(accountId, ids, ctx)
require.NoError(err)
require.Empty(errMap)
require.Equal(ss, sessionState)
require.NotEqual(os, state)
os = state
}
{
shouldBeEmpty, sessionState, state, _, err := s.client.QueryCalendarEvents([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
require.NoError(err)
require.Contains(shouldBeEmpty, accountId)
resp := shouldBeEmpty[accountId]
require.Empty(resp.Results)
require.NotNil(resp.Total)
require.Equal(uint(0), *resp.Total)
require.Equal(ss, sessionState)
require.Equal(os, state)
}
exceptions := []string{}
if !EnableEventMayInviteFields {
exceptions = append(exceptions, "mayInvite")
@@ -165,7 +215,7 @@ func (s *StalwartTest) fillCalendar( //NOSONAR
boxes := CalendarBoxes{}
created := []Calendar{}
ss := SessionState("")
ss := EmptySessionState
as := EmptyState
printer := func(s string) { golog.Println(s) }
@@ -269,7 +319,7 @@ func (s *StalwartTest) fillCalendar( //NOSONAR
}
require.NotEmpty(sessionState)
require.NotEmpty(state)
if ss != SessionState("") {
if ss != EmptySessionState {
require.Equal(ss, sessionState)
}
if as != EmptyState {
@@ -294,11 +344,11 @@ type EventsBoxes struct {
func (s *StalwartTest) fillEvents( //NOSONAR
t *testing.T,
count uint,
session *Session,
ctx Context,
user User,
) (string, string, map[string]CalendarEvent, EventsBoxes, error) {
require := require.New(t)
c, err := NewTestJmapClient(session, user.name, user.password, true, true)
c, err := NewTestJmapClient(ctx.Session, user.name, user.password, true, true)
require.NoError(err)
defer c.Close()
@@ -332,7 +382,6 @@ func (s *StalwartTest) fillEvents( //NOSONAR
isDraft := false
mainLocationId := ""
locationIds := []string{}
locationMaps := map[string]map[string]any{}
locationObjs := map[string]jscalendar.Location{}
{
n := 1
@@ -340,8 +389,7 @@ func (s *StalwartTest) fillEvents( //NOSONAR
n++
}
for range n {
locationId, locationMap, locationObj := pickLocation()
locationMaps[locationId] = locationMap
locationId, locationObj := pickLocation()
locationObjs[locationId] = locationObj
locationIds = append(locationIds, locationId)
if n > 0 && mainLocationId == "" {
@@ -349,8 +397,8 @@ func (s *StalwartTest) fillEvents( //NOSONAR
}
}
}
virtualLocationId, virtualLocationMap, virtualLocationObj := pickVirtualLocation()
participantMaps, participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId})
virtualLocationId, virtualLocationObj := pickVirtualLocation()
participantObjs, organizerEmail := createParticipants(uid, locationIds, []string{virtualLocationId})
duration := pickRandom("PT30M", "PT45M", "PT1H", "PT90M")
tz := pickRandom(timezones...)
daysDiff := rand.Intn(31) - 15
@@ -379,52 +427,10 @@ func (s *StalwartTest) fillEvents( //NOSONAR
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, //NOSONAR
},
"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),
@@ -449,7 +455,7 @@ func (s *StalwartTest) fillEvents( //NOSONAR
TimeZone: tz,
HideAttendees: false,
ReplyTo: map[jscalendar.ReplyMethod]string{
jscalendar.ReplyMethodImip: "mailto:" + organizerEmail,
jscalendar.ReplyMethodImip: "mailto:" + organizerEmail, //NOSONAR
},
Locations: locationObjs,
VirtualLocations: map[string]jscalendar.VirtualLocation{
@@ -470,31 +476,26 @@ func (s *StalwartTest) fillEvents( //NOSONAR
}
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) {
err = propmap(i%2 == 0, 1, 1, &obj.Links, func(int, string) (jscalendar.Link, error) {
mime := ""
uri := ""
rel := jscalendar.RelAbout
@@ -508,17 +509,12 @@ func (s *StalwartTest) fillEvents( //NOSONAR
mime = "image/jpeg" //NOSONAR
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
return jscalendar.Link{
Type: jscalendar.LinkType,
Href: uri,
ContentType: mime,
Rel: rel,
}, nil
})
if rand.Intn(10) > 7 {
@@ -530,15 +526,6 @@ func (s *StalwartTest) fillEvents( //NOSONAR
} 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,
@@ -551,23 +538,18 @@ func (s *StalwartTest) fillEvents( //NOSONAR
obj.RecurrenceRule = &rr
}
id, err := s.CreateEvent(c, accountId, event)
created, _, _, _, err := s.client.CreateCalendarEvent(accountId, obj, ctx)
if err != nil {
return accountId, calendarId, nil, boxes, err
}
obj.Id = id
filled[id] = obj
filled[created.Id] = *created
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, event)
}
var rooms = []jscalendar.Location{
{
Type: jscalendar.LocationType,
@@ -630,56 +612,35 @@ var virtualRooms = []jscalendar.VirtualLocation{
},
}
func pickLocation() (string, map[string]any, jscalendar.Location) {
func pickLocation() (string, 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
return locationId, room
}
func pickVirtualLocation() (string, map[string]any, jscalendar.VirtualLocation) {
func pickVirtualLocation() (string, 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
return locationId, 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) {
func createParticipants(uid string, locationIds []string, virtualLocationIds []string) (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
organizerId, organizerEmail, organizerObj := createParticipant(0, uid, pickRandom(options...), "", "")
objs[organizerId] = organizerObj
for i := 1; i < n; i++ {
id, _, participantMap, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail)
maps[id] = participantMap
id, _, participantObj := createParticipant(i, uid, pickRandom(options...), organizerId, organizerEmail)
objs[id] = participantObj
}
return maps, objs, organizerEmail
return objs, organizerEmail
}
func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string) (string, string, map[string]any, jscalendar.Participant) {
func createParticipant(i int, uid string, locationId string, organizerEmail string, organizerId string) (string, string, jscalendar.Participant) {
participantId := id()
person := gofakeit.Person()
roles := RegularRoles
@@ -731,26 +692,6 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri
}
}
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,
@@ -773,36 +714,27 @@ func createParticipant(i int, uid string, locationId string, organizerEmail stri
}
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) {
err = propmap(i%2 == 0, 1, 2, &o.Links, func(int, string) (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
return 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
return participantId, person.Contact.Email, o
}
var Keywords = []string{
@@ -825,3 +757,7 @@ var Categories = []string{
func pickCategories() map[string]bool {
return toBoolMap(pickRandoms(Categories...))
}
func ptr[T any](t T) *T {
return &t
}

View File

@@ -2,10 +2,12 @@ package jmap
import (
golog "log"
"maps"
"math/rand"
"regexp"
"slices"
"testing"
"time"
"github.com/stretchr/testify/require"
@@ -86,7 +88,7 @@ func TestContacts(t *testing.T) {
session := s.Session(user.name)
ctx := s.Context(session)
accountId, addressbookId, expectedContactCardsById, boxes, err := s.fillContacts(t, count, session, user)
accountId, addressbookId, expectedContactCardsById, boxes, err := s.fillContacts(t, count, session, ctx, user)
require.NoError(err)
require.NotEmpty(accountId)
require.NotEmpty(addressbookId)
@@ -98,7 +100,7 @@ func TestContacts(t *testing.T) {
{Property: ContactCardPropertyCreated, IsAscending: true},
}
contactsByAccount, _, _, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
contactsByAccount, ss, os, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
require.NoError(err)
require.Len(contactsByAccount, 1)
@@ -140,6 +142,44 @@ func TestContacts(t *testing.T) {
matchContact(t, fetched.List[0], actual)
}
{
now := time.Now().Truncate(time.Duration(1) * time.Second).UTC()
for _, event := range expectedContactCardsById {
change := ContactCardChange{
Language: strPtr("xyz"),
Updated: ptr(now),
}
changed, sessionState, state, _, err := s.client.UpdateContactCard(accountId, event.Id, change, ctx)
require.NoError(err)
require.Equal("xyz", changed.Language)
require.Equal(now, changed.Updated)
require.Equal(ss, sessionState)
require.NotEqual(os, state)
os = state
}
}
{
ids := structs.Map(slices.Collect(maps.Values(expectedContactCardsById)), func(e ContactCard) string { return e.Id })
errMap, sessionState, state, _, err := s.client.DeleteContactCard(accountId, ids, ctx)
require.NoError(err)
require.Empty(errMap)
require.Equal(ss, sessionState)
require.NotEqual(os, state)
os = state
}
{
shouldBeEmpty, sessionState, state, _, err := s.client.QueryContactCards([]string{accountId}, filter, sortBy, 0, 0, true, ctx)
require.NoError(err)
require.Contains(shouldBeEmpty, accountId)
resp := shouldBeEmpty[accountId]
require.Empty(resp.Results)
require.NotNil(resp.Total)
require.Equal(uint(0), *resp.Total)
require.Equal(ss, sessionState)
require.Equal(os, state)
}
exceptions := []string{}
if !EnableMediaWithBlobId {
exceptions = append(exceptions, "mediaWithBlobId")
@@ -181,7 +221,7 @@ func (s *StalwartTest) fillAddressBook( //NOSONAR
boxes := AddressBookBoxes{}
created := []AddressBook{}
ss := SessionState("")
ss := EmptySessionState
as := EmptyState
printer := func(s string) { golog.Println(s) }
@@ -228,7 +268,7 @@ func (s *StalwartTest) fillAddressBook( //NOSONAR
}
require.NotEmpty(sessionState)
require.NotEmpty(state)
if ss != SessionState("") {
if ss != EmptySessionState {
require.Equal(ss, sessionState)
}
if as != EmptyState {
@@ -248,6 +288,7 @@ func (s *StalwartTest) fillContacts( //NOSONAR
t *testing.T,
count uint,
session *Session,
ctx Context,
user User,
) (string, string, map[string]ContactCard, ContactsBoxes, error) {
require := require.New(t)
@@ -288,17 +329,9 @@ func (s *StalwartTest) fillContacts( //NOSONAR
filled := map[string]ContactCard{}
for i := range count {
person := gofakeit.Person()
nameMap, nameObj := createName(person)
nameObj := createName(person)
language := pickLanguage()
contact := map[string]any{
"@type": "Card",
"version": "1.0",
"addressBookIds": toBoolMap([]string{addressbookId}),
"prodId": productName,
"language": language,
"kind": "individual",
"name": nameMap,
}
card := ContactCard{
Type: jscontact.ContactCardType,
Version: "1.0",
@@ -310,34 +343,29 @@ func (s *StalwartTest) fillContacts( //NOSONAR
}
if i%3 == 0 {
nicknameMap, nicknameObj := createNickName(person)
nicknameObj := createNickName(person)
id := id()
contact["nicknames"] = map[string]map[string]any{id: nicknameMap}
card.Nicknames = map[string]jscontact.Nickname{id: nicknameObj}
boxes.nicknames = true
}
{
emailMaps := map[string]map[string]any{}
emailObjs := map[string]jscontact.EmailAddress{}
emailId := id()
emailMap, emailObj := createEmail(person, 10)
emailMaps[emailId] = emailMap
emailObj := createEmail(person, 10)
emailObjs[emailId] = emailObj
for i := range rand.Intn(3) {
id := id()
m, o := createSecondaryEmail(gofakeit.Email(), i*100)
emailMaps[id] = m
o := createSecondaryEmail(gofakeit.Email(), i*100)
emailObjs[id] = o
boxes.secondaryEmails = true
}
if len(emailMaps) > 0 {
contact["emails"] = emailMaps
if len(emailObjs) > 0 {
card.Emails = emailObjs
}
}
if err := propmap(i%2 == 0, 1, 2, contact, "phones", &card.Phones, func(i int, id string) (map[string]any, jscontact.Phone, error) {
if err := propmap(i%2 == 0, 1, 2, &card.Phones, func(i int, id string) (jscontact.Phone, error) {
boxes.phones = true
num := person.Contact.Phone
if i > 0 {
@@ -355,21 +383,16 @@ func (s *StalwartTest) fillContacts( //NOSONAR
contexts[jscontact.PhoneContextPrivate] = true
}
tel := "tel:" + "+1" + num
return map[string]any{
"@type": "Phone",
"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) }),
}, jscontact.Phone{
Type: jscontact.PhoneType,
Number: tel,
Features: features,
Contexts: contexts,
}, nil
return jscontact.Phone{
Type: jscontact.PhoneType,
Number: tel,
Features: features,
Contexts: contexts,
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
if err := propmap(i%5 < 4, 1, 2, contact, "addresses", &card.Addresses, func(i int, id string) (map[string]any, jscontact.Address, error) {
if err := propmap(i%5 < 4, 1, 2, &card.Addresses, func(i int, id string) (jscontact.Address, error) {
var source *gofakeit.AddressInfo
if i == 0 {
source = person.Address
@@ -392,108 +415,70 @@ func (s *StalwartTest) fillContacts( //NOSONAR
jscontact.AddressComponent{Type: jscontact.AddressComponentType, Kind: jscontact.AddressComponentKindPostcode, Value: source.Zip},
)
tz := pickRandom(timezones...)
return map[string]any{
"@type": "Address",
"components": structs.Map(components, func(c jscontact.AddressComponent) map[string]string {
return map[string]string{"kind": string(c.Kind), "value": c.Value}
}),
"defaultSeparator": ", ",
"isOrdered": true,
"timeZone": tz,
}, jscontact.Address{
Type: jscontact.AddressType,
Components: components,
DefaultSeparator: ", ",
IsOrdered: true,
TimeZone: tz,
}, nil
return jscontact.Address{
Type: jscontact.AddressType,
Components: components,
DefaultSeparator: ", ",
IsOrdered: true,
TimeZone: tz,
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
if err := propmap(i%2 == 0, 1, 2, contact, "onlineServices", &card.OnlineServices, func(i int, id string) (map[string]any, jscontact.OnlineService, error) {
if err := propmap(i%2 == 0, 1, 2, &card.OnlineServices, func(i int, id string) (jscontact.OnlineService, error) {
boxes.onlineService = true
switch rand.Intn(3) {
case 0:
return map[string]any{
"@type": "OnlineService",
"service": "Mastodon",
"user": "@" + person.Contact.Email,
"uri": "https://mastodon.example.com/@" + strings.ToLower(person.FirstName),
}, jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Service: "Mastodon",
User: "@" + person.Contact.Email,
Uri: "https://mastodon.example.com/@" + strings.ToLower(person.FirstName),
}, nil
return jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Service: "Mastodon",
User: "@" + person.Contact.Email,
Uri: "https://mastodon.example.com/@" + strings.ToLower(person.FirstName),
}, nil
case 1:
return map[string]any{
"@type": "OnlineService",
"uri": "xmpp:" + person.Contact.Email,
}, jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Uri: "xmpp:" + person.Contact.Email,
}, nil
return jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Uri: "xmpp:" + person.Contact.Email,
}, nil
default:
return map[string]any{
"@type": "OnlineService",
"service": "Discord",
"user": person.Contact.Email,
"uri": "https://discord.example.com/user/" + person.Contact.Email,
}, jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Service: "Discord",
User: person.Contact.Email,
Uri: "https://discord.example.com/user/" + person.Contact.Email,
}, nil
return jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Service: "Discord",
User: person.Contact.Email,
Uri: "https://discord.example.com/user/" + person.Contact.Email,
}, nil
}
}); err != nil {
return "", "", nil, boxes, err
}
if err := propmap(i%3 == 0, 1, 2, contact, "preferredLanguages", &card.PreferredLanguages, func(i int, id string) (map[string]any, jscontact.LanguagePref, error) {
if err := propmap(i%3 == 0, 1, 2, &card.PreferredLanguages, func(i int, id string) (jscontact.LanguagePref, error) {
boxes.preferredLanguage = true
lang := pickRandom("en", "fr", "de", "es", "it")
contexts := pickRandoms1("work", "private")
return map[string]any{
"@type": "LanguagePref",
"language": lang,
"contexts": toBoolMap(contexts),
"pref": i + 1,
}, 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),
}, nil
return 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),
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
if i%2 == 0 {
organizationMaps := map[string]map[string]any{}
organizationObjs := map[string]jscontact.Organization{}
titleMaps := map[string]map[string]any{}
titleObjs := map[string]jscontact.Title{}
for range 1 + rand.Intn(2) {
boxes.organization = true
orgId := id()
titleId := id()
organizationMaps[orgId] = map[string]any{
"@type": "Organization",
"name": person.Job.Company,
"contexts": toBoolMapS("work"),
}
organizationObjs[orgId] = jscontact.Organization{
Type: jscontact.OrganizationType,
Name: person.Job.Company,
Contexts: toBoolMapS(jscontact.OrganizationContextWork),
}
titleMaps[titleId] = map[string]any{
"@type": "Title",
"kind": "title",
"name": person.Job.Title,
"organizationId": orgId,
}
titleObjs[titleId] = jscontact.Title{
Type: jscontact.TitleType,
Kind: jscontact.TitleKindTitle,
@@ -501,38 +486,32 @@ func (s *StalwartTest) fillContacts( //NOSONAR
OrganizationId: orgId,
}
}
contact["organizations"] = organizationMaps
contact["titles"] = titleMaps
card.Organizations = organizationObjs
card.Titles = titleObjs
}
if err := propmap(i%2 == 0, 1, 1, contact, "cryptoKeys", &card.CryptoKeys, func(i int, id string) (map[string]any, jscontact.CryptoKey, error) {
if err := propmap(i%2 == 0, 1, 1, &card.CryptoKeys, func(i int, id string) (jscontact.CryptoKey, error) {
boxes.cryptoKey = true
entity, err := openpgp.NewEntity(person.FirstName+" "+person.LastName, "test", person.Contact.Email, nil)
if err != nil {
return nil, jscontact.CryptoKey{}, err
return jscontact.CryptoKey{}, err
}
var b bytes.Buffer
err = entity.PrimaryKey.Serialize(&b)
if err != nil {
return nil, jscontact.CryptoKey{}, err
return jscontact.CryptoKey{}, err
}
encoded := base64.RawStdEncoding.EncodeToString(b.Bytes())
return map[string]any{
"@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
return 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) {
if err := propmap(i%2 == 0, 1, 2, &card.Media, func(i int, id string) (jscontact.Media, error) {
label := fmt.Sprintf("photo-%d", 1000+rand.Intn(9000))
r := 0
@@ -552,40 +531,27 @@ func (s *StalwartTest) fillContacts( //NOSONAR
mime := "image/png"
uri := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img)
contexts := toBoolMapS(jscontact.MediaContextPrivate)
return map[string]any{
"@type": "Media",
"kind": string(jscontact.MediaKindPhoto),
"uri": uri,
"mediaType": mime,
"contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
"label": label,
}, jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
Uri: uri,
MediaType: mime,
Contexts: contexts,
Label: label,
}, nil
return jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
Uri: uri,
MediaType: mime,
Contexts: contexts,
Label: label,
}, 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
return jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
Uri: uri,
Contexts: contexts,
Label: label,
}, nil
default:
boxes.mediaWithBlobId = true
@@ -593,56 +559,41 @@ func (s *StalwartTest) fillContacts( //NOSONAR
img := gofakeit.ImageJpeg(size, size)
blob, err := c.uploadBlob(accountId, img, "image/jpeg")
if err != nil {
return nil, jscontact.Media{}, err
return jscontact.Media{}, err
}
contexts := toBoolMapS(jscontact.MediaContextPrivate)
return map[string]any{
"@type": "Media",
"kind": string(jscontact.MediaKindPhoto),
"blobId": blob.BlobId,
"contexts": structs.MapKeys(contexts, func(c jscontact.MediaContext) string { return string(c) }),
"label": label,
}, jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
BlobId: blob.BlobId,
MediaType: blob.Type,
Contexts: contexts,
Label: label,
}, nil
return jscontact.Media{
Type: jscontact.MediaType,
Kind: jscontact.MediaKindPhoto,
BlobId: blob.BlobId,
MediaType: blob.Type,
Contexts: contexts,
Label: label,
}, nil
}
}); err != nil {
return "", "", nil, boxes, err
}
if err := propmap(i%2 == 0, 1, 1, contact, "links", &card.Links, func(i int, id string) (map[string]any, jscontact.Link, error) {
if err := propmap(i%2 == 0, 1, 1, &card.Links, func(i int, id string) (jscontact.Link, error) {
boxes.link = true
return map[string]any{
"@type": "Link",
"kind": "contact",
"uri": "mailto:" + person.Contact.Email,
"pref": (i + 1) * 10,
}, jscontact.Link{
Type: jscontact.LinkType,
Kind: jscontact.LinkKindContact,
Uri: "mailto:" + person.Contact.Email,
Pref: uint((i + 1) * 10),
}, nil
return jscontact.Link{
Type: jscontact.LinkType,
Kind: jscontact.LinkKindContact,
Uri: "mailto:" + person.Contact.Email,
Pref: uint((i + 1) * 10),
}, nil
}); err != nil {
return "", "", nil, boxes, err
}
id, err := s.CreateContact(c, accountId, contact)
created, _, _, _, err := s.client.CreateContactCard(accountId, card, ctx)
if err != nil {
return "", "", nil, boxes, err
return accountId, addressbookId, filled, boxes, err
}
card.Id = id
filled[id] = card
printer(fmt.Sprintf("🧑🏻 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, id))
require.NotNil(created)
filled[created.Id] = *created
printer(fmt.Sprintf("🧑🏻 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, created.Id))
}
return accountId, addressbookId, filled, boxes, nil
}
func (s *StalwartTest) CreateContact(j *TestJmapClient, accountId string, contact map[string]any) (string, error) {
return j.create1(accountId, ContactCardType, contact)
}

View File

@@ -707,48 +707,6 @@ func (c Commander[T]) command(body map[string]any) (T, error) {
return c.closure(methodResponses)
}
func (j *TestJmapClient) create(id string, objectType ObjectType, body map[string]any) (string, error) {
return newCommander(j, func(methodResponses []any) (string, error) {
z := methodResponses[0].([]any)
f := z[1].(map[string]any)
if x, ok := f["created"]; ok {
created := x.(map[string]any)
if c, ok := created[id].(map[string]any); ok {
return c["id"].(string), nil
} else {
return "", fmt.Errorf("failed to create %v", objectType)
}
} else {
if ncx, ok := f["notCreated"]; ok {
nc := ncx.(map[string]any)
c := nc[id].(map[string]any)
return "", fmt.Errorf("failed to create %v: %v", objectType, c["description"])
} else {
return "", fmt.Errorf("failed to create %v", objectType)
}
}
}).command(body)
}
func (j *TestJmapClient) create1(accountId string, objectType ObjectType, obj map[string]any) (string, error) {
body := map[string]any{
"using": structs.Map(objectType.Namespaces, func(n JmapNamespace) string { return string(n) }),
"methodCalls": []any{
[]any{
objectType.Name + "/set",
map[string]any{
"accountId": accountId,
"create": map[string]any{
"c": obj,
},
},
"0",
},
},
}
return j.create("c", objectType, body)
}
func (j *TestJmapClient) objectsById(accountId string, objectType ObjectType) (map[string]map[string]any, error) {
m := map[string]map[string]any{}
{
@@ -785,91 +743,60 @@ func (j *TestJmapClient) objectsById(accountId string, objectType ObjectType) (m
return m, nil
}
func createName(person *gofakeit.PersonInfo) (map[string]any, jscontact.Name) {
o := jscontact.Name{
func createName(person *gofakeit.PersonInfo) jscontact.Name {
name := jscontact.Name{
Type: jscontact.NameType,
}
m := map[string]any{
"@type": "Name",
}
mComps := make([]map[string]string, 2)
oComps := make([]jscontact.NameComponent, 2)
mComps[0] = map[string]string{
"kind": "given",
"value": person.FirstName,
}
oComps[0] = jscontact.NameComponent{
comps := make([]jscontact.NameComponent, 2)
comps[0] = jscontact.NameComponent{
Type: jscontact.NameComponentType,
Kind: jscontact.NameComponentKindGiven,
Value: person.FirstName,
}
mComps[1] = map[string]string{
"kind": "surname",
"value": person.LastName,
}
oComps[1] = jscontact.NameComponent{
comps[1] = jscontact.NameComponent{
Type: jscontact.NameComponentType,
Kind: jscontact.NameComponentKindSurname,
Value: person.LastName,
}
m["components"] = mComps
o.Components = oComps
m["isOrdered"] = true
o.IsOrdered = true
m["defaultSeparator"] = " "
o.DefaultSeparator = " "
name.Components = comps
name.IsOrdered = true
name.DefaultSeparator = " "
full := fmt.Sprintf("%s %s", person.FirstName, person.LastName)
m["full"] = full
o.Full = full
return m, o
name.Full = full
return name
}
func createNickName(_ *gofakeit.PersonInfo) (map[string]any, jscontact.Nickname) {
func createNickName(_ *gofakeit.PersonInfo) 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) })),
}, jscontact.Nickname{
Type: jscontact.NicknameType,
Name: name,
Contexts: orNilMap(toBoolMap(contexts)),
}
return jscontact.Nickname{
Type: jscontact.NicknameType,
Name: name,
Contexts: orNilMap(toBoolMap(contexts)),
}
}
func createEmail(person *gofakeit.PersonInfo, pref int) (map[string]any, jscontact.EmailAddress) {
func createEmail(person *gofakeit.PersonInfo, pref int) jscontact.EmailAddress {
email := person.Contact.Email
contexts := pickRandoms1(jscontact.EmailAddressContextWork, jscontact.EmailAddressContextPrivate)
label := strings.ToLower(person.FirstName)
return map[string]any{
"@type": "EmailAddress",
"address": email,
"contexts": toBoolMap(structs.Map(contexts, func(s jscontact.EmailAddressContext) string { return string(s) })),
"label": label,
"pref": pref,
}, jscontact.EmailAddress{
Type: jscontact.EmailAddressType,
Address: email,
Contexts: orNilMap(toBoolMap(contexts)),
Label: label,
Pref: uint(pref),
}
return jscontact.EmailAddress{
Type: jscontact.EmailAddressType,
Address: email,
Contexts: orNilMap(toBoolMap(contexts)),
Label: label,
Pref: uint(pref),
}
}
func createSecondaryEmail(email string, pref int) (map[string]any, jscontact.EmailAddress) {
func createSecondaryEmail(email string, pref int) 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,
}, jscontact.EmailAddress{
Type: jscontact.EmailAddressType,
Address: email,
Contexts: orNilMap(toBoolMap(contexts)),
Pref: uint(pref),
}
return jscontact.EmailAddress{
Type: jscontact.EmailAddressType,
Address: email,
Contexts: orNilMap(toBoolMap(contexts)),
Pref: uint(pref),
}
}
var idFirstLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
@@ -1087,27 +1014,22 @@ var extendedColors = []string{
}
*/
func propmap[T any](enabled bool, min int, max int, container map[string]any, name string, cardProperty *map[string]T, generator func(int, string) (map[string]any, T, error)) error {
func propmap[T any](enabled bool, min int, max int, cardProperty *map[string]T, generator func(int, string) (T, error)) error {
if !enabled {
return nil
}
n := min + rand.Intn(max-min+1)
m := make(map[string]map[string]any, n)
o := make(map[string]T, n)
for i := range n {
id := id()
itemForMap, itemForCard, err := generator(i, id)
itemForCard, err := generator(i, id)
if err != nil {
return err
}
if itemForMap != nil {
m[id] = itemForMap
o[id] = itemForCard
}
o[id] = itemForCard
}
if len(m) > 0 {
container[name] = m
if len(o) > 0 {
*cardProperty = o
}
return nil
@@ -1255,19 +1177,18 @@ func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change](
principalIds = structs.Map(principals.List, func(p Principal) string { return p.Id })
}
ss := SessionState("")
ss := EmptySessionState
as := EmptyState
// we need to fetch the ID of the default object that automatically exists for each user, in order to exclude it
// from the tests below
defaultContainerId := ""
preExistingIds := []string{}
{
resp, sessionState, state, _, err := get(s, accountId, []string{}, ctx)
require.NoError(err)
require.Empty(resp.GetNotFound())
objs := obj(resp)
require.Len(objs, 1) // the personal calendar that exists by default
defaultContainerId = id(objs[0])
preExistingIds = structs.Map(objs, id)
ss = sessionState
as = state
}
@@ -1287,8 +1208,8 @@ func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change](
require.NoError(err)
require.Empty(resp.GetNotFound())
objs := obj(resp)
// lets skip the default object since we did not create that one
found := structs.Filter(objs, func(a OBJ) bool { return id(a) != defaultContainerId })
// lets skip the objects that already exist since we did not create those
found := structs.Filter(objs, func(a OBJ) bool { return !slices.Contains(preExistingIds, id(a)) })
require.Len(found, int(num))
m := structs.Index(found, id)
require.Len(m, int(num))
@@ -1320,7 +1241,25 @@ func containerTest[OBJ Idable, RESP GetResponse[OBJ], BOXES any, CHANGE Change](
require.Equal(objs[0], a)
}
// lets modify each AddressBook
// let's retrieve them all by their IDs, but this time all at once
{
ids := structs.Map(all, id)
resp, sessionState, state, _, err := get(s, accountId, ids, ctx)
require.NoError(err)
require.Empty(resp.GetNotFound())
objs := obj(resp)
require.Len(objs, len(all))
require.Equal(sessionState, ss)
require.Equal(state, as)
allById := structs.Index(all, id)
for _, r := range resp.GetList() {
a, ok := allById[r.GetId()]
require.True(ok, "failed to find object that was retrieved in mass ID request in the list of objects that were created")
require.Equal(a, r)
}
}
// lets modify each object
for _, a := range all {
i := id(a)
ch := change(a)

View File

@@ -1,6 +1,7 @@
package jmap
import (
"encoding/json"
"io"
"time"
@@ -930,6 +931,8 @@ type SessionPrimaryAccounts struct {
type SessionState string
const EmptySessionState = SessionState("")
type State string
const EmptyState = State("")
@@ -1258,6 +1261,19 @@ type Idable interface {
// ```
type PatchObject map[string]any
func toPatchObject[T any](value T) (PatchObject, error) {
b, err := json.Marshal(value)
if err != nil {
return PatchObject{}, err
}
var target PatchObject
err = json.Unmarshal(b, &target)
if err != nil {
return PatchObject{}, err
}
return target, nil
}
// Reference to Previous Method Results
//
// To allow clients to make more efficient use of the network and avoid round trips, an argument to one method
@@ -1336,7 +1352,7 @@ type SetResponse[T Foo] interface {
}
type Change interface {
AsPatch() PatchObject
AsPatch() (PatchObject, error)
}
type ChangesCommand[T Foo] interface {
@@ -1659,24 +1675,8 @@ type MailboxChange struct {
var _ Change = MailboxChange{}
func (m MailboxChange) AsPatch() PatchObject {
p := PatchObject{}
if m.Name != "" {
p["name"] = m.Name
}
if m.ParentId != "" {
p["parentId"] = m.ParentId
}
if m.Role != "" {
p["role"] = m.Role
}
if m.SortOrder != nil {
p["sortOrder"] = m.SortOrder
}
if m.IsSubscribed != nil {
p["isSubscribed"] = m.IsSubscribed
}
return p
func (m MailboxChange) AsPatch() (PatchObject, error) {
return toPatchObject(m)
}
type MailboxGetCommand struct {
@@ -3953,27 +3953,8 @@ type IdentityChange struct {
var _ Change = IdentityChange{}
func (i IdentityChange) AsPatch() PatchObject {
p := PatchObject{}
if i.Name != "" {
p["name"] = i.Name
}
if i.Email != "" {
p["email"] = i.Email
}
if i.ReplyTo != "" {
p["replyTo"] = i.ReplyTo
}
if i.Bcc != nil {
p["bcc"] = i.Bcc
}
if i.TextSignature != nil {
p["textSignature"] = i.TextSignature
}
if i.HtmlSignature != nil {
p["htmlSignature"] = i.HtmlSignature
}
return p
func (i IdentityChange) AsPatch() (PatchObject, error) {
return toPatchObject(i)
}
type IdentityGetResponse struct {
@@ -4794,6 +4775,222 @@ var ContactCardProperties = []string{
ContactCardPropertyPersonalInfo,
}
type ContactCardChange struct {
// The set of AddressBook ids this Card belongs to.
//
// A card MUST belong to at least one AddressBook at all times (until it is destroyed).
//
// The set is represented as an object, with each key being an AddressBook id.
//
// The value for each key in the object MUST be true.
//
// This is a JMAP extension and not part of [RFC9553].
AddressBookIds map[string]bool `json:"addressBookIds,omitempty"`
// The JSContact type of the Card object: the value MUST be "Card".
Type jscontact.TypeOfContactCard `json:"@type,omitempty"`
// The JSContact version of this Card.
//
// The value MUST be one of the IANA-registered JSContact Version values for the version property.
Version *jscontact.JSContactVersion `json:"version,omitzero"`
// The kind of the entity the Card represents (default: `individual`).
//
// Values are:
// * `individual`: a single person
// * `group`: a group of people or entities
// * `org`: an organization
// * `location`: a named location
// * `device`: a device such as an appliance, a computer, or a network element
// * `application`: a software application
Kind *jscontact.ContactCardKind `json:"kind,omitempty"`
// The language tag, as defined in [RFC5646].
//
// The language tag that best describes the language used for text in the Card, optionally including
// additional information such as the script.
//
// Note that values MAY be localized in the `localizations` property.
Language *string `json:"language,omitempty"`
// The set of Cards that are members of this group Card.
//
// Each key in the set is the uid property value of the member, and each boolean value MUST be `true`.
// If this property is set, then the value of the kind property MUST be `group`.
//
// The opposite is not true. A group Card will usually contain the members property to specify the members
// of the group, but it is not required to.
//
// A group Card without the members property can be considered an abstract grouping or one whose members
// are known empirically (e.g., `IETF Participants`).
Members map[string]bool `json:"members,omitempty"`
// The identifier for the product that created the Card.
//
// If set, the value MUST be at least one character long.
ProdId *string `json:"prodId,omitempty"`
// The set of Card objects that relate to the Card.
//
// The value is a map, where each key is the uid property value of the related Card, and the value
// defines the relation
//
// ```json
// {
// "relatedTo": {
// "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6": {
// "relation": {"friend": true}
// },
// "8cacdfb7d1ffdb59@example.com": {
// "relation": {}
// }
// }
// }
// ```
RelatedTo map[string]jscontact.Relation `json:"relatedTo,omitempty"`
// The date and time when the data in the Card was last modified (UTCDateTime).
Updated *time.Time `json:"updated,omitzero"`
// The name of the entity represented by the Card.
//
// This can be any type of name, e.g., it can, but need not, be the legal name of a person.
Name *jscontact.Name `json:"name,omitempty"`
// The nicknames of the entity represented by the Card.
Nicknames map[string]jscontact.Nickname `json:"nicknames,omitempty"`
// The company or organization names and units associated with the Card.
Organizations map[string]jscontact.Organization `json:"organizations,omitempty"`
// The information that directs how to address, speak to, or refer to the entity that is represented by the Card.
SpeakToAs *jscontact.SpeakToAs `json:"speakToAs,omitempty"`
// The job titles or functional positions of the entity represented by the Card.
Titles map[string]jscontact.Title `json:"titles,omitempty"`
// The email addresses in which to contact the entity represented by the Card.
Emails map[string]jscontact.EmailAddress `json:"emails,omitempty"`
// The online services that are associated with the entity represented by the Card.
//
// This can be messaging services, social media profiles, and other.
OnlineServices map[string]jscontact.OnlineService `json:"onlineServices,omitempty"`
// The phone numbers by which to contact the entity represented by the Card.
Phones map[string]jscontact.Phone `json:"phones,omitempty"`
// The preferred languages for contacting the entity associated with the Card.
PreferredLanguages map[string]jscontact.LanguagePref `json:"preferredLanguages,omitempty"`
// The calendaring resources of the entity represented by the Card, such as to look up free-busy information.
//
// A Calendar object has all properties of the Resource data type, with the following additional definitions:
// * The `@type` property value MUST be `Calendar`, if set
// * The `kind` property is mandatory. Its enumerated values are:
// * `calendar`: The resource is a calendar that contains entries such as calendar events or tasks
// * `freeBusy`: The resource allows for free-busy lookups, for example, to schedule group events
Calendars map[string]jscontact.Calendar `json:"calendars,omitempty"`
// The scheduling addresses by which the entity may receive calendar scheduling invitations.
SchedulingAddresses map[string]jscontact.SchedulingAddress `json:"schedulingAddresses,omitempty"`
// The addresses of the entity represented by the Card, such as postal addresses or geographic locations.
Addresses map[string]jscontact.Address `json:"addresses,omitempty"`
// The cryptographic resources such as public keys and certificates associated with the entity represented by the Card.
//
// A CryptoKey object has all properties of the `Resource` data type, with the following additional definition:
// the `@type` property value MUST be `CryptoKey`, if set.
//
// The following example shows how to refer to an external cryptographic resource:
// ```json
// "cryptoKeys": {
// "mykey1": {
// "uri": "https://www.example.com/keys/jdoe.cer"
// }
// }
// ```
CryptoKeys map[string]jscontact.CryptoKey `json:"cryptoKeys,omitempty"`
// The directories containing information about the entity represented by the Card.
//
// A Directory object has all properties of the `Resource` data type, with the following additional definitions:
// * The `@type` property value MUST be `Directory`, if set
// * The `kind` property is mandatory; tts enumerated values are:
// * `directory`: the resource is a directory service that the entity represented by the Card is a part of; this
// typically is an organizational directory that also contains associated entities, e.g., co-workers and management
// in a company directory
// * `entry`: the resource is a directory entry of the entity represented by the Card; in contrast to the `directory`
// type, this is the specific URI for the entity within a directory
Directories map[string]jscontact.Directory `json:"directories,omitempty"`
// The links to resources that do not fit any of the other use-case-specific resource properties.
//
// A Link object has all properties of the `Resource` data type, with the following additional definitions:
// * The `@type` property value MUST be `Link`, if set
// * The `kind` property is optional; tts enumerated values are:
// * `contact`: the resource is a URI by which the entity represented by the Card may be contacted;
// this includes web forms or other media that require user interaction
Links map[string]jscontact.Link `json:"links,omitempty"`
// The media resources such as photographs, avatars, or sounds that are associated with the entity represented by the Card.
//
// A Media object has all properties of the Resource data type, with the following additional definitions:
// * the `@type` property value MUST be `Media`, if set
// * the `kind` property is mandatory; its enumerated values are:
// * `photo`: the resource is a photograph or avatar
// * `sound`: the resource is audio media, e.g., to specify the proper pronunciation of the name property contents
// * `logo`: the resource is a graphic image or logo associated with the entity represented by the Card
Media map[string]jscontact.Media `json:"media,omitempty"`
// The property values localized to languages other than the main `language` of the Card.
//
// Localizations provide language-specific alternatives for existing property values and SHOULD NOT add new properties.
//
// The keys in the localizations property value are language tags [RFC5646]; the values are of type `PatchObject` and
// localize the Card in that language tag.
//
// The paths in the `PatchObject` are relative to the Card that includes the localizations property.
//
// A patch MUST NOT target the localizations property.
//
// Conceptually, a Card is localized as follows:
// * Determine the language tag in which the Card should be localized.
// * If the localizations property includes a key for that language, obtain the PatchObject value;
// if there is no such key, stop.
// * Create a copy of the Card, but do not copy the localizations property.
// * Apply all patches in the PatchObject to the copy of the Card.
// * Optionally, set the language property in the copy of the Card.
// * Use the patched copy of the Card as the localized variant of the original Card.
//
// A patch in the `PatchObject` may contain any value type.
//
// Its value MUST be a valid value according to the definition of the patched property.
Localizations map[string]jscontact.PatchObject `json:"localizations,omitempty"`
// The memorable dates and events for the entity represented by the Card.
Anniversaries map[string]jscontact.Anniversary `json:"anniversaries,omitempty"`
// The set of free-text keywords, also known as tags.
//
// Each key in the set is a keyword, and each boolean value MUST be `true`.
Keywords map[string]bool `json:"keywords,omitempty"`
// The free-text notes that are associated with the Card.
Notes map[string]jscontact.Note `json:"notes,omitempty"`
// The personal information of the entity represented by the Card.
PersonalInfo map[string]jscontact.PersonalInfo `json:"personalInfo,omitempty"`
}
var _ Change = ContactCardChange{}
func (e ContactCardChange) AsPatch() (PatchObject, error) {
return toPatchObject(e)
}
type CalendarRights struct {
// The user may read the free-busy information for this calendar.
MayReadFreeBusy bool `json:"mayReadFreeBusy"`
@@ -5016,11 +5213,11 @@ type CalendarChange struct {
// The user-visible name of the calendar.
//
// This may be any UTF-8 string of at least 1 character in length and maximum 255 octets in size.
Name *string `json:"name"`
Name *string `json:"name,omitzero"`
// An optional longer-form description of the calendar, to provide context in shared environments
// where users need more than just the name.
Description *string `json:"description,omitempty"`
Description *string `json:"description,omitzero"`
// A color to be used when displaying events associated with the calendar.
//
@@ -5029,7 +5226,7 @@ type CalendarChange struct {
// notation, as defined in Section 4.2.1 of CSS Color Module Level 3.
//
// The color SHOULD have sufficient contrast to be used as text on a white background.
Color *string `json:"color,omitempty"`
Color *string `json:"color,omitzero"`
// Defines the sort order of calendars when presented in the clients UI, so it is consistent
// between devices.
@@ -5055,7 +5252,7 @@ type CalendarChange struct {
// For example, a company may have a large number of shared calendars which all employees have
// permission to access, but you would only subscribe to the ones you care about and want to be able
// to have normally accessible.
IsSubscribed *bool `json:"isSubscribed"`
IsSubscribed *bool `json:"isSubscribed,omitempty"`
// Should the calendars events be displayed to the user at the moment?
//
@@ -5063,7 +5260,9 @@ type CalendarChange struct {
//
// If an event is in multiple calendars, it should be displayed if `isVisible` is `true`
// for any of those calendars.
IsVisible *bool `json:"isVisible" default:"true" doc:"opt"`
//
// Currently unsupported in Stalwart when modifying existing objects.
IsVisible *bool `json:"isVisible,omitempty" default:"true" doc:"opt"`
// Should the calendars events be used as part of availability calculation?
//
@@ -5138,35 +5337,8 @@ type CalendarChange struct {
var _ Change = CalendarChange{}
func (a CalendarChange) AsPatch() PatchObject {
p := PatchObject{}
if a.Name != nil {
p["name"] = *a.Name
}
if a.Description != nil {
p["description"] = *a.Description
}
if a.Color != nil {
p["color"] = *a.Color
}
if a.SortOrder != nil {
p["sortOrder"] = *a.SortOrder
}
if a.IsSubscribed != nil {
p["isSubscribed"] = *a.IsSubscribed
}
if a.IsVisible != nil {
p["isVisible"] = *a.IsVisible
}
if a.IncludeInAvailability != nil {
p["includeInAvailability"] = *a.IncludeInAvailability
}
// TODO DefaultAlertsWithTime
// TODO DefaultAlertsWithoutTime
// TODO TimeZone
// TODO ShareWith
// TODO MyRights
return p
func (c CalendarChange) AsPatch() (PatchObject, error) {
return toPatchObject(c)
}
// A CalendarEvent object contains information about an event, or recurring series of events,
@@ -5252,6 +5424,79 @@ type CalendarEvent struct {
jscalendar.Event
}
type CalendarEventChange struct {
// The set of Calendar ids this event belongs to.
//
// An event MUST belong to one or more Calendars at all times (until it is destroyed).
//
// The set is represented as an object, with each key being a Calendar id.
//
// The value for each key in the object MUST be `true`.
CalendarIds map[string]bool `json:"calendarIds,omitempty"`
// If true, this event is to be considered a draft.
//
// The server will not send any scheduling messages to participants or send push notifications
// for alerts.
//
// This may only be set to `true` upon creation.
//
// Once set to `false`, the value cannot be updated to `true`.
//
// This property MUST NOT appear in `recurrenceOverrides`.
IsDraft *bool `json:"isDraft,omitzero"`
// Is this the authoritative source for this event (i.e., does it control scheduling for
// this event; the event has not been added as a result of an invitation from another calendar system)?
//
// This is true if, and only if:
// * the events `replyTo` property is null; or
// * the account will receive messages sent to at least one of the methods specified in the `replyTo` property of the event.
IsOrigin *bool `json:"isOrigin,omitzero"`
// For simple clients that do not implement time zone support.
//
// Clients should only use this if also asking the server to expand recurrences, as you cannot accurately
// expand a recurrence without the original time zone.
//
// This property is calculated at fetch time by the server.
//
// Time zones are political and they can and do change at any time.
//
// Fetching exactly the same property again may return a different results if the time zone data has been updated on the server.
//
// Time zone data changes are not considered `updates` to the event.
//
// If set, the server will convert the UTC date to the event's current time zone and store the local time.
//
// This property is not included in `CalendarEvent/get` responses by default and must be requested explicitly.
//
// Floating events (events without a time zone) will be interpreted as per the time zone given as a `CalendarEvent/get` argument.
//
// Note that it is not possible to accurately calculate the expansion of recurrence rules or recurrence overrides with the
// `utcStart` property rather than the local start time. Even simple recurrences such as "repeat weekly" may cross a
// daylight-savings boundary and end up at a different UTC time. Clients that wish to use "utcStart" are RECOMMENDED to
// request the server expand recurrences.
UtcStart UTCDate `json:"utcStart,omitzero"`
// The server calculates the end time in UTC from the start/timeZone/duration properties of the event.
//
// This property is not included by default and must be requested explicitly.
//
// Like `utcStart`, it is calculated at fetch time if requested and may change due to time zone data changes.
//
// Floating events will be interpreted as per the time zone given as a `CalendarEvent/get` argument.
UtcEnd UTCDate `json:"utcEnd,omitzero"`
jscalendar.EventChange
}
var _ Change = CalendarEventChange{}
func (e CalendarEventChange) AsPatch() (PatchObject, error) {
return toPatchObject(e)
}
var _ Idable = &CalendarEvent{}
func (f CalendarEvent) GetObjectType() ObjectType { return CalendarEventType }
@@ -6356,7 +6601,7 @@ type AddressBookChange struct {
// The user-visible name of the AddressBook.
//
// This may be any UTF-8 string of at least 1 character in length and maximum 255 octets in size.
Name *string `json:"name"`
Name *string `json:"name,omitempty"`
// An optional longer-form description of the AddressBook, to provide context in shared environments
// where users need more than just the name.
@@ -6381,7 +6626,7 @@ type AddressBookChange struct {
//
// If false, the AddressBook and its contents SHOULD only be displayed when the user explicitly requests it
// or to offer it for the user to subscribe to.
IsSubscribed *bool `json:"isSubscribed"`
IsSubscribed *bool `json:"isSubscribed,omitzero"`
// A map of Principal id to rights for principals this AddressBook is shared with.
//
@@ -6398,21 +6643,8 @@ type AddressBookChange struct {
var _ Change = AddressBookChange{}
func (a AddressBookChange) AsPatch() PatchObject {
p := PatchObject{}
if a.Name != nil {
p["name"] = *a.Name
}
if a.Description != nil {
p["description"] = *a.Description
}
if a.IsSubscribed != nil {
p["isSubscribed"] = *a.IsSubscribed
}
if a.ShareWith != nil {
p["shareWith"] = a.ShareWith
}
return p
func (a AddressBookChange) AsPatch() (PatchObject, error) {
return toPatchObject(a)
}
type AddressBookSetCommand struct {
@@ -6842,12 +7074,12 @@ type ContactCardQueryResponse struct {
// Only if requested.
//
// This argument MUST be omitted if the calculateTotal request argument is not true.
Total uint `json:"total,omitempty,omitzero"`
Total *uint `json:"total,omitempty"`
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
Limit uint `json:"limit,omitempty,omitzero"`
Limit uint `json:"limit,omitzero"`
}
var _ QueryResponse[ContactCard] = &ContactCardQueryResponse{}
@@ -6999,8 +7231,6 @@ func (r ContactCardChangesResponse) GetUpdated() []string { return r.Updated
func (r ContactCardChangesResponse) GetDestroyed() []string { return r.Destroyed }
func (r ContactCardChangesResponse) GetMarker() ContactCard { return ContactCard{} }
type ContactCardUpdate map[string]any
type ContactCardSetCommand struct {
// The id of the account to use.
AccountId string `json:"accountId"`
@@ -7048,7 +7278,7 @@ type ContactCardSetCommand struct {
//
// The client may choose to optimise network usage by just sending the diff or may send the whole object; the server
// processes it the same either way.
Update map[string]ContactCardUpdate `json:"update,omitempty"`
Update map[string]PatchObject `json:"update,omitempty"`
// A list of ids for ContactCard objects to permanently delete, or null if no objects are to be destroyed.
Destroy []string `json:"destroy,omitempty"`
@@ -7572,12 +7802,12 @@ type CalendarEventQueryResponse struct {
// Only if requested.
//
// This argument MUST be omitted if the calculateTotal request argument is not true.
Total uint `json:"total,omitempty,omitzero"`
Total *uint `json:"total,omitempty"`
// The limit enforced by the server on the maximum number of results to return (if set by the server).
//
// This is only returned if the server set a limit or used a different limit than that given in the request.
Limit uint `json:"limit,omitempty,omitzero"`
Limit uint `json:"limit,omitzero"`
}
var _ QueryResponse[CalendarEvent] = &CalendarEventQueryResponse{}
@@ -7735,8 +7965,6 @@ func (r CalendarEventChangesResponse) GetUpdated() []string { return r.Updat
func (r CalendarEventChangesResponse) GetDestroyed() []string { return r.Destroyed }
func (r CalendarEventChangesResponse) GetMarker() CalendarEvent { return CalendarEvent{} }
type CalendarEventUpdate map[string]any
type CalendarEventSetCommand struct {
// The id of the account to use.
AccountId string `json:"accountId"`
@@ -7784,7 +8012,7 @@ type CalendarEventSetCommand struct {
//
// The client may choose to optimise network usage by just sending the diff or may send the whole object; the server
// processes it the same either way.
Update map[string]CalendarEventUpdate `json:"update,omitempty"`
Update map[string]PatchObject `json:"update,omitempty"`
// A list of ids for CalendarEvent objects to permanently delete, or null if no objects are to be destroyed.
Destroy []string `json:"destroy,omitempty"`

View File

@@ -398,10 +398,18 @@ func update[T Foo, CHANGES Change, SET SetCommand[T], GET GetCommand[T], RESP an
logger := client.logger(name, ctx)
ctx = ctx.WithLogger(logger)
update := setCommandFactory(map[string]PatchObject{id: changes.AsPatch()})
var zero RESP
var update SET
{
patch, err := changes.AsPatch()
if err != nil {
return zero, "", "", "", jmapError(err, JmapPatchObjectSerialization)
}
update = setCommandFactory(map[string]PatchObject{id: patch})
}
get := getCommandFactory(id)
cmd, err := client.request(ctx, objType.Namespaces, invocation(update, "0"), invocation(get, "1"))
var zero RESP
if err != nil {
return zero, "", "", "", err
}

View File

@@ -423,6 +423,14 @@ func uintPtrIf(i uint, condition bool) *uint {
}
}
func uintPtrIfPtr(i *uint, condition bool) *uint {
if condition {
return i
} else {
return nil
}
}
func ns(namespaces ...JmapNamespace) []JmapNamespace {
result := make([]JmapNamespace, len(namespaces)+1)
result[0] = JmapCore

View File

@@ -790,6 +790,19 @@ func (t *LocalDateTime) UnmarshalJSON(b []byte) error {
// automatically still inherit this.
type PatchObject map[string]any
func toPatchObject[T any](value T) (PatchObject, error) {
b, err := json.Marshal(value)
if err != nil {
return PatchObject{}, err
}
var target PatchObject
err = json.Unmarshal(b, &target)
if err != nil {
return PatchObject{}, err
}
return target, nil
}
// A Relation object defines the relation to other objects, using a possibly empty set of relation types.
//
// The object that defines this relation is the linking object, while the other object is the linked
@@ -1740,6 +1753,25 @@ type CommonObject struct {
Color string `json:"color,omitempty"`
}
type CommonObjectChange struct {
Uid *string `json:"uid,omitempty"`
ProdId *string `json:"prodId,omitempty"`
Created UTCDateTime `json:"created,omitzero"`
Updated UTCDateTime `json:"updated,omitzero"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
DescriptionContentType *string `json:"descriptionContentType,omitempty" doc:"opt" default:"text/plain"`
Links map[string]Link `json:"links,omitempty"`
Locale *string `json:"locale,omitempty"`
Keywords map[string]bool `json:"keywords,omitempty"`
Categories map[string]bool `json:"categories,omitempty"`
Color *string `json:"color,omitempty"`
}
func (m CommonObjectChange) AsPatch() (PatchObject, error) {
return toPatchObject(m)
}
// ### Recurrence Properties
//
// Some events and tasks occur at regular or irregular intervals. Rather than having to copy the data for every occurrence,
@@ -2100,6 +2132,40 @@ type Object struct {
HideAttendees bool `json:"hideAttendees,omitzero" doc:"opt" default:"false"`
}
type ObjectChange struct {
CommonObjectChange
RelatedTo map[string]Relation `json:"relatedTo,omitempty"`
Sequence *uint `json:"sequence,omitzero"`
ShowWithoutTime *bool `json:"showWithoutTime,omitzero" doc:"opt" default:"false"`
Locations map[string]Location `json:"locations,omitempty"`
MainLocationId *string `json:"mainLocationId,omitempty"`
VirtualLocations map[string]VirtualLocation `json:"virtualLocations,omitempty"`
RecurrenceId *LocalDateTime `json:"recurrenceId,omitempty"`
RecurrenceIdTimeZone string `json:"recurrenceIdTimeZone,omitempty"`
RecurrenceRule *RecurrenceRule `json:"recurrenceRule,omitempty"`
ExcludedRecurrenceRules []RecurrenceRule `json:"excludedRecurrenceRules,omitempty"`
RecurrenceOverrides map[LocalDateTime]PatchObject `json:"recurrenceOverrides,omitempty"`
Excluded *bool `json:"excluded,omitzero"`
Priority *int `json:"priority,omitzero"`
FreeBusyStatus *FreeBusyStatus `json:"freeBusyStatus,omitempty" doc:"opt" default:"busy"`
Privacy *Privacy `json:"privacy,omitempty"`
ReplyTo map[ReplyMethod]string `json:"replyTo,omitempty"`
SentBy string `json:"sentBy,omitempty"`
Participants map[string]Participant `json:"participants,omitempty"`
RequestStatus string `json:"requestStatus,omitempty"`
UseDefaultAlerts *bool `json:"useDefaultAlerts,omitzero" doc:"opt" default:"false"`
Alerts map[string]Alert `json:"alerts,omitempty"`
Localizations map[string]PatchObject `json:"localizations,omitempty"`
TimeZone *string `json:"timeZone,omitempty"`
MayInviteSelf *bool `json:"mayInviteSelf,omitzero" doc:"opt" default:"false"`
MayInviteOthers *bool `json:"mayInviteOthers,omitzero" doc:"opt" default:"false"`
HideAttendees *bool `json:"hideAttendees,omitzero" doc:"opt" default:"false"`
}
func (m ObjectChange) AsPatch() (PatchObject, error) {
return toPatchObject(m)
}
type Event struct {
Type TypeOfEvent `json:"@type,omitempty"`
@@ -2135,6 +2201,45 @@ type Event struct {
Status Status `json:"status,omitempty"`
}
type EventChange struct {
Type TypeOfEvent `json:"@type,omitempty"`
ObjectChange
// This is the date/time the event starts in the event's time zone (as specified in the timeZone property, see Section 4.7.1).
Start LocalDateTime `json:"start,omitempty"`
// This is the zero or positive duration of the event in the event's start time zone.
//
// The end time of an event can be found by adding the duration to the event's start time.
//
// An Event MAY involve start and end locations that are in different time zones
// (e.g., a transcontinental flight). This can be expressed using the `relativeTo` and `timeZone` properties of
// the `Event`'s Location objects (see Section 4.2.5).
Duration *Duration `json:"duration,omitempty"`
// This identifies the time zone in which this event ends, for cases where the start and time zones of the event differ
// (e.g., a transcontinental flight).
//
// If this property is not set, then the event starts and ends in the same time zone.
//
// This property MUST NOT be set if the timeZone property value is null or not set.
EndTimeZone string `json:"endTimeZone,omitempty"`
// This is the scheduling status (Section 4.4) of an Event.
//
// If set, it MUST be one of the following values, another value registered in the IANA
// "JSCalendar Enum Values" registry, or a vendor-specific value (see Section 3.3):
// * `confirmed`: indicates the event is definitely happening
// * `cancelled`: indicates the event has been cancelled
// * `tentative`: indicates the event may happen
Status *Status `json:"status,omitempty"`
}
func (e EventChange) AsPatch() (PatchObject, error) {
return toPatchObject(e)
}
type Task struct {
Type TypeOfTask `json:"@type,omitempty"`

View File

@@ -714,3 +714,33 @@ func TestEvent(t *testing.T) {
},
})
}
func TestPatch(t *testing.T) {
require := require.New(t)
for _, tt := range []struct {
change ObjectChange
expected PatchObject
}{
{ObjectChange{}, PatchObject{}},
{ObjectChange{
CommonObjectChange: CommonObjectChange{
Uid: strPtr("e9787e0b-e824-4284-964e-6b5d77af4bc9"),
},
}, PatchObject{
"uid": "e9787e0b-e824-4284-964e-6b5d77af4bc9",
}},
} {
b, err := json.Marshal(tt.expected)
require.NoError(err)
title := string(b)
t.Run(title, func(t *testing.T) {
patch, err := tt.change.AsPatch()
require.NoError(err)
require.Equal(tt.expected, patch)
})
}
}
func strPtr(s string) *string {
return &s
}