diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c0a79f98..4649da8d 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 + packages: ./tests/msc3874 ./tests/msc3902 ./tests/msc4306 env: "COMPLEMENT_ENABLE_DIRTY_RUNS=1 COMPLEMENT_SHARE_ENV_PREFIX=PASS_ PASS_SYNAPSE_COMPLEMENT_DATABASE=sqlite" timeout: 20m diff --git a/client/client.go b/client/client.go index 52bbe42a..904eba12 100644 --- a/client/client.go +++ b/client/client.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "log" "math/rand" "net/http" "net/http/httputil" @@ -261,12 +262,12 @@ func (c *CSAPI) GetAllPushRules(t ct.TestLike) gjson.Result { return gjson.ParseBytes(pushRulesBytes) } -// GetPushRule queries the contents of a client's push rule by scope, kind and rule ID. +// MustGetPushRule queries the contents of a client's push rule by scope, kind and rule ID. // A parsed gjson result is returned. Fails the test if the query to server returns a non-2xx status code. // // Example of checking that a global underride rule contains the expected actions: // -// containsDisplayNameRule := c.GetPushRule(t, "global", "underride", ".m.rule.contains_display_name") +// containsDisplayNameRule := c.MustGetPushRule(t, "global", "underride", ".m.rule.contains_display_name") // must.MatchGJSON( // t, // containsDisplayNameRule, @@ -276,14 +277,23 @@ func (c *CSAPI) GetAllPushRules(t ct.TestLike) gjson.Result { // map[string]interface{}{"set_tweak": "highlight"}, // }), // ) -func (c *CSAPI) GetPushRule(t ct.TestLike, scope string, kind string, ruleID string) gjson.Result { +func (c *CSAPI) MustGetPushRule(t ct.TestLike, scope string, kind string, ruleID string) gjson.Result { t.Helper() - res := c.MustDo(t, "GET", []string{"_matrix", "client", "v3", "pushrules", scope, kind, ruleID}) + res := c.GetPushRule(t, scope, kind, ruleID) + mustRespond2xx(t, res) + pushRuleBytes := ParseJSON(t, res) return gjson.ParseBytes(pushRuleBytes) } +// GetPushRule queries the contents of a client's push rule by scope, kind and rule ID. +func (c *CSAPI) GetPushRule(t ct.TestLike, scope string, kind string, ruleID string) *http.Response { + t.Helper() + + return c.Do(t, "GET", []string{"_matrix", "client", "v3", "pushrules", scope, kind, ruleID}) +} + // SetPushRule creates a new push rule on the user, or modifies an existing one. // If `before` or `after` parameters are not set to an empty string, their values // will be set as the `before` and `after` query parameters respectively on the @@ -310,6 +320,14 @@ func (c *CSAPI) SetPushRule(t ct.TestLike, scope string, kind string, ruleID str return c.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "pushrules", scope, kind, ruleID}, WithJSONBody(t, body), WithQueries(queryParams)) } +// MustDisablePushRule disables a push rule on the user. +// Fails the test if response is non-2xx. +func (c *CSAPI) MustDisablePushRule(t ct.TestLike, scope string, kind string, ruleID string) { + c.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "pushrules", scope, kind, ruleID, "enabled"}, WithJSONBody(t, map[string]interface{}{ + "enabled": false, + })) +} + // Unsafe_SendEventUnsynced sends `e` into the room. This function is UNSAFE as it does not wait // for the event to be fully processed. This can cause flakey tests. Prefer `SendEventSynced`. // Returns the event ID of the sent event. @@ -735,6 +753,19 @@ func (t *loggedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error return res, err } +// Extracts a JSON object given a search key +// Caller must check `result.Exists()` to see whether the object actually exists. +func GetOptionalJSONFieldObject(t ct.TestLike, body []byte, wantKey string) gjson.Result { + t.Helper() + res := gjson.GetBytes(body, wantKey) + if !res.Exists() { + log.Printf("OptionalJSONFieldObject: key '%s' absent from %s", wantKey, string(body)) + } else if !res.IsObject() { + ct.Fatalf(t, "OptionalJSONFieldObject: key '%s' is not an object, body: %s", wantKey, string(body)) + } + return res +} + // GetJSONFieldStr extracts a value from a byte-encoded JSON body given a search key func GetJSONFieldStr(t ct.TestLike, body []byte, wantKey string) string { t.Helper() diff --git a/tests/csapi/thread_notifications_test.go b/tests/csapi/thread_notifications_test.go index 4c9e8b1e..33434675 100644 --- a/tests/csapi/thread_notifications_test.go +++ b/tests/csapi/thread_notifications_test.go @@ -51,6 +51,21 @@ func syncHasThreadedReadReceipt(roomID, userID, eventID, threadID string) client }) } +// Disables push rules that are introduced in MSC4306 (if present), +// because they interfere with the normal semantics of notifications in threads. +func disableMsc4306PushRules(t *testing.T, user *client.CSAPI) { + rules := []string{".io.element.msc4306.rule.subscribed_thread", ".io.element.msc4306.rule.unsubscribed_thread"} + for _, rule := range rules { + res := user.GetPushRule(t, "global", "postcontent", rule) + if res.StatusCode == 404 { + // No push rule to disable + continue + } + + user.MustDisablePushRule(t, "global", "postcontent", rule) + } +} + // Test behavior of threaded receipts and notifications. // // 1. Send a series of messages, some of which are in threads. @@ -79,7 +94,9 @@ func TestThreadedReceipts(t *testing.T) { // Create a room with alice and bob. alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) + disableMsc4306PushRules(t, alice) bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) + disableMsc4306PushRules(t, bob) roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) bob.MustJoinRoom(t, roomID, nil) @@ -312,7 +329,9 @@ func TestThreadReceiptsInSyncMSC4102(t *testing.T) { // Create a room with alice and bob. alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) + disableMsc4306PushRules(t, alice) bob := deployment.Register(t, "hs2", helpers.RegistrationOpts{}) + disableMsc4306PushRules(t, bob) roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) bob.MustJoinRoom(t, roomID, []spec.ServerName{ deployment.GetFullyQualifiedHomeserverName(t, "hs1"), diff --git a/tests/msc3930/msc3930_test.go b/tests/msc3930/msc3930_test.go index 7ae7c8e5..777ed78b 100644 --- a/tests/msc3930/msc3930_test.go +++ b/tests/msc3930/msc3930_test.go @@ -39,7 +39,7 @@ func TestPollsLocalPushRules(t *testing.T) { // Request each of the push rule IDs defined by MSC3930 and verify their structure. // This push rule silences all poll responses. - pollResponseRule := alice.GetPushRule(t, "global", "override", pollResponseRuleID) + pollResponseRule := alice.MustGetPushRule(t, "global", "override", pollResponseRuleID) must.MatchGJSON( t, pollResponseRule, @@ -55,7 +55,7 @@ func TestPollsLocalPushRules(t *testing.T) { ) // This push rule creates a sound and notifies the user when a poll is started in a one-to-one room. - pollStartOneToOneRule := alice.GetPushRule(t, "global", "underride", pollStartOneToOneRuleID) + pollStartOneToOneRule := alice.MustGetPushRule(t, "global", "underride", pollStartOneToOneRuleID) must.MatchGJSON( t, pollStartOneToOneRule, @@ -78,7 +78,7 @@ func TestPollsLocalPushRules(t *testing.T) { ) // This push rule creates a sound and notifies the user when a poll is ended in a one-to-one room. - pollEndOneToOneRule := alice.GetPushRule(t, "global", "underride", pollEndOneToOneRuleID) + pollEndOneToOneRule := alice.MustGetPushRule(t, "global", "underride", pollEndOneToOneRuleID) must.MatchGJSON( t, pollEndOneToOneRule, @@ -101,7 +101,7 @@ func TestPollsLocalPushRules(t *testing.T) { ) // This push rule notifies the user when a poll is started in any room. - pollStartRule := alice.GetPushRule(t, "global", "underride", pollStartRuleID) + pollStartRule := alice.MustGetPushRule(t, "global", "underride", pollStartRuleID) must.MatchGJSON( t, pollStartRule, @@ -118,7 +118,7 @@ func TestPollsLocalPushRules(t *testing.T) { ) // This push rule notifies the user when a poll is ended in any room. - pollEndRule := alice.GetPushRule(t, "global", "underride", pollEndRuleID) + pollEndRule := alice.MustGetPushRule(t, "global", "underride", pollEndRuleID) must.MatchGJSON( t, pollEndRule, diff --git a/tests/msc4306/main_test.go b/tests/msc4306/main_test.go new file mode 100644 index 00000000..70db4930 --- /dev/null +++ b/tests/msc4306/main_test.go @@ -0,0 +1,11 @@ +package tests + +import ( + "testing" + + "github.com/matrix-org/complement" +) + +func TestMain(m *testing.M) { + complement.TestMain(m, "msc4306") +} diff --git a/tests/msc4306/thread_subscriptions_sliding_sync_test.go b/tests/msc4306/thread_subscriptions_sliding_sync_test.go new file mode 100644 index 00000000..e87f3e72 --- /dev/null +++ b/tests/msc4306/thread_subscriptions_sliding_sync_test.go @@ -0,0 +1,99 @@ +package tests + +import ( + "testing" + + "github.com/matrix-org/complement" + "github.com/matrix-org/complement/b" + "github.com/matrix-org/complement/client" + "github.com/matrix-org/complement/helpers" + "github.com/matrix-org/complement/match" + "github.com/matrix-org/complement/must" + "github.com/tidwall/gjson" +) + +func MustDoSlidingSync(t *testing.T, user *client.CSAPI, pos string, thread_subs_ext map[string]interface{}) (string, gjson.Result) { + body := map[string]interface{}{ + "extensions": map[string]interface{}{ + "io.element.msc4308.thread_subscriptions": thread_subs_ext, + }, + } + if pos != "" { + body["pos"] = pos + } + resp := user.MustDo(t, "POST", []string{"_matrix", "client", "unstable", "org.matrix.simplified_msc3575", "sync"}, client.WithJSONBody(t, body)) + respBody := client.ParseJSON(t, resp) + + newPos := client.GetJSONFieldStr(t, respBody, "pos") + extension := client.GetOptionalJSONFieldObject(t, respBody, "extensions.io\\.element\\.msc4308\\.thread_subscriptions") + if !extension.Exists() { + // Missing extension is semantically the same as an empty one + // So eliminate the nil for simplicity + extension = gjson.Parse("{}") + } + return newPos, extension +} + +// Tests the thread subscriptions extension to sliding sync, introduced in MSC4308 +// but treated as the same unit as MSC4306. +func TestMSC4308ThreadSubscriptionsSlidingSync(t *testing.T) { + deployment := complement.Deploy(t, 1) + defer deployment.Destroy(t) + + t.Run("Receives thread subscriptions over initial sliding sync", func(t *testing.T) { + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) + roomID := alice.MustCreateRoom(t, map[string]interface{}{}) + threadRootID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "msgtype": "m.text", + "body": "what do you think? reply in a thread!", + }, + }) + + // Subscribe to the thread manually + alice.MustDo(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{})) + + _, thread_subscription_ext := MustDoSlidingSync(t, alice, "", map[string]interface{}{ + "enabled": true, + "limit": 2, + }) + + must.MatchGJSON(t, thread_subscription_ext, + match.JSONKeyTypeEqual("subscribed."+gjson.Escape(roomID)+"."+gjson.Escape(threadRootID)+".bump_stamp", gjson.Number), + match.JSONKeyEqual("subscribed."+gjson.Escape(roomID)+"."+gjson.Escape(threadRootID)+".automatic", false), + ) + }) + + t.Run("Receives thread subscriptions over incremental sliding sync", func(t *testing.T) { + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) + roomID := alice.MustCreateRoom(t, map[string]interface{}{}) + threadRootID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "msgtype": "m.text", + "body": "what do you think? reply in a thread!", + }, + }) + + newPos, ext := MustDoSlidingSync(t, alice, "", map[string]interface{}{ + "enabled": true, + "limit": 2, + }) + must.MatchGJSON(t, ext, + match.JSONKeyMissing("subscribed")) + + // Subscribe to the thread manually + alice.MustDo(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{})) + + _, ext = MustDoSlidingSync(t, alice, newPos, map[string]interface{}{ + "enabled": true, + "limit": 2, + }) + + must.MatchGJSON(t, ext, + match.JSONKeyTypeEqual("subscribed."+gjson.Escape(roomID)+"."+gjson.Escape(threadRootID)+".bump_stamp", gjson.Number), + match.JSONKeyEqual("subscribed."+gjson.Escape(roomID)+"."+gjson.Escape(threadRootID)+".automatic", false), + ) + }) +} diff --git a/tests/msc4306/thread_subscriptions_test.go b/tests/msc4306/thread_subscriptions_test.go new file mode 100644 index 00000000..ab580da2 --- /dev/null +++ b/tests/msc4306/thread_subscriptions_test.go @@ -0,0 +1,330 @@ +package tests + +import ( + "testing" + + "github.com/matrix-org/complement" + "github.com/matrix-org/complement/b" + "github.com/matrix-org/complement/client" + "github.com/matrix-org/complement/helpers" + "github.com/matrix-org/complement/match" + "github.com/matrix-org/complement/must" +) + +func TestThreadSubscriptions(t *testing.T) { + deployment := complement.Deploy(t, 1) + defer deployment.Destroy(t) + + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) + + t.Run("Can subscribe to and unsubscribe from a thread", func(t *testing.T) { + roomID := alice.MustCreateRoom(t, map[string]interface{}{}) + threadRootID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "msgtype": "m.text", + "body": "what do you think? reply in a thread!", + }, + }) + + // Subscribe to the thread manually + alice.MustDo(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{})) + + must.MatchResponse(t, alice.MustDo(t, "GET", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}), match.HTTPResponse{ + JSON: []match.JSON{ + match.JSONKeyEqual("automatic", false), + }, + }) + + // Unsubscribe from the thread + alice.MustDo(t, "DELETE", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}) + + must.MatchResponse(t, alice.Do(t, "GET", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}), match.HTTPResponse{ + StatusCode: 404, + JSON: []match.JSON{ + match.JSONKeyEqual("errcode", "M_NOT_FOUND"), + }, + }) + }) + + t.Run("Cannot use thread root as automatic subscription cause event", func(t *testing.T) { + roomID := alice.MustCreateRoom(t, map[string]interface{}{}) + threadRootID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "msgtype": "m.text", + "body": "what do you think? reply in a thread!", + }, + }) + + response := alice.Do(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{ + "automatic": threadRootID, + })) + + must.MatchResponse(t, response, match.HTTPResponse{ + StatusCode: 400, + JSON: []match.JSON{ + match.JSONKeyEqual("errcode", "IO.ELEMENT.MSC4306.M_NOT_IN_THREAD"), + }, + }) + }) + + t.Run("Can create automatic subscription to a thread", func(t *testing.T) { + roomID := alice.MustCreateRoom(t, map[string]interface{}{}) + threadRootID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "msgtype": "m.text", + "body": "what do you think? reply in a thread!", + }, + }) + + // Create a message in the thread + threadReplyID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "msgtype": "m.text", + "body": "this is a reply", + "m.relates_to": map[string]interface{}{ + "rel_type": "m.thread", + "event_id": threadRootID, + }, + }, + }) + + alice.MustDo(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{ + "automatic": threadReplyID, + })) + + must.MatchResponse(t, alice.MustDo(t, "GET", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}), match.HTTPResponse{ + JSON: []match.JSON{ + match.JSONKeyEqual("automatic", true), + }, + }) + }) + + t.Run("Manual subscriptions overwrite automatic subscriptions", func(t *testing.T) { + roomID := alice.MustCreateRoom(t, map[string]interface{}{}) + threadRootID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{"body": "Thread Root", "msgtype": "m.text"}, + }) + + // Create a message in the thread + threadReplyID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "body": "Thread Reply", + "msgtype": "m.text", + "m.relates_to": map[string]interface{}{ + "rel_type": "m.thread", + "event_id": threadRootID, + }, + }, + }) + + // Create automatic subscription first + alice.MustDo(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{ + "automatic": threadReplyID, + })) + + // Then create manual subscription + alice.MustDo(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{})) + + must.MatchResponse(t, alice.MustDo(t, "GET", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}), match.HTTPResponse{ + JSON: []match.JSON{ + match.JSONKeyEqual("automatic", false), + }, + }) + }) + + t.Run("Error when using invalid automatic event ID", func(t *testing.T) { + roomID := alice.MustCreateRoom(t, map[string]interface{}{}) + threadRootID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{"body": "Thread Root", "msgtype": "m.text"}, + }) + otherThreadRootID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{"body": "another thread root", "msgtype": "m.text"}, + }) + + // Send message, but *not* in the right thread + otherThreadReplyID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "body": "Not in the same thread", + "msgtype": "m.text", + "m.relates_to": map[string]interface{}{ + "rel_type": "m.thread", + "event_id": otherThreadRootID, + }, + }, + }) + + // We can't create an automatic subscription to the first thread with + // the message in the wrong thread + response := alice.Do(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{ + "automatic": otherThreadReplyID, + })) + + must.MatchResponse(t, response, match.HTTPResponse{ + StatusCode: 400, + JSON: []match.JSON{ + match.JSONKeyEqual("errcode", "IO.ELEMENT.MSC4306.M_NOT_IN_THREAD"), + }, + }) + }) + + // Tests idempotence + t.Run("Unsubscribe succeeds even with no subscription", func(t *testing.T) { + roomID := alice.MustCreateRoom(t, map[string]interface{}{}) + threadRootID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{"body": "Thread Root", "msgtype": "m.text"}, + }) + + // Unsubscribe, but without being subscribed first + response := alice.Do(t, "DELETE", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}) + + must.MatchResponse(t, response, match.HTTPResponse{ + StatusCode: 200, + }) + }) + + t.Run("Nonexistent threads return 404", func(t *testing.T) { + roomID := alice.MustCreateRoom(t, map[string]interface{}{}) + nonExistentID := "$notathread:example.org" + + // try PUT + response := alice.Do(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", nonExistentID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{})) + must.MatchResponse(t, response, match.HTTPResponse{ + StatusCode: 404, + }) + + // try GET + response = alice.Do(t, "GET", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", nonExistentID, "subscription"}) + must.MatchResponse(t, response, match.HTTPResponse{ + StatusCode: 404, + }) + }) + + t.Run("Server-side automatic subscription ordering conflict", func(t *testing.T) { + /* + The desired timeline of events and subscriptions is as such: + + 1. threadRoot + 2. threadReply1 + 3. auto-subscribe + 4. threadReply2 + 5. unsubscribe + 4. threadReply3 + 6. try to auto-subscribe using threadReply1: denied + 7. try to auto-subscribe using threadReply2: denied + 8. try to auto-subscribe using threadReply3: OK + */ + + roomID := alice.MustCreateRoom(t, map[string]interface{}{}) + + // 1. Create a thread root message + threadRootID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{"body": "Thread Root", "msgtype": "m.text"}, + }) + + // 2. + threadReply1ID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "body": "Thread Reply 1", + "msgtype": "m.text", + "m.relates_to": map[string]interface{}{ + "rel_type": "m.thread", + "event_id": threadRootID, + }, + }, + }) + + // 3. + alice.MustDo(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{ + "automatic": threadReply1ID, + })) + + // 4. + threadReply2ID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "body": "Thread Reply 2", + "msgtype": "m.text", + "m.relates_to": map[string]interface{}{ + "rel_type": "m.thread", + "event_id": threadRootID, + }, + }, + }) + + // 5. + alice.MustDo(t, "DELETE", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}) + + // 6. + threadReply3ID := alice.SendEventSynced(t, roomID, b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "body": "Thread Reply 3", + "msgtype": "m.text", + "m.relates_to": map[string]interface{}{ + "rel_type": "m.thread", + "event_id": threadRootID, + }, + }, + }) + + // 7. + response := alice.Do(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{ + "automatic": threadReply1ID, + })) + + must.MatchResponse(t, response, match.HTTPResponse{ + StatusCode: 409, + JSON: []match.JSON{ + match.JSONKeyEqual("errcode", "IO.ELEMENT.MSC4306.M_CONFLICTING_UNSUBSCRIPTION"), + }, + }) + + response = alice.Do(t, "GET", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}) + must.MatchResponse(t, response, match.HTTPResponse{ + StatusCode: 404, + }) + + // 8. + response = alice.Do(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{ + "automatic": threadReply2ID, + })) + + must.MatchResponse(t, response, match.HTTPResponse{ + StatusCode: 409, + JSON: []match.JSON{ + match.JSONKeyEqual("errcode", "IO.ELEMENT.MSC4306.M_CONFLICTING_UNSUBSCRIPTION"), + }, + }) + + response = alice.Do(t, "GET", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}) + must.MatchResponse(t, response, match.HTTPResponse{ + StatusCode: 404, + }) + + // 9. + response = alice.Do(t, "PUT", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}, client.WithJSONBody(t, map[string]interface{}{ + "automatic": threadReply3ID, + })) + + must.MatchResponse(t, response, match.HTTPResponse{ + StatusCode: 200, + }) + + response = alice.Do(t, "GET", []string{"_matrix", "client", "unstable", "io.element.msc4306", "rooms", roomID, "thread", threadRootID, "subscription"}) + must.MatchResponse(t, response, match.HTTPResponse{ + StatusCode: 200, + }) + }) +}