package clientapi_test import ( "fmt" "net/http" "net/url" "testing" "time" ) // Relations and threads: what /relations lists, what /threads lists, and // the summaries a parent carries in unsigned.m.relations wherever it is // served. // sendEvent sends an event of any type and returns its ID. func (ts *testServer) sendEvent(acc account, roomID, eventType string, content map[string]any) string { ts.t.Helper() path := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s", url.PathEscape(roomID), url.PathEscape(eventType), fmt.Sprintf("txn-%d", time.Now().UnixNano())) rec := ts.put(path, acc.AccessToken, content) expectStatus(ts.t, rec, http.StatusOK) eventID, _ := jsonBody(ts.t, rec)["event_id"].(string) if eventID == "" { ts.t.Fatalf("send returned no event ID: %s", rec.Body.String()) } return eventID } // threadReply sends a message in a thread. func (ts *testServer) threadReply(acc account, roomID, rootID, body string) string { ts.t.Helper() return ts.sendEvent(acc, roomID, "m.room.message", map[string]any{ "msgtype": "m.text", "body": body, "m.relates_to": map[string]any{"rel_type": "m.thread", "event_id": rootID}, }) } type relationsResult struct { Chunk []syncEvent `json:"chunk"` NextBatch string `json:"next_batch"` RecursionDepth int `json:"recursion_depth"` } func (ts *testServer) relations(acc account, roomID, path string, query url.Values) relationsResult { ts.t.Helper() full := "/_matrix/client/v1/rooms/" + url.PathEscape(roomID) + "/relations/" + path if encoded := query.Encode(); encoded != "" { full += "?" + encoded } rec := ts.get(full, acc.AccessToken) expectStatus(ts.t, rec, http.StatusOK) return decode[relationsResult](ts.t, rec) } // threadSummaryOf digs unsigned.m.relations.m.thread out of an event. func threadSummaryOf(t *testing.T, event map[string]any) map[string]any { t.Helper() unsigned, _ := event["unsigned"].(map[string]any) relations, _ := unsigned["m.relations"].(map[string]any) summary, _ := relations["m.thread"].(map[string]any) return summary } func TestRelationsListsChildrenNewestFirst(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) root := ts.sendMessage(alice, roomID, "root") r1 := ts.threadReply(alice, roomID, root, "one") r2 := ts.threadReply(alice, roomID, root, "two") reaction := ts.sendEvent(alice, roomID, "m.reaction", map[string]any{ "m.relates_to": map[string]any{"rel_type": "m.annotation", "event_id": root, "key": "👍"}, }) ts.sendMessage(alice, roomID, "unrelated") all := ts.relations(alice, roomID, url.PathEscape(root), nil) if got := idsOf(all.Chunk); !equalStrings(got, []string{reaction, r2, r1}) { t.Fatalf("relations = %v, want the reaction and the two replies newest first", got) } threads := ts.relations(alice, roomID, url.PathEscape(root)+"/m.thread", nil) if got := idsOf(threads.Chunk); !equalStrings(got, []string{r2, r1}) { t.Errorf("m.thread relations = %v, want the two replies", got) } reactions := ts.relations(alice, roomID, url.PathEscape(root)+"/m.annotation/m.reaction", nil) if got := idsOf(reactions.Chunk); !equalStrings(got, []string{reaction}) { t.Errorf("m.annotation m.reaction relations = %v, want the reaction", got) } // Paging one at a time walks every child and stops. var paged []string from := "" for range 10 { query := url.Values{"limit": {"1"}} if from != "" { query.Set("from", from) } page := ts.relations(alice, roomID, url.PathEscape(root), query) paged = append(paged, idsOf(page.Chunk)...) if page.NextBatch == "" { break } from = page.NextBatch } if !equalStrings(paged, []string{reaction, r2, r1}) { t.Errorf("paged relations = %v", paged) } } func TestRelationsRecurse(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) root := ts.sendMessage(alice, roomID, "root") reply := ts.threadReply(alice, roomID, root, "reply") edit := ts.sendEvent(alice, roomID, "m.room.message", map[string]any{ "msgtype": "m.text", "body": "* reply!", "m.new_content": map[string]any{"msgtype": "m.text", "body": "reply!"}, "m.relates_to": map[string]any{"rel_type": "m.replace", "event_id": reply}, }) flat := ts.relations(alice, roomID, url.PathEscape(root), nil) if got := idsOf(flat.Chunk); !equalStrings(got, []string{reply}) { t.Errorf("non-recursive relations = %v, want only the reply", got) } deep := ts.relations(alice, roomID, url.PathEscape(root), url.Values{"recurse": {"true"}}) if got := idsOf(deep.Chunk); !equalStrings(got, []string{edit, reply}) { t.Errorf("recursive relations = %v, want the edit of the reply too", got) } if deep.RecursionDepth != 2 { t.Errorf("recursion_depth = %d, want 2", deep.RecursionDepth) } } func TestBundledAggregations(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"}) escaped := url.PathEscape(roomID) expectStatus(t, ts.post("/_matrix/client/v3/join/"+escaped, bob.AccessToken, map[string]any{}), http.StatusOK) root := ts.sendMessage(alice, roomID, "root") ts.threadReply(bob, roomID, root, "first") latest := ts.threadReply(bob, roomID, root, "second") edit := ts.sendEvent(alice, roomID, "m.room.message", map[string]any{ "msgtype": "m.text", "body": "* root, edited", "m.new_content": map[string]any{"msgtype": "m.text", "body": "root, edited"}, "m.relates_to": map[string]any{"rel_type": "m.replace", "event_id": root}, }) // An edit by somebody else is not an edit. ts.sendEvent(bob, roomID, "m.room.message", map[string]any{ "msgtype": "m.text", "body": "* hijacked", "m.new_content": map[string]any{"msgtype": "m.text", "body": "hijacked"}, "m.relates_to": map[string]any{"rel_type": "m.replace", "event_id": root}, }) check := func(who string, acc account, participated bool) { t.Helper() rec := ts.get("/_matrix/client/v3/rooms/"+escaped+"/event/"+url.PathEscape(root), acc.AccessToken) expectStatus(t, rec, http.StatusOK) event := jsonBody(t, rec) summary := threadSummaryOf(t, event) if summary == nil { t.Fatalf("%s: the root has no m.thread summary: %v", who, event["unsigned"]) } if summary["count"] != float64(2) { t.Errorf("%s: thread count = %v, want 2", who, summary["count"]) } latestEvent, _ := summary["latest_event"].(map[string]any) if latestEvent["event_id"] != latest { t.Errorf("%s: latest_event = %v, want %s", who, latestEvent["event_id"], latest) } if summary["current_user_participated"] != participated { t.Errorf("%s: current_user_participated = %v, want %v", who, summary["current_user_participated"], participated) } unsigned, _ := event["unsigned"].(map[string]any) relations, _ := unsigned["m.relations"].(map[string]any) replace, _ := relations["m.replace"].(map[string]any) if replace["event_id"] != edit { t.Errorf("%s: m.replace = %v, want alice's own edit %s", who, replace["event_id"], edit) } } // Alice started the thread and Bob replied in it: both took part. check("alice", alice, true) check("bob", bob, true) carol := ts.register("carol", "correct-horse-battery-staple") expectStatus(t, ts.post("/_matrix/client/v3/join/"+escaped, carol.AccessToken, map[string]any{}), http.StatusOK) check("carol", carol, false) // The same summary rides along on /messages. page := ts.get("/_matrix/client/v3/rooms/"+escaped+"/messages?dir=b&limit=50", alice.AccessToken) expectStatus(t, page, http.StatusOK) chunk, _ := jsonBody(t, page)["chunk"].([]any) found := false for _, raw := range chunk { e, _ := raw.(map[string]any) if e["event_id"] == root { found = threadSummaryOf(t, e) != nil } } if !found { t.Error("the root in /messages carries no m.thread summary") } } func TestThreadsEndpoint(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"}) escaped := url.PathEscape(roomID) expectStatus(t, ts.post("/_matrix/client/v3/join/"+escaped, bob.AccessToken, map[string]any{}), http.StatusOK) first := ts.sendMessage(alice, roomID, "first root") second := ts.sendMessage(bob, roomID, "second root") ts.sendMessage(alice, roomID, "not a thread") ts.threadReply(alice, roomID, first, "in the first") ts.threadReply(alice, roomID, second, "in the second") ts.threadReply(alice, roomID, first, "the first again") threads := func(acc account, query string) relationsResult { t.Helper() rec := ts.get("/_matrix/client/v1/rooms/"+escaped+"/threads"+query, acc.AccessToken) expectStatus(t, rec, http.StatusOK) return decode[relationsResult](t, rec) } all := threads(bob, "") if got := idsOf(all.Chunk); !equalStrings(got, []string{first, second}) { t.Fatalf("threads = %v, want the first (latest activity) then the second", got) } // Bob started the second thread and never replied in the first. mine := threads(bob, "?include=participated") if got := idsOf(mine.Chunk); !equalStrings(got, []string{second}) { t.Errorf("participated threads = %v, want only bob's own", got) } page1 := threads(bob, "?limit=1") if got := idsOf(page1.Chunk); !equalStrings(got, []string{first}) || page1.NextBatch == "" { t.Fatalf("first page = %v, next_batch %q", got, page1.NextBatch) } page2 := threads(bob, "?limit=1&from="+url.QueryEscape(page1.NextBatch)) if got := idsOf(page2.Chunk); !equalStrings(got, []string{second}) || page2.NextBatch != "" { t.Errorf("second page = %v, next_batch %q", got, page2.NextBatch) } rec := ts.get("/_matrix/client/v1/rooms/"+escaped+"/threads?include=everything", bob.AccessToken) expectStatus(t, rec, http.StatusBadRequest) } func TestRedactedRelationDisappears(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) escaped := url.PathEscape(roomID) root := ts.sendMessage(alice, roomID, "root") reply := ts.threadReply(alice, roomID, root, "reply") expectStatus(t, ts.put("/_matrix/client/v3/rooms/"+escaped+"/redact/"+url.PathEscape(reply)+"/r1", alice.AccessToken, map[string]any{}), http.StatusOK) if got := ts.relations(alice, roomID, url.PathEscape(root), nil); len(got.Chunk) != 0 { t.Errorf("relations after the reply was redacted = %v", idsOf(got.Chunk)) } rec := ts.get("/_matrix/client/v3/rooms/"+escaped+"/event/"+url.PathEscape(root), alice.AccessToken) if summary := threadSummaryOf(t, jsonBody(t, rec)); summary != nil { t.Errorf("the root still has a thread summary: %v", summary) } } func TestRelationsOfAnInvisibleEvent(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) root := ts.sendMessage(alice, roomID, "secret") rec := ts.get("/_matrix/client/v1/rooms/"+url.PathEscape(roomID)+"/relations/"+url.PathEscape(root), bob.AccessToken) expectStatus(t, rec, http.StatusForbidden) } func idsOf(events []syncEvent) []string { out := make([]string, 0, len(events)) for _, e := range events { out = append(out, e.EventID) } return out } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }