Mobile: - The room list's title picks a space: All chats, or one space, which narrows the list to its rooms (and its subspaces' rooms). - A space browser over /hierarchy: the rooms of a space and of its subspaces, joined or joinable, with Join; for those who may, adding an existing room or a new one to the space. - New space in the new-chat sheet; invites to spaces show with the other invites and open the space when joined. - Fixes several setState calls that were handed a Future (reloading the members, sessions, push distributor, threads), which Flutter rejects. Server: /hierarchy left out children_state for a room without children. The spec requires it on every room (an empty list), and SpaceRoom's omitempty dropped empty lists too, despite its comment. The famedly Dart SDK fails to parse such a response. The client API now wraps each room so the field is always written; the federation response, which wants it left out of the children it lists, is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
7.9 KiB
Go
212 lines
7.9 KiB
Go
package clientapi_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"testing"
|
|
)
|
|
|
|
type hierarchyResult struct {
|
|
Rooms []struct {
|
|
RoomID string `json:"room_id"`
|
|
Name string `json:"name"`
|
|
RoomType string `json:"room_type"`
|
|
JoinRule string `json:"join_rule"`
|
|
NumJoinedMembers int `json:"num_joined_members"`
|
|
ChildrenState []map[string]any `json:"children_state"`
|
|
} `json:"rooms"`
|
|
NextBatch string `json:"next_batch"`
|
|
}
|
|
|
|
func (h hierarchyResult) ids() []string {
|
|
out := make([]string, 0, len(h.Rooms))
|
|
for _, r := range h.Rooms {
|
|
out = append(out, r.RoomID)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (ts *testServer) hierarchy(acc account, roomID, query string) hierarchyResult {
|
|
ts.t.Helper()
|
|
rec := ts.get("/_matrix/client/v1/rooms/"+url.PathEscape(roomID)+"/hierarchy"+query, acc.AccessToken)
|
|
expectStatus(ts.t, rec, http.StatusOK)
|
|
return decode[hierarchyResult](ts.t, rec)
|
|
}
|
|
|
|
// addChild puts a room into a space.
|
|
func (ts *testServer) addChild(acc account, spaceID, childID string, content map[string]any) {
|
|
ts.t.Helper()
|
|
if content == nil {
|
|
content = map[string]any{}
|
|
}
|
|
if _, ok := content["via"]; !ok {
|
|
content["via"] = []string{"warp.example"}
|
|
}
|
|
rec := ts.put("/_matrix/client/v3/rooms/"+url.PathEscape(spaceID)+"/state/m.space.child/"+url.PathEscape(childID),
|
|
acc.AccessToken, content)
|
|
expectStatus(ts.t, rec, http.StatusOK)
|
|
}
|
|
|
|
func (ts *testServer) createSpace(acc account, name string, public bool) string {
|
|
ts.t.Helper()
|
|
body := map[string]any{"name": name, "creation_content": map[string]any{"type": "m.space"}}
|
|
if public {
|
|
body["preset"] = "public_chat"
|
|
}
|
|
return ts.createRoom(acc, body)
|
|
}
|
|
|
|
func TestSpaceHierarchy(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
alice := ts.register("alice", "correct-horse-battery-staple")
|
|
bob := ts.register("bob", "correct-horse-battery-staple")
|
|
|
|
space := ts.createSpace(alice, "Space", true)
|
|
sub := ts.createSpace(alice, "Subspace", true)
|
|
general := ts.createRoom(alice, map[string]any{"name": "General", "preset": "public_chat"})
|
|
random := ts.createRoom(alice, map[string]any{"name": "Random", "preset": "public_chat"})
|
|
secret := ts.createRoom(alice, map[string]any{"name": "Secret"})
|
|
deep := ts.createRoom(alice, map[string]any{"name": "Deep", "preset": "public_chat"})
|
|
|
|
ts.addChild(alice, space, random, map[string]any{"order": "b"})
|
|
ts.addChild(alice, space, general, map[string]any{"order": "a", "suggested": true})
|
|
ts.addChild(alice, space, sub, nil)
|
|
ts.addChild(alice, space, secret, nil)
|
|
ts.addChild(alice, sub, deep, nil)
|
|
// A child event with no via is a removed child.
|
|
removed := ts.createRoom(alice, map[string]any{"name": "Gone", "preset": "public_chat"})
|
|
ts.addChild(alice, space, removed, map[string]any{"via": []string{}})
|
|
|
|
// Alice is in every room, so she sees all of them: the root, its
|
|
// children by order and then by timestamp, then the subspace's child.
|
|
all := ts.hierarchy(alice, space, "")
|
|
want := []string{space, general, random, sub, secret, deep}
|
|
if got := all.ids(); !equalStrings(got, want) {
|
|
t.Fatalf("alice's hierarchy = %v, want %v", got, want)
|
|
}
|
|
if all.Rooms[0].RoomType != "m.space" || len(all.Rooms[0].ChildrenState) != 4 {
|
|
t.Errorf("root = %+v, want a space with four live children", all.Rooms[0])
|
|
}
|
|
|
|
// Bob is in nothing: the invite-only room is hidden from him.
|
|
if got := ts.hierarchy(bob, space, "").ids(); !equalStrings(got, []string{space, general, random, sub, deep}) {
|
|
t.Errorf("bob's hierarchy = %v", got)
|
|
}
|
|
|
|
if got := ts.hierarchy(alice, space, "?max_depth=1").ids(); !equalStrings(got, []string{space, general, random, sub, secret}) {
|
|
t.Errorf("max_depth=1 = %v", got)
|
|
}
|
|
if got := ts.hierarchy(alice, space, "?suggested_only=true").ids(); !equalStrings(got, []string{space, general}) {
|
|
t.Errorf("suggested_only = %v", got)
|
|
}
|
|
|
|
// Paging two at a time walks the same order.
|
|
var paged []string
|
|
from := ""
|
|
for range 10 {
|
|
query := "?limit=2"
|
|
if from != "" {
|
|
query += "&from=" + url.QueryEscape(from)
|
|
}
|
|
page := ts.hierarchy(alice, space, query)
|
|
paged = append(paged, page.ids()...)
|
|
if page.NextBatch == "" {
|
|
break
|
|
}
|
|
from = page.NextBatch
|
|
}
|
|
if !equalStrings(paged, want) {
|
|
t.Errorf("paged hierarchy = %v, want %v", paged, want)
|
|
}
|
|
}
|
|
|
|
func TestSpaceHierarchyAccess(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
alice := ts.register("alice", "correct-horse-battery-staple")
|
|
bob := ts.register("bob", "correct-horse-battery-staple")
|
|
carol := ts.register("carol", "correct-horse-battery-staple")
|
|
|
|
private := ts.createSpace(alice, "Private", false)
|
|
rec := ts.get("/_matrix/client/v1/rooms/"+url.PathEscape(private)+"/hierarchy", bob.AccessToken)
|
|
expectStatus(t, rec, http.StatusForbidden)
|
|
|
|
// A restricted room is visible to the members of the room it lets in.
|
|
space := ts.createSpace(alice, "Space", true)
|
|
restricted := ts.createRoom(alice, map[string]any{
|
|
"name": "Members only",
|
|
"initial_state": []map[string]any{{
|
|
"type": "m.room.join_rules", "state_key": "",
|
|
"content": map[string]any{
|
|
"join_rule": "restricted",
|
|
"allow": []map[string]any{{"type": "m.room_membership", "room_id": space}},
|
|
},
|
|
}},
|
|
})
|
|
ts.addChild(alice, space, restricted, nil)
|
|
expectStatus(t, ts.post("/_matrix/client/v3/join/"+url.PathEscape(space), bob.AccessToken, map[string]any{}), http.StatusOK)
|
|
|
|
if got := ts.hierarchy(bob, space, "").ids(); !equalStrings(got, []string{space, restricted}) {
|
|
t.Errorf("a member of the space sees %v, want the restricted room too", got)
|
|
}
|
|
if got := ts.hierarchy(carol, space, "").ids(); !equalStrings(got, []string{space}) {
|
|
t.Errorf("a stranger sees %v, want the space alone", got)
|
|
}
|
|
}
|
|
|
|
func TestRoomSummary(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
alice := ts.register("alice", "correct-horse-battery-staple")
|
|
bob := ts.register("bob", "correct-horse-battery-staple")
|
|
public := ts.createRoom(alice, map[string]any{
|
|
"name": "Lobby", "topic": "Say hello", "preset": "public_chat", "room_alias_name": "lobby",
|
|
})
|
|
private := ts.createRoom(alice, map[string]any{"name": "Private"})
|
|
|
|
for _, target := range []string{public, "#lobby:warp.example"} {
|
|
rec := ts.get("/_matrix/client/v1/room_summary/"+url.PathEscape(target), bob.AccessToken)
|
|
expectStatus(t, rec, http.StatusOK)
|
|
body := jsonBody(t, rec)
|
|
if body["room_id"] != public || body["name"] != "Lobby" || body["topic"] != "Say hello" ||
|
|
body["membership"] != "leave" || body["join_rule"] != "public" || body["room_version"] == nil {
|
|
t.Errorf("summary of %s = %v", target, body)
|
|
}
|
|
}
|
|
|
|
rec := ts.get("/_matrix/client/v1/room_summary/"+url.PathEscape(private), bob.AccessToken)
|
|
expectStatus(t, rec, http.StatusForbidden)
|
|
rec = ts.get("/_matrix/client/v1/room_summary/"+url.PathEscape(private), alice.AccessToken)
|
|
expectStatus(t, rec, http.StatusOK)
|
|
if jsonBody(t, rec)["membership"] != "join" {
|
|
t.Errorf("alice's summary of her own room says membership %v", jsonBody(t, rec)["membership"])
|
|
}
|
|
}
|
|
|
|
// children_state is required on every room /hierarchy returns, an empty
|
|
// list for one with no children: clients that parse the response strictly
|
|
// (the famedly Dart SDK) fail on a room without it.
|
|
func TestSpaceHierarchyEmptyChildrenState(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
alice := ts.register("alice", "correct-horse-battery-staple")
|
|
space := ts.createSpace(alice, "Empty space", false)
|
|
room := ts.createRoom(alice, map[string]any{"name": "Leaf"})
|
|
ts.addChild(alice, space, room, nil)
|
|
|
|
rec := ts.get("/_matrix/client/v1/rooms/"+url.PathEscape(space)+"/hierarchy", alice.AccessToken)
|
|
expectStatus(t, rec, http.StatusOK)
|
|
raw := decode[struct {
|
|
Rooms []map[string]any `json:"rooms"`
|
|
}](t, rec)
|
|
if len(raw.Rooms) != 2 {
|
|
t.Fatalf("got %d rooms, want the space and its child", len(raw.Rooms))
|
|
}
|
|
for _, r := range raw.Rooms {
|
|
children, ok := r["children_state"].([]any)
|
|
if !ok {
|
|
t.Errorf("%v: children_state = %#v, want a list", r["room_id"], r["children_state"])
|
|
}
|
|
if r["room_id"] == room && len(children) != 0 {
|
|
t.Errorf("the leaf room has children_state %v", children)
|
|
}
|
|
}
|
|
}
|