Skip to content
Merged
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
10 changes: 10 additions & 0 deletions client/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ type SyncReq struct {
// since will be returned.
// By default, this is false.
FullState bool
// Controls whether to set MSC422 `use_state_after` request parameter to get
// `state_after` in the reponse (alternative to `state`).
UseStateAfter bool
// Controls whether the client is automatically marked as online by polling this API. If this
// parameter is omitted then the client is automatically marked as online when it uses this API.
// Otherwise if the parameter is set to “offline” then the client is not marked as being online
Expand Down Expand Up @@ -173,6 +176,13 @@ func (c *CSAPI) Sync(t ct.TestLike, syncReq SyncReq) (gjson.Result, *http.Respon
if syncReq.FullState {
query["full_state"] = []string{"true"}
}
if syncReq.UseStateAfter {
// The spec is already stabilized
query["use_state_after"] = []string{"true"}
// FIXME: Some implementations haven't stabilized yet (Synapse) so we'll keep this
// here until then.
query["org.matrix.msc4222.use_state_after"] = []string{"true"}
}
if syncReq.SetPresence != "" {
query["set_presence"] = []string{syncReq.SetPresence}
}
Expand Down
79 changes: 0 additions & 79 deletions tests/msc4140/delayed_event_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,85 +360,6 @@ func TestDelayedEvents(t *testing.T) {
})
})

t.Run("delayed state is not cancelled by new state from the same user", func(t *testing.T) {
var res *http.Response

stateKey := "to_not_be_cancelled_by_same_user"

defer cleanupDelayedEvents(t, user)

setterKey := "setter"
setterExpected := "on_timeout"
user.MustDo(
t,
"PUT",
getPathForState(roomID, eventType, stateKey),
client.WithJSONBody(t, map[string]interface{}{
setterKey: setterExpected,
}),
getDelayQueryParam("900"),
)
matchDelayedEvents(t, user, delayedEventsNumberEqual(1))

user.MustDo(
t,
"PUT",
getPathForState(roomID, eventType, stateKey),
client.WithJSONBody(t, map[string]interface{}{
setterKey: "manual",
}),
)
matchDelayedEvents(t, user, delayedEventsNumberEqual(1))

time.Sleep(1 * time.Second)
res = user.MustDo(t, "GET", getPathForState(roomID, eventType, stateKey))
must.MatchResponse(t, res, match.HTTPResponse{
JSON: []match.JSON{
match.JSONKeyEqual(setterKey, setterExpected),
},
})
})

t.Run("delayed state is cancelled by new state from another user", func(t *testing.T) {
var res *http.Response

stateKey := "to_be_cancelled_by_other_user"

defer cleanupDelayedEvents(t, user)
defer cleanupDelayedEvents(t, user2)

setterKey := "setter"
user.MustDo(
t,
"PUT",
getPathForState(roomID, eventType, stateKey),
client.WithJSONBody(t, map[string]interface{}{
setterKey: "on_timeout",
}),
getDelayQueryParam("900"),
)
matchDelayedEvents(t, user, delayedEventsNumberEqual(1))

setterExpected := "manual"
user2.MustDo(
t,
"PUT",
getPathForState(roomID, eventType, stateKey),
client.WithJSONBody(t, map[string]interface{}{
setterKey: setterExpected,
}),
)
matchDelayedEvents(t, user, delayedEventsNumberEqual(0))

time.Sleep(1 * time.Second)
res = user.MustDo(t, "GET", getPathForState(roomID, eventType, stateKey))
must.MatchResponse(t, res, match.HTTPResponse{
JSON: []match.JSON{
match.JSONKeyEqual(setterKey, setterExpected),
},
})
})

t.Run("delayed state events are kept on server restart", func(t *testing.T) {
// Spec cannot enforce server restart behaviour
runtime.SkipIf(t, runtime.Dendrite, runtime.Conduit, runtime.Conduwuit)
Expand Down
11 changes: 11 additions & 0 deletions tests/msc4222/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, "msc4222")
}
175 changes: 175 additions & 0 deletions tests/msc4222/msc4222_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package tests

import (
"maps"
"slices"
"testing"

"github.com/matrix-org/complement"
"github.com/matrix-org/complement/client"
"github.com/matrix-org/complement/helpers"
"github.com/matrix-org/complement/should"
"github.com/tidwall/gjson"
)

func TestSync(t *testing.T) {
deployment := complement.Deploy(t, 1)
defer deployment.Destroy(t)
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice"})
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob"})

t.Run("parallel", func(t *testing.T) {
// When lazy-loading room members is enabled, for a public room, the `state_after`
// in an initial sync request should include membership from every `sender` in the
// `timeline`
//
// We're specifically testing the scenario where a new "DM" is created and the other person
// joins without speaking yet.
t.Run("Initial sync with lazy-loading room members -> public room `state_after` includes all members from timeline", func(t *testing.T) {
t.Parallel()

// Alice creates a room
roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"})
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))

// Bob joins the room
bob.MustJoinRoom(t, roomID, nil)

// Wait for Bob's join to be seen by Alice's sync (this is not necessarily instant)
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))

// Ensure `state_after` looks correct
expectedSendersFromTimeline := []string{ alice.UserID, bob.UserID }
syncFilter := `{
"room": {
"timeline": { "limit": 20 },
"state": { "lazy_load_members": true }
}
}`
testInitialSyncStateAfterIncludesTimelineSenders(t, alice, roomID, expectedSendersFromTimeline, syncFilter)
})

// When lazy-loading room members is enabled, for a private room, the `state_after`
// in an initial sync request should include membership from every `sender` in the
// `timeline`
//
// We're specifically testing the scenario where a new "DM" is created and the other person
// joins without speaking yet.
t.Run("Initial sync with lazy-loading room members -> private room `state_after` includes all members from timeline", func(t *testing.T) {
t.Parallel()

// Alice creates a room
roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "private_chat"})
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))

// Alice invites Bob
alice.MustInviteRoom(t, roomID, bob.UserID)

// Wait for Bob to get the invite
bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID))

// Bob joins the room
bob.MustJoinRoom(t, roomID, nil)

// Wait for Bob's join to be seen by Alice's sync (this is not necessarily instant)
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))

// Ensure `state_after` looks correct
expectedSendersFromTimeline := []string{ alice.UserID, bob.UserID }
syncFilter := `{
"room": {
"timeline": { "limit": 20 },
"state": { "lazy_load_members": true }
}
}`
testInitialSyncStateAfterIncludesTimelineSenders(t, alice, roomID, expectedSendersFromTimeline, syncFilter)
})
})
}


// The `state_after` in an initial sync request should at-least include membership from
// every `sender` in the `timeline`.
func testInitialSyncStateAfterIncludesTimelineSenders(
t *testing.T,
syncingUser *client.CSAPI,
roomID string,
expectedSendersFromTimeline []string,
syncFilter string,
) {
t.Helper()

// `syncingUser` makes an initial sync request with lazy-loading members enabled
//
// The spec says `lazy_load_members` is valid field for both `timeline` and
// `state` but as far as I can tell, only makes sense for `state` and that's
// what Synapse keys off of.
res, _ := syncingUser.MustSync(t, client.SyncReq{UseStateAfter: true, Filter: syncFilter})
joinedRoomRes := res.Get("rooms.join." + client.GjsonEscape(roomID))
if !joinedRoomRes.Exists() {
t.Fatalf("Unable to find roomID=%s in the join part of the sync response: %s", roomID, res)
}

// Collect the senders of all the time timeline events.
roomTimelineRes := joinedRoomRes.Get("timeline.events");
if !roomTimelineRes.IsArray() {
t.Fatalf("Timeline events is not an array (found %s) %s", roomTimelineRes.Type.String(), res)
}
sendersFromTimeline := make(map[string]struct{}, 0)
for _, event := range roomTimelineRes.Array() {
sendersFromTimeline[event.Get("sender").Str] = struct{}{}
}
// We expect to see timeline events from `expectedSendersFromTimeline`
err := should.ContainSubset(
slices.Collect(maps.Keys(sendersFromTimeline)),
expectedSendersFromTimeline,
)
if err != nil {
t.Fatalf(
"Expected to see timeline events from (%s) but only saw %s. " +
"Got error: %s. join part of the sync response: %s",
expectedSendersFromTimeline,
slices.Collect(maps.Keys(sendersFromTimeline)),
err.Error(),
res,
)
}

// Collect the `m.room.membership` from `state_after`
//
// Try looking up the stable variant `state_after` first, then fallback to the
// unstable version
roomStateAfterResStable := joinedRoomRes.Get("state_after.events");
roomStateAfterResUnstable := joinedRoomRes.Get("org\\.matrix\\.msc4222\\.state_after.events");
var roomStateAfterRes gjson.Result
if roomStateAfterResStable.Exists() {
roomStateAfterRes = roomStateAfterResStable
} else if roomStateAfterResUnstable.Exists() {
roomStateAfterRes = roomStateAfterResUnstable
}
// Sanity check syntax
if !roomStateAfterRes.IsArray() {
t.Fatalf("state_after events is not an array (found %s) %s", roomStateAfterRes.Type.String(), res)
}
membershipFromState := make(map[string]struct{}, 0)
for _, event := range roomStateAfterRes.Array() {
if event.Get("type").Str == "m.room.member" {
membershipFromState[event.Get("sender").Str] = struct{}{}
}
}
// We should see membership state from every `sender` in the `timeline`.
err = should.ContainSubset(
slices.Collect(maps.Keys(membershipFromState)),
slices.Collect(maps.Keys(sendersFromTimeline)),
)
if err != nil {
t.Fatalf(
"Expected to see membership state (%s) from every sender in the timeline (%s). " +
"Got error: %s. join part of the sync response: %s",
slices.Collect(maps.Keys(membershipFromState)),
slices.Collect(maps.Keys(sendersFromTimeline)),
err.Error(),
res,
)
}
}
Loading