From c32d2c478d67715b0e52a5ca469149c720763700 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Thu, 12 Mar 2026 13:56:27 -0400 Subject: [PATCH 1/8] Initial MSC4429 tests --- tests/msc4429/main_test.go | 11 ++ tests/msc4429/msc4429_test.go | 207 ++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 tests/msc4429/main_test.go create mode 100644 tests/msc4429/msc4429_test.go diff --git a/tests/msc4429/main_test.go b/tests/msc4429/main_test.go new file mode 100644 index 000000000..29daa335c --- /dev/null +++ b/tests/msc4429/main_test.go @@ -0,0 +1,11 @@ +package tests + +import ( + "testing" + + "github.com/matrix-org/complement" +) + +func TestMain(m *testing.M) { + complement.TestMain(m, "msc4429") +} diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go new file mode 100644 index 000000000..3ee6bf9d0 --- /dev/null +++ b/tests/msc4429/msc4429_test.go @@ -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" + msc4429UsersUnstable = "org\\.matrix\\.msc4429\\.users" +) + +func TestMSC4429ProfileUpdates(t *testing.T) { + 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}) + + 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) { + 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{}) + 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) { + 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) + } +} + +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 + } +} From 58fbe50935525d346d635d98e156cfdac2e08379 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 10:01:59 +0100 Subject: [PATCH 2/8] Comment out stable prefix support for now --- tests/msc4429/msc4429_test.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 3ee6bf9d0..f7cb9ce74 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -15,7 +15,8 @@ import ( ) const ( - msc4429UsersStable = "users" + // TODO: Support stable prefix once MSC4429 is accepted. + // msc4429UsersStable = "users" msc4429UsersUnstable = "org\\.matrix\\.msc4429\\.users" ) @@ -167,11 +168,6 @@ func mustSetProfileField(t *testing.T, user *client.CSAPI, field string, value i // 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() { From 543b1135486800cc0524c70ed2bb2d0acc189904 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 10:56:50 +0100 Subject: [PATCH 3/8] Use MustSyncUntil in initial sync test Add various comments throughout for clarification. --- tests/msc4429/msc4429_test.go | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index f7cb9ce74..9336ca143 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -11,7 +11,6 @@ import ( "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 ( @@ -30,27 +29,30 @@ func TestMSC4429ProfileUpdates(t *testing.T) { mustCreateSharedRoom(t, alice, bob) + // Bob sets their displayname. bob.MustSetDisplayName(t, "Bob Display") + // Bob sets their status. mustSetProfileField(t, bob, "m.status", map[string]interface{}{ "text": "busy", "emoji": "🛑", }) - // Exclude 'displayname' + // Alice /sync's, but only asks for "m.status" changes. + // Exclude 'displayname'. filter := mustBuildMSC4429Filter(t, []string{"m.status"}) res, _ := alice.MustSync(t, client.SyncReq{Filter: filter}) - 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{}{ + // We should see the m.status profile update. + alice.MustSyncUntil(t, client.SyncReq{Filter: filter}, syncHasProfileUpdate(alice.UserID, "m.status", map[string]interface{}{ "text": "busy", "emoji": "🛑", })) + + // We should NOT see a displayname profile update. assertNoProfileUpdate(t, res, bob.UserID, "displayname") }) + // Receiving profile updates are an opt-in mechanism, according to MSC4429. t.Run("No updates without profile_fields filter", func(t *testing.T) { alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-nofilter"}) bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-nofilter"}) @@ -59,14 +61,18 @@ func TestMSC4429ProfileUpdates(t *testing.T) { // No filter = no profile fields returned. _, since := alice.MustSync(t, client.SyncReq{}) + + // Bob sets their status. mustSetProfileField(t, bob, "m.status", map[string]interface{}{ "text": "away", }) + // Assert that alice does not receive it. res, _ := alice.MustSync(t, client.SyncReq{Since: since}) assertNoProfileUpdate(t, res, bob.UserID, "m.status") }) + // Check that only the latest update is returned per-user per-field. t.Run("Incremental sync returns the latest update", func(t *testing.T) { alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-latest"}) bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-latest"}) @@ -92,6 +98,7 @@ func TestMSC4429ProfileUpdates(t *testing.T) { ) }) + // Test that the homeserver informs the client when a profile field is cleared. 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"}) @@ -101,9 +108,12 @@ func TestMSC4429ProfileUpdates(t *testing.T) { filter := mustBuildMSC4429Filter(t, []string{"m.status"}) _, since := alice.MustSync(t, client.SyncReq{Filter: filter}) + // Bob sets a status. mustSetProfileField(t, bob, "m.status", map[string]interface{}{ "text": "busy", }) + + // Wait until alice can see the status. since = alice.MustSyncUntil( t, client.SyncReq{Since: since, Filter: filter}, @@ -112,7 +122,10 @@ func TestMSC4429ProfileUpdates(t *testing.T) { }), ) + // Bob clears their status. mustSetProfileField(t, bob, "m.status", nil) + + // Wait until alice sees the status be set to `null` (nil). alice.MustSyncUntil( t, client.SyncReq{Since: since, Filter: filter}, @@ -176,6 +189,8 @@ func getProfileUpdate(res gjson.Result, userID, field string) (gjson.Result, boo return gjson.Result{}, false } +// assertNoProfileUpdate asserts that a user has not updated a field of their +// profile in the given legacy /sync response JSON. func assertNoProfileUpdate(t *testing.T, res gjson.Result, userID, field string) { t.Helper() if update, ok := getProfileUpdate(res, userID, field); ok { @@ -183,6 +198,8 @@ func assertNoProfileUpdate(t *testing.T, res gjson.Result, userID, field string) } } +// syncHasProfileUpdate checks whether a given sync response contains a profile +// update of the given, expected field and value. func syncHasProfileUpdate(userID, field string, expected interface{}) client.SyncCheckOpt { return func(clientUserID string, topLevelSyncJSON gjson.Result) error { update, ok := getProfileUpdate(topLevelSyncJSON, userID, field) From 2a89f7d16861089b77ee12d6d4cbb363f254e25a Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 11:02:33 +0100 Subject: [PATCH 4/8] Clarify why we perform an initial sync --- tests/msc4429/msc4429_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 9336ca143..556454557 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -59,7 +59,7 @@ func TestMSC4429ProfileUpdates(t *testing.T) { mustCreateSharedRoom(t, alice, bob) - // No filter = no profile fields returned. + // Perform an initial sync to get a since token. _, since := alice.MustSync(t, client.SyncReq{}) // Bob sets their status. @@ -67,7 +67,7 @@ func TestMSC4429ProfileUpdates(t *testing.T) { "text": "away", }) - // Assert that alice does not receive it. + // Assert that alice does not receive it in an incremental sync. res, _ := alice.MustSync(t, client.SyncReq{Since: since}) assertNoProfileUpdate(t, res, bob.UserID, "m.status") }) From 5ea104f969429316dfdb5fe7a52e1cc7e69978b7 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 11:23:16 +0100 Subject: [PATCH 5/8] Assert third user can see status update --- tests/msc4429/msc4429_test.go | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 556454557..68a4940ca 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -56,8 +56,9 @@ func TestMSC4429ProfileUpdates(t *testing.T) { t.Run("No updates without profile_fields filter", func(t *testing.T) { alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-nofilter"}) bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-nofilter"}) + charlie := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "charlie-nofilter"}) - mustCreateSharedRoom(t, alice, bob) + mustCreateSharedRoom(t, alice, bob, charlie) // Perform an initial sync to get a since token. _, since := alice.MustSync(t, client.SyncReq{}) @@ -65,9 +66,18 @@ func TestMSC4429ProfileUpdates(t *testing.T) { // Bob sets their status. mustSetProfileField(t, bob, "m.status", map[string]interface{}{ "text": "away", + "emoji": "🟡", }) - // Assert that alice does not receive it in an incremental sync. + // Assert that charlie receives bob's profile update in an incremental sync + // with the appropriate filter set. + filter := mustBuildMSC4429Filter(t, []string{"m.status"}) + charlie.MustSyncUntil(t, client.SyncReq{Filter: filter}, syncHasProfileUpdate(bob.UserID, "m.status", map[string]interface{}{ + "text": "away", + "emoji": "🟡", + })) + + // Assert that alice does not receive the profile update in an incremental sync. res, _ := alice.MustSync(t, client.SyncReq{Since: since}) assertNoProfileUpdate(t, res, bob.UserID, "m.status") }) @@ -155,15 +165,20 @@ func mustBuildMSC4429Filter(t *testing.T, ids []string) string { // 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 { +func mustCreateSharedRoom(t *testing.T, users ...*client.CSAPI) string { t.Helper() - roomID := alice.MustCreateRoom(t, map[string]interface{}{ + + // Use one of the users to create the room. + roomID := users[0].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)) + + // Join all of the given users to the room. + for _, user := range users { + user.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(user.UserID, roomID)) + user.MustJoinRoom(t, roomID, nil) + } + return roomID } From a1934aca56123b3e94a8c1ca710f6a4b17d4a510 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 11:39:29 +0100 Subject: [PATCH 6/8] Clarify assertNoprofileUpdate error message --- tests/msc4429/msc4429_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 68a4940ca..008176903 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -209,7 +209,7 @@ func getProfileUpdate(res gjson.Result, userID, field string) (gjson.Result, boo 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) + t.Fatalf("expected no profile update for %s %s: %s", userID, field, update.Raw) } } From 4cddfeab619504b8ed4a9c86df3892953cb63c75 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 14:34:46 +0100 Subject: [PATCH 7/8] Add test for receiving `null` profile update when users no longer share a room --- tests/msc4429/msc4429_test.go | 72 ++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 008176903..857299fbb 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -65,7 +65,7 @@ func TestMSC4429ProfileUpdates(t *testing.T) { // Bob sets their status. mustSetProfileField(t, bob, "m.status", map[string]interface{}{ - "text": "away", + "text": "away", "emoji": "🟡", }) @@ -142,6 +142,50 @@ func TestMSC4429ProfileUpdates(t *testing.T) { syncHasProfileUpdate(bob.UserID, "m.status", nil), ) }) + + t.Run("A user leaving the last shared room returns a profile update of null", func(t *testing.T) { + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-leave"}) + bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-leave"}) + + roomID := mustCreateSharedRoom(t, alice, bob) + + filter := mustBuildMSC4429Filter(t, []string{"m.status"}) + since := alice.MustSyncUntil( + t, + client.SyncReq{Filter: filter}, + client.SyncJoinedTo(alice.UserID, roomID), + ) + + // Bob sets a status while Alice and Bob share a room. + mustSetProfileField(t, bob, "m.status", map[string]interface{}{ + "text": "busy", + }) + + // Alice receives Bob's changed profile field. + since = alice.MustSyncUntil( + t, + client.SyncReq{Since: since, Filter: filter}, + syncHasProfileUpdate(bob.UserID, "m.status", map[string]interface{}{ + "text": "busy", + }), + ) + + // Bob leaves the only room shared with Alice. + bob.MustLeaveRoom(t, roomID) + + // Alice receives a null profile_updates value for Bob. This tells + // clients to clear their local cache for Bob's profile. + alice.MustSyncUntil( + t, + client.SyncReq{Since: since, Filter: filter}, + // Check that bob left the room and we get a null profile field for them. + // + // `MustSyncUntil` will loop until both checks are true. That + // doesn't necessarily have to happen in the same sync response. + client.SyncLeftFrom(bob.UserID, roomID), + syncHasProfileUpdatesNull(bob.UserID), + ) + }) } // mustBuildMSC4429Filter builds a filter that can be used to limit the field @@ -204,6 +248,17 @@ func getProfileUpdate(res gjson.Result, userID, field string) (gjson.Result, boo return gjson.Result{}, false } +// getProfileUpdates extracts all profile updates for a given user from a legacy +// `/sync` response. +func getProfileUpdates(res gjson.Result, userID string) (gjson.Result, bool) { + unstablePath := msc4429UsersUnstable + "." + client.GjsonEscape(userID) + ".profile_updates" + unstableRes := res.Get(unstablePath) + if unstableRes.Exists() { + return unstableRes, true + } + return gjson.Result{}, false +} + // assertNoProfileUpdate asserts that a user has not updated a field of their // profile in the given legacy /sync response JSON. func assertNoProfileUpdate(t *testing.T, res gjson.Result, userID, field string) { @@ -233,3 +288,18 @@ func syncHasProfileUpdate(userID, field string, expected interface{}) client.Syn return nil } } + +// syncHasProfileUpdatesNull checks whether a sync response contains a null +// profile_updates value for the given user. +func syncHasProfileUpdatesNull(userID string) client.SyncCheckOpt { + return func(clientUserID string, topLevelSyncJSON gjson.Result) error { + updates, ok := getProfileUpdates(topLevelSyncJSON, userID) + if !ok { + return fmt.Errorf("missing profile updates for %s", userID) + } + if updates.Type != gjson.Null { + return fmt.Errorf("expected a null profile update for %s, got %s", userID, updates.Type) + } + return nil + } +} From 2b67137ea2b3c495584c231b6313c7389ec6b4b8 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 15:10:28 +0100 Subject: [PATCH 8/8] Add MSC4429 tests to Synapse in CI --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 528d0f21c..210bf5e33 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,7 +42,7 @@ jobs: - homeserver: Synapse repo: element-hq/synapse tags: synapse_blacklist - packages: ./tests/msc3874 ./tests/msc3902 ./tests/msc4306 + packages: ./tests/msc3874 ./tests/msc3902 ./tests/msc4306 ./tests/msc4429 env: "COMPLEMENT_ENABLE_DIRTY_RUNS=1 COMPLEMENT_SHARE_ENV_PREFIX=PASS_ PASS_SYNAPSE_COMPLEMENT_DATABASE=sqlite" timeout: 20m