From 5b7f1957efebb3197d77dd41cd02c4e9ecddb5c6 Mon Sep 17 00:00:00 2001 From: Paul Chobert Date: Thu, 24 Sep 2026 18:23:13 +0200 Subject: [PATCH 1/2] MSC4140: schedule delayed events through the dedicated endpoint MSC4140 schedules delayed events with `PUT /rooms/{roomId}/delayed_event/{eventType}/{txnId}`, taking `delay_ms`, `content` and, for state events, `state_key` in the body. Scheduling through the `org.matrix.msc4140.delay` query parameter on `/send` and `/state` is now one of the MSC's rejected alternatives. Schedule every delayed event through the unstable form of the endpoint, giving state events a transaction ID of their own. The existing "same txnID" subtest now checks that the endpoint is transactional, and the state event tests check that a state event scheduled through it lands as room state. A new test checks that a missing, zero or negative `delay_ms` is rejected with a 400. One test still schedules a message event and a state event through the query parameter, so that form stays covered until homeservers drop it. Signed-off-by: Paul Chobert --- tests/msc4140/delayed_event_test.go | 152 ++++++++++++++++++++++------ 1 file changed, 122 insertions(+), 30 deletions(-) diff --git a/tests/msc4140/delayed_event_test.go b/tests/msc4140/delayed_event_test.go index 6c93ffb53..47c1b9e38 100644 --- a/tests/msc4140/delayed_event_test.go +++ b/tests/msc4140/delayed_event_test.go @@ -62,6 +62,31 @@ func TestDelayedEvents(t *testing.T) { }) }) + t.Run("cannot schedule a delayed event without a positive delay", func(t *testing.T) { + for i, tc := range []struct { + name string + body map[string]interface{} + }{ + {"missing delay_ms", map[string]interface{}{"content": map[string]interface{}{}}}, + {"zero delay_ms", getDelayedEventBody(0, map[string]interface{}{})}, + {"negative delay_ms", getDelayedEventBody(-1, map[string]interface{}{})}, + } { + t.Run(tc.name, func(t *testing.T) { + res := user.Do( + t, + "PUT", + getPathForDelayedEvent(roomID, eventType, fmt.Sprintf("txn-delayed-invalid-delay-%d", i)), + client.WithJSONBody(t, tc.body), + ) + must.MatchResponse(t, res, match.HTTPResponse{ + StatusCode: 400, + }) + }) + } + + matchDelayedEvents(t, user, delayedEventsNumberEqual(0)) + }) + // FIXME: Too much mixing of tests that should be more independent t.Run("delayed message events are sent on timeout", func(t *testing.T) { var res *http.Response @@ -75,15 +100,15 @@ func TestDelayedEvents(t *testing.T) { countKey := "count" numEvents := 3 - for i, delayStr := range []string{"700", "800", "900"} { + for i, delayMs := range []int64{700, 800, 900} { + body := getDelayedEventBody(delayMs, map[string]interface{}{ + countKey: i + 1, + }) res = user.MustDo( t, "PUT", - getPathForSend(roomID, eventType, fmt.Sprintf(txnIdBase, i)), - client.WithJSONBody(t, map[string]interface{}{ - countKey: i + 1, - }), - getDelayQueryParam(delayStr), + getPathForDelayedEvent(roomID, eventType, fmt.Sprintf(txnIdBase, i)), + client.WithJSONBody(t, body), ) delayID := client.GetJSONFieldStr(t, client.ParseJSON(t, res), "delay_id") @@ -91,8 +116,8 @@ func TestDelayedEvents(t *testing.T) { res := user.MustDo( t, "PUT", - getPathForSend(roomID, eventType, fmt.Sprintf(txnIdBase, i)), - getDelayQueryParam(delayStr), + getPathForDelayedEvent(roomID, eventType, fmt.Sprintf(txnIdBase, i)), + client.WithJSONBody(t, body), ) must.MatchResponse(t, res, match.HTTPResponse{ JSON: []match.JSON{ @@ -146,11 +171,10 @@ func TestDelayedEvents(t *testing.T) { user.MustDo( t, "PUT", - getPathForState(roomID, eventType, stateKey), - client.WithJSONBody(t, map[string]interface{}{ + getPathForDelayedEvent(roomID, eventType, "txn-delayed-state-timeout"), + client.WithJSONBody(t, getDelayedStateEventBody(900, stateKey, map[string]interface{}{ setterKey: setterExpected, - }), - getDelayQueryParam("900"), + })), ) // Ensure that a delayed event is now scheduled @@ -199,6 +223,58 @@ func TestDelayedEvents(t *testing.T) { matchDelayedEvents(t, user, delayedEventsNumberEqual(0)) }) + // TODO: Remove once homeservers stop accepting the `org.matrix.msc4140.delay` query + // parameter on `/send` and `/state`, which MSC4140 lists as a rejected alternative to + // the dedicated endpoint. + t.Run("delayed events scheduled with the delay query parameter are sent on timeout", func(t *testing.T) { + var res *http.Response + + defer cleanupDelayedEvents(t, user) + + stateKey := "to_send_on_timeout_with_query_param" + + // Schedule a delayed message event and a delayed state event + setterKey := "setter" + setterExpected := "on_timeout_with_query_param" + user.MustDo( + t, + "PUT", + getPathForSend(roomID, eventType, "txn-delayed-msg-query-param"), + client.WithJSONBody(t, map[string]interface{}{ + setterKey: setterExpected, + }), + getDelayQueryParam("900"), + ) + user.MustDo( + t, + "PUT", + getPathForState(roomID, eventType, stateKey), + client.WithJSONBody(t, map[string]interface{}{ + setterKey: setterExpected, + }), + getDelayQueryParam("900"), + ) + matchDelayedEvents(t, user, delayedEventsNumberEqual(2)) + + // Check for both delayed events being sent (using `MustSyncUntil` to account for + // any processing or worker replication delays) + user.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHas(roomID, func(ev gjson.Result) bool { + return ev.Get("type").Str == eventType && !ev.Get("state_key").Exists() && ev.Get("content."+setterKey).Str == setterExpected + })) + user.MustSyncUntil(t, client.SyncReq{UseStateAfter: true}, client.SyncStateAfterHas(roomID, func(ev gjson.Result) bool { + return ev.Get("type").Str == eventType && ev.Get("state_key").Str == stateKey + })) + // Make sure the state looks as expected after + res = user.MustDo(t, "GET", getPathForState(roomID, eventType, stateKey)) + must.MatchResponse(t, res, match.HTTPResponse{ + JSON: []match.JSON{ + match.JSONKeyEqual(setterKey, setterExpected), + }, + }) + // No more delayed events + matchDelayedEvents(t, user, delayedEventsNumberEqual(0)) + }) + t.Run("cannot update a delayed event without an action", func(t *testing.T) { res := unauthedClient.Do( t, @@ -254,11 +330,10 @@ func TestDelayedEvents(t *testing.T) { res = user.MustDo( t, "PUT", - getPathForState(roomID, eventType, stateKey), - client.WithJSONBody(t, map[string]interface{}{ + getPathForDelayedEvent(roomID, eventType, "txn-delayed-state-cancel"), + client.WithJSONBody(t, getDelayedStateEventBody(1500, stateKey, map[string]interface{}{ setterKey: setterExpected, - }), - getDelayQueryParam("1500"), + })), ) delayID := client.GetJSONFieldStr(t, client.ParseJSON(t, res), "delay_id") @@ -308,11 +383,10 @@ func TestDelayedEvents(t *testing.T) { res = user.MustDo( t, "PUT", - getPathForState(roomID, eventType, stateKey), - client.WithJSONBody(t, map[string]interface{}{ + getPathForDelayedEvent(roomID, eventType, "txn-delayed-state-send"), + client.WithJSONBody(t, getDelayedStateEventBody(100000, stateKey, map[string]interface{}{ setterKey: setterExpected, - }), - getDelayQueryParam("100000"), + })), ) delayID := client.GetJSONFieldStr(t, client.ParseJSON(t, res), "delay_id") @@ -364,11 +438,10 @@ func TestDelayedEvents(t *testing.T) { res = user.MustDo( t, "PUT", - getPathForState(roomID, eventType, stateKey), - client.WithJSONBody(t, map[string]interface{}{ + getPathForDelayedEvent(roomID, eventType, "txn-delayed-state-restart"), + client.WithJSONBody(t, getDelayedStateEventBody(1500, stateKey, map[string]interface{}{ setterKey: setterExpected, - }), - getDelayQueryParam("1500"), + })), ) delayID := client.GetJSONFieldStr(t, client.ParseJSON(t, res), "delay_id") @@ -437,9 +510,8 @@ func TestDelayedEvents(t *testing.T) { user.MustDo( t, "PUT", - getPathForState(roomID, eventType, stateKey1), - client.WithJSONBody(t, map[string]interface{}{}), - getDelayQueryParam("900"), + getPathForDelayedEvent(roomID, eventType, "txn-delayed-state-server-restart-1"), + client.WithJSONBody(t, getDelayedStateEventBody(900, stateKey1, map[string]interface{}{})), ) numberOfDelayedEvents++ @@ -471,11 +543,10 @@ func TestDelayedEvents(t *testing.T) { user.MustDo( t, "PUT", + getPathForDelayedEvent(roomID, eventType, fmt.Sprintf("txn-delayed-state-server-restart-%d", i+2)), // Avoid clashing state keys as that would cancel previous delayed events on the // same key (start at 2). - getPathForState(roomID, eventType, fmt.Sprintf("%d", i+2)), - client.WithJSONBody(t, map[string]interface{}{}), - getDelayQueryParam(fmt.Sprintf("%d", delay.Milliseconds())), + client.WithJSONBody(t, getDelayedStateEventBody(delay.Milliseconds(), fmt.Sprintf("%d", i+2), map[string]interface{}{})), ) numberOfDelayedEvents++ } @@ -532,6 +603,27 @@ func getPathForUpdateDelayedEvent(delayId string, action DelayedEventAction) []s return append(getPathForDelayedEvents(), delayId, string(action)) } +func getPathForDelayedEvent(roomID string, eventType string, txnID string) []string { + return []string{"_matrix", "client", "unstable", "org.matrix.msc4140", "rooms", roomID, "delayed_event", eventType, txnID} +} + +// getDelayedEventBody returns the body of a request to schedule a delayed message event +// through `getPathForDelayedEvent`. +func getDelayedEventBody(delayMs int64, content map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "delay_ms": delayMs, + "content": content, + } +} + +// getDelayedStateEventBody returns the body of a request to schedule a delayed state event +// through `getPathForDelayedEvent`. +func getDelayedStateEventBody(delayMs int64, stateKey string, content map[string]interface{}) map[string]interface{} { + body := getDelayedEventBody(delayMs, content) + body["state_key"] = stateKey + return body +} + func getPathForSend(roomID string, eventType string, txnId string) []string { return []string{"_matrix", "client", "v3", "rooms", roomID, "send", eventType, txnId} } From 4f33baaf5b2c554d865cc1a9757a77511af9c307 Mon Sep 17 00:00:00 2001 From: Paul Chobert Date: Fri, 25 Sep 2026 10:43:37 +0200 Subject: [PATCH 2/2] MSC4140: stop testing the delay query parameter on /send and /state MSC4140 lists scheduling delayed events through the `org.matrix.msc4140.delay` query parameter on `/send` and `/state` as a rejected alternative to the dedicated endpoint, so it is not part of the MSC to test for. Remove the test that still used it, along with the `getPathForSend` and `getDelayQueryParam` helpers only it needed. Signed-off-by: Paul Chobert --- tests/msc4140/delayed_event_test.go | 62 ----------------------------- 1 file changed, 62 deletions(-) diff --git a/tests/msc4140/delayed_event_test.go b/tests/msc4140/delayed_event_test.go index 47c1b9e38..9281737ec 100644 --- a/tests/msc4140/delayed_event_test.go +++ b/tests/msc4140/delayed_event_test.go @@ -223,58 +223,6 @@ func TestDelayedEvents(t *testing.T) { matchDelayedEvents(t, user, delayedEventsNumberEqual(0)) }) - // TODO: Remove once homeservers stop accepting the `org.matrix.msc4140.delay` query - // parameter on `/send` and `/state`, which MSC4140 lists as a rejected alternative to - // the dedicated endpoint. - t.Run("delayed events scheduled with the delay query parameter are sent on timeout", func(t *testing.T) { - var res *http.Response - - defer cleanupDelayedEvents(t, user) - - stateKey := "to_send_on_timeout_with_query_param" - - // Schedule a delayed message event and a delayed state event - setterKey := "setter" - setterExpected := "on_timeout_with_query_param" - user.MustDo( - t, - "PUT", - getPathForSend(roomID, eventType, "txn-delayed-msg-query-param"), - client.WithJSONBody(t, map[string]interface{}{ - setterKey: setterExpected, - }), - getDelayQueryParam("900"), - ) - user.MustDo( - t, - "PUT", - getPathForState(roomID, eventType, stateKey), - client.WithJSONBody(t, map[string]interface{}{ - setterKey: setterExpected, - }), - getDelayQueryParam("900"), - ) - matchDelayedEvents(t, user, delayedEventsNumberEqual(2)) - - // Check for both delayed events being sent (using `MustSyncUntil` to account for - // any processing or worker replication delays) - user.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHas(roomID, func(ev gjson.Result) bool { - return ev.Get("type").Str == eventType && !ev.Get("state_key").Exists() && ev.Get("content."+setterKey).Str == setterExpected - })) - user.MustSyncUntil(t, client.SyncReq{UseStateAfter: true}, client.SyncStateAfterHas(roomID, func(ev gjson.Result) bool { - return ev.Get("type").Str == eventType && ev.Get("state_key").Str == stateKey - })) - // Make sure the state looks as expected after - res = user.MustDo(t, "GET", getPathForState(roomID, eventType, stateKey)) - must.MatchResponse(t, res, match.HTTPResponse{ - JSON: []match.JSON{ - match.JSONKeyEqual(setterKey, setterExpected), - }, - }) - // No more delayed events - matchDelayedEvents(t, user, delayedEventsNumberEqual(0)) - }) - t.Run("cannot update a delayed event without an action", func(t *testing.T) { res := unauthedClient.Do( t, @@ -624,20 +572,10 @@ func getDelayedStateEventBody(delayMs int64, stateKey string, content map[string return body } -func getPathForSend(roomID string, eventType string, txnId string) []string { - return []string{"_matrix", "client", "v3", "rooms", roomID, "send", eventType, txnId} -} - func getPathForState(roomID string, eventType string, stateKey string) []string { return []string{"_matrix", "client", "v3", "rooms", roomID, "state", eventType, stateKey} } -func getDelayQueryParam(delayStr string) client.RequestOpt { - return client.WithQueries(url.Values{ - "org.matrix.msc4140.delay": []string{delayStr}, - }) -} - func getDelayedEvents(t *testing.T, user *client.CSAPI) *http.Response { t.Helper() return user.MustDo(t, "GET", getPathForDelayedEvents())