Skip to content

Commit a1ff978

Browse files
Add MSC4222 /sync state_after test for initial sync lazy-loading room members (#842)
This is by no means an exhaustive test suite. This test was simply used to try to reproduce element-hq/synapse#19455 (comment) Synapse fix: element-hq/synapse#19460
1 parent 3c3f7de commit a1ff978

File tree

3 files changed

+196
-0
lines changed

3 files changed

+196
-0
lines changed

client/sync.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ type SyncReq struct {
3939
// since will be returned.
4040
// By default, this is false.
4141
FullState bool
42+
// Controls whether to set MSC422 `use_state_after` request parameter to get
43+
// `state_after` in the reponse (alternative to `state`).
44+
UseStateAfter bool
4245
// Controls whether the client is automatically marked as online by polling this API. If this
4346
// parameter is omitted then the client is automatically marked as online when it uses this API.
4447
// Otherwise if the parameter is set to “offline” then the client is not marked as being online
@@ -173,6 +176,13 @@ func (c *CSAPI) Sync(t ct.TestLike, syncReq SyncReq) (gjson.Result, *http.Respon
173176
if syncReq.FullState {
174177
query["full_state"] = []string{"true"}
175178
}
179+
if syncReq.UseStateAfter {
180+
// The spec is already stabilized
181+
query["use_state_after"] = []string{"true"}
182+
// FIXME: Some implementations haven't stabilized yet (Synapse) so we'll keep this
183+
// here until then.
184+
query["org.matrix.msc4222.use_state_after"] = []string{"true"}
185+
}
176186
if syncReq.SetPresence != "" {
177187
query["set_presence"] = []string{syncReq.SetPresence}
178188
}

tests/msc4222/main_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package tests
2+
3+
import (
4+
"testing"
5+
6+
"github.com/matrix-org/complement"
7+
)
8+
9+
func TestMain(m *testing.M) {
10+
complement.TestMain(m, "msc4222")
11+
}

tests/msc4222/msc4222_test.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package tests
2+
3+
import (
4+
"maps"
5+
"slices"
6+
"testing"
7+
8+
"github.com/matrix-org/complement"
9+
"github.com/matrix-org/complement/client"
10+
"github.com/matrix-org/complement/helpers"
11+
"github.com/matrix-org/complement/should"
12+
"github.com/tidwall/gjson"
13+
)
14+
15+
func TestSync(t *testing.T) {
16+
deployment := complement.Deploy(t, 1)
17+
defer deployment.Destroy(t)
18+
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice"})
19+
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob"})
20+
21+
t.Run("parallel", func(t *testing.T) {
22+
// When lazy-loading room members is enabled, for a public room, the `state_after`
23+
// in an initial sync request should include membership from every `sender` in the
24+
// `timeline`
25+
//
26+
// We're specifically testing the scenario where a new "DM" is created and the other person
27+
// joins without speaking yet.
28+
t.Run("Initial sync with lazy-loading room members -> public room `state_after` includes all members from timeline", func(t *testing.T) {
29+
t.Parallel()
30+
31+
// Alice creates a room
32+
roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"})
33+
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))
34+
35+
// Bob joins the room
36+
bob.MustJoinRoom(t, roomID, nil)
37+
38+
// Wait for Bob's join to be seen by Alice's sync (this is not necessarily instant)
39+
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))
40+
41+
// Ensure `state_after` looks correct
42+
expectedSendersFromTimeline := []string{ alice.UserID, bob.UserID }
43+
syncFilter := `{
44+
"room": {
45+
"timeline": { "limit": 20 },
46+
"state": { "lazy_load_members": true }
47+
}
48+
}`
49+
testInitialSyncStateAfterIncludesTimelineSenders(t, alice, roomID, expectedSendersFromTimeline, syncFilter)
50+
})
51+
52+
// When lazy-loading room members is enabled, for a private room, the `state_after`
53+
// in an initial sync request should include membership from every `sender` in the
54+
// `timeline`
55+
//
56+
// We're specifically testing the scenario where a new "DM" is created and the other person
57+
// joins without speaking yet.
58+
t.Run("Initial sync with lazy-loading room members -> private room `state_after` includes all members from timeline", func(t *testing.T) {
59+
t.Parallel()
60+
61+
// Alice creates a room
62+
roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "private_chat"})
63+
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))
64+
65+
// Alice invites Bob
66+
alice.MustInviteRoom(t, roomID, bob.UserID)
67+
68+
// Wait for Bob to get the invite
69+
bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID))
70+
71+
// Bob joins the room
72+
bob.MustJoinRoom(t, roomID, nil)
73+
74+
// Wait for Bob's join to be seen by Alice's sync (this is not necessarily instant)
75+
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))
76+
77+
// Ensure `state_after` looks correct
78+
expectedSendersFromTimeline := []string{ alice.UserID, bob.UserID }
79+
syncFilter := `{
80+
"room": {
81+
"timeline": { "limit": 20 },
82+
"state": { "lazy_load_members": true }
83+
}
84+
}`
85+
testInitialSyncStateAfterIncludesTimelineSenders(t, alice, roomID, expectedSendersFromTimeline, syncFilter)
86+
})
87+
})
88+
}
89+
90+
91+
// The `state_after` in an initial sync request should at-least include membership from
92+
// every `sender` in the `timeline`.
93+
func testInitialSyncStateAfterIncludesTimelineSenders(
94+
t *testing.T,
95+
syncingUser *client.CSAPI,
96+
roomID string,
97+
expectedSendersFromTimeline []string,
98+
syncFilter string,
99+
) {
100+
t.Helper()
101+
102+
// `syncingUser` makes an initial sync request with lazy-loading members enabled
103+
//
104+
// The spec says `lazy_load_members` is valid field for both `timeline` and
105+
// `state` but as far as I can tell, only makes sense for `state` and that's
106+
// what Synapse keys off of.
107+
res, _ := syncingUser.MustSync(t, client.SyncReq{UseStateAfter: true, Filter: syncFilter})
108+
joinedRoomRes := res.Get("rooms.join." + client.GjsonEscape(roomID))
109+
if !joinedRoomRes.Exists() {
110+
t.Fatalf("Unable to find roomID=%s in the join part of the sync response: %s", roomID, res)
111+
}
112+
113+
// Collect the senders of all the time timeline events.
114+
roomTimelineRes := joinedRoomRes.Get("timeline.events");
115+
if !roomTimelineRes.IsArray() {
116+
t.Fatalf("Timeline events is not an array (found %s) %s", roomTimelineRes.Type.String(), res)
117+
}
118+
sendersFromTimeline := make(map[string]struct{}, 0)
119+
for _, event := range roomTimelineRes.Array() {
120+
sendersFromTimeline[event.Get("sender").Str] = struct{}{}
121+
}
122+
// We expect to see timeline events from `expectedSendersFromTimeline`
123+
err := should.ContainSubset(
124+
slices.Collect(maps.Keys(sendersFromTimeline)),
125+
expectedSendersFromTimeline,
126+
)
127+
if err != nil {
128+
t.Fatalf(
129+
"Expected to see timeline events from (%s) but only saw %s. " +
130+
"Got error: %s. join part of the sync response: %s",
131+
expectedSendersFromTimeline,
132+
slices.Collect(maps.Keys(sendersFromTimeline)),
133+
err.Error(),
134+
res,
135+
)
136+
}
137+
138+
// Collect the `m.room.membership` from `state_after`
139+
//
140+
// Try looking up the stable variant `state_after` first, then fallback to the
141+
// unstable version
142+
roomStateAfterResStable := joinedRoomRes.Get("state_after.events");
143+
roomStateAfterResUnstable := joinedRoomRes.Get("org\\.matrix\\.msc4222\\.state_after.events");
144+
var roomStateAfterRes gjson.Result
145+
if roomStateAfterResStable.Exists() {
146+
roomStateAfterRes = roomStateAfterResStable
147+
} else if roomStateAfterResUnstable.Exists() {
148+
roomStateAfterRes = roomStateAfterResUnstable
149+
}
150+
// Sanity check syntax
151+
if !roomStateAfterRes.IsArray() {
152+
t.Fatalf("state_after events is not an array (found %s) %s", roomStateAfterRes.Type.String(), res)
153+
}
154+
membershipFromState := make(map[string]struct{}, 0)
155+
for _, event := range roomStateAfterRes.Array() {
156+
if event.Get("type").Str == "m.room.member" {
157+
membershipFromState[event.Get("sender").Str] = struct{}{}
158+
}
159+
}
160+
// We should see membership state from every `sender` in the `timeline`.
161+
err = should.ContainSubset(
162+
slices.Collect(maps.Keys(membershipFromState)),
163+
slices.Collect(maps.Keys(sendersFromTimeline)),
164+
)
165+
if err != nil {
166+
t.Fatalf(
167+
"Expected to see membership state (%s) from every sender in the timeline (%s). " +
168+
"Got error: %s. join part of the sync response: %s",
169+
slices.Collect(maps.Keys(membershipFromState)),
170+
slices.Collect(maps.Keys(sendersFromTimeline)),
171+
err.Error(),
172+
res,
173+
)
174+
}
175+
}

0 commit comments

Comments
 (0)