Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions tests/msc4429/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package tests

import (
"testing"

"github.com/matrix-org/complement"
)

func TestMain(m *testing.M) {
complement.TestMain(m, "msc4429")
}
207 changes: 207 additions & 0 deletions tests/msc4429/msc4429_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package tests

import (
"encoding/json"
"fmt"
"testing"

"github.com/tidwall/gjson"

"github.com/matrix-org/complement"
"github.com/matrix-org/complement/client"
"github.com/matrix-org/complement/helpers"
"github.com/matrix-org/complement/match"
"github.com/matrix-org/complement/must"
)

const (
msc4429UsersStable = "users"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems pre-mature to support stable given matrix-org/matrix-spec-proposals#4429 is no where near that point 🤔

Perhaps we just comment that part out for now. Useful for our future reference when we stabilize

msc4429UsersUnstable = "org\\.matrix\\.msc4429\\.users"
)

func TestMSC4429ProfileUpdates(t *testing.T) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Half-Shot mentioned on the MSC that it would be good for the homeserver to send down nulls to clear a user's profile once they no longer share any rooms with you (after leaving a room).

It would be good to add a test case for this, ideally replacing the profile object with just a null as mentioned in my follow-up comment, to indicate "all fields should be cleared".

deployment := complement.Deploy(t, 1)
defer deployment.Destroy(t)

t.Run("Initial sync includes requested profile fields and filters others", func(t *testing.T) {
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-initial"})
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-initial"})

mustCreateSharedRoom(t, alice, bob)

bob.MustSetDisplayName(t, "Bob Display")
mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "busy",
"emoji": "🛑",
})

// Exclude 'displayname'
filter := mustBuildMSC4429Filter(t, []string{"m.status"})
res, _ := alice.MustSync(t, client.SyncReq{Filter: filter})
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be flaky if we don't use a sync until utility for profile updates to show up. (read after write consistency)

The worker processing profile updates might not be the same one that serves /sync.


update, ok := getProfileUpdate(res, bob.UserID, "m.status")
if !ok {
t.Fatalf("missing m.status profile update for %s in initial sync: %s", bob.UserID, res.Raw)
}
must.MatchGJSON(t, update, match.JSONKeyEqual("", map[string]interface{}{
"text": "busy",
"emoji": "🛑",
}))
assertNoProfileUpdate(t, res, bob.UserID, "displayname")
})

t.Run("No updates without profile_fields filter", func(t *testing.T) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain why, "opt-in" per MSC4429

alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-nofilter"})
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-nofilter"})

mustCreateSharedRoom(t, alice, bob)

// No filter = no profile fields returned.
_, since := alice.MustSync(t, client.SyncReq{})
Comment on lines +59 to +60
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't actually assert anything for the initial sync request

mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "away",
})
Comment on lines +59 to +63
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a sanity check, we should have another observer user that sees the m.status with a filter, and then alice syncs without a filter

Comment on lines +61 to +63
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separate action from previous request

Suggested change
mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "away",
})
mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "away",
})


res, _ := alice.MustSync(t, client.SyncReq{Since: since})
assertNoProfileUpdate(t, res, bob.UserID, "m.status")
})

t.Run("Incremental sync returns the latest update", func(t *testing.T) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we should have another test to make sure more subsequent incremental /sync include new updates for the new user.

This ensures that any caching (remembering which users were sent down before) is correct.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, we kinda have this already with "Cleared profile field is returned as null"

alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-latest"})
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-latest"})

mustCreateSharedRoom(t, alice, bob)

filter := mustBuildMSC4429Filter(t, []string{"m.status"})
_, since := alice.MustSync(t, client.SyncReq{Filter: filter})

mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "first",
})
mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "second",
})

alice.MustSyncUntil(
t,
client.SyncReq{Since: since, Filter: filter},
syncHasProfileUpdate(bob.UserID, "m.status", map[string]interface{}{
"text": "second",
}),
)
})

t.Run("Cleared profile field is returned as null", func(t *testing.T) {
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-clear"})
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-clear"})

mustCreateSharedRoom(t, alice, bob)

filter := mustBuildMSC4429Filter(t, []string{"m.status"})
_, since := alice.MustSync(t, client.SyncReq{Filter: filter})

mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "busy",
})
since = alice.MustSyncUntil(
t,
client.SyncReq{Since: since, Filter: filter},
syncHasProfileUpdate(bob.UserID, "m.status", map[string]interface{}{
"text": "busy",
}),
)

mustSetProfileField(t, bob, "m.status", nil)
alice.MustSyncUntil(
t,
client.SyncReq{Since: since, Filter: filter},
syncHasProfileUpdate(bob.UserID, "m.status", nil),
)
})
}

// mustBuildMSC4429Filter builds a filter that can be used to limit the field
// IDs returned in a `/sync` response.
func mustBuildMSC4429Filter(t *testing.T, ids []string) string {
t.Helper()
filter := map[string]interface{}{
"profile_fields": map[string]interface{}{
"ids": ids,
},
"org.matrix.msc4429.profile_fields": map[string]interface{}{
"ids": ids,
},
}
encoded, err := json.Marshal(filter)
if err != nil {
t.Fatalf("failed to marshal MSC4429 filter: %s", err)
}
return string(encoded)
}

// mustCreateSharedRoom creates a shared room between `alice` and `bob` and returns the
// room ID.
func mustCreateSharedRoom(t *testing.T, alice *client.CSAPI, bob *client.CSAPI) string {
t.Helper()
roomID := alice.MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
})
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))
bob.MustJoinRoom(t, roomID, nil)
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))
bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))
return roomID
}

// mustSetProfileField sets the given profile field ID to the given value on the given user's
// profile.
func mustSetProfileField(t *testing.T, user *client.CSAPI, field string, value interface{}) {
t.Helper()
user.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "profile", user.UserID, field},
client.WithJSONBody(t, map[string]interface{}{
field: value,
}),
)
}

// getProfileUpdate extracts the given profile updates for a given user by field
// ID from a legacy `/sync` response.
func getProfileUpdate(res gjson.Result, userID, field string) (gjson.Result, bool) {
stablePath := msc4429UsersStable + "." + client.GjsonEscape(userID) + ".profile_updates." + client.GjsonEscape(field)
stableRes := res.Get(stablePath)
if stableRes.Exists() {
return stableRes, true
}
unstablePath := msc4429UsersUnstable + "." + client.GjsonEscape(userID) + ".profile_updates." + client.GjsonEscape(field)
unstableRes := res.Get(unstablePath)
if unstableRes.Exists() {
return unstableRes, true
}
return gjson.Result{}, false
}

func assertNoProfileUpdate(t *testing.T, res gjson.Result, userID, field string) {
t.Helper()
if update, ok := getProfileUpdate(res, userID, field); ok {
t.Fatalf("unexpected profile update for %s %s: %s", userID, field, update.Raw)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is correct but the language is ambiguous whether we expected no profile update or just a different profile update.

}
}

func syncHasProfileUpdate(userID, field string, expected interface{}) client.SyncCheckOpt {
return func(clientUserID string, topLevelSyncJSON gjson.Result) error {
update, ok := getProfileUpdate(topLevelSyncJSON, userID, field)
if !ok {
return fmt.Errorf("missing profile update for %s %s", userID, field)
}
if expected == nil {
if update.Type != gjson.Null {
return fmt.Errorf("expected null profile update for %s %s, got %s", userID, field, update.Type)
}
return nil
}
if err := match.JSONKeyEqual("", expected)(update); err != nil {
return fmt.Errorf("profile update mismatch for %s %s: %w", userID, field, err)
}
return nil
}
}
Loading