From aa81ebc94dcdf5efb4542f8cb2dbf5b18dc0ca9c Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 12 Feb 2026 15:12:43 -0600 Subject: [PATCH 1/7] Add MSC4222 test for initial sync lazy-loading room members This test is used to try to reproduce https://github.com/element-hq/synapse/issues/19455#issuecomment-3890623384 but found that it seems to the rigth thing. --- client/sync.go | 10 ++++ tests/csapi/sync_test.go | 1 + tests/msc4222/main_test.go | 11 ++++ tests/msc4222/msc4222_test.go | 100 ++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+) create mode 100644 tests/msc4222/main_test.go create mode 100644 tests/msc4222/msc4222_test.go diff --git a/client/sync.go b/client/sync.go index 7939d944..701f73af 100644 --- a/client/sync.go +++ b/client/sync.go @@ -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 @@ -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} } diff --git a/tests/csapi/sync_test.go b/tests/csapi/sync_test.go index 3dec9ea5..8ba39b38 100644 --- a/tests/csapi/sync_test.go +++ b/tests/csapi/sync_test.go @@ -149,6 +149,7 @@ func TestSync(t *testing.T) { res, _ := alice.MustSync(t, client.SyncReq{Filter: filterID, Since: nextBatch, FullState: true}) checkJoinFieldsExist(t, res, roomID) }) + // sytest: Newly joined room is included in an incremental sync t.Run("Newly joined room is included in an incremental sync", func(t *testing.T) { t.Parallel() diff --git a/tests/msc4222/main_test.go b/tests/msc4222/main_test.go new file mode 100644 index 00000000..c88846b2 --- /dev/null +++ b/tests/msc4222/main_test.go @@ -0,0 +1,11 @@ +package tests + +import ( + "testing" + + "github.com/matrix-org/complement" +) + +func TestMain(m *testing.M) { + complement.TestMain(m, "msc4222") +} diff --git a/tests/msc4222/msc4222_test.go b/tests/msc4222/msc4222_test.go new file mode 100644 index 00000000..bb0a30ff --- /dev/null +++ b/tests/msc4222/msc4222_test.go @@ -0,0 +1,100 @@ +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/must" + "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, the `state_after` in an initial sync + // request should include membership from every `sender` in the `timeline` + t.Run("Initial sync with lazy-loading room members -> `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) + + // Make double sure that bob is joined to the room + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + // Now, Alice 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. + aliceSyncFilter := `{ + "room": { + "timeline": { "limit": 20 }, + "state": { "lazy_load_members": true } + } + }` + res, _ := alice.MustSync(t, client.SyncReq{UseStateAfter: true, Filter: aliceSyncFilter}) + 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 alice and bob + must.ContainSubset(t, + slices.Collect(maps.Keys(sendersFromTimeline)), + []string{ alice.UserID, bob.UserID }, + ) + + // 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` (alice + // and bob). + must.ContainSubset(t, + slices.Collect(maps.Keys(membershipFromState)), + slices.Collect(maps.Keys(sendersFromTimeline)), + ) + + }) + }) +} From 158656a4f21a42074f21eb2545dc25fb6b41f2b7 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 12 Feb 2026 15:15:42 -0600 Subject: [PATCH 2/7] Explain test further --- tests/msc4222/msc4222_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/msc4222/msc4222_test.go b/tests/msc4222/msc4222_test.go index bb0a30ff..c58cbf4f 100644 --- a/tests/msc4222/msc4222_test.go +++ b/tests/msc4222/msc4222_test.go @@ -21,6 +21,9 @@ func TestSync(t *testing.T) { t.Run("parallel", func(t *testing.T) { // When lazy-loading room members is enabled, 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 -> `state_after` includes all members from timeline", func(t *testing.T) { t.Parallel() From 8e85ad21817727111bd707af1810d6e1e49c1b1b Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 13 Feb 2026 15:40:44 +0000 Subject: [PATCH 3/7] Change to private room and invite --- tests/msc4222/msc4222_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/msc4222/msc4222_test.go b/tests/msc4222/msc4222_test.go index c58cbf4f..37def595 100644 --- a/tests/msc4222/msc4222_test.go +++ b/tests/msc4222/msc4222_test.go @@ -28,9 +28,15 @@ func TestSync(t *testing.T) { t.Parallel() // Alice creates a room - roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) + roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "trusted_private_chat"}) alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID)) + // Alice invites Bob + alice.MustInviteRoom(t, roomID, bob.UserID) + + // Bob must get the invite + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + // Bob joins the room bob.MustJoinRoom(t, roomID, nil) From c3733322b76e9acb4cbdb7fe64a1d8fce123bf5e Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 13 Feb 2026 12:08:16 -0600 Subject: [PATCH 4/7] Have test for both public and private --- tests/msc4222/msc4222_test.go | 91 +++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/tests/msc4222/msc4222_test.go b/tests/msc4222/msc4222_test.go index 37def595..21b95eb1 100644 --- a/tests/msc4222/msc4222_test.go +++ b/tests/msc4222/msc4222_test.go @@ -19,16 +19,99 @@ func TestSync(t *testing.T) { bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob"}) t.Run("parallel", func(t *testing.T) { - // When lazy-loading room members is enabled, the `state_after` in an initial sync - // request should include membership from every `sender` in the `timeline` + // 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 -> `state_after` includes all members from timeline", func(t *testing.T) { + 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": "trusted_private_chat"}) + 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) + + // Make double sure that bob is joined to the room + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + // Now, Alice 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. + aliceSyncFilter := `{ + "room": { + "timeline": { "limit": 20 }, + "state": { "lazy_load_members": true } + } + }` + res, _ := alice.MustSync(t, client.SyncReq{UseStateAfter: true, Filter: aliceSyncFilter}) + 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 alice and bob + must.ContainSubset(t, + slices.Collect(maps.Keys(sendersFromTimeline)), + []string{ alice.UserID, bob.UserID }, + ) + + // 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` (alice + // and bob). + must.ContainSubset(t, + slices.Collect(maps.Keys(membershipFromState)), + slices.Collect(maps.Keys(sendersFromTimeline)), + ) + + }) + + // 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 From b3c058e6738e5d84bfeef480f082dc5689daf38f Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 13 Feb 2026 12:21:38 -0600 Subject: [PATCH 5/7] Better errors when it fails --- tests/msc4222/msc4222_test.go | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/tests/msc4222/msc4222_test.go b/tests/msc4222/msc4222_test.go index 21b95eb1..6d10f7d8 100644 --- a/tests/msc4222/msc4222_test.go +++ b/tests/msc4222/msc4222_test.go @@ -9,6 +9,7 @@ import ( "github.com/matrix-org/complement/client" "github.com/matrix-org/complement/helpers" "github.com/matrix-org/complement/must" + "github.com/matrix-org/complement/should" "github.com/tidwall/gjson" ) @@ -153,10 +154,21 @@ func TestSync(t *testing.T) { sendersFromTimeline[event.Get("sender").Str] = struct{}{} } // We expect to see timeline events from alice and bob - must.ContainSubset(t, + expectedSendersFromTimeline := []string{ alice.UserID, bob.UserID } + err := should.ContainSubset( slices.Collect(maps.Keys(sendersFromTimeline)), - []string{ alice.UserID, bob.UserID }, + 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` // @@ -182,11 +194,20 @@ func TestSync(t *testing.T) { } // We should see membership state from every `sender` in the `timeline` (alice // and bob). - must.ContainSubset(t, + 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, + ) + } }) }) } From 7a77aa759eb68937212d51f092a833c0d09fe53b Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 13 Feb 2026 12:34:31 -0600 Subject: [PATCH 6/7] Deduplicate assertion logic (`testInitialSyncStateAfterIncludesTimelineSenders`) --- tests/msc4222/msc4222_test.go | 240 ++++++++++++++-------------------- 1 file changed, 101 insertions(+), 139 deletions(-) diff --git a/tests/msc4222/msc4222_test.go b/tests/msc4222/msc4222_test.go index 6d10f7d8..4be42292 100644 --- a/tests/msc4222/msc4222_test.go +++ b/tests/msc4222/msc4222_test.go @@ -8,7 +8,6 @@ import ( "github.com/matrix-org/complement" "github.com/matrix-org/complement/client" "github.com/matrix-org/complement/helpers" - "github.com/matrix-org/complement/must" "github.com/matrix-org/complement/should" "github.com/tidwall/gjson" ) @@ -39,67 +38,15 @@ func TestSync(t *testing.T) { // Make double sure that bob is joined to the room alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) - // Now, Alice 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. - aliceSyncFilter := `{ - "room": { - "timeline": { "limit": 20 }, - "state": { "lazy_load_members": true } + // Ensure `state_after` looks correct + expectedSendersFromTimeline := []string{ alice.UserID, bob.UserID } + syncFilter := `{ + "room": { + "timeline": { "limit": 20 }, + "state": { "lazy_load_members": true } } }` - res, _ := alice.MustSync(t, client.SyncReq{UseStateAfter: true, Filter: aliceSyncFilter}) - 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 alice and bob - must.ContainSubset(t, - slices.Collect(maps.Keys(sendersFromTimeline)), - []string{ alice.UserID, bob.UserID }, - ) - - // 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` (alice - // and bob). - must.ContainSubset(t, - slices.Collect(maps.Keys(membershipFromState)), - slices.Collect(maps.Keys(sendersFromTimeline)), - ) - + testInitialSyncStateAfterIncludesTimelineSenders(t, alice, roomID, expectedSendersFromTimeline, syncFilter) }) // When lazy-loading room members is enabled, for a private room, the `state_after` @@ -127,87 +74,102 @@ func TestSync(t *testing.T) { // Make double sure that bob is joined to the room alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) - // Now, Alice 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. - aliceSyncFilter := `{ - "room": { - "timeline": { "limit": 20 }, - "state": { "lazy_load_members": true } - } - }` - res, _ := alice.MustSync(t, client.SyncReq{UseStateAfter: true, Filter: aliceSyncFilter}) - 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 alice and bob + // Ensure `state_after` looks correct expectedSendersFromTimeline := []string{ alice.UserID, bob.UserID } - 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{}{} + syncFilter := `{ + "room": { + "timeline": { "limit": 20 }, + "state": { "lazy_load_members": true } } - } - // We should see membership state from every `sender` in the `timeline` (alice - // and bob). - 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, - ) - } + }` + 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, + ) + } +} From c4149a9795cf08ae9bde71aedb7ed20d425e54b1 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 13 Feb 2026 12:42:03 -0600 Subject: [PATCH 7/7] Remove stray new line --- tests/csapi/sync_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/csapi/sync_test.go b/tests/csapi/sync_test.go index 8ba39b38..3dec9ea5 100644 --- a/tests/csapi/sync_test.go +++ b/tests/csapi/sync_test.go @@ -149,7 +149,6 @@ func TestSync(t *testing.T) { res, _ := alice.MustSync(t, client.SyncReq{Filter: filterID, Since: nextBatch, FullState: true}) checkJoinFieldsExist(t, res, roomID) }) - // sytest: Newly joined room is included in an incremental sync t.Run("Newly joined room is included in an incremental sync", func(t *testing.T) { t.Parallel()