package clientapi_test import ( "net/http" "net/url" "testing" ) type searchResult struct { SearchCategories struct { RoomEvents struct { Count int `json:"count"` Highlights []string `json:"highlights"` NextBatch string `json:"next_batch"` Results []struct { Rank float64 `json:"rank"` Result syncEvent `json:"result"` Context *struct { EventsBefore []syncEvent `json:"events_before"` EventsAfter []syncEvent `json:"events_after"` ProfileInfo map[string]map[string]any `json:"profile_info"` } `json:"context"` } `json:"results"` State map[string][]syncEvent `json:"state"` Groups map[string]map[string]map[string]any `json:"groups"` } `json:"room_events"` } `json:"search_categories"` } func (ts *testServer) search(acc account, criteria map[string]any, nextBatch string) searchResult { ts.t.Helper() path := "/_matrix/client/v3/search" if nextBatch != "" { path += "?next_batch=" + url.QueryEscape(nextBatch) } rec := ts.post(path, acc.AccessToken, map[string]any{ "search_categories": map[string]any{"room_events": criteria}, }) expectStatus(ts.t, rec, http.StatusOK) return decode[searchResult](ts.t, rec) } func TestSearchFindsOnlyWhatTheUserMaySee(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") shared := ts.createRoom(alice, map[string]any{"preset": "public_chat"}) private := ts.createRoom(alice, nil) expectStatus(t, ts.post("/_matrix/client/v3/join/"+url.PathEscape(shared), bob.AccessToken, map[string]any{}), http.StatusOK) ts.sendMessage(alice, shared, "the quick brown fox") ts.sendMessage(alice, private, "a secret fox") ts.sendMessage(bob, shared, "lazy dog") got := ts.search(bob, map[string]any{"search_term": "fox"}, "").SearchCategories.RoomEvents if got.Count != 1 || len(got.Results) != 1 || got.Results[0].Result.Content["body"] != "the quick brown fox" { t.Fatalf("bob's search for fox = %+v, want the one message in the room he is in", got) } if len(got.Highlights) != 1 || got.Highlights[0] != "fox" { t.Errorf("highlights = %v", got.Highlights) } all := ts.search(alice, map[string]any{"search_term": "fox"}, "").SearchCategories.RoomEvents if all.Count != 2 { t.Errorf("alice's search for fox found %d, want both rooms' messages", all.Count) } only := ts.search(alice, map[string]any{ "search_term": "fox", "filter": map[string]any{"rooms": []string{private}}, }, "").SearchCategories.RoomEvents if only.Count != 1 || only.Results[0].Result.RoomID != private { t.Errorf("search filtered to one room = %+v", only) } // After Bob leaves, what was said while he was there stays findable // and what comes later does not. expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(shared)+"/leave", bob.AccessToken, map[string]any{}), http.StatusOK) ts.sendMessage(alice, shared, "another fox, later") after := ts.search(bob, map[string]any{"search_term": "fox"}, "").SearchCategories.RoomEvents if after.Count != 1 { t.Errorf("after leaving, bob's search found %d, want only the message from before", after.Count) } } func TestSearchContextStateGroupsAndPaging(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") ts.sendMessage(alice, roomID, "needle one") ts.sendMessage(alice, roomID, "after") ts.sendMessage(alice, roomID, "needle two") ts.sendMessage(alice, roomID, "needle three") got := ts.search(alice, map[string]any{ "search_term": "needle", "order_by": "recent", "event_context": map[string]any{"before_limit": 1, "after_limit": 1, "include_profile": true}, "include_state": true, "groupings": map[string]any{"group_by": []map[string]any{{"key": "room_id"}}}, "filter": map[string]any{"limit": 2}, }, "").SearchCategories.RoomEvents if got.Count != 3 || len(got.Results) != 2 { t.Fatalf("first page: count %d, %d results", got.Count, len(got.Results)) } if body := got.Results[0].Result.Content["body"]; body != "needle three" { t.Errorf("newest first: got %v", body) } ctx := got.Results[1].Context if ctx == nil || len(ctx.EventsBefore) != 1 || len(ctx.EventsAfter) != 1 || ctx.EventsBefore[0].Content["body"] != "after" || ctx.EventsAfter[0].Content["body"] != "needle three" { t.Errorf("context of needle two = %+v", ctx) } if _, ok := ctx.ProfileInfo[alice.UserID]; !ok { t.Errorf("profile_info has no entry for alice: %v", ctx.ProfileInfo) } if len(got.State[roomID]) == 0 { t.Error("include_state returned no state for the room") } if group := got.Groups["room_id"][roomID]; group == nil { t.Errorf("groups = %v", got.Groups) } if got.NextBatch == "" { t.Fatal("a full first page carried no next_batch") } next := ts.search(alice, map[string]any{ "search_term": "needle", "order_by": "recent", "filter": map[string]any{"limit": 2}, }, got.NextBatch).SearchCategories.RoomEvents if len(next.Results) != 1 || next.Results[0].Result.Content["body"] != "needle one" { t.Errorf("second page = %+v", next.Results) } ranked := ts.search(alice, map[string]any{"search_term": "needle", "filter": map[string]any{"limit": 2}}, "").SearchCategories.RoomEvents rest := ts.search(alice, map[string]any{"search_term": "needle", "filter": map[string]any{"limit": 2}}, ranked.NextBatch).SearchCategories.RoomEvents if len(ranked.Results)+len(rest.Results) != 3 || rest.NextBatch != "" { t.Errorf("ranked pages: %d then %d results, next %q", len(ranked.Results), len(rest.Results), rest.NextBatch) } rec := ts.post("/_matrix/client/v3/search", alice.AccessToken, map[string]any{ "search_categories": map[string]any{"room_events": map[string]any{"search_term": "x", "order_by": "loudest"}}, }) expectStatus(t, rec, http.StatusBadRequest) }