package clientapi_test import ( "fmt" "net/http" "net/url" "testing" ) // /messages and /context: a room's history, which is the other half of the // read model. The thing worth pinning down is that paging terminates and // that it loses nothing — a client that pages from the bottom of a room to // the top must see every event exactly once. type messagesResult struct { Start string `json:"start"` End string `json:"end"` Chunk []syncEvent `json:"chunk"` State []syncEvent `json:"state"` } // messages performs one /messages call. func (ts *testServer) messages(acc account, roomID string, query url.Values) messagesResult { ts.t.Helper() path := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/messages" if encoded := query.Encode(); encoded != "" { path += "?" + encoded } rec := ts.get(path, acc.AccessToken) expectStatus(ts.t, rec, http.StatusOK) return decode[messagesResult](ts.t, rec) } func TestMessagesPagesBackwardsThroughTheWholeRoom(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) const messages = 7 for i := range messages { ts.sendMessage(alice, roomID, fmt.Sprintf("line %d", i)) } // Page backwards in twos until the server stops handing out a token. var ( seen []string from string pages int ) for { query := url.Values{"dir": {"b"}, "limit": {"2"}} if from != "" { query.Set("from", from) } page := ts.messages(alice, roomID, query) seen = append(seen, bodiesOf(page.Chunk)...) pages++ if pages > 20 { t.Fatal("paging backwards did not terminate") } if page.End == "" { break } if len(page.Chunk) == 0 { t.Fatalf("page %d was empty but carried an end token %q", pages, page.End) } from = page.End } // Every message exactly once. The order is newest first within a page // and the pages walk backwards, so the whole set is what matters here; // the ordering inside a page is covered below. if len(seen) != messages { t.Fatalf("saw %d messages over %d pages, want %d: %v", len(seen), pages, messages, seen) } counts := map[string]int{} for _, body := range seen { counts[body]++ } for i := range messages { body := fmt.Sprintf("line %d", i) if counts[body] != 1 { t.Errorf("%q appeared %d times, want once", body, counts[body]) } } } func TestMessagesBackwardsStartsAtTheNewestEvent(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) ts.sendMessage(alice, roomID, "older") ts.sendMessage(alice, roomID, "newest") page := ts.messages(alice, roomID, url.Values{"dir": {"b"}, "limit": {"1"}}) got := bodiesOf(page.Chunk) if len(got) != 1 || got[0] != "newest" { t.Errorf("chunk = %v, want [newest]: a backwards page with no ?from= starts at the end of the room", got) } } func TestMessagesForwardsFromTheStartOfTheRoom(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) ts.sendMessage(alice, roomID, "first") ts.sendMessage(alice, roomID, "second") page := ts.messages(alice, roomID, url.Values{"dir": {"f"}, "limit": {"100"}}) got := typesOf(page.Chunk) if len(got) == 0 || got[0] != "m.room.create" { t.Fatalf("chunk starts with %v, want the create event first", got) } bodies := bodiesOf(page.Chunk) if len(bodies) != 2 || bodies[0] != "first" || bodies[1] != "second" { t.Errorf("bodies = %v, want [first second] in order", bodies) } } func TestMessagesClosesTheGapASyncReported(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) for i := range 5 { ts.sendMessage(alice, roomID, fmt.Sprintf("line %d", i)) } // A sync with a tight limit leaves a gap and hands back the token to // close it with. That token has to be a valid ?from= here: it is the // whole reason the two endpoints share a token format. result := ts.syncWith(alice, url.Values{ "filter": {`{"room":{"timeline":{"limit":2}}}`}, }) room := result.Rooms.Join[roomID] if !room.Timeline.Limited || room.Timeline.PrevBatch == "" { t.Fatalf("expected a limited timeline with a prev_batch, got %+v", room.Timeline) } page := ts.messages(alice, roomID, url.Values{ "dir": {"b"}, "from": {room.Timeline.PrevBatch}, "limit": {"10"}, }) // Nothing the sync already showed may appear again. synced := map[string]bool{} for _, e := range room.Timeline.Events { synced[e.EventID] = true } for _, e := range page.Chunk { if synced[e.EventID] { t.Errorf("%s was in both the sync and the page that was meant to precede it", e.EventID) } } if len(page.Chunk) == 0 { t.Error("the page closing the gap was empty") } } func TestMessagesAppliesAFilter(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) ts.sendMessage(alice, roomID, "a message") page := ts.messages(alice, roomID, url.Values{ "dir": {"b"}, "limit": {"50"}, "filter": {`{"types":["m.room.message"]}`}, }) for _, e := range page.Chunk { if e.Type != "m.room.message" { t.Errorf("chunk contains %s, want only m.room.message", e.Type) } } if len(page.Chunk) != 1 { t.Errorf("chunk has %d events, want the one message", len(page.Chunk)) } } func TestMessagesRejectsABadDirection(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) rec := ts.get("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/messages?dir=sideways", alice.AccessToken) expectError(t, rec, http.StatusBadRequest, "M_INVALID_PARAM") } func TestMessagesRefusesANonMember(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "hunter2-hunter2-hunter2") // A private room: nothing about it is world-readable. roomID := ts.createRoom(alice, map[string]any{"preset": "private_chat"}) rec := ts.get("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/messages?dir=b", bob.AccessToken) expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN") } type contextResult struct { Start string `json:"start"` End string `json:"end"` EventsBefore []syncEvent `json:"events_before"` Event syncEvent `json:"event"` EventsAfter []syncEvent `json:"events_after"` State []syncEvent `json:"state"` } func TestContextReturnsTheEventAndItsSurroundings(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) ts.sendMessage(alice, roomID, "before 1") ts.sendMessage(alice, roomID, "before 2") target := ts.sendMessage(alice, roomID, "the target") ts.sendMessage(alice, roomID, "after 1") ts.sendMessage(alice, roomID, "after 2") rec := ts.get(fmt.Sprintf("/_matrix/client/v3/rooms/%s/context/%s?limit=5", url.PathEscape(roomID), url.PathEscape(target)), alice.AccessToken) expectStatus(t, rec, http.StatusOK) result := decode[contextResult](t, rec) if result.Event.EventID != target { t.Errorf("event = %s, want %s", result.Event.EventID, target) } // events_before is newest first, so the message immediately before // the target comes first. before := bodiesOf(result.EventsBefore) if len(before) == 0 || before[0] != "before 2" { t.Errorf("events_before = %v, want it to start with \"before 2\"", before) } after := bodiesOf(result.EventsAfter) if len(after) == 0 || after[0] != "after 1" { t.Errorf("events_after = %v, want it to start with \"after 1\"", after) } if result.Start == "" || result.End == "" { t.Error("a context must carry both paging tokens") } if len(result.State) == 0 { t.Error("a context must carry the state needed to render its chunk") } } func TestContextStateIsLazyLoadedWhenAsked(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) target := ts.sendMessage(alice, roomID, "only one speaker") rec := ts.get(fmt.Sprintf("/_matrix/client/v3/rooms/%s/context/%s?filter=%s", url.PathEscape(roomID), url.PathEscape(target), url.QueryEscape(`{"lazy_load_members":true}`)), alice.AccessToken) expectStatus(t, rec, http.StatusOK) result := decode[contextResult](t, rec) for _, e := range result.State { if e.Type != "m.room.member" { t.Errorf("lazy-loaded state contains %s, want only member events", e.Type) } } if len(result.State) != 1 { t.Errorf("state has %d events, want just the one sender's member event", len(result.State)) } } func TestContextOfAnUnknownEventIsNotFound(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) rec := ts.get("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/context/$nope", alice.AccessToken) expectError(t, rec, http.StatusNotFound, "M_NOT_FOUND") } func TestMessagesBackwardsChunkIsNewestFirst(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) ts.sendMessage(alice, roomID, "first") ts.sendMessage(alice, roomID, "second") ts.sendMessage(alice, roomID, "third") // The spec says a backwards page is in reverse chronological order, // because that is the order a client scrolling up consumes it in. page := ts.messages(alice, roomID, url.Values{ "dir": {"b"}, "limit": {"50"}, "filter": {`{"types":["m.room.message"]}`}, }) got := bodiesOf(page.Chunk) want := []string{"third", "second", "first"} if len(got) != len(want) { t.Fatalf("chunk = %v, want %v", got, want) } for i := range got { if got[i] != want[i] { t.Fatalf("chunk = %v, want %v", got, want) } } // Forwards is the other way round. forwards := ts.messages(alice, roomID, url.Values{ "dir": {"f"}, "limit": {"50"}, "filter": {`{"types":["m.room.message"]}`}, }) if got := bodiesOf(forwards.Chunk); len(got) == 0 || got[0] != "first" { t.Errorf("forwards chunk = %v, want it to start with \"first\"", got) } } func TestMessagesStartNamesWhereThePageActuallyBegan(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) ts.sendMessage(alice, roomID, "only") // ?from= is optional. Without it, `start` has to name the end of the // room the page began at: a client that paged backwards and then used // `start` to page forwards again would otherwise be handed the whole // room a second time. page := ts.messages(alice, roomID, url.Values{"dir": {"b"}, "limit": {"50"}}) if page.Start == "" || page.Start == "s0" { t.Fatalf("start = %q, want the position the page began at", page.Start) } forwards := ts.messages(alice, roomID, url.Values{ "dir": {"f"}, "from": {page.Start}, "limit": {"50"}, }) if len(forwards.Chunk) != 0 { t.Errorf("paging forwards from the end returned %d events, want none", len(forwards.Chunk)) } }