Files
opencloud/pkg/jmap/integration_addressbook_test.go
Pascal Bleser 7e8caab979 groupware: add strongly typed aliases for AccountId, PrincipalId and SupplierId
Purpose is to make APIs and parameters easier to understand, since plain
strings are used all over the place for all sorts of identifiers.
2026-06-16 16:51:37 +02:00

667 lines
21 KiB
Go

package jmap
import (
golog "log"
"maps"
"math/rand"
"regexp"
"slices"
"testing"
"time"
"github.com/stretchr/testify/require"
"bytes"
"encoding/base64"
"fmt"
"math"
"strconv"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/brianvoe/gofakeit/v7"
"github.com/opencloud-eu/opencloud/pkg/jscontact"
"github.com/opencloud-eu/opencloud/pkg/structs"
)
const (
// currently not supported, reported as https://github.com/stalwartlabs/stalwart/issues/2431
EnableMediaWithBlobId = false
)
type AddressBookBoxes struct {
sharedReadOnly bool
sharedReadWrite bool
sharedDelete bool
sortOrdered bool
}
func TestAddressBooks(t *testing.T) {
if skip(t) {
return
}
containerTest(t,
func(session *Session) AccountId { return session.PrimaryAccounts.Contacts },
list,
getid,
func(s *StalwartTest, accountId AccountId, ids []string, ctx Context) (Result[AddressBookGetResponse], error) {
return s.client.GetAddressbooks(accountId, ids, ctx)
},
func(s *StalwartTest, accountId AccountId, id string, change AddressBookChange, ctx Context) (Result[AddressBook], error) { //NOSONAR
return s.client.UpdateAddressBook(accountId, id, change, ctx)
},
func(s *StalwartTest, accountId AccountId, ids []string, ctx Context) (Result[map[string]SetError], error) { //NOSONAR
return s.client.DeleteAddressBook(accountId, ids, ctx)
},
func(s *StalwartTest, t *testing.T, accountId AccountId, count uint, ctx Context, user User, principalIds []PrincipalId) (AddressBookBoxes, []AddressBook, SessionState, State, error) {
return s.fillAddressBook(t, accountId, count, ctx, user, principalIds)
},
func(orig AddressBook) AddressBookChange {
return AddressBookChange{
Description: ptr(orig.Description + " (changed)"),
IsSubscribed: ptr(!orig.IsSubscribed),
}
},
func(t *testing.T, orig AddressBook, _ AddressBookChange, changed AddressBook) {
require.Equal(t, orig.Name, changed.Name)
require.Equal(t, orig.Description+" (changed)", changed.Description)
require.Equal(t, !orig.IsSubscribed, changed.IsSubscribed)
},
)
}
func TestContacts(t *testing.T) {
if skip(t) {
return
}
count := uint(20 + rand.Intn(30))
require := require.New(t)
s, err := newStalwartTest(t)
require.NoError(err)
defer s.Close()
user := pickUser()
session := s.Session(user.name)
ctx := s.Context(session)
accountId, addressbookId, expectedContactCardsById, boxes, err := s.fillContacts(t, count, session, ctx, user)
require.NoError(err)
require.NotEmpty(accountId)
require.NotEmpty(addressbookId)
filter := ContactCardFilterCondition{
InAddressBook: addressbookId,
}
sortBy := []ContactCardComparator{
{Property: ContactCardPropertyCreated, IsAscending: true},
}
var results *ContactCardSearchResults
ss := EmptySessionState
os := EmptyState
{
result, err := s.client.QueryContactCards(toNullQueryParams([]AccountId{accountId}), nil, filter, sortBy, true, ctx)
require.NoError(err)
require.Len(result.Payload, 1)
require.Contains(result.Payload, accountId)
results = result.Payload[accountId]
require.Len(results.Results, int(count))
require.Nil(results.Limit)
require.NotNil(results.Position)
require.Equal(uint(0), *results.Position)
require.NotNil(results.Total)
require.Equal(count, *results.Total)
require.Equal(ChangeCalculation(true), results.CanCalculateChanges)
ss = result.GetSessionState()
require.NotEmpty(ss)
os = result.GetState()
require.NotEmpty(os)
}
for _, actual := range results.Results {
expected, ok := expectedContactCardsById[actual.Id]
require.True(ok, "failed to find created contact by its id")
matchContact(t, actual, expected)
}
// retrieve all objects at once
{
ids := structs.Map(results.Results, func(c ContactCard) string { return c.Id })
result, err := s.client.GetContactCards(accountId, ids, ctx)
require.NoError(err)
require.Empty(result.Payload.NotFound)
require.Len(result.Payload.List, len(ids))
byId := structs.Index(result.Payload.List, func(r ContactCard) string { return r.Id })
for _, actual := range results.Results {
expected, ok := byId[actual.Id]
require.True(ok, "failed to find created contact by its id")
matchContact(t, actual, expected)
}
}
// retrieve each object one by one
for _, actual := range results.Results {
result, err := s.client.GetContactCards(accountId, []string{actual.Id}, ctx)
require.NoError(err)
require.Len(result.Payload.List, 1)
matchContact(t, result.Payload.List[0], actual)
}
{
limit := uint(10)
slices := count / limit
remainder := count
require.Greater(slices, uint(1), "we need to have more than 10 objects in order to test the pagination of search results")
for i := range slices {
position := int(i * limit)
page := min(remainder, limit)
result, err := s.client.QueryContactCards(map[AccountId]QueryParams{accountId: {Position: position}}, &limit, filter, sortBy, true, ctx)
require.NoError(err)
require.Len(result.Payload, 1)
require.Contains(result.Payload, accountId)
results := result.Payload[accountId]
require.Equal(len(results.Results), int(page))
require.NotNil(results.Limit)
require.Equal(limit, *results.Limit)
require.NotNil(results.Position)
require.Equal(uint(position), *results.Position)
require.Equal(ChangeCalculation(true), results.CanCalculateChanges)
require.NotNil(results.Total)
require.Equal(count, *results.Total)
remainder -= uint(len(results.Results))
require.Equal(ss, result.GetSessionState())
}
}
{
chunkSize := 3
anchor := results.Results[0].Id
offset := 0
i := 0
for chunk := range slices.Chunk(results.Results, chunkSize) {
result, err := s.client.QueryContactCards(map[AccountId]QueryParams{accountId: {Anchor: anchor, AnchorOffset: &offset}}, uintPtr(chunkSize), filter, sortBy, true, ctx)
require.Equal(ss, result.GetSessionState())
require.NoError(err)
require.Len(result.Payload, 1)
require.Contains(result.Payload, accountId)
results := result.Payload[accountId]
l := len(results.Results)
require.LessOrEqual(l, chunkSize)
require.NotZero(l)
require.NotNil(results.Limit)
require.Equal(uint(chunkSize), *results.Limit)
require.Equal(ChangeCalculation(true), results.CanCalculateChanges)
require.NotNil(results.Total)
require.Equal(count, *results.Total)
for i := range l {
require.Equal(chunk[i].Id, results.Results[i].Id)
}
anchor = chunk[len(chunk)-1].Id
offset = 1
i++
}
}
{
now := time.Now().Truncate(time.Duration(1) * time.Second).UTC()
for _, event := range expectedContactCardsById {
change := ContactCardChange{
Language: ptr("xyz"),
Updated: ptr(now),
}
result, err := s.client.UpdateContactCard(accountId, event.Id, change, ctx)
require.NoError(err)
require.Equal("xyz", result.Payload.Language)
require.Equal(now, result.Payload.Updated)
require.Equal(ss, result.GetSessionState())
require.NotEqual(os, result.GetState())
os = result.GetState()
}
}
{
ids := structs.Map(slices.Collect(maps.Values(expectedContactCardsById)), func(e ContactCard) string { return e.Id })
result, err := s.client.DeleteContactCard(accountId, ids, ctx)
require.NoError(err)
require.Empty(result.Payload)
require.Equal(ss, result.GetSessionState())
require.NotEqual(os, result.GetState())
os = result.GetState()
}
{
result, err := s.client.QueryContactCards(toNullQueryParams([]AccountId{accountId}), nil, filter, sortBy, true, ctx)
require.NoError(err)
require.Contains(result.Payload, accountId)
resp := result.Payload[accountId]
require.Empty(resp.Results)
require.NotNil(resp.Total)
require.Equal(uint(0), *resp.Total)
require.Equal(ss, result.GetSessionState())
require.Equal(os, result.GetState())
}
exceptions := []string{}
if !EnableMediaWithBlobId {
exceptions = append(exceptions, "mediaWithBlobId")
}
allBoxesAreTicked(t, boxes, exceptions...)
}
func matchContact(t *testing.T, actual ContactCard, expected ContactCard) {
// require.Equal(t, expected, actual)
deepEqual(t, expected, actual)
}
type ContactsBoxes struct {
nicknames bool
secondaryEmails bool
secondaryAddress bool
phones bool
onlineService bool
preferredLanguage bool
mediaWithBlobId bool
mediaWithDataUri bool
mediaWithExternalUri bool
organization bool
cryptoKey bool
link bool
}
var streetNumberRegex = regexp.MustCompile(`^(\d+)\s+(.+)$`)
func (s *StalwartTest) fillAddressBook( //NOSONAR
t *testing.T,
accountId AccountId,
count uint,
ctx Context,
_ User,
principalIds []PrincipalId,
) (AddressBookBoxes, []AddressBook, SessionState, State, error) {
require := require.New(t)
boxes := AddressBookBoxes{}
created := []AddressBook{}
ss := EmptySessionState
as := EmptyState
printer := func(s string) { golog.Println(s) }
for i := range count {
name := gofakeit.Company()
description := gofakeit.SentenceSimple()
subscribed := gofakeit.Bool()
abook := AddressBookChange{
Name: &name,
Description: &description,
IsSubscribed: &subscribed,
}
if i%2 == 0 {
abook.SortOrder = uintPtr(gofakeit.Uint())
boxes.sortOrdered = true
}
var sharing *AddressBookRights = nil
switch i % 4 {
default:
// no sharing
case 1:
sharing = &AddressBookRights{MayRead: true, MayWrite: true, MayAdmin: false, MayDelete: false}
boxes.sharedReadWrite = true
case 2:
sharing = &AddressBookRights{MayRead: true, MayWrite: false, MayAdmin: false, MayDelete: false}
boxes.sharedReadOnly = true
case 3:
sharing = &AddressBookRights{MayRead: true, MayWrite: true, MayAdmin: false, MayDelete: true}
boxes.sharedDelete = true
}
if sharing != nil {
numPrincipals := 1 + rand.Intn(len(principalIds)-1)
m := make(map[PrincipalId]AddressBookRights, numPrincipals)
for _, p := range pickRandomN(numPrincipals, principalIds...) {
m[p] = *sharing
}
abook.ShareWith = m
}
result, err := s.client.CreateAddressBook(accountId, abook, ctx)
if err != nil {
return boxes, created, ss, as, err
}
require.NotEmpty(result.GetSessionState())
require.NotEmpty(result.GetState())
if ss != EmptySessionState {
require.Equal(ss, result.GetSessionState())
}
if as != EmptyState {
require.NotEqual(as, result.GetState())
}
require.NotNil(result.Payload)
created = append(created, *result.Payload)
ss = result.GetSessionState()
as = result.GetState()
printer(fmt.Sprintf("📔 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, result.Payload.Id))
}
return boxes, created, ss, as, nil
}
func (s *StalwartTest) fillContacts( //NOSONAR
t *testing.T,
count uint,
session *Session,
ctx Context,
user User,
) (AccountId, string, map[string]ContactCard, ContactsBoxes, error) {
require := require.New(t)
c, err := NewTestJmapClient(session, user.name, user.password, true, true)
require.NoError(err)
defer c.Close()
boxes := ContactsBoxes{}
printer := func(s string) { golog.Println(s) }
accountId := c.session.PrimaryAccounts.Contacts
require.NotEmpty(accountId, "no primary account for contacts in session")
addressbookId := ""
{
addressBooksById, err := c.objectsById(accountId, AddressBookType)
require.NoError(err)
for id, addressbook := range addressBooksById {
if isDefault, ok := addressbook["isDefault"]; ok {
if isDefault.(bool) {
addressbookId = id
break
}
} else {
printer(fmt.Sprintf("abook without isDefault: %v", addressbook))
}
}
if addressbookId == "" {
ids := structs.Keys(addressBooksById)
slices.Sort(ids)
addressbookId = ids[0]
}
}
require.NotEmpty(addressbookId)
filled := map[string]ContactCard{}
for i := range count {
person := gofakeit.Person()
nameObj := createName(person)
language := pickLanguage()
card := ContactCardChange{
Type: jscontact.ContactCardType,
Version: ptr(jscontact.JSContactVersion_1_0),
AddressBookIds: toBoolPtrMap([]string{addressbookId}),
ProdId: &productName,
Language: &language,
Kind: ptr(jscontact.ContactCardKindIndividual),
Name: &nameObj,
}
if i%3 == 0 {
nicknameObj := createNickName(person)
id := id()
card.Nicknames = map[string]jscontact.Nickname{id: nicknameObj}
boxes.nicknames = true
}
{
emailObjs := map[string]jscontact.EmailAddress{}
emailId := id()
emailObj := createEmail(person, 10)
emailObjs[emailId] = emailObj
for i := range rand.Intn(3) {
id := id()
o := createSecondaryEmail(gofakeit.Email(), i*100)
emailObjs[id] = o
boxes.secondaryEmails = true
}
if len(emailObjs) > 0 {
card.Emails = emailObjs
}
}
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 {
num = gofakeit.Phone()
}
var features map[jscontact.PhoneFeature]bool = nil
if rand.Intn(3) < 2 {
features = toBoolMapS(jscontact.PhoneFeatureMobile, jscontact.PhoneFeatureVoice, jscontact.PhoneFeatureVideo, jscontact.PhoneFeatureText)
} else {
features = toBoolMapS(jscontact.PhoneFeatureVoice, jscontact.PhoneFeatureMainNumber)
}
contexts := map[jscontact.PhoneContext]bool{jscontact.PhoneContextWork: true}
if rand.Intn(2) < 1 {
contexts[jscontact.PhoneContextPrivate] = true
}
tel := "tel:" + "+1" + num
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, &card.Addresses, func(i int, id string) (jscontact.Address, error) {
var source *gofakeit.AddressInfo
if i == 0 {
source = person.Address
} else {
source = gofakeit.Address()
boxes.secondaryAddress = true
}
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]})
} 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.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 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, &card.OnlineServices, func(i int, id string) (jscontact.OnlineService, error) {
boxes.onlineService = true
switch rand.Intn(3) {
case 0:
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 jscontact.OnlineService{
Type: jscontact.OnlineServiceType,
Uri: "xmpp:" + person.Contact.Email,
}, nil
default:
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, &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 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 {
organizationObjs := map[string]jscontact.Organization{}
titleObjs := map[string]jscontact.Title{}
for range 1 + rand.Intn(2) {
boxes.organization = true
orgId := id()
titleId := id()
organizationObjs[orgId] = jscontact.Organization{
Type: jscontact.OrganizationType,
Name: person.Job.Company,
Contexts: toBoolMapS(jscontact.OrganizationContextWork),
}
titleObjs[titleId] = jscontact.Title{
Type: jscontact.TitleType,
Kind: jscontact.TitleKindTitle,
Name: person.Job.Title,
OrganizationId: orgId,
}
}
card.Organizations = organizationObjs
card.Titles = titleObjs
}
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 jscontact.CryptoKey{}, err
}
var b bytes.Buffer
err = entity.PrimaryKey.Serialize(&b)
if err != nil {
return jscontact.CryptoKey{}, err
}
encoded := base64.RawStdEncoding.EncodeToString(b.Bytes())
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, &card.Media, func(i int, id string) (jscontact.Media, error) {
label := fmt.Sprintf("photo-%d", 1000+rand.Intn(9000))
r := 0
if EnableMediaWithBlobId {
r = rand.Intn(3)
} else {
r = rand.Intn(2)
}
switch r {
case 0:
boxes.mediaWithDataUri = true
// use data uri
//size := 16 + rand.Intn(512-16+1) // <- let's not do that right now, makes debugging errors very difficult due to the ASCII wall noise
size := pickRandom(16, 24, 32, 48, 64)
img := gofakeit.ImagePng(size, size)
mime := "image/png"
uri := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img)
contexts := toBoolMapS(jscontact.MediaContextPrivate)
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 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)
blob, err := c.uploadBlob(accountId, img, "image/jpeg")
if err != nil {
return jscontact.Media{}, err
}
contexts := toBoolMapS(jscontact.MediaContextPrivate)
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, &card.Links, func(i int, id string) (jscontact.Link, error) {
boxes.link = true
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
}
result, err := s.client.CreateContactCard(accountId, card, ctx)
if err != nil {
return accountId, addressbookId, filled, boxes, err
}
require.NotNil(result.Payload)
filled[result.Payload.Id] = *result.Payload
printer(fmt.Sprintf("🧑🏻 created %*s/%v id=%v", int(math.Log10(float64(count))+1), strconv.Itoa(int(i+1)), count, result.Payload.Id))
}
return accountId, addressbookId, filled, boxes, nil
}