Skip to main content

ruma_client_api/search/
search_events.rs

1//! `POST /_matrix/client/*/search`
2//!
3//! Search events.
4
5mod result_group_map_serde;
6
7pub mod v3 {
8    //! `/v3/` ([spec])
9    //!
10    //! [spec]: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3search
11
12    use std::{
13        collections::{BTreeMap, btree_map},
14        ops::Deref,
15    };
16
17    use as_variant::as_variant;
18    use js_int::{UInt, uint};
19    use ruma_common::{
20        OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId,
21        api::{auth_scheme::AccessToken, request, response},
22        metadata,
23        serde::{Raw, StringEnum},
24    };
25    use ruma_events::{AnyStateEvent, AnyTimelineEvent};
26    use serde::{Deserialize, Serialize};
27
28    use crate::{PrivOwnedStr, filter::RoomEventFilter};
29
30    metadata! {
31        method: POST,
32        rate_limited: true,
33        authentication: AccessToken,
34        history: {
35            1.0 => "/_matrix/client/r0/search",
36            1.1 => "/_matrix/client/v3/search",
37        }
38    }
39
40    /// Request type for the `search` endpoint.
41    #[request(error = crate::Error)]
42    pub struct Request {
43        /// The point to return events from.
44        ///
45        /// If given, this should be a `next_batch` result from a previous call to this endpoint.
46        #[ruma_api(query)]
47        pub next_batch: Option<String>,
48
49        /// Describes which categories to search in and their criteria.
50        pub search_categories: Categories,
51    }
52
53    /// Response type for the `search` endpoint.
54    #[response(error = crate::Error)]
55    pub struct Response {
56        /// A grouping of search results by category.
57        pub search_categories: ResultCategories,
58    }
59
60    impl Request {
61        /// Creates a new `Request` with the given categories.
62        pub fn new(search_categories: Categories) -> Self {
63            Self { next_batch: None, search_categories }
64        }
65    }
66
67    impl Response {
68        /// Creates a new `Response` with the given search results.
69        pub fn new(search_categories: ResultCategories) -> Self {
70            Self { search_categories }
71        }
72    }
73
74    /// Categories of events that can be searched for.
75    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
76    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
77    pub struct Categories {
78        /// Criteria for searching room events.
79        #[serde(skip_serializing_if = "Option::is_none")]
80        pub room_events: Option<Criteria>,
81    }
82
83    impl Categories {
84        /// Creates an empty `Categories`.
85        pub fn new() -> Self {
86            Default::default()
87        }
88    }
89
90    /// Criteria for searching a category of events.
91    #[derive(Clone, Debug, Deserialize, Serialize)]
92    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
93    pub struct Criteria {
94        /// The string to search events for.
95        pub search_term: String,
96
97        /// The keys to search for.
98        ///
99        /// Defaults to all keys.
100        #[serde(skip_serializing_if = "Option::is_none")]
101        pub keys: Option<Vec<SearchKeys>>,
102
103        /// A `Filter` to apply to the search.
104        #[serde(default, skip_serializing_if = "RoomEventFilter::is_empty")]
105        pub filter: RoomEventFilter,
106
107        /// The order in which to search for results.
108        #[serde(skip_serializing_if = "Option::is_none")]
109        pub order_by: Option<OrderBy>,
110
111        /// Configures whether any context for the events returned are included in the response.
112        #[serde(default, skip_serializing_if = "EventContext::is_default")]
113        pub event_context: EventContext,
114
115        /// Requests the server return the current state for each room returned.
116        #[serde(skip_serializing_if = "Option::is_none")]
117        pub include_state: Option<bool>,
118
119        /// Requests that the server partitions the result set based on the provided list of keys.
120        #[serde(default, skip_serializing_if = "Groupings::is_empty")]
121        pub groupings: Groupings,
122    }
123
124    impl Criteria {
125        /// Creates a new `Criteria` with the given search term.
126        pub fn new(search_term: String) -> Self {
127            Self {
128                search_term,
129                keys: None,
130                filter: RoomEventFilter::default(),
131                order_by: None,
132                event_context: Default::default(),
133                include_state: None,
134                groupings: Default::default(),
135            }
136        }
137    }
138
139    /// Configures whether any context for the events returned are included in the response.
140    #[derive(Clone, Debug, Deserialize, Serialize)]
141    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
142    pub struct EventContext {
143        /// How many events before the result are returned.
144        #[serde(
145            default = "default_event_context_limit",
146            skip_serializing_if = "is_default_event_context_limit"
147        )]
148        pub before_limit: UInt,
149
150        /// How many events after the result are returned.
151        #[serde(
152            default = "default_event_context_limit",
153            skip_serializing_if = "is_default_event_context_limit"
154        )]
155        pub after_limit: UInt,
156
157        /// Requests that the server returns the historic profile information for the users that
158        /// sent the events that were returned.
159        #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")]
160        pub include_profile: bool,
161    }
162
163    fn default_event_context_limit() -> UInt {
164        uint!(5)
165    }
166
167    #[allow(clippy::trivially_copy_pass_by_ref)]
168    fn is_default_event_context_limit(val: &UInt) -> bool {
169        *val == default_event_context_limit()
170    }
171
172    impl EventContext {
173        /// Creates an `EventContext` with all-default values.
174        pub fn new() -> Self {
175            Self {
176                before_limit: default_event_context_limit(),
177                after_limit: default_event_context_limit(),
178                include_profile: false,
179            }
180        }
181
182        /// Returns whether all fields have their default value.
183        pub fn is_default(&self) -> bool {
184            self.before_limit == default_event_context_limit()
185                && self.after_limit == default_event_context_limit()
186                && !self.include_profile
187        }
188    }
189
190    impl Default for EventContext {
191        fn default() -> Self {
192            Self::new()
193        }
194    }
195
196    /// Context for search results, if requested.
197    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
198    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
199    pub struct EventContextResult {
200        /// Pagination token for the end of the chunk.
201        #[serde(skip_serializing_if = "Option::is_none")]
202        pub end: Option<String>,
203
204        /// Events just after the result.
205        #[serde(default, skip_serializing_if = "Vec::is_empty")]
206        pub events_after: Vec<Raw<AnyTimelineEvent>>,
207
208        /// Events just before the result.
209        #[serde(default, skip_serializing_if = "Vec::is_empty")]
210        pub events_before: Vec<Raw<AnyTimelineEvent>>,
211
212        /// The historic profile information of the users that sent the events returned.
213        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
214        pub profile_info: BTreeMap<OwnedUserId, UserProfile>,
215
216        /// Pagination token for the start of the chunk.
217        #[serde(skip_serializing_if = "Option::is_none")]
218        pub start: Option<String>,
219    }
220
221    impl EventContextResult {
222        /// Creates an empty `EventContextResult`.
223        pub fn new() -> Self {
224            Default::default()
225        }
226
227        /// Returns whether all fields are `None` or an empty list.
228        pub fn is_empty(&self) -> bool {
229            self.end.is_none()
230                && self.events_after.is_empty()
231                && self.events_before.is_empty()
232                && self.profile_info.is_empty()
233                && self.start.is_none()
234        }
235    }
236
237    /// A grouping for partitioning the result set.
238    #[derive(Clone, Default, Debug, Deserialize, Serialize)]
239    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
240    pub struct Grouping {
241        /// The key within events to use for this grouping.
242        pub key: Option<GroupingKey>,
243    }
244
245    impl Grouping {
246        /// Creates an empty `Grouping`.
247        pub fn new() -> Self {
248            Default::default()
249        }
250
251        /// Returns whether `key` is `None`.
252        pub fn is_empty(&self) -> bool {
253            self.key.is_none()
254        }
255    }
256
257    /// The key within events to use for this grouping.
258    #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
259    #[derive(Clone, StringEnum)]
260    #[ruma_enum(rename_all = "snake_case")]
261    #[non_exhaustive]
262    pub enum GroupingKey {
263        /// `room_id`
264        RoomId,
265
266        /// `sender`
267        Sender,
268
269        #[doc(hidden)]
270        _Custom(PrivOwnedStr),
271    }
272
273    /// Requests that the server partitions the result set based on the provided list of keys.
274    #[derive(Clone, Default, Debug, Deserialize, Serialize)]
275    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
276    pub struct Groupings {
277        /// List of groups to request.
278        #[serde(default, skip_serializing_if = "<[_]>::is_empty")]
279        pub group_by: Vec<Grouping>,
280    }
281
282    impl Groupings {
283        /// Creates an empty `Groupings`.
284        pub fn new() -> Self {
285            Default::default()
286        }
287
288        /// Returns `true` if all fields are empty.
289        pub fn is_empty(&self) -> bool {
290            self.group_by.is_empty()
291        }
292    }
293
294    /// The keys to search for.
295    #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
296    #[derive(Clone, StringEnum)]
297    #[non_exhaustive]
298    pub enum SearchKeys {
299        /// content.body
300        #[ruma_enum(rename = "content.body")]
301        ContentBody,
302
303        /// content.name
304        #[ruma_enum(rename = "content.name")]
305        ContentName,
306
307        /// content.topic
308        #[ruma_enum(rename = "content.topic")]
309        ContentTopic,
310
311        #[doc(hidden)]
312        _Custom(PrivOwnedStr),
313    }
314
315    /// The order in which to search for results.
316    #[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
317    #[derive(Clone, StringEnum)]
318    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
319    #[ruma_enum(rename_all = "snake_case")]
320    pub enum OrderBy {
321        /// Prioritize recent events.
322        Recent,
323
324        /// Prioritize events by a numerical ranking of how closely they matched the search
325        /// criteria.
326        Rank,
327
328        #[doc(hidden)]
329        _Custom(PrivOwnedStr),
330    }
331
332    /// Categories of events that can be searched for.
333    #[derive(Clone, Default, Debug, Deserialize, Serialize)]
334    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
335    pub struct ResultCategories {
336        /// Room event results.
337        #[serde(default, skip_serializing_if = "ResultRoomEvents::is_empty")]
338        pub room_events: ResultRoomEvents,
339    }
340
341    impl ResultCategories {
342        /// Creates an empty `ResultCategories`.
343        pub fn new() -> Self {
344            Default::default()
345        }
346    }
347
348    /// Categories of events that can be searched for.
349    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
350    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
351    pub struct ResultRoomEvents {
352        /// An approximate count of the total number of results found.
353        #[serde(skip_serializing_if = "Option::is_none")]
354        pub count: Option<UInt>,
355
356        /// Any groups that were requested.
357        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
358        pub groups: ResultGroupMapsByGroupingKey,
359
360        /// Token that can be used to get the next batch of results, by passing as the `next_batch`
361        /// parameter to the next call.
362        ///
363        /// If this field is absent, there are no more results.
364        #[serde(skip_serializing_if = "Option::is_none")]
365        pub next_batch: Option<String>,
366
367        /// List of results in the requested order.
368        #[serde(default, skip_serializing_if = "Vec::is_empty")]
369        pub results: Vec<SearchResult>,
370
371        /// The current state for every room in the results.
372        ///
373        /// This is included if the request had the `include_state` key set with a value of `true`.
374        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
375        pub state: BTreeMap<OwnedRoomId, Vec<Raw<AnyStateEvent>>>,
376
377        /// List of words which should be highlighted, useful for stemming which may
378        /// change the query terms.
379        #[serde(default, skip_serializing_if = "Vec::is_empty")]
380        pub highlights: Vec<String>,
381    }
382
383    impl ResultRoomEvents {
384        /// Creates an empty `ResultRoomEvents`.
385        pub fn new() -> Self {
386            Default::default()
387        }
388
389        /// Returns `true` if all fields are empty / `None`.
390        pub fn is_empty(&self) -> bool {
391            self.count.is_none()
392                && self.groups.is_empty()
393                && self.next_batch.is_none()
394                && self.results.is_empty()
395                && self.state.is_empty()
396                && self.highlights.is_empty()
397        }
398    }
399
400    /// A map of [`GroupingKey`] to the associated [`ResultGroupMap`].
401    ///
402    /// This type is used to ensure that a supported [`ResultGroupMap`] always uses the appropriate
403    /// [`GroupingKey`].
404    #[derive(Clone, Debug, Default)]
405    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
406    pub struct ResultGroupMapsByGroupingKey(BTreeMap<GroupingKey, ResultGroupMap>);
407
408    impl ResultGroupMapsByGroupingKey {
409        /// Construct an empty `ResultGroupMapsByGroupingKey`.
410        pub fn new() -> Self {
411            Self::default()
412        }
413
414        /// Insert the given [`ResultGroupMap`].
415        ///
416        /// If a map with the same [`GroupingKey`] was already present, it is returned.
417        pub fn insert(&mut self, map: ResultGroupMap) -> Option<ResultGroupMap> {
418            self.0.insert(map.grouping_key(), map)
419        }
420    }
421
422    impl Deref for ResultGroupMapsByGroupingKey {
423        type Target = BTreeMap<GroupingKey, ResultGroupMap>;
424
425        fn deref(&self) -> &Self::Target {
426            &self.0
427        }
428    }
429
430    impl FromIterator<ResultGroupMap> for ResultGroupMapsByGroupingKey {
431        fn from_iter<T: IntoIterator<Item = ResultGroupMap>>(iter: T) -> Self {
432            Self(iter.into_iter().map(|map| (map.grouping_key(), map)).collect())
433        }
434    }
435
436    impl Extend<ResultGroupMap> for ResultGroupMapsByGroupingKey {
437        fn extend<T: IntoIterator<Item = ResultGroupMap>>(&mut self, iter: T) {
438            self.0.extend(iter.into_iter().map(|map| (map.grouping_key(), map)));
439        }
440    }
441
442    impl IntoIterator for ResultGroupMapsByGroupingKey {
443        type Item = ResultGroupMap;
444        type IntoIter = btree_map::IntoValues<GroupingKey, ResultGroupMap>;
445
446        fn into_iter(self) -> Self::IntoIter {
447            self.0.into_values()
448        }
449    }
450
451    /// A map of results grouped by key.
452    #[derive(Clone, Debug)]
453    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
454    pub enum ResultGroupMap {
455        /// Results grouped by room ID.
456        RoomId(BTreeMap<OwnedRoomId, ResultGroup>),
457
458        /// Results grouped by sender.
459        Sender(BTreeMap<OwnedUserId, ResultGroup>),
460
461        #[doc(hidden)]
462        _Custom(CustomResultGroupMap),
463    }
464
465    impl ResultGroupMap {
466        /// The key that was used to group this map.
467        pub fn grouping_key(&self) -> GroupingKey {
468            match self {
469                Self::RoomId(_) => GroupingKey::RoomId,
470                Self::Sender(_) => GroupingKey::Sender,
471                Self::_Custom(custom) => custom.grouping_key.as_str().into(),
472            }
473        }
474
475        /// The map of grouped results, if this uses a custom key.
476        pub fn custom_map(&self) -> Option<&BTreeMap<String, ResultGroup>> {
477            as_variant!(self, Self::_Custom).map(|custom| &custom.map)
478        }
479
480        /// Convert this into the map of grouped results, if this uses a custom key.
481        pub fn into_custom_map(self) -> Option<BTreeMap<String, ResultGroup>> {
482            as_variant!(self, Self::_Custom).map(|custom| custom.map)
483        }
484    }
485
486    /// A map of results grouped by custom key type.
487    #[doc(hidden)]
488    #[derive(Clone, Debug)]
489    pub struct CustomResultGroupMap {
490        /// The type of key that was used to group the results.
491        pub(super) grouping_key: String,
492
493        /// The grouped results.
494        pub(super) map: BTreeMap<String, ResultGroup>,
495    }
496
497    /// A group of results.
498    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
499    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
500    pub struct ResultGroup {
501        /// Token that can be used to get the next batch of results in the group, by passing as the
502        /// `next_batch` parameter to the next call.
503        ///
504        /// If this field is absent, there are no more results in this group.
505        #[serde(skip_serializing_if = "Option::is_none")]
506        pub next_batch: Option<String>,
507
508        /// Key that can be used to order different groups.
509        #[serde(skip_serializing_if = "Option::is_none")]
510        pub order: Option<UInt>,
511
512        /// Which results are in this group.
513        #[serde(default, skip_serializing_if = "Vec::is_empty")]
514        pub results: Vec<OwnedEventId>,
515    }
516
517    impl ResultGroup {
518        /// Creates an empty `ResultGroup`.
519        pub fn new() -> Self {
520            Default::default()
521        }
522
523        /// Returns `true` if all fields are empty / `None`.
524        pub fn is_empty(&self) -> bool {
525            self.next_batch.is_none() && self.order.is_none() && self.results.is_empty()
526        }
527    }
528
529    /// A search result.
530    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
531    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
532    pub struct SearchResult {
533        /// Context for result, if requested.
534        #[serde(default, skip_serializing_if = "EventContextResult::is_empty")]
535        pub context: EventContextResult,
536
537        /// A number that describes how closely this result matches the search.
538        ///
539        /// Higher is closer.
540        #[serde(skip_serializing_if = "Option::is_none")]
541        pub rank: Option<f64>,
542
543        /// The event that matched.
544        #[serde(skip_serializing_if = "Option::is_none")]
545        pub result: Option<Raw<AnyTimelineEvent>>,
546    }
547
548    impl SearchResult {
549        /// Creates an empty `SearchResult`.
550        pub fn new() -> Self {
551            Default::default()
552        }
553
554        /// Returns `true` if all fields are empty / `None`.
555        pub fn is_empty(&self) -> bool {
556            self.context.is_empty() && self.rank.is_none() && self.result.is_none()
557        }
558    }
559
560    /// A user profile.
561    #[derive(Clone, Debug, Default, Deserialize, Serialize)]
562    #[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
563    pub struct UserProfile {
564        /// The user's avatar URL, if set.
565        ///
566        /// If you activate the `compat-empty-string-null` feature, this field being an empty
567        /// string in JSON will result in `None` here during deserialization.
568        #[serde(skip_serializing_if = "Option::is_none")]
569        #[cfg_attr(
570            feature = "compat-empty-string-null",
571            serde(default, deserialize_with = "ruma_common::serde::empty_string_as_none")
572        )]
573        pub avatar_url: Option<OwnedMxcUri>,
574
575        /// The user's display name, if set.
576        #[serde(skip_serializing_if = "Option::is_none")]
577        pub displayname: Option<String>,
578    }
579
580    impl UserProfile {
581        /// Creates an empty `UserProfile`.
582        pub fn new() -> Self {
583            Default::default()
584        }
585
586        /// Returns `true` if all fields are `None`.
587        pub fn is_empty(&self) -> bool {
588            self.avatar_url.is_none() && self.displayname.is_none()
589        }
590    }
591}
592
593#[cfg(all(test, feature = "client", feature = "server"))]
594mod tests {
595    use std::{borrow::Cow, collections::BTreeMap};
596
597    use assert_matches2::assert_matches;
598    use js_int::uint;
599    use ruma_common::{
600        api::{
601            IncomingRequest, IncomingResponse, OutgoingRequest, OutgoingResponse,
602            SupportedVersions, auth_scheme::SendAccessToken,
603        },
604        event_id, room_id,
605    };
606    use serde_json::{
607        Value as JsonValue, from_slice as from_json_slice, json, to_vec as to_json_vec,
608    };
609
610    use super::v3::{GroupingKey, OrderBy, Request, Response, ResultGroupMap, SearchKeys};
611
612    #[test]
613    fn request_roundtrip() {
614        let body = json!({
615            "search_categories": {
616                "room_events": {
617                    "groupings": {
618                        "group_by": [
619                            { "key": "room_id" },
620                        ],
621                    },
622                    "keys": ["content.body"],
623                    "order_by": "recent",
624                    "search_term": "martians and men"
625                }
626            }
627        });
628
629        let http_request = http::Request::post("http://localhost/_matrix/client/v3/search")
630            .body(to_json_vec(&body).unwrap())
631            .unwrap();
632        let request = Request::try_from_http_request(http_request, &[] as &[&str]).unwrap();
633
634        let criteria = request.search_categories.room_events.as_ref().unwrap();
635        assert_eq!(criteria.groupings.group_by.len(), 1);
636        assert_eq!(criteria.groupings.group_by[0].key, Some(GroupingKey::RoomId));
637        let keys = criteria.keys.as_ref().unwrap();
638        assert_eq!(keys.len(), 1);
639        assert_eq!(keys[0], SearchKeys::ContentBody);
640        assert_eq!(criteria.order_by, Some(OrderBy::Recent));
641        assert_eq!(criteria.search_term, "martians and men");
642
643        let http_request = request
644            .try_into_http_request::<Vec<u8>>(
645                "http://localhost",
646                SendAccessToken::IfRequired("access_token"),
647                Cow::Owned(SupportedVersions::from_parts(&["v1.4".to_owned()], &BTreeMap::new())),
648            )
649            .unwrap();
650        assert_eq!(from_json_slice::<JsonValue>(http_request.body()).unwrap(), body);
651    }
652
653    #[test]
654    fn response_roundtrip() {
655        let body = json!({
656            "search_categories": {
657                "room_events": {
658                    "count": 1224,
659                    "groups": {
660                        "room_id": {
661                            "!qPewotXpIctQySfjSy:localhost": {
662                                "next_batch": "BdgFsdfHSf-dsFD",
663                                "order": 1,
664                                "results": ["$144429830826TWwbB:localhost"],
665                            },
666                        },
667                    },
668                    "highlights": [
669                        "martians",
670                        "men",
671                    ],
672                    "next_batch": "5FdgFsd234dfgsdfFD",
673                    "results": [
674                        {
675                            "rank": 0.004_248_66,
676                            "result": {
677                                "content": {
678                                    "body": "This is an example text message",
679                                    "format": "org.matrix.custom.html",
680                                    "formatted_body": "<b>This is an example text message</b>",
681                                    "msgtype": "m.text",
682                                },
683                                "event_id": "$144429830826TWwbB:localhost",
684                                "origin_server_ts": 1_735_824_653,
685                                "room_id": "!qPewotXpIctQySfjSy:localhost",
686                                "sender": "@example:example.org",
687                                "type": "m.room.message",
688                                "unsigned": {
689                                    "age": 1234,
690                                    "membership": "join",
691                                }
692                            }
693                        }
694                    ]
695                }
696            }
697        });
698        let result_event_id = event_id!("$144429830826TWwbB:localhost");
699
700        let http_request = http::Response::new(to_json_vec(&body).unwrap());
701        let response = Response::try_from_http_response(http_request).unwrap();
702
703        let results = &response.search_categories.room_events;
704        assert_eq!(results.count, Some(uint!(1224)));
705        assert_eq!(results.groups.len(), 1);
706        assert_matches!(
707            results.groups.get(&GroupingKey::RoomId),
708            Some(ResultGroupMap::RoomId(room_id_group_map))
709        );
710        assert_eq!(room_id_group_map.len(), 1);
711        let room_id_group =
712            room_id_group_map.get(room_id!("!qPewotXpIctQySfjSy:localhost")).unwrap();
713        assert_eq!(room_id_group.results, &[result_event_id]);
714        assert_eq!(results.highlights, &["martians", "men"]);
715        assert_eq!(results.next_batch.as_deref(), Some("5FdgFsd234dfgsdfFD"));
716        assert_eq!(results.results.len(), 1);
717        assert_eq!(results.results[0].rank, Some(0.004_248_66));
718        let result = results.results[0].result.as_ref().unwrap().deserialize().unwrap();
719        assert_eq!(result.event_id(), result_event_id);
720
721        let http_response = response.try_into_http_response::<Vec<u8>>().unwrap();
722        assert_eq!(from_json_slice::<JsonValue>(http_response.body()).unwrap(), body);
723    }
724}