From 415c62cf7c41fb54f8e47665bff6531a91c3ef95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Wed, 20 May 2026 13:53:03 +0200 Subject: [PATCH 01/10] Implement StreamChatClient.SearchMessagesAsync --- .../StreamChat/Core/IStreamChatClient.cs | 25 +++ .../Filters/BaseFieldToFilter.cs | 3 + .../Core/QueryBuilders/Filters/Messages.meta | 8 + .../Messages/MessageFieldAttachmentType.cs | 21 ++ .../MessageFieldAttachmentType.cs.meta | 11 ++ .../Filters/Messages/MessageFieldCreatedAt.cs | 28 +++ .../Messages/MessageFieldCreatedAt.cs.meta | 11 ++ .../Filters/Messages/MessageFieldCustom.cs | 53 +++++ .../Messages/MessageFieldCustom.cs.meta | 11 ++ .../Messages/MessageFieldMentionedUserId.cs | 31 +++ .../MessageFieldMentionedUserId.cs.meta | 11 ++ .../Filters/Messages/MessageFieldParentId.cs | 34 ++++ .../Messages/MessageFieldParentId.cs.meta | 11 ++ .../Filters/Messages/MessageFieldPinned.cs | 15 ++ .../Messages/MessageFieldPinned.cs.meta | 11 ++ .../Filters/Messages/MessageFieldPollId.cs | 27 +++ .../Messages/MessageFieldPollId.cs.meta | 11 ++ .../Messages/MessageFieldReactionType.cs | 21 ++ .../Messages/MessageFieldReactionType.cs.meta | 11 ++ .../Messages/MessageFieldShowInChannel.cs | 15 ++ .../MessageFieldShowInChannel.cs.meta | 11 ++ .../Filters/Messages/MessageFieldSilent.cs | 14 ++ .../Messages/MessageFieldSilent.cs.meta | 11 ++ .../Filters/Messages/MessageFieldText.cs | 31 +++ .../Filters/Messages/MessageFieldText.cs.meta | 11 ++ .../MessageFieldThreadParticipantId.cs | 30 +++ .../MessageFieldThreadParticipantId.cs.meta | 11 ++ .../Filters/Messages/MessageFieldType.cs | 20 ++ .../Filters/Messages/MessageFieldType.cs.meta | 11 ++ .../Filters/Messages/MessageFieldUpdatedAt.cs | 28 +++ .../Messages/MessageFieldUpdatedAt.cs.meta | 11 ++ .../Filters/Messages/MessageFieldUserId.cs | 26 +++ .../Messages/MessageFieldUserId.cs.meta | 11 ++ .../Filters/Messages/MessageFilter.cs | 60 ++++++ .../Filters/Messages/MessageFilter.cs.meta | 11 ++ .../Core/QueryBuilders/Sort/MessagesSort.cs | 44 +++++ .../QueryBuilders/Sort/MessagesSort.cs.meta | 11 ++ .../QueryBuilders/Sort/MessagesSortObject.cs | 36 ++++ .../Sort/MessagesSortObject.cs.meta | 11 ++ .../Requests/StreamSearchMessagesRequest.cs | 104 ++++++++++ .../StreamSearchMessagesRequest.cs.meta | 11 ++ .../Responses/StreamSearchMessageResult.cs | 31 +++ .../StreamSearchMessageResult.cs.meta | 11 ++ .../Responses/StreamSearchMessagesResponse.cs | 37 ++++ .../StreamSearchMessagesResponse.cs.meta | 11 ++ .../Core/Responses/StreamSearchWarning.cs | 31 +++ .../Responses/StreamSearchWarning.cs.meta | 11 ++ .../StreamChat/Core/StreamChatClient.cs | 184 ++++++++++++++++++ 48 files changed, 1199 insertions(+) create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs create mode 100644 Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs create mode 100644 Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs.meta create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs create mode 100644 Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs.meta diff --git a/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs b/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs index 8ffd826c..1f4a61a9 100644 --- a/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/IStreamChatClient.cs @@ -270,6 +270,31 @@ Task GetThreadAsync(string parentMessageId, /// Query request Task QueryThreadsAsync(StreamQueryThreadsRequest request); + /// + /// Search messages across the channels the local user can access. + /// + /// Unlike the low-level Client.LowLevelClient.MessageApi.SearchMessagesAsync, results + /// are returned as cached, stateful (and accompanying + /// ) instances - the same objects already in the cache are + /// reused, and they continue to react to realtime WebSocket events. + /// + /// + /// The requires a channel-level filter (e.g. + /// ChannelFilter.Members.In(localUser)). Additional message-level filters can be + /// expressed with MessageFilter.* builders, and a free-text phrase can be supplied + /// via . See + /// for pagination and sorting options. + /// + /// + /// Search parameters - channel filter, message filter, query phrase, + /// sort, and pagination. + /// [Optional] Cancellation token for the request. + /// Stateful results plus pagination cursors. + /// https://getstream.io/chat/docs/unity/search/?language=unity + Task SearchMessagesAsync( + StreamSearchMessagesRequest request, + CancellationToken cancellationToken = default(CancellationToken)); + /// /// Upsert users. Upsert means update this user or create if not found /// diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/BaseFieldToFilter.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/BaseFieldToFilter.cs index 5889db0d..e54548a8 100644 --- a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/BaseFieldToFilter.cs +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/BaseFieldToFilter.cs @@ -87,5 +87,8 @@ protected FieldFilterRule InternalAutocomplete(string value) protected FieldFilterRule InternalContains(string value) => new FieldFilterRule(FieldName, QueryOperatorType.Contains, value); + + protected FieldFilterRule InternalExists(bool exists) + => new FieldFilterRule(FieldName, QueryOperatorType.Exists, exists); } } \ No newline at end of file diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages.meta new file mode 100644 index 00000000..53c28cdb --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 12a1ae9203376aa4d90b214896d6c176 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs new file mode 100644 index 00000000..746a6dfc --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by the type of an attachment on the message (image, video, + /// file, audio, giphy, location, or any custom type). + /// + public sealed class MessageFieldAttachmentType : BaseFieldToFilter + { + public override string FieldName => "attachments.type"; + + public FieldFilterRule EqualsTo(string attachmentType) => InternalEqualsTo(attachmentType); + + public FieldFilterRule Contains(string attachmentType) => InternalContains(attachmentType); + + public FieldFilterRule In(IEnumerable attachmentTypes) => InternalIn(attachmentTypes); + + public FieldFilterRule In(params string[] attachmentTypes) => InternalIn(attachmentTypes); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs.meta new file mode 100644 index 00000000..10ee944a --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldAttachmentType.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ad3e2c90c46e0f84db35b098e3ab06ed +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs new file mode 100644 index 00000000..9dbdf854 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs @@ -0,0 +1,28 @@ +using System; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by message timestamp. + /// + public sealed class MessageFieldCreatedAt : BaseFieldToFilter + { + public override string FieldName => "created_at"; + + public FieldFilterRule EqualsTo(DateTime date) => InternalEqualsTo(date); + public FieldFilterRule EqualsTo(DateTimeOffset date) => InternalEqualsTo(date); + + public FieldFilterRule GreaterThan(DateTime date) => InternalGreaterThan(date); + public FieldFilterRule GreaterThan(DateTimeOffset date) => InternalGreaterThan(date); + + public FieldFilterRule GreaterThanOrEquals(DateTime date) => InternalGreaterThanOrEquals(date); + public FieldFilterRule GreaterThanOrEquals(DateTimeOffset date) => InternalGreaterThanOrEquals(date); + + public FieldFilterRule LessThan(DateTime date) => InternalLessThan(date); + public FieldFilterRule LessThan(DateTimeOffset date) => InternalLessThan(date); + + public FieldFilterRule LessThanOrEquals(DateTime date) => InternalLessThanOrEquals(date); + public FieldFilterRule LessThanOrEquals(DateTimeOffset date) => InternalLessThanOrEquals(date); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs.meta new file mode 100644 index 00000000..ed3776a2 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCreatedAt.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f71526cd6b0a1e1429d9d6c435c5f05b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs new file mode 100644 index 00000000..8390b8dd --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using StreamChat.Core.State; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by an arbitrary custom message field (any top-level key the customer attached to the message). + /// + public sealed class MessageFieldCustom : BaseFieldToFilter + { + public override string FieldName { get; } + + public MessageFieldCustom(string customFieldName) + { + StreamAsserts.AssertNotNullOrEmpty(customFieldName, nameof(customFieldName)); + FieldName = customFieldName; + } + + public FieldFilterRule EqualsTo(string value) => InternalEqualsTo(value); + public FieldFilterRule EqualsTo(bool value) => InternalEqualsTo(value); + public FieldFilterRule EqualsTo(int value) => InternalEqualsTo(value); + public FieldFilterRule EqualsTo(DateTime value) => InternalEqualsTo(value); + public FieldFilterRule EqualsTo(DateTimeOffset value) => InternalEqualsTo(value); + + public FieldFilterRule In(IEnumerable values) => InternalIn(values); + public FieldFilterRule In(params string[] values) => InternalIn(values); + + public FieldFilterRule GreaterThan(int value) => InternalGreaterThan(value); + public FieldFilterRule GreaterThan(string value) => InternalGreaterThan(value); + public FieldFilterRule GreaterThan(DateTime value) => InternalGreaterThan(value); + public FieldFilterRule GreaterThan(DateTimeOffset value) => InternalGreaterThan(value); + + public FieldFilterRule GreaterThanOrEquals(int value) => InternalGreaterThanOrEquals(value); + public FieldFilterRule GreaterThanOrEquals(string value) => InternalGreaterThanOrEquals(value); + public FieldFilterRule GreaterThanOrEquals(DateTime value) => InternalGreaterThanOrEquals(value); + public FieldFilterRule GreaterThanOrEquals(DateTimeOffset value) => InternalGreaterThanOrEquals(value); + + public FieldFilterRule LessThan(int value) => InternalLessThan(value); + public FieldFilterRule LessThan(string value) => InternalLessThan(value); + public FieldFilterRule LessThan(DateTime value) => InternalLessThan(value); + public FieldFilterRule LessThan(DateTimeOffset value) => InternalLessThan(value); + + public FieldFilterRule LessThanOrEquals(int value) => InternalLessThanOrEquals(value); + public FieldFilterRule LessThanOrEquals(string value) => InternalLessThanOrEquals(value); + public FieldFilterRule LessThanOrEquals(DateTime value) => InternalLessThanOrEquals(value); + public FieldFilterRule LessThanOrEquals(DateTimeOffset value) => InternalLessThanOrEquals(value); + + public FieldFilterRule Contains(string value) => InternalContains(value); + + public FieldFilterRule Exists(bool exists) => InternalExists(exists); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs.meta new file mode 100644 index 00000000..f3666ef9 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldCustom.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c1f624ee34cf12841bf7c795fcb4c638 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs new file mode 100644 index 00000000..487d1d1c --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by the id of a user mentioned in the message + /// (). + /// + public sealed class MessageFieldMentionedUserId : BaseFieldToFilter + { + public override string FieldName => "mentioned_users.id"; + + public FieldFilterRule EqualsTo(string userId) => InternalEqualsTo(userId); + + public FieldFilterRule EqualsTo(IStreamUser user) => InternalEqualsTo(user.Id); + + public FieldFilterRule Contains(string userId) => InternalContains(userId); + + public FieldFilterRule Contains(IStreamUser user) => InternalContains(user.Id); + + public FieldFilterRule In(IEnumerable userIds) => InternalIn(userIds); + + public FieldFilterRule In(params string[] userIds) => InternalIn(userIds); + + public FieldFilterRule In(IEnumerable users) => InternalIn(users.Select(_ => _.Id)); + + public FieldFilterRule In(params IStreamUser[] users) => InternalIn(users.Select(_ => _.Id)); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs.meta new file mode 100644 index 00000000..eb6ef424 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldMentionedUserId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 92cefb1a02b05d54aa8167aaf8fe9465 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs new file mode 100644 index 00000000..bcdad229 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by . + /// + /// Typical usage: + /// + /// Exists(true) - only thread replies. + /// Exists(false) - only top-level messages. + /// EqualsTo(parentId) - replies to a specific parent message. + /// + /// + public sealed class MessageFieldParentId : BaseFieldToFilter + { + public override string FieldName => "parent_id"; + + public FieldFilterRule EqualsTo(string parentMessageId) => InternalEqualsTo(parentMessageId); + + public FieldFilterRule EqualsTo(IStreamMessage parentMessage) => InternalEqualsTo(parentMessage.Id); + + public FieldFilterRule In(IEnumerable parentMessageIds) => InternalIn(parentMessageIds); + + public FieldFilterRule In(params string[] parentMessageIds) => InternalIn(parentMessageIds); + + /// + /// When true, returns only replies (messages whose parent_id is set). + /// When false, returns only top-level (non-reply) messages. + /// + public FieldFilterRule Exists(bool exists) => InternalExists(exists); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs.meta new file mode 100644 index 00000000..cf00dbec --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldParentId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8453c21d05b7d354e9b339965ab6eb25 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs new file mode 100644 index 00000000..9dd34a6e --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs @@ -0,0 +1,15 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by . + /// Useful for cross-channel pinned-message searches. + /// + public sealed class MessageFieldPinned : BaseFieldToFilter + { + public override string FieldName => "pinned"; + + public FieldFilterRule EqualsTo(bool pinned) => InternalEqualsTo(pinned); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs.meta new file mode 100644 index 00000000..4ab86db5 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPinned.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 25b7f4db1fcc6f840a3579877f4545f6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs new file mode 100644 index 00000000..5271c757 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by the id of a poll attached to the message. + /// + /// Use with true to find any message that has a poll attached, + /// or pass a specific poll id to find the message that hosts a known poll. + /// + public sealed class MessageFieldPollId : BaseFieldToFilter + { + public override string FieldName => "poll_id"; + + public FieldFilterRule EqualsTo(string pollId) => InternalEqualsTo(pollId); + + public FieldFilterRule In(IEnumerable pollIds) => InternalIn(pollIds); + + public FieldFilterRule In(params string[] pollIds) => InternalIn(pollIds); + + /// + /// When true, returns only messages that have a poll attached. + /// When false, returns only messages without a poll. + /// + public FieldFilterRule Exists(bool exists) => InternalExists(exists); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs.meta new file mode 100644 index 00000000..7d3eed72 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldPollId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0adffc8d461563d4c8caf779058f1155 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs new file mode 100644 index 00000000..d0dbdf11 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by the type of a reaction on the message (e.g. like, love, fire). + /// Matches the latest reactions tracked server-side. + /// + public sealed class MessageFieldReactionType : BaseFieldToFilter + { + public override string FieldName => "latest_reactions.type"; + + public FieldFilterRule EqualsTo(string reactionType) => InternalEqualsTo(reactionType); + + public FieldFilterRule Contains(string reactionType) => InternalContains(reactionType); + + public FieldFilterRule In(IEnumerable reactionTypes) => InternalIn(reactionTypes); + + public FieldFilterRule In(params string[] reactionTypes) => InternalIn(reactionTypes); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs.meta new file mode 100644 index 00000000..82f89055 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldReactionType.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a44b919dcbbc6d48a35c098cc8f0bbd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs new file mode 100644 index 00000000..ff499037 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs @@ -0,0 +1,15 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by . Relevant for thread replies that are also + /// shown in the parent channel feed. + /// + public sealed class MessageFieldShowInChannel : BaseFieldToFilter + { + public override string FieldName => "show_in_channel"; + + public FieldFilterRule EqualsTo(bool showInChannel) => InternalEqualsTo(showInChannel); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs.meta new file mode 100644 index 00000000..3ffadb98 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldShowInChannel.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 163b3770081860f4d9b081e39326e111 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs new file mode 100644 index 00000000..4452200b --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs @@ -0,0 +1,14 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by . + /// + public sealed class MessageFieldSilent : BaseFieldToFilter + { + public override string FieldName => "silent"; + + public FieldFilterRule EqualsTo(bool silent) => InternalEqualsTo(silent); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs.meta new file mode 100644 index 00000000..4673accd --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldSilent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d909a6342303b5d43a2353c16511aa28 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs new file mode 100644 index 00000000..f2b2b2cd --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs @@ -0,0 +1,31 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by . + /// + /// Note: combining a text rule with + /// is rejected by the server + /// and validated client-side. + /// + public sealed class MessageFieldText : BaseFieldToFilter + { + public override string FieldName => "text"; + + /// + /// Return only messages where is EQUAL to the provided value. + /// + public FieldFilterRule EqualsTo(string text) => InternalEqualsTo(text); + + /// + /// Return only messages where CONTAINS the provided phrase. + /// + public FieldFilterRule Contains(string phrase) => InternalContains(phrase); + + /// + /// Return only messages where matches the provided autocomplete phrase. + /// + public FieldFilterRule Autocomplete(string phrase) => InternalAutocomplete(phrase); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs.meta new file mode 100644 index 00000000..374abaae --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldText.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c0a31cadb092db540b80b057127fca14 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs new file mode 100644 index 00000000..461fb7c3 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Linq; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by the id of a user participating in the thread the message belongs to. + /// + public sealed class MessageFieldThreadParticipantId : BaseFieldToFilter + { + public override string FieldName => "thread_participants.id"; + + public FieldFilterRule EqualsTo(string userId) => InternalEqualsTo(userId); + + public FieldFilterRule EqualsTo(IStreamUser user) => InternalEqualsTo(user.Id); + + public FieldFilterRule Contains(string userId) => InternalContains(userId); + + public FieldFilterRule Contains(IStreamUser user) => InternalContains(user.Id); + + public FieldFilterRule In(IEnumerable userIds) => InternalIn(userIds); + + public FieldFilterRule In(params string[] userIds) => InternalIn(userIds); + + public FieldFilterRule In(IEnumerable users) => InternalIn(users.Select(_ => _.Id)); + + public FieldFilterRule In(params IStreamUser[] users) => InternalIn(users.Select(_ => _.Id)); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs.meta new file mode 100644 index 00000000..01d64324 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldThreadParticipantId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 54a3ff0de0069584db1bffd9e03b619d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs new file mode 100644 index 00000000..60fc55bf --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by message type (). + /// Common values: regular, system, deleted, reply, ephemeral. + /// + public sealed class MessageFieldType : BaseFieldToFilter + { + public override string FieldName => "type"; + + public FieldFilterRule EqualsTo(string messageType) => InternalEqualsTo(messageType); + + public FieldFilterRule In(IEnumerable messageTypes) => InternalIn(messageTypes); + + public FieldFilterRule In(params string[] messageTypes) => InternalIn(messageTypes); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs.meta new file mode 100644 index 00000000..5138f056 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldType.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d67fc6842b6968348be027a872d6fdce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs new file mode 100644 index 00000000..73daac48 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs @@ -0,0 +1,28 @@ +using System; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by message timestamp. + /// + public sealed class MessageFieldUpdatedAt : BaseFieldToFilter + { + public override string FieldName => "updated_at"; + + public FieldFilterRule EqualsTo(DateTime date) => InternalEqualsTo(date); + public FieldFilterRule EqualsTo(DateTimeOffset date) => InternalEqualsTo(date); + + public FieldFilterRule GreaterThan(DateTime date) => InternalGreaterThan(date); + public FieldFilterRule GreaterThan(DateTimeOffset date) => InternalGreaterThan(date); + + public FieldFilterRule GreaterThanOrEquals(DateTime date) => InternalGreaterThanOrEquals(date); + public FieldFilterRule GreaterThanOrEquals(DateTimeOffset date) => InternalGreaterThanOrEquals(date); + + public FieldFilterRule LessThan(DateTime date) => InternalLessThan(date); + public FieldFilterRule LessThan(DateTimeOffset date) => InternalLessThan(date); + + public FieldFilterRule LessThanOrEquals(DateTime date) => InternalLessThanOrEquals(date); + public FieldFilterRule LessThanOrEquals(DateTimeOffset date) => InternalLessThanOrEquals(date); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs.meta new file mode 100644 index 00000000..f9c76828 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUpdatedAt.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 94b8bba30f6d61e42918e400e848c460 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs new file mode 100644 index 00000000..4be3892d --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Linq; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filter by message author's user id (.). + /// + public sealed class MessageFieldUserId : BaseFieldToFilter + { + public override string FieldName => "user.id"; + + public FieldFilterRule EqualsTo(string userId) => InternalEqualsTo(userId); + + public FieldFilterRule EqualsTo(IStreamUser user) => InternalEqualsTo(user.Id); + + public FieldFilterRule In(IEnumerable userIds) => InternalIn(userIds); + + public FieldFilterRule In(params string[] userIds) => InternalIn(userIds); + + public FieldFilterRule In(IEnumerable users) => InternalIn(users.Select(_ => _.Id)); + + public FieldFilterRule In(params IStreamUser[] users) => InternalIn(users.Select(_ => _.Id)); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs.meta new file mode 100644 index 00000000..6d60fa0b --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFieldUserId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5dde1fb4540acab4b933f1a3f5807638 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs new file mode 100644 index 00000000..cb4396d1 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs @@ -0,0 +1,60 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.QueryBuilders.Filters.Messages +{ + /// + /// Filters for conditions used by + /// . + /// + /// These rules go into + /// and are applied to messages within the channels matched by + /// . + /// + public static class MessageFilter + { + /// + public static MessageFieldText Text { get; } = new MessageFieldText(); + + /// + public static MessageFieldUserId UserId { get; } = new MessageFieldUserId(); + + /// + public static MessageFieldType Type { get; } = new MessageFieldType(); + + /// + public static MessageFieldCreatedAt CreatedAt { get; } = new MessageFieldCreatedAt(); + + /// + public static MessageFieldUpdatedAt UpdatedAt { get; } = new MessageFieldUpdatedAt(); + + /// + public static MessageFieldParentId ParentId { get; } = new MessageFieldParentId(); + + /// + public static MessageFieldPinned Pinned { get; } = new MessageFieldPinned(); + + /// + public static MessageFieldSilent Silent { get; } = new MessageFieldSilent(); + + /// + public static MessageFieldMentionedUserId MentionedUserId { get; } = new MessageFieldMentionedUserId(); + + /// + public static MessageFieldThreadParticipantId ThreadParticipantId { get; } = new MessageFieldThreadParticipantId(); + + /// + public static MessageFieldAttachmentType AttachmentType { get; } = new MessageFieldAttachmentType(); + + /// + public static MessageFieldReactionType ReactionType { get; } = new MessageFieldReactionType(); + + /// + public static MessageFieldPollId PollId { get; } = new MessageFieldPollId(); + + /// + public static MessageFieldShowInChannel ShowInChannel { get; } = new MessageFieldShowInChannel(); + + /// + public static MessageFieldCustom Custom(string customFieldName) => new MessageFieldCustom(customFieldName); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs.meta new file mode 100644 index 00000000..7b1384d4 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/Messages/MessageFilter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d8f63c8488b32c459aff48d37330da3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs new file mode 100644 index 00000000..856d25a2 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs @@ -0,0 +1,44 @@ +namespace StreamChat.Core.QueryBuilders.Sort +{ + /// + /// Factory for sort object building. + /// + /// + /// Note: the server forbids combining a sort with a non-zero offset. To paginate + /// sorted results use the Next cursor returned by the previous response. + /// + public static class MessagesSort + { + /// + /// Sort in ascending order (lowest to highest) by the specified field. + /// + public static MessagesSortObject OrderByAscending(MessageSortFieldName fieldName) + { + var instance = new MessagesSortObject(); + instance.OrderByAscending(fieldName); + return instance; + } + + /// + /// Sort in descending order (highest to lowest) by the specified field. + /// + public static MessagesSortObject OrderByDescending(MessageSortFieldName fieldName) + { + var instance = new MessagesSortObject(); + instance.OrderByDescending(fieldName); + return instance; + } + + /// + /// Then sort in ascending order (lowest to highest) by the specified field. + /// + public static MessagesSortObject ThenByAscending(this MessagesSortObject sort, MessageSortFieldName fieldName) + => sort.OrderByAscending(fieldName); + + /// + /// Then sort in descending order (highest to lowest) by the specified field. + /// + public static MessagesSortObject ThenByDescending(this MessagesSortObject sort, MessageSortFieldName fieldName) + => sort.OrderByDescending(fieldName); + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs.meta new file mode 100644 index 00000000..37bbb3b9 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSort.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a6573e3abec6fe4aa6592e539dc7a40 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs new file mode 100644 index 00000000..4ed76349 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs @@ -0,0 +1,36 @@ +using System; + +namespace StreamChat.Core.QueryBuilders.Sort +{ + /// + /// Sort object for . + /// + public sealed class MessagesSortObject : QuerySort + { + protected override MessagesSortObject Instance => this; + + protected override string ToUnderlyingFieldName(MessageSortFieldName fieldName) + { + switch (fieldName) + { + case MessageSortFieldName.CreatedAt: return "created_at"; + case MessageSortFieldName.UpdatedAt: return "updated_at"; + case MessageSortFieldName.Relevance: return "relevance"; + case MessageSortFieldName.Id: return "id"; + default: + throw new ArgumentOutOfRangeException(nameof(fieldName), fieldName, null); + } + } + } + + /// + /// Sort field names for . + /// + public enum MessageSortFieldName + { + CreatedAt, + UpdatedAt, + Relevance, + Id, + } +} diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs.meta b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs.meta new file mode 100644 index 00000000..caa853b0 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Sort/MessagesSortObject.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a3384c2c836c2424ab7d941057bb28fd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs new file mode 100644 index 00000000..74f12a21 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Linq; +using StreamChat.Core.InternalDTO.Requests; +using StreamChat.Core.LowLevelClient; +using StreamChat.Core.QueryBuilders.Filters; +using StreamChat.Core.QueryBuilders.Sort; +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.Requests +{ + /// + /// Request for . + /// + /// + /// is required by the server - at least one channel-level rule + /// must be provided (e.g. ChannelFilter.Members.In(localUser)) to scope the search. + /// + /// + public sealed class StreamSearchMessagesRequest : ISavableTo + { + /// + /// REQUIRED. Filter restricting which channels are searched. Use + /// to build the rules. + /// + /// Typical: ChannelFilter.Members.In(Client.LocalUserData.User). + /// + public IEnumerable ChannelFilter { get; set; } + + /// + /// Optional. Filter restricting which messages within the matched channels match. Use + /// to build the rules. + /// + /// Mutually exclusive at the text field with ; that combination + /// is rejected client-side before the request is sent. + /// + public IEnumerable MessageFilter { get; set; } + + /// + /// Optional. Free-text search phrase. Performs full-text search on the message text. + /// + /// Cannot be combined with a rule targeting the text field. + /// + public string Query { get; set; } + + /// + /// Optional. Max number of results per page. The server default and recommended max for + /// offset-based pagination is 30. + /// + public int? Limit { get; set; } + + /// + /// Optional. Offset-based pagination. Capped at 1000 total results by the server. + /// + /// Mutually exclusive with and with when greater than zero. + /// + public int? Offset { get; set; } + + /// + /// Optional. Cursor for the next page - pass the + /// value from a previous response. + /// + /// Mutually exclusive with . + /// + public string Next { get; set; } + + /// + /// Optional. Sort criteria. The server forbids combining a sort with a non-zero + /// ; use for sorted pagination. + /// + public MessagesSortObject Sort { get; set; } + + /// + /// Whether the SDK should start watching the channels that appear in the result set so + /// that the returned instances and their parent + /// receive realtime WebSocket updates. + /// + /// Default: false. Recommended for a search-results UI - watch the channel only when + /// the user opens one of the hits to avoid mass-watching channels behind the customer's back. + /// When false, hit messages are still cached as , but their + /// parent only receives realtime events once explicitly watched + /// (e.g. via or + /// ). + /// + public bool WatchResultChannels { get; set; } + + SearchRequestInternalDTO ISavableTo.SaveToDto() + { + return new SearchRequestInternalDTO + { + FilterConditions = ChannelFilter? + .Select(_ => _.GenerateFilterEntry()) + .ToDictionary(x => x.Key, x => x.Value), + MessageFilterConditions = MessageFilter? + .Select(_ => _.GenerateFilterEntry()) + .ToDictionary(x => x.Key, x => x.Value), + Query = Query, + Limit = Limit, + Offset = Offset, + Next = Next, + Sort = Sort?.ToSortParamRequestList(), + }; + } + } +} diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs.meta b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs.meta new file mode 100644 index 00000000..74216a12 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 584252d3533a2564fbbee33f2e0ad8cc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs new file mode 100644 index 00000000..69de3187 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs @@ -0,0 +1,31 @@ +using StreamChat.Core.StatefulModels; + +namespace StreamChat.Core.Responses +{ + /// + /// A single hit from . + /// + /// Both and are stateful, cache-tracked instances: + /// if the same message/channel is already in the cache (because the channel is watched, the + /// message was loaded as a reply, etc.) the same object reference is returned here. + /// + public sealed class StreamSearchMessageResult + { + /// + /// The matching message. Updated by realtime events the same way as any other + /// stateful message returned by the SDK. + /// + public IStreamMessage Message { get; internal set; } + + /// + /// The channel the message belongs to. May be the same instance as one in + /// if the channel is already watched. + /// + /// + /// The channel object is cached but is not automatically watched (no WS subscription) + /// unless is set + /// to true. + /// + public IStreamChannel Channel { get; internal set; } + } +} diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs.meta b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs.meta new file mode 100644 index 00000000..2105649d --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessageResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7490a677bccbd0249b513b6d10901f5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs new file mode 100644 index 00000000..57c8ccf4 --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace StreamChat.Core.Responses +{ + /// + /// Response from . + /// + public sealed class StreamSearchMessagesResponse + { + /// + /// Stateful, cached message hits in server-defined order. + /// + public IReadOnlyList Results { get; internal set; } + + /// + /// Cursor for the next page; null if there are no more pages. + /// Pass this value as to retrieve the next page. + /// + public string Next { get; internal set; } + + /// + /// Cursor for the previous page; null on the first page. + /// + public string Previous { get; internal set; } + + /// + /// Human-readable request duration as reported by the server. + /// + public string Duration { get; internal set; } + + /// + /// Optional warning emitted by the server about the search result set + /// (e.g. truncated channel scope). + /// + public StreamSearchWarning ResultsWarning { get; internal set; } + } +} diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs.meta b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs.meta new file mode 100644 index 00000000..938591ae --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchMessagesResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 77e52e143dd57e040a7a2a83e1c4b3cc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs new file mode 100644 index 00000000..0550375c --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace StreamChat.Core.Responses +{ + /// + /// Warning emitted by the server alongside a + /// response (e.g. when the searched-channel scope was truncated). + /// + public sealed class StreamSearchWarning + { + /// + /// Numeric warning code as reported by the server, or null if not provided. + /// + public int? Code { get; internal set; } + + /// + /// Human-readable description of the warning. + /// + public string Description { get; internal set; } + + /// + /// Number of channels included in the searched scope, when reported by the server. + /// + public int? ChannelSearchCount { get; internal set; } + + /// + /// Cids of the channels that were searched, when reported by the server. + /// + public IReadOnlyList ChannelIds { get; internal set; } + } +} diff --git a/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs.meta b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs.meta new file mode 100644 index 00000000..a3adf7ad --- /dev/null +++ b/Assets/Plugins/StreamChat/Core/Responses/StreamSearchWarning.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0557f6a5a14d73a478ce95ac1798a66b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index ef519ac0..772228aa 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -502,6 +502,190 @@ public async Task QueryThreadsAsync(StreamQueryThrea }; } + public async Task SearchMessagesAsync( + StreamSearchMessagesRequest request, + CancellationToken cancellationToken = default(CancellationToken)) + { + ValidateSearchMessagesRequest(request); + + cancellationToken.ThrowIfCancellationRequested(); + + var requestDto = request.TrySaveToDto(); + var responseDto = + await InternalLowLevelClient.InternalMessageApi.SearchMessagesAsync(requestDto); + + cancellationToken.ThrowIfCancellationRequested(); + + var results = new List(); + var distinctChannels = new Dictionary(); + + if (responseDto?.Results != null) + { + foreach (var resultDto in responseDto.Results) + { + var searchMsgDto = resultDto?.Message; + if (searchMsgDto == null) + { + continue; + } + + IStreamChannel channel = null; + if (searchMsgDto.Channel != null) + { + channel = _cache.TryCreateOrUpdate(searchMsgDto.Channel); + if (channel != null && !distinctChannels.ContainsKey(channel.Cid)) + { + distinctChannels.Add(channel.Cid, channel); + } + } + + var messageDto = ProjectSearchResultToMessageDto(searchMsgDto); + var message = _cache.TryCreateOrUpdate(messageDto); + + results.Add(new StreamSearchMessageResult + { + Message = message, + Channel = channel, + }); + } + } + + if (request.WatchResultChannels && distinctChannels.Count > 0) + { + foreach (var channel in distinctChannels.Values) + { + cancellationToken.ThrowIfCancellationRequested(); + + //StreamTodo: parallelise once cancellation is plumbed; serial keeps load predictable for now. + await InternalGetOrCreateChannelWithIdAsync(channel.Type, channel.Id); + } + } + + return new StreamSearchMessagesResponse + { + Results = results, + Next = responseDto?.Next, + Previous = responseDto?.Previous, + Duration = responseDto?.Duration, + ResultsWarning = BuildSearchWarning(responseDto?.ResultsWarning), + }; + } + + private static void ValidateSearchMessagesRequest(StreamSearchMessagesRequest request) + { + StreamAsserts.AssertNotNull(request, nameof(request)); + + var hasChannelFilter = request.ChannelFilter != null && request.ChannelFilter.Any(); + if (!hasChannelFilter) + { + throw new ArgumentException( + "ChannelFilter is required for SearchMessagesAsync. Add at least one rule, " + + "e.g. ChannelFilter.Members.In(Client.LocalUserData.User).", + nameof(request)); + } + + if (request.Offset.HasValue && !string.IsNullOrEmpty(request.Next)) + { + throw new ArgumentException( + "Offset and Next pagination are mutually exclusive on SearchMessagesAsync.", + nameof(request)); + } + + if (request.Sort != null && request.Offset.HasValue && request.Offset.Value > 0) + { + throw new ArgumentException( + "Sort cannot be combined with a non-zero Offset on SearchMessagesAsync. " + + "Use the Next cursor for sorted pagination.", + nameof(request)); + } + + if (request.Limit.HasValue && request.Limit.Value < 1) + { + throw new ArgumentOutOfRangeException(nameof(request), + "Limit must be greater than or equal to 1."); + } + + if (!string.IsNullOrEmpty(request.Query) && request.MessageFilter != null) + { + foreach (var rule in request.MessageFilter) + { + if (rule != null && rule.Field == "text") + { + throw new ArgumentException( + "Query and a MessageFilter rule on the 'text' field cannot be combined. " + + "Pick one - either pass a free-text Query, or filter by MessageFilter.Text.", + nameof(request)); + } + } + } + } + + private static MessageInternalDTO ProjectSearchResultToMessageDto(SearchResultMessageInternalDTO source) + { + // Project the search-specific payload onto the canonical message DTO so that the cache + // can reuse the existing StreamMessage create/update path. Every field on + // SearchResultMessageInternalDTO has a one-to-one counterpart on MessageInternalDTO + // except for the embedded Channel, which is cached separately. + return new MessageInternalDTO + { + Attachments = source.Attachments, + BeforeMessageSendFailed = source.BeforeMessageSendFailed, + Cid = source.Cid, + Command = source.Command, + CreatedAt = source.CreatedAt, + Custom = source.Custom, + DeletedAt = source.DeletedAt, + DeletedReplyCount = source.DeletedReplyCount, + Html = source.Html, + I18n = source.I18n, + Id = source.Id, + ImageLabels = source.ImageLabels, + LatestReactions = source.LatestReactions, + MentionedUsers = source.MentionedUsers, + MessageTextUpdatedAt = source.MessageTextUpdatedAt, + Mml = source.Mml, + OwnReactions = source.OwnReactions, + ParentId = source.ParentId, + PinExpires = source.PinExpires, + Pinned = source.Pinned, + PinnedAt = source.PinnedAt, + PinnedBy = source.PinnedBy, + Poll = source.Poll, + PollId = source.PollId, + QuotedMessage = source.QuotedMessage, + QuotedMessageId = source.QuotedMessageId, + ReactionCounts = source.ReactionCounts, + ReactionGroups = source.ReactionGroups, + ReactionScores = source.ReactionScores, + ReplyCount = source.ReplyCount, + Shadowed = source.Shadowed, + ShowInChannel = source.ShowInChannel, + Silent = source.Silent, + Text = source.Text, + ThreadParticipants = source.ThreadParticipants, + Type = source.Type, + UpdatedAt = source.UpdatedAt, + User = source.User, + AdditionalProperties = source.AdditionalProperties, + }; + } + + private static StreamSearchWarning BuildSearchWarning(SearchWarningInternalDTO dto) + { + if (dto == null) + { + return null; + } + + return new StreamSearchWarning + { + Code = dto.WarningCode, + Description = dto.WarningDescription, + ChannelSearchCount = dto.ChannelSearchCount, + ChannelIds = dto.ChannelSearchCids, + }; + } + public Task> UpsertUsersAsync(IEnumerable userRequests) => UpsertUsers(userRequests); From 2eefbcafa46337bdcaaf2ed3b3c262acb0de24af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Wed, 20 May 2026 14:13:00 +0200 Subject: [PATCH 02/10] Add tests for search feature --- .../StatefulClient/SearchMessagesTests.cs | 794 ++++++++++++++++++ .../SearchMessagesTests.cs.meta | 11 + 2 files changed, 805 insertions(+) create mode 100644 Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs create mode 100644 Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs.meta diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs new file mode 100644 index 00000000..34ac7768 --- /dev/null +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs @@ -0,0 +1,794 @@ +#if STREAM_TESTS_ENABLED +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using StreamChat.Core.LowLevelClient; +using StreamChat.Core.QueryBuilders.Filters; +using StreamChat.Core.QueryBuilders.Filters.Channels; +using StreamChat.Core.QueryBuilders.Filters.Messages; +using StreamChat.Core.QueryBuilders.Sort; +using StreamChat.Core.Requests; +using StreamChat.Core.StatefulModels; +using UnityEngine.TestTools; + +namespace StreamChat.Tests.StatefulClient +{ + /// + /// Tests for . + /// + /// + /// Coverage matches the test plan in docs/specs/search-messages.md: + /// integration scenarios for the most important use cases plus client-side validation + /// rules and filter / sort builder shape assertions. + /// + /// + internal class SearchMessagesTests : BaseStateIntegrationTests + { + // --------------------------------------------------------------------- + // Builder shape tests (no live server, no connection required) + // --------------------------------------------------------------------- + + [Test] + public void When_message_filter_mentioned_user_id_contains_then_field_and_operator_are_correct() + { + var entry = MessageFilter.MentionedUserId.Contains("bob").GenerateFilterEntry(); + Assert.AreEqual("mentioned_users.id", entry.Key); + AssertOperator(entry, "$contains", "bob"); + } + + [Test] + public void When_message_filter_attachment_type_in_then_field_and_operator_are_correct() + { + var rule = MessageFilter.AttachmentType.In(new[] { "image", "video" }); + Assert.AreEqual("attachments.type", rule.Field); + + var entry = rule.GenerateFilterEntry(); + AssertOperator(entry, "$in", new[] { "image", "video" }); + } + + [Test] + public void When_message_filter_created_at_gte_then_field_and_operator_are_correct() + { + var when = new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero); + var rule = MessageFilter.CreatedAt.GreaterThanOrEquals(when); + Assert.AreEqual("created_at", rule.Field); + + var entry = rule.GenerateFilterEntry(); + var op = (IDictionary)entry.Value; + Assert.IsTrue(op.ContainsKey("$gte")); + } + + [Test] + public void When_message_filter_parent_id_exists_then_field_and_operator_are_correct() + { + var entry = MessageFilter.ParentId.Exists(true).GenerateFilterEntry(); + Assert.AreEqual("parent_id", entry.Key); + AssertOperator(entry, "$exists", true); + } + + [Test] + public void When_message_filter_pinned_equals_then_field_and_operator_are_correct() + { + var entry = MessageFilter.Pinned.EqualsTo(true).GenerateFilterEntry(); + Assert.AreEqual("pinned", entry.Key); + AssertOperator(entry, "$eq", true); + } + + [Test] + public void When_message_filter_silent_equals_then_field_and_operator_are_correct() + { + var entry = MessageFilter.Silent.EqualsTo(false).GenerateFilterEntry(); + Assert.AreEqual("silent", entry.Key); + AssertOperator(entry, "$eq", false); + } + + [Test] + public void When_message_filter_type_equals_then_field_and_operator_are_correct() + { + var entry = MessageFilter.Type.EqualsTo("regular").GenerateFilterEntry(); + Assert.AreEqual("type", entry.Key); + AssertOperator(entry, "$eq", "regular"); + } + + [Test] + public void When_message_filter_user_id_in_then_field_and_operator_are_correct() + { + var entry = MessageFilter.UserId.In(new[] { "alice", "bob" }).GenerateFilterEntry(); + Assert.AreEqual("user.id", entry.Key); + AssertOperator(entry, "$in", new[] { "alice", "bob" }); + } + + [Test] + public void When_message_filter_custom_field_equals_then_uses_supplied_field_name() + { + var entry = MessageFilter.Custom("priority").EqualsTo("high").GenerateFilterEntry(); + Assert.AreEqual("priority", entry.Key); + AssertOperator(entry, "$eq", "high"); + } + + [Test] + public void When_messages_sort_order_by_descending_created_at_then_dto_contains_field_and_minus_one_direction() + { + var sort = MessagesSort.OrderByDescending(MessageSortFieldName.CreatedAt); + var dto = sort.ToSortParamRequestList(); + + Assert.IsNotNull(dto); + Assert.AreEqual(1, dto.Count); + Assert.AreEqual("created_at", dto[0].Field); + Assert.AreEqual(-1, dto[0].Direction); + } + + [Test] + public void When_messages_sort_then_by_ascending_then_dto_contains_both_entries_in_order() + { + var sort = MessagesSort + .OrderByDescending(MessageSortFieldName.CreatedAt) + .ThenByAscending(MessageSortFieldName.Id); + + var dto = sort.ToSortParamRequestList(); + + Assert.IsNotNull(dto); + Assert.AreEqual(2, dto.Count); + Assert.AreEqual("created_at", dto[0].Field); + Assert.AreEqual(-1, dto[0].Direction); + Assert.AreEqual("id", dto[1].Field); + Assert.AreEqual(1, dto[1].Direction); + } + + [Test] + public void When_request_save_to_dto_then_channel_and_message_filters_are_separated() + { + var request = new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo("messaging:abc"), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.MentionedUserId.Contains("bob"), + }, + Query = "hello", + Limit = 30, + Offset = 0, + }; + + var dto = ((ISavableTo)request) + .SaveToDto(); + + Assert.IsNotNull(dto.FilterConditions); + Assert.IsTrue(dto.FilterConditions.ContainsKey("cid")); + + Assert.IsNotNull(dto.MessageFilterConditions); + Assert.IsTrue(dto.MessageFilterConditions.ContainsKey("mentioned_users.id")); + + Assert.AreEqual("hello", dto.Query); + Assert.AreEqual(30, dto.Limit); + Assert.AreEqual(0, dto.Offset); + } + + // --------------------------------------------------------------------- + // Client-side validation tests + // --------------------------------------------------------------------- + + [UnityTest] + public IEnumerator When_search_with_null_request_expect_throws() + => ConnectAndExecute(When_search_with_null_request_expect_throws_Async); + + private async Task When_search_with_null_request_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(null)); + } + + [UnityTest] + public IEnumerator When_search_with_null_channel_filter_expect_throws() + => ConnectAndExecute(When_search_with_null_channel_filter_expect_throws_Async); + + private async Task When_search_with_null_channel_filter_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = null, + Query = "anything", + })); + } + + [UnityTest] + public IEnumerator When_search_with_empty_channel_filter_expect_throws() + => ConnectAndExecute(When_search_with_empty_channel_filter_expect_throws_Async); + + private async Task When_search_with_empty_channel_filter_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[0], + Query = "anything", + })); + } + + [UnityTest] + public IEnumerator When_search_with_offset_and_next_both_set_expect_throws() + => ConnectAndExecute(When_search_with_offset_and_next_both_set_expect_throws_Async); + + private async Task When_search_with_offset_and_next_both_set_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "x", + Offset = 30, + Next = "fake-cursor", + })); + } + + [UnityTest] + public IEnumerator When_search_with_sort_and_non_zero_offset_expect_throws() + => ConnectAndExecute(When_search_with_sort_and_non_zero_offset_expect_throws_Async); + + private async Task When_search_with_sort_and_non_zero_offset_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "x", + Offset = 30, + Sort = MessagesSort.OrderByDescending(MessageSortFieldName.CreatedAt), + })); + } + + [UnityTest] + public IEnumerator When_search_with_query_and_text_message_filter_expect_throws() + => ConnectAndExecute(When_search_with_query_and_text_message_filter_expect_throws_Async); + + private async Task When_search_with_query_and_text_message_filter_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "hello", + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.Text.Contains("hello"), + }, + })); + } + + [UnityTest] + public IEnumerator When_search_with_limit_below_one_expect_throws() + => ConnectAndExecute(When_search_with_limit_below_one_expect_throws_Async); + + private async Task When_search_with_limit_below_one_expect_throws_Async() + { + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "x", + Limit = 0, + })); + } + + [UnityTest] + public IEnumerator When_search_with_cancelled_token_expect_throws_operation_cancelled() + => ConnectAndExecute(When_search_with_cancelled_token_expect_throws_operation_cancelled_Async); + + private async Task When_search_with_cancelled_token_expect_throws_operation_cancelled_Async() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "x", + }, cts.Token)); + } + + // --------------------------------------------------------------------- + // Integration tests (require live server) + // --------------------------------------------------------------------- + + [UnityTest] + public IEnumerator When_search_by_mentioned_user_id_expect_only_messages_mentioning_that_user() + => ConnectAndExecute(When_search_by_mentioned_user_id_expect_only_messages_mentioning_that_user_Async); + + private async Task When_search_by_mentioned_user_id_expect_only_messages_mentioning_that_user_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var userToMention = await CreateUniqueTempUserAsync("Michael"); + + await channel.SendNewMessageAsync("Hello"); + await channel.SendNewMessageAsync("How are you"); + var mentionMessage = await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + Text = "Hey there!", + MentionedUsers = new List { userToMention }, + }); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.MentionedUserId.Contains(userToMention.Id), + }, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == mentionMessage.Id)); + + Assert.IsNotEmpty(response.Results); + var hit = response.Results.FirstOrDefault(r => r.Message.Id == mentionMessage.Id); + Assert.IsNotNull(hit, "Expected to find the mention message in search results."); + + // Spec 4.3 + 5.3: results expose stateful IStreamMessage + IStreamChannel. + Assert.IsInstanceOf(hit.Message); + Assert.IsInstanceOf(hit.Channel); + Assert.AreEqual(channel.Cid, hit.Channel.Cid); + } + + [UnityTest] + public IEnumerator When_search_with_query_text_expect_matching_message_returned() + => ConnectAndExecute(When_search_with_query_text_expect_matching_message_returned_Async); + + private async Task When_search_with_query_text_expect_matching_message_returned_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var unique = "needle-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var matching = await channel.SendNewMessageAsync("Special content with " + unique); + await channel.SendNewMessageAsync("Plain message without the token"); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = unique, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == matching.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == matching.Id)); + Assert.IsTrue(response.Results.All(r => r.Channel != null && r.Channel.Cid == channel.Cid), + "All hits should belong to the requested channel."); + } + + [UnityTest] + public IEnumerator When_search_restricted_by_single_cid_expect_only_that_channels_messages() + => ConnectAndExecute(When_search_restricted_by_single_cid_expect_only_that_channels_messages_Async); + + private async Task When_search_restricted_by_single_cid_expect_only_that_channels_messages_Async() + { + var channelA = await CreateUniqueTempChannelAsync(); + var channelB = await CreateUniqueTempChannelAsync(); + + var token = "scoped-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var msgInA = await channelA.SendNewMessageAsync("In A: " + token); + await channelB.SendNewMessageAsync("In B: " + token); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channelA.Cid), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msgInA.Id)); + + Assert.IsNotEmpty(response.Results); + Assert.IsTrue(response.Results.All(r => r.Channel.Cid == channelA.Cid), + "Only messages from channelA should be returned when Cid filter restricts to it."); + } + + [UnityTest] + public IEnumerator When_search_returns_message_already_in_watched_channel_expect_same_instance() + => ConnectAndExecute(When_search_returns_message_already_in_watched_channel_expect_same_instance_Async); + + private async Task When_search_returns_message_already_in_watched_channel_expect_same_instance_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "identity-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var sent = await channel.SendNewMessageAsync(token); + + // The sent message lives in channel.Messages (the channel is watched). + var cached = channel.Messages.First(m => m.Id == sent.Id); + Assert.AreSame(sent, cached); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == sent.Id)); + + var hit = response.Results.First(r => r.Message.Id == sent.Id); + + // Spec 6.1: cache identity - the search hit is the exact same instance. + Assert.AreSame(cached, hit.Message, + "Search hit Message should be the same cached instance as channel.Messages."); + Assert.AreSame(channel, hit.Channel, + "Search hit Channel should be the same cached instance as the watched channel."); + } + + [UnityTest] + public IEnumerator When_search_with_custom_field_filter_expect_matching_messages() + => ConnectAndExecute(When_search_with_custom_field_filter_expect_matching_messages_Async); + + private async Task When_search_with_custom_field_filter_expect_matching_messages_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + // Use a unique custom field name per run to avoid cross-test interference on shared indices. + var customKey = "test_priority_" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var high = await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + Text = "High priority message", + CustomData = new StreamCustomDataRequest { { customKey, "high" } } + }); + + await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + Text = "Low priority message", + CustomData = new StreamCustomDataRequest { { customKey, "low" } } + }); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.Custom(customKey).EqualsTo("high"), + }, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == high.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == high.Id)); + Assert.IsTrue(response.Results.All(r => + r.Message.CustomData != null && + r.Message.CustomData.Get(customKey) == "high"), + "Every result should have the custom field set to 'high'."); + } + + [UnityTest] + public IEnumerator When_search_with_parent_id_exists_true_expect_only_replies() + => ConnectAndExecute(When_search_with_parent_id_exists_true_expect_only_replies_Async); + + private async Task When_search_with_parent_id_exists_true_expect_only_replies_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "replies-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var parent = await channel.SendNewMessageAsync("Parent " + token); + var reply = await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + Text = "Reply " + token, + ParentId = parent.Id, + }); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.ParentId.Exists(true), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == reply.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == reply.Id)); + Assert.IsTrue(response.Results.All(r => !string.IsNullOrEmpty(r.Message.ParentId)), + "ParentId.Exists(true) should only return reply messages."); + Assert.IsFalse(response.Results.Any(r => r.Message.Id == parent.Id), + "Parent message should not be returned when filtering for replies only."); + } + + [UnityTest] + public IEnumerator When_search_with_parent_id_exists_false_expect_only_top_level_messages() + => ConnectAndExecute(When_search_with_parent_id_exists_false_expect_only_top_level_messages_Async); + + private async Task When_search_with_parent_id_exists_false_expect_only_top_level_messages_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "toplevel-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var parent = await channel.SendNewMessageAsync("Parent " + token); + await channel.SendNewMessageAsync(new StreamSendMessageRequest + { + Text = "Reply " + token, + ParentId = parent.Id, + }); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.ParentId.Exists(false), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == parent.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == parent.Id)); + Assert.IsTrue(response.Results.All(r => string.IsNullOrEmpty(r.Message.ParentId)), + "ParentId.Exists(false) should only return top-level (non-reply) messages."); + } + + [UnityTest] + public IEnumerator When_search_with_date_range_expect_only_messages_within_range() + => ConnectAndExecute(When_search_with_date_range_expect_only_messages_within_range_Async); + + private async Task When_search_with_date_range_expect_only_messages_within_range_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "daterange-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var lowerBound = DateTimeOffset.UtcNow.AddMinutes(-2); + var msg = await channel.SendNewMessageAsync("In window: " + token); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.CreatedAt.GreaterThanOrEquals(lowerBound), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == msg.Id)); + Assert.IsTrue(response.Results.All(r => r.Message.CreatedAt >= lowerBound.AddSeconds(-5)), + "All returned messages must have CreatedAt >= the lower bound (small allowance for clock skew)."); + } + + [UnityTest] + public IEnumerator When_search_with_sort_descending_expect_results_monotonically_decreasing() + => ConnectAndExecute(When_search_with_sort_descending_expect_results_monotonically_decreasing_Async); + + private async Task When_search_with_sort_descending_expect_results_monotonically_decreasing_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "sortdesc-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var sentIds = new List(); + for (var i = 0; i < 3; i++) + { + var m = await channel.SendNewMessageAsync("Msg " + i + " " + token); + sentIds.Add(m.Id); + } + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + Sort = MessagesSort.OrderByDescending(MessageSortFieldName.CreatedAt), + }), r => r != null && r.Results != null && r.Results.Count >= sentIds.Count); + + // We only assert ordering on results that belong to this run (matched by the unique token). + var ours = response.Results + .Where(r => sentIds.Contains(r.Message.Id)) + .ToList(); + + Assert.AreEqual(sentIds.Count, ours.Count, "Expected all messages from this run to be returned."); + + for (var i = 1; i < ours.Count; i++) + { + Assert.IsTrue(ours[i - 1].Message.CreatedAt >= ours[i].Message.CreatedAt, + "Descending sort: each subsequent CreatedAt should be <= the previous."); + } + } + + [UnityTest] + public IEnumerator When_search_with_limit_then_response_capped_at_limit() + => ConnectAndExecute(When_search_with_limit_then_response_capped_at_limit_Async); + + private async Task When_search_with_limit_then_response_capped_at_limit_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "limit-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + for (var i = 0; i < 3; i++) + { + await channel.SendNewMessageAsync("Msg " + i + " " + token); + } + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + Limit = 1, + }), r => r != null && r.Results != null && r.Results.Count >= 1); + + Assert.AreEqual(1, response.Results.Count, + "Limit=1 should return at most one message per page."); + } + + [UnityTest] + public IEnumerator When_search_with_cursor_pagination_expect_next_cursor_and_disjoint_pages() + => ConnectAndExecute(When_search_with_cursor_pagination_expect_next_cursor_and_disjoint_pages_Async); + + private async Task When_search_with_cursor_pagination_expect_next_cursor_and_disjoint_pages_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "cursor-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var sentIds = new List(); + for (var i = 0; i < 3; i++) + { + var m = await channel.SendNewMessageAsync("Msg " + i + " " + token); + sentIds.Add(m.Id); + } + + var page1 = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + Limit = 1, + Sort = MessagesSort.OrderByAscending(MessageSortFieldName.CreatedAt), + }), r => r != null && r.Results != null && r.Results.Count == 1 && !string.IsNullOrEmpty(r.Next)); + + Assert.IsFalse(string.IsNullOrEmpty(page1.Next), "Page 1 must return a Next cursor."); + + var page2 = await Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + Limit = 1, + Sort = MessagesSort.OrderByAscending(MessageSortFieldName.CreatedAt), + Next = page1.Next, + }); + + Assert.IsNotNull(page2); + Assert.IsNotEmpty(page2.Results); + + var page1Ids = new HashSet(page1.Results.Select(r => r.Message.Id)); + Assert.IsFalse(page2.Results.Any(r => page1Ids.Contains(r.Message.Id)), + "Page 2 must not contain any message from page 1."); + } + + [UnityTest] + public IEnumerator When_search_returns_message_and_then_soft_deleted_expect_hit_reflects_deletion() + => ConnectAndExecute(When_search_returns_message_and_then_soft_deleted_expect_hit_reflects_deletion_Async); + + private async Task When_search_returns_message_and_then_soft_deleted_expect_hit_reflects_deletion_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "softdel-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + var sent = await channel.SendNewMessageAsync(token); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == sent.Id)); + + var hit = response.Results.First(r => r.Message.Id == sent.Id); + + // Soft-delete via the channel API; cache identity means the search hit gets the update. + await sent.SoftDeleteAsync(); + await WaitWhileTrueAsync(() => !hit.Message.DeletedAt.HasValue, + description: "search hit Message.DeletedAt to be populated after soft-delete"); + + Assert.IsTrue(hit.Message.IsDeleted, "Hit message IsDeleted should be true after soft-delete."); + Assert.IsTrue(hit.Message.DeletedAt.HasValue); + } + + [UnityTest] + public IEnumerator When_search_with_watch_result_channels_true_expect_channel_in_watched_channels() + => ConnectAndExecute(When_search_with_watch_result_channels_true_expect_channel_in_watched_channels_Async); + + private async Task When_search_with_watch_result_channels_true_expect_channel_in_watched_channels_Async() + { + var channel = await CreateUniqueTempChannelAsync(); + var token = "watchtrue-" + Guid.NewGuid().ToString("N").Substring(0, 8); + var msg = await channel.SendNewMessageAsync(token); + + var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + WatchResultChannels = true, + }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); + + Assert.IsTrue(response.Results.Any(r => r.Message.Id == msg.Id)); + Assert.IsTrue(Client.WatchedChannels.Any(c => c.Cid == channel.Cid), + "With WatchResultChannels=true the hit channel must appear in WatchedChannels."); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private static void AssertOperator(KeyValuePair entry, string expectedOperator, + object expectedValue) + { + Assert.IsNotNull(entry.Value, "Filter entry value should not be null."); + var dict = entry.Value as IDictionary; + Assert.IsNotNull(dict, "Filter entry value should serialize to a dictionary."); + Assert.IsTrue(dict.ContainsKey(expectedOperator), + "Expected operator '" + expectedOperator + "' not present. Got: " + + string.Join(",", dict.Keys)); + Assert.AreEqual(expectedValue, dict[expectedOperator]); + } + + private static async Task AssertThrowsAsync(Func action) where TException : Exception + { + try + { + await action(); + } + catch (TException) + { + return; + } + catch (Exception e) + { + Assert.Fail("Expected " + typeof(TException).Name + " but caught " + e.GetType().Name + + ": " + e.Message); + return; + } + + Assert.Fail("Expected " + typeof(TException).Name + " but no exception was thrown."); + } + } +} +#endif diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs.meta b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs.meta new file mode 100644 index 00000000..88cf78ea --- /dev/null +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9148c3f07291328408f6dc9fcafe3958 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 4f479769377e02aceaee746b69ab22bc9484df13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Thu, 21 May 2026 15:52:37 +0200 Subject: [PATCH 03/10] Fix test to not use both Query and MessageFilter (disallowed by API) --- .../Requests/StreamSearchMessagesRequest.cs | 101 ++++++++++++++++-- .../StreamChat/Core/StreamChatClient.cs | 19 ++-- .../StatefulClient/SearchMessagesTests.cs | 33 +++++- 3 files changed, 133 insertions(+), 20 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs index 74f12a21..9d1f3b3d 100644 --- a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs @@ -27,18 +27,107 @@ public sealed class StreamSearchMessagesRequest : ISavableTo ChannelFilter { get; set; } /// - /// Optional. Filter restricting which messages within the matched channels match. Use - /// to build the rules. + /// Optional. Structured, strongly-typed filter applied to individual messages inside the + /// channels selected by . This is the "WHERE" clause for the + /// message itself: only messages that match every rule in the list are returned. /// - /// Mutually exclusive at the text field with ; that combination - /// is rejected client-side before the request is sent. + /// + /// Build rules with . + /// Common use cases include: + /// + /// + /// + /// Mentions of a specific user — MessageFilter.MentionedUserId.Contains(userId). + /// + /// + /// Messages by a given author or set of authors — + /// MessageFilter.UserId.EqualsTo("alice") or + /// MessageFilter.UserId.In(new[] { "alice", "bob" }). + /// + /// + /// Messages of a specific type — MessageFilter.Type.EqualsTo("regular") / + /// "system" / "deleted". + /// + /// + /// Replies only or top-level only — MessageFilter.ParentId.Exists(true) for + /// thread replies, MessageFilter.ParentId.Exists(false) for top-level messages. + /// + /// + /// Date ranges — MessageFilter.CreatedAt.GreaterThanOrEquals(from) combined with + /// MessageFilter.CreatedAt.LessThanOrEquals(to). + /// + /// + /// Attachments of a given type — MessageFilter.AttachmentType.In(new[] { "image", "video" }). + /// + /// + /// Pinned, silent, or polls — MessageFilter.Pinned.EqualsTo(true), + /// MessageFilter.Silent.EqualsTo(false), MessageFilter.PollId.Exists(true). + /// + /// + /// Reactions of a given type — + /// MessageFilter.ReactionType.Contains("fire"). + /// + /// + /// Custom message fields — MessageFilter.Custom("priority").EqualsTo("high"). + /// + /// + /// Text matching as a structured rule — MessageFilter.Text.Contains("invoice"). + /// Use this form when you also need other rules in the same request (see remark below). + /// + /// + /// + /// + /// All rules in the list are combined with logical AND. For OR / NOR combinations, use the + /// compound builders on the filter façade. + /// + /// + /// + /// Remark: mutually exclusive with . The server rejects requests that + /// specify both a free-text query and message_filter_conditions; the SDK + /// catches this client-side and throws . If you need + /// text matching alongside other constraints, drop and use + /// MessageFilter.Text.Contains(...) here instead. + /// /// public IEnumerable MessageFilter { get; set; } /// - /// Optional. Free-text search phrase. Performs full-text search on the message text. + /// Optional. Free-text search phrase executed by the server's full-text search engine + /// against the message body. This is the shortest path to a "search bar" experience — + /// the user types a phrase and the server returns the most relevant matching messages + /// across every channel selected by . + /// + /// + /// Use this when: + /// + /// + /// + /// You only need to match on message text and want server-side ranking (relevance, + /// stemming, fuzzy matching where supported) rather than the literal substring matching + /// of MessageFilter.Text.Contains(...). + /// + /// + /// You are wiring up a generic search input — pass whatever the user typed verbatim. + /// + /// + /// You want to combine free-text search with channel-level constraints only + /// (e.g. "search 'release notes' in channels I'm a member of") — supply + /// rules and leave null. + /// + /// + /// + /// + /// Pair with set to MessagesSort.OrderByDescending(MessageSortFieldName.Relevance) + /// to surface the best matches first. + /// /// - /// Cannot be combined with a rule targeting the text field. + /// + /// Remark: mutually exclusive with . The server rejects + /// requests that specify both; the SDK catches this client-side and throws + /// . If you need to combine text matching with + /// other message-level constraints, omit and express the text rule + /// inside via MessageFilter.Text.Contains(...). + /// /// public string Query { get; set; } diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index 772228aa..cd109fa9 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -605,18 +605,15 @@ private static void ValidateSearchMessagesRequest(StreamSearchMessagesRequest re "Limit must be greater than or equal to 1."); } - if (!string.IsNullOrEmpty(request.Query) && request.MessageFilter != null) + if (!string.IsNullOrEmpty(request.Query) && request.MessageFilter != null && + request.MessageFilter.Any(r => r != null)) { - foreach (var rule in request.MessageFilter) - { - if (rule != null && rule.Field == "text") - { - throw new ArgumentException( - "Query and a MessageFilter rule on the 'text' field cannot be combined. " + - "Pick one - either pass a free-text Query, or filter by MessageFilter.Text.", - nameof(request)); - } - } + throw new ArgumentException( + "Query and MessageFilter cannot be combined on SearchMessagesAsync. " + + "The server rejects requests that specify both a free-text query and " + + "message_filter_conditions. Pick one - either pass a free-text Query, or " + + "express the same constraint via MessageFilter (e.g. MessageFilter.Text.Contains(...)).", + nameof(request)); } } diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs index 34ac7768..411e4279 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs @@ -272,6 +272,30 @@ await AssertThrowsAsync( })); } + [UnityTest] + public IEnumerator When_search_with_query_and_non_text_message_filter_expect_throws() + => ConnectAndExecute(When_search_with_query_and_non_text_message_filter_expect_throws_Async); + + private async Task When_search_with_query_and_non_text_message_filter_expect_throws_Async() + { + // Server rejects ANY combination of `query` + `message_filter_conditions`, not just on + // the `text` field. The client must surface that as ArgumentException up-front so callers + // don't get a confusing 400 from the server. + await AssertThrowsAsync( + () => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Members.In(Client.LocalUserData.User), + }, + Query = "hello", + MessageFilter = new IFieldFilterRule[] + { + MessageFilter.ParentId.Exists(false), + }, + })); + } + [UnityTest] public IEnumerator When_search_with_limit_below_one_expect_throws() => ConnectAndExecute(When_search_with_limit_below_one_expect_throws_Async); @@ -497,6 +521,8 @@ private async Task When_search_with_parent_id_exists_true_expect_only_replies_As ParentId = parent.Id, }); + // Note: cannot combine Query with MessageFilter (server rejects it). The unique + // channel scope is sufficient to isolate this test's messages. var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest { ChannelFilter = new IFieldFilterRule[] @@ -507,7 +533,6 @@ private async Task When_search_with_parent_id_exists_true_expect_only_replies_As { MessageFilter.ParentId.Exists(true), }, - Query = token, }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == reply.Id)); Assert.IsTrue(response.Results.Any(r => r.Message.Id == reply.Id)); @@ -533,6 +558,8 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest ParentId = parent.Id, }); + // Note: cannot combine Query with MessageFilter (server rejects it). The unique + // channel scope is sufficient to isolate this test's messages. var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest { ChannelFilter = new IFieldFilterRule[] @@ -543,7 +570,6 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest { MessageFilter.ParentId.Exists(false), }, - Query = token, }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == parent.Id)); Assert.IsTrue(response.Results.Any(r => r.Message.Id == parent.Id)); @@ -563,6 +589,8 @@ private async Task When_search_with_date_range_expect_only_messages_within_range var lowerBound = DateTimeOffset.UtcNow.AddMinutes(-2); var msg = await channel.SendNewMessageAsync("In window: " + token); + // Note: cannot combine Query with MessageFilter (server rejects it). The unique + // channel scope is sufficient to isolate this test's messages. var response = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest { ChannelFilter = new IFieldFilterRule[] @@ -573,7 +601,6 @@ private async Task When_search_with_date_range_expect_only_messages_within_range { MessageFilter.CreatedAt.GreaterThanOrEquals(lowerBound), }, - Query = token, }), r => r != null && r.Results != null && r.Results.Any(x => x.Message != null && x.Message.Id == msg.Id)); Assert.IsTrue(response.Results.Any(r => r.Message.Id == msg.Id)); From dde9038990ec0ff857224e57e046aa44d033bce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 22 May 2026 11:32:18 +0200 Subject: [PATCH 04/10] Fix date serialization. The API was returning "Search failed with error: "field "created_at" expects type date"" error --- .../QueryBuilders/Filters/FieldFilterRule.cs | 15 ++--- .../Libs/Utils/StreamDateFormatter.cs | 59 +++++++++++++++++++ .../Libs/Utils/StreamDateFormatter.cs.meta | 11 ++++ .../StreamChat/Tests/UnityTestUtils.cs | 4 -- 4 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs create mode 100644 Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs.meta diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs index ef029f2d..d8c14b3c 100644 --- a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; +using StreamChat.Libs.Utils; namespace StreamChat.Core.QueryBuilders.Filters { @@ -36,14 +36,14 @@ public FieldFilterRule(string field, QueryOperatorType operatorType, DateTime va { Field = field; OperatorType = operatorType; - Value = ToRfc3339String(value); + Value = value.ToStreamDateString(); } public FieldFilterRule(string field, QueryOperatorType operatorType, DateTimeOffset value) { Field = field; OperatorType = operatorType; - Value = ToRfc3339String(value); + Value = value.ToStreamDateString(); } public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable value) @@ -57,14 +57,14 @@ public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable { Field = field; OperatorType = operatorType; - Value = value.ToArray(); + Value = value.Select(StreamDateFormatter.ToStreamDateString).ToArray(); } public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable value) { Field = field; OperatorType = operatorType; - Value = value.ToArray(); + Value = value.Select(StreamDateFormatter.ToStreamDateString).ToArray(); } //StreamTodo: research how to reduce allocation here @@ -79,10 +79,5 @@ public KeyValuePair GenerateFilterEntry() } ); - private static string ToRfc3339String(DateTime dateTime) - => dateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fffzzz", DateTimeFormatInfo.InvariantInfo); - - private static string ToRfc3339String(DateTimeOffset dateTimeOffset) - => dateTimeOffset.ToString("yyyy-MM-dd'T'HH:mm:ss.fffzzz", DateTimeFormatInfo.InvariantInfo); } } \ No newline at end of file diff --git a/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs new file mode 100644 index 00000000..b18f4015 --- /dev/null +++ b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs @@ -0,0 +1,59 @@ +using System; +using System.Globalization; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StreamChat.Core")] + +namespace StreamChat.Libs.Utils +{ + /// + /// Formats / values in the canonical + /// Stream API format: yyyy-MM-ddTHH:mm:ss.fffZ (UTC, millisecond precision, literal "Z"). + /// + /// This matches the format used by all other Stream SDKs (see + /// StreamDateFormatter in stream-chat-android) and is the only form accepted by every + /// Stream endpoint. In particular, the /search endpoint's message_filter_conditions + /// rejects the numeric-offset form (+00:00) with + /// "field "created_at" expects type date", so any date sent to the API must go through + /// this formatter to stay portable across endpoints. + /// + internal static class StreamDateFormatter + { + // Equivalent to Java's "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" used by the Android SDK. + private const string DateFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + + /// + /// Formats in the canonical Stream API format. + /// The value is normalised to UTC before formatting: + /// is used as-is, is + /// converted via , and + /// is assumed to already be UTC. + /// + internal static string ToStreamDateString(this DateTime dateTime) + { + DateTime utc; + switch (dateTime.Kind) + { + case DateTimeKind.Utc: + utc = dateTime; + break; + case DateTimeKind.Local: + utc = dateTime.ToUniversalTime(); + break; + default: + utc = DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); + break; + } + + return utc.ToString(DateFormat, DateTimeFormatInfo.InvariantInfo); + } + + /// + /// Formats in the canonical Stream API format. + /// The value is converted to UTC before formatting, so the wire output always ends in + /// Z regardless of the source offset. + /// + internal static string ToStreamDateString(this DateTimeOffset dateTimeOffset) + => dateTimeOffset.UtcDateTime.ToString(DateFormat, DateTimeFormatInfo.InvariantInfo); + } +} diff --git a/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs.meta b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs.meta new file mode 100644 index 00000000..eb201c7c --- /dev/null +++ b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3fe2ad1e7e9d4d859afea1dc11593bc2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/StreamChat/Tests/UnityTestUtils.cs b/Assets/Plugins/StreamChat/Tests/UnityTestUtils.cs index 67cae6ff..964b4e28 100644 --- a/Assets/Plugins/StreamChat/Tests/UnityTestUtils.cs +++ b/Assets/Plugins/StreamChat/Tests/UnityTestUtils.cs @@ -1,7 +1,6 @@ #if STREAM_TESTS_ENABLED using System; using System.Collections; -using System.Globalization; using System.Threading.Tasks; using StreamChat.Core; using StreamChat.Core.LowLevelClient; @@ -144,9 +143,6 @@ public static IEnumerator RunTaskAsEnumerator(this Task task) throw task.Exception; } - public static string ToRfc3339String(this DateTime dateTime) - => dateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fffzzz", DateTimeFormatInfo.InvariantInfo); - private static Exception UnwrapAggregateException(Exception exception) { if (exception is AggregateException aggregateException && From 5958cfae932e9695a33d946a3be208cbb5894073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 22 May 2026 12:10:01 +0200 Subject: [PATCH 05/10] Fix "TearDown : System.ArgumentException : 'async void' methods are not supported, please use 'async Task' instead" --- .../Tests/StatefulClient/BaseStateIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs index 5533f67b..61518626 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs @@ -28,7 +28,7 @@ public void OneTimeUp() } [OneTimeTearDown] - public async void OneTimeTearDown() + public async Task OneTimeTearDown() { Debug.Log("------------ TearDown"); From c95e348f2c644bbbbb98b5b5d50a7148dc936137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 22 May 2026 13:10:14 +0200 Subject: [PATCH 06/10] Fix tests deadlock --- .../BaseStateIntegrationTests.cs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs index 61518626..cb8dbcb2 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs @@ -28,12 +28,28 @@ public void OneTimeUp() } [OneTimeTearDown] - public async Task OneTimeTearDown() + public void OneTimeTearDown() { Debug.Log("------------ TearDown"); - await DeleteTempChannelsAsync(); - await StreamTestClients.Instance.RemoveLockAsync(this); + // NUnit drives an `async Task` OneTimeTearDown by blocking the main thread on the + // returned task (effectively `task.GetAwaiter().GetResult()`). Any `await` inside + // captures Unity's UnitySynchronizationContext and posts its continuation back to + // the main thread, which is the very thread NUnit is blocking - classic async-over- + // sync deadlock. Symptom: `Debug.Log("------------ TearDown")` is the last log line + // in Editor.log and Unity hangs with no further output (the kicked-off DELETE + // /channels HTTP call completes, but its continuation never gets to resume). + // + // We can't go back to `async void` (NUnit rejects it with `ArgumentException: + // 'async void' methods are not supported`). Hopping the cleanup onto the thread + // pool detaches it from the Unity SynchronizationContext, so the awaited + // continuations resume on thread-pool threads and the main thread is only + // blocked waiting for a task that no longer needs it. + Task.Run(async () => + { + await DeleteTempChannelsAsync(); + await StreamTestClients.Instance.RemoveLockAsync(this); + }).GetAwaiter().GetResult(); } protected static StreamChatClient Client => StreamTestClients.Instance.StateClient; From af2a824962f52f0f487b8d1e6be07c6cf160006b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 22 May 2026 14:02:45 +0200 Subject: [PATCH 07/10] Make test more resilient to changes not being immediately available on the backend --- .../StatefulClient/SearchMessagesTests.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs index 411e4279..752d9000 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs @@ -692,7 +692,24 @@ private async Task When_search_with_cursor_pagination_expect_next_cursor_and_dis sentIds.Add(m.Id); } - var page1 = await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + // Stream's search index is eventually consistent. Wait until ALL three messages + // are searchable BEFORE testing cursor pagination - otherwise the server happily + // returns a single result without a `next` cursor (because, from its point of view, + // there are no more pages yet) and the cursor predicate below would race against + // indexing for a long time. + await TryAsync(() => Client.SearchMessagesAsync(new StreamSearchMessagesRequest + { + ChannelFilter = new IFieldFilterRule[] + { + ChannelFilter.Cid.EqualsTo(channel.Cid), + }, + Query = token, + Limit = 30, + }), r => r != null && r.Results != null && + sentIds.All(id => r.Results.Any(x => x.Message != null && x.Message.Id == id)), + description: "all 3 messages to be indexed for cursor pagination test"); + + var page1 = await Client.SearchMessagesAsync(new StreamSearchMessagesRequest { ChannelFilter = new IFieldFilterRule[] { @@ -701,8 +718,10 @@ private async Task When_search_with_cursor_pagination_expect_next_cursor_and_dis Query = token, Limit = 1, Sort = MessagesSort.OrderByAscending(MessageSortFieldName.CreatedAt), - }), r => r != null && r.Results != null && r.Results.Count == 1 && !string.IsNullOrEmpty(r.Next)); + }); + Assert.IsNotNull(page1); + Assert.AreEqual(1, page1.Results.Count, "Page 1 must contain exactly Limit=1 result."); Assert.IsFalse(string.IsNullOrEmpty(page1.Next), "Page 1 must return a Next cursor."); var page2 = await Client.SearchMessagesAsync(new StreamSearchMessagesRequest From 6f6d61f3f4bd8749ea29e35a1c59d4f4892651fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Fri, 22 May 2026 14:17:59 +0200 Subject: [PATCH 08/10] Apply API response to cache when soft delete is used so that the local user state doesn't need to wait for WS event --- .../Core/StatefulModels/StreamMessage.cs | 17 +++- .../BaseStateIntegrationTests.cs | 2 + .../Tests/StatefulClient/MessagesTests.cs | 86 ++++++++++++++++++- .../StatefulClient/SearchMessagesTests.cs | 8 +- 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs index 6e813c9c..8e896484 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamMessage.cs @@ -92,8 +92,21 @@ internal sealed class StreamMessage : StreamStatefulModelBase, public bool IsDeleted => Type == MessageType.Deleted; - //Do not update message from response, the WS event might have been processed and we would overwrite it with an old state - public Task SoftDeleteAsync() => LowLevelClient.InternalMessageApi.DeleteMessageAsync(Id, hard: false); + // Apply the REST response to the cache so callers don't have to wait for the + // `message.deleted` WS event before observing `DeletedAt` / `IsDeleted` / cleared + // text on this very instance. The WS event still fires on watchers (including this + // client) and goes through StreamChannel.HandleMessageDeletedEvent; that path is + // idempotent against the state we set here, so a late-arriving event won't regress + // the message back to a non-deleted state. + public async Task SoftDeleteAsync() + { + var response = await LowLevelClient.InternalMessageApi.DeleteMessageAsync(Id, hard: false); + if (response?.Message != null) + { + Cache.TryCreateOrUpdate(response.Message); + } + InternalHandleSoftDelete(); + } //Do not update message from response, the WS event might have been processed and we would overwrite it with an old state public Task HardDeleteAsync() => LowLevelClient.InternalMessageApi.DeleteMessageAsync(Id, hard: true); diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs index cb8dbcb2..e3c9d322 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/BaseStateIntegrationTests.cs @@ -234,6 +234,8 @@ private sealed class WaitProgressLogger { private static readonly TimeSpan[] Thresholds = { + TimeSpan.FromMinutes(0.5), + TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10), diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/MessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/MessagesTests.cs index 467d3625..7c8be7ba 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/MessagesTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/MessagesTests.cs @@ -139,13 +139,95 @@ public async Task When_message_soft_delete_message_expect_text_cleared_Async() var messageInChannel = channel.Messages.FirstOrDefault(_ => _.Id == sentMessage.Id); Assert.NotNull(messageInChannel); + // SoftDeleteAsync applies the REST response to the cache before returning, + // so DeletedAt and the cleared text are visible immediately on the same + // instance the customer is holding. No need to wait for the WS event here - + // see When_other_client_soft_deletes_message_expect_message_deleted_event_observed + // for the watcher-side WS verification. await messageInChannel.SoftDeleteAsync(); - await WaitWhileTrueAsync(() => !messageInChannel.DeletedAt.HasValue); - Assert.NotNull(messageInChannel); Assert.IsNotNull(messageInChannel.DeletedAt); Assert.IsEmpty(messageInChannel.Text); + Assert.IsTrue(messageInChannel.IsDeleted); + } + + /// + /// Companion to : + /// the deleter applies the REST response to its own cache synchronously, so the + /// only path we still need WS coverage for is *another* watching client. This + /// test asserts that a watcher sees the soft-delete via the `message.deleted` + /// WS event - both as a `MessageDeleted` channel event and as state on the + /// cached . + /// + [UnityTest] + public IEnumerator When_other_client_soft_deletes_message_expect_message_deleted_event_observed() + => ConnectAndExecute(When_other_client_soft_deletes_message_expect_message_deleted_event_observed_Async); + + private async Task When_other_client_soft_deletes_message_expect_message_deleted_event_observed_Async() + { + var otherClient = await GetConnectedOtherClientAsync(); + + var channel = await CreateUniqueTempChannelAsync(); + + // Watch the same channel from `otherClient` so it receives WS events for it. + var otherClientChannel = await otherClient.GetOrCreateChannelWithIdAsync(channel.Type, channel.Id); + Assert.AreEqual(channel.Cid, otherClientChannel.Cid); + + const string MessageText = "to-be-soft-deleted"; + var sentMessage = await channel.SendNewMessageAsync(MessageText); + + // Wait until the watcher has observed the new message via WS so we have + // a stateful instance to assert on once it's deleted. Without this, a + // very fast soft-delete could race the `message.new` delivery and we'd + // miss the message entirely on `otherClient`. + await WaitWhileFalseAsync( + () => otherClientChannel.Messages.Any(m => m.Id == sentMessage.Id), + description: "watcher to receive message.new for the message about to be deleted"); + + var observedOnOther = otherClientChannel.Messages.Single(m => m.Id == sentMessage.Id); + + var deletedEventCount = 0; + IStreamMessage eventMessage = null; + bool? eventIsHardDelete = null; + + void OnDeleted(IStreamChannel ch, IStreamMessage msg, bool isHardDelete) + { + if (msg.Id != sentMessage.Id) + { + return; + } + + deletedEventCount++; + eventMessage = msg; + eventIsHardDelete = isHardDelete; + } + + otherClientChannel.MessageDeleted += OnDeleted; + try + { + await sentMessage.SoftDeleteAsync(); + + await WaitWhileFalseAsync( + () => deletedEventCount > 0, + description: "watcher to receive message.deleted WS event after soft-delete"); + + Assert.AreEqual(1, deletedEventCount, "MessageDeleted should fire exactly once on the watcher."); + Assert.IsFalse(eventIsHardDelete.GetValueOrDefault(true), + "WS event must report this as a soft-delete (isHardDelete=false)."); + Assert.AreSame(observedOnOther, eventMessage, + "Event must surface the same cached message instance the watcher already holds."); + Assert.IsTrue(observedOnOther.IsDeleted, + "Watcher's cached message should be flagged as deleted after WS event."); + Assert.IsTrue(observedOnOther.DeletedAt.HasValue, + "Watcher's cached message should have DeletedAt populated after WS event."); + Assert.IsEmpty(observedOnOther.Text, + "Watcher's cached message text should be cleared after soft-delete WS event."); + } + finally + { + otherClientChannel.MessageDeleted -= OnDeleted; + } } [UnityTest] diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs index 752d9000..28a4dc1a 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/SearchMessagesTests.cs @@ -766,13 +766,15 @@ private async Task When_search_returns_message_and_then_soft_deleted_expect_hit_ var hit = response.Results.First(r => r.Message.Id == sent.Id); - // Soft-delete via the channel API; cache identity means the search hit gets the update. + // Search results share cache identity with messages obtained through any other + // surface (here: the freshly-sent `sent`). SoftDeleteAsync applies the REST + // response to the cache before returning, so the search hit reflects the + // deletion on the same instance with no WS round-trip needed. await sent.SoftDeleteAsync(); - await WaitWhileTrueAsync(() => !hit.Message.DeletedAt.HasValue, - description: "search hit Message.DeletedAt to be populated after soft-delete"); Assert.IsTrue(hit.Message.IsDeleted, "Hit message IsDeleted should be true after soft-delete."); Assert.IsTrue(hit.Message.DeletedAt.HasValue); + Assert.AreSame(sent, hit.Message, "Search hit and sent message must be the same cached instance."); } [UnityTest] From ed22ad0945e8f11691cc7329d41791cddc765928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Wed, 27 May 2026 11:09:03 +0200 Subject: [PATCH 09/10] Fix inconsistent datetime serialization expected by the API --- .../QueryBuilders/Filters/FieldFilterRule.cs | 91 ++++++++++++-- .../Requests/StreamSearchMessagesRequest.cs | 10 +- .../Libs/Utils/StreamDateFormatter.cs | 111 +++++++++++++++--- 3 files changed, 183 insertions(+), 29 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs index d8c14b3c..a594f0ea 100644 --- a/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs +++ b/Assets/Plugins/StreamChat/Core/QueryBuilders/Filters/FieldFilterRule.cs @@ -24,7 +24,7 @@ public FieldFilterRule(string field, QueryOperatorType operatorType, string valu OperatorType = operatorType; Value = value; } - + public FieldFilterRule(string field, QueryOperatorType operatorType, int value) { Field = field; @@ -36,14 +36,17 @@ public FieldFilterRule(string field, QueryOperatorType operatorType, DateTime va { Field = field; OperatorType = operatorType; - Value = value.ToStreamDateString(); + // Store the raw DateTime so callers can pick the wire format at serialization time + // (different Stream endpoints accept different RFC 3339 sub-forms - see StreamDateFormat). + Value = value; } public FieldFilterRule(string field, QueryOperatorType operatorType, DateTimeOffset value) { Field = field; OperatorType = operatorType; - Value = value.ToStreamDateString(); + // See note above about deferred date formatting. + Value = value; } public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable value) @@ -52,32 +55,100 @@ public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable OperatorType = operatorType; Value = value.ToArray(); } - + public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable value) { Field = field; OperatorType = operatorType; - Value = value.Select(StreamDateFormatter.ToStreamDateString).ToArray(); + // See note above about deferred date formatting. + Value = value.ToArray(); } - + public FieldFilterRule(string field, QueryOperatorType operatorType, IEnumerable value) { Field = field; OperatorType = operatorType; - Value = value.Select(StreamDateFormatter.ToStreamDateString).ToArray(); + // See note above about deferred date formatting. + Value = value.ToArray(); } + /// + /// Returns the filter entry using the default endpoint-portable date form + /// (). Callers targeting POST /search's + /// message_filter_conditions must use the format-aware overload + /// () with + /// . + /// //StreamTodo: research how to reduce allocation here public KeyValuePair GenerateFilterEntry() + => GenerateFilterEntry(StreamDateFormat.UtcOffset); + + /// + /// Returns the filter entry, formatting any date values using . + /// Non-date values are passed through untouched. + /// + internal KeyValuePair GenerateFilterEntry(StreamDateFormat dateFormat) => new KeyValuePair ( Field, new Dictionary { { - OperatorType.ToOperatorKeyword(), Value + OperatorType.ToOperatorKeyword(), FormatValueForWire(Value, dateFormat) } } ); - + + private static object FormatValueForWire(object value, StreamDateFormat dateFormat) + { + if (value is DateTime dt) + { + return dt.ToStreamDateString(dateFormat); + } + + if (value is DateTimeOffset dto) + { + return dto.ToStreamDateString(dateFormat); + } + + if (value is DateTime[] dts) + { + return dts.Select(d => d.ToStreamDateString(dateFormat)).ToArray(); + } + + if (value is DateTimeOffset[] dtos) + { + return dtos.Select(d => d.ToStreamDateString(dateFormat)).ToArray(); + } + + return value; + } + } + + /// + /// Internal helpers for serializing instances to the wire + /// dictionary with an explicit . + /// + /// + /// The public contract is intentionally + /// parameterless to avoid breaking external implementations. SDK-internal call sites that + /// need the (Z) form - currently only + /// POST /search's message_filter_conditions / filter_conditions - go + /// through this helper. Anything implementing that isn't the + /// SDK's own transparently falls back to the parameterless + /// path (i.e. ). + /// + /// + internal static class FieldFilterRuleExtensions + { + internal static KeyValuePair GenerateFilterEntry(this IFieldFilterRule rule, + StreamDateFormat dateFormat) + { + if (rule is FieldFilterRule concrete) + { + return concrete.GenerateFilterEntry(dateFormat); + } + + return rule.GenerateFilterEntry(); + } } -} \ No newline at end of file +} diff --git a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs index 9d1f3b3d..55733708 100644 --- a/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs +++ b/Assets/Plugins/StreamChat/Core/Requests/StreamSearchMessagesRequest.cs @@ -5,6 +5,7 @@ using StreamChat.Core.QueryBuilders.Filters; using StreamChat.Core.QueryBuilders.Sort; using StreamChat.Core.StatefulModels; +using StreamChat.Libs.Utils; namespace StreamChat.Core.Requests { @@ -174,13 +175,18 @@ public sealed class StreamSearchMessagesRequest : ISavableTo.SaveToDto() { + // POST /search rejects the "+00:00" offset form on date values inside + // message_filter_conditions / filter_conditions with + // "field \"created_at\" expects type date" (HTTP 400, code 4). It only accepts + // the canonical "Z" UTC form, so opt into StreamDateFormat.Utc here. This is the + // opposite of every other endpoint, which crashes (HTTP 500) on the "Z" form. return new SearchRequestInternalDTO { FilterConditions = ChannelFilter? - .Select(_ => _.GenerateFilterEntry()) + .Select(_ => _.GenerateFilterEntry(StreamDateFormat.Utc)) .ToDictionary(x => x.Key, x => x.Value), MessageFilterConditions = MessageFilter? - .Select(_ => _.GenerateFilterEntry()) + .Select(_ => _.GenerateFilterEntry(StreamDateFormat.Utc)) .ToDictionary(x => x.Key, x => x.Value), Query = Query, Limit = Limit, diff --git a/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs index b18f4015..6f34c6d9 100644 --- a/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs +++ b/Assets/Plugins/StreamChat/Libs/Utils/StreamDateFormatter.cs @@ -7,29 +7,82 @@ namespace StreamChat.Libs.Utils { /// - /// Formats / values in the canonical - /// Stream API format: yyyy-MM-ddTHH:mm:ss.fffZ (UTC, millisecond precision, literal "Z"). + /// Wire format choice for dates sent to the Stream API. /// - /// This matches the format used by all other Stream SDKs (see - /// StreamDateFormatter in stream-chat-android) and is the only form accepted by every - /// Stream endpoint. In particular, the /search endpoint's message_filter_conditions - /// rejects the numeric-offset form (+00:00) with - /// "field "created_at" expects type date", so any date sent to the API must go through - /// this formatter to stay portable across endpoints. + /// The two forms are semantically identical (both encode UTC, RFC 3339), but individual + /// Stream endpoints accept only one of them today: + /// + /// + /// + /// (yyyy-MM-ddTHH:mm:ss.fff+00:00) - required by + /// POST /channels filter_conditions. Sending the Z form causes the + /// server to return HTTP 500 with an empty error message. + /// + /// + /// + /// + /// (yyyy-MM-ddTHH:mm:ss.fffZ) - required by POST /search + /// message_filter_conditions. Sending the offset form is rejected with + /// "field \"created_at\" expects type date" (HTTP 400, code 4). + /// + /// + /// + /// + internal enum StreamDateFormat + { + /// + /// Numeric-offset UTC form: yyyy-MM-ddTHH:mm:ss.fff+00:00. + /// Used by filter_conditions on most endpoints (channels, users, threads, polls). + /// + UtcOffset, + + /// + /// Canonical Zulu UTC form: yyyy-MM-ddTHH:mm:ss.fffZ. + /// Required by message_filter_conditions on POST /search. Matches the + /// format used by other Stream SDKs (e.g. StreamDateFormatter in stream-chat-android). + /// + Utc, + } + + /// + /// Formats / values for the Stream API. + /// + /// + /// Different Stream endpoints disagree on the acceptable RFC 3339 sub-form, so callers must + /// pass an explicit when they know what the target endpoint + /// expects. The parameterless overloads default to , + /// which is the form accepted by every endpoint except POST /search's + /// message_filter_conditions; that one path must opt into + /// . + /// + /// + /// See for the endpoint-by-endpoint compatibility matrix. /// internal static class StreamDateFormatter { - // Equivalent to Java's "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" used by the Android SDK. - private const string DateFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + // "yyyy-MM-ddTHH:mm:ss.fff+00:00" + private const string UtcOffsetFormat = "yyyy-MM-dd'T'HH:mm:ss.fffzzz"; + + // "yyyy-MM-ddTHH:mm:ss.fffZ" - equivalent to Java's "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" used by the Android SDK. + private const string UtcFormat = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; /// - /// Formats in the canonical Stream API format. + /// Formats using the default endpoint-portable form + /// (). Use the overload taking an explicit + /// when sending to POST /search's + /// message_filter_conditions, which only accepts . + /// + internal static string ToStreamDateString(this DateTime dateTime) + => dateTime.ToStreamDateString(StreamDateFormat.UtcOffset); + + /// + /// Formats in the requested Stream API form. /// The value is normalised to UTC before formatting: /// is used as-is, is /// converted via , and /// is assumed to already be UTC. /// - internal static string ToStreamDateString(this DateTime dateTime) + internal static string ToStreamDateString(this DateTime dateTime, StreamDateFormat format) { DateTime utc; switch (dateTime.Kind) @@ -45,15 +98,39 @@ internal static string ToStreamDateString(this DateTime dateTime) break; } - return utc.ToString(DateFormat, DateTimeFormatInfo.InvariantInfo); + return utc.ToString(GetPattern(format), DateTimeFormatInfo.InvariantInfo); } /// - /// Formats in the canonical Stream API format. - /// The value is converted to UTC before formatting, so the wire output always ends in - /// Z regardless of the source offset. + /// Formats using the default endpoint-portable form + /// (). Use the overload taking an explicit + /// when sending to POST /search's + /// message_filter_conditions, which only accepts . /// internal static string ToStreamDateString(this DateTimeOffset dateTimeOffset) - => dateTimeOffset.UtcDateTime.ToString(DateFormat, DateTimeFormatInfo.InvariantInfo); + => dateTimeOffset.ToStreamDateString(StreamDateFormat.UtcOffset); + + /// + /// Formats in the requested Stream API form. + /// The value is converted to UTC before formatting; under + /// the wire output therefore always ends in + /// +00:00, and under it ends in Z, + /// regardless of the source offset. + /// + internal static string ToStreamDateString(this DateTimeOffset dateTimeOffset, StreamDateFormat format) + => dateTimeOffset.UtcDateTime.ToString(GetPattern(format), DateTimeFormatInfo.InvariantInfo); + + private static string GetPattern(StreamDateFormat format) + { + switch (format) + { + case StreamDateFormat.UtcOffset: + return UtcOffsetFormat; + case StreamDateFormat.Utc: + return UtcFormat; + default: + throw new ArgumentOutOfRangeException(nameof(format), format, null); + } + } } } From 60f2bef83dee992ba4e79fe223e93ff740f4993b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Wed, 27 May 2026 12:12:51 +0200 Subject: [PATCH 10/10] rewrite test for clarity + extend timeout --- .../Tests/StatefulClient/ThreadsTests.cs | 84 ++++++++++++------- 1 file changed, 56 insertions(+), 28 deletions(-) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs index 53f21095..97aad7a8 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs @@ -196,7 +196,8 @@ private async Task When_load_replies_paginated_three_pages_expect_all_replies_un "Page 2 must come before page 1 (newest)"); } - private static void AssertOrderedAscendingByCreatedAt(System.Collections.Generic.IReadOnlyList messages) + private static void AssertOrderedAscendingByCreatedAt( + System.Collections.Generic.IReadOnlyList messages) { for (var i = 1; i < messages.Count; i++) { @@ -304,7 +305,8 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest var thread = await Client.GetThreadAsync(parent.Id, replyLimit: 5, participantLimit: 5); await WaitWhileTrueAsync(() => (thread.ParticipantCount ?? 0) == 0, - description: "thread.ParticipantCount to become non-zero after GetThreadAsync (participant-count preservation)"); + description: + "thread.ParticipantCount to become non-zero after GetThreadAsync (participant-count preservation)"); var participantsBefore = thread.ParticipantCount; var activeParticipantsBefore = thread.ActiveParticipantCount; @@ -341,7 +343,8 @@ await WaitWhileTrueAsync(() => (thread.ParticipantCount ?? 0) == 0, // happened (the REST response also carries the new title, but the WS event is // what gives the bug an opportunity to overwrite). await WaitWhileTrueAsync(() => !sawTitleUpdate || observations.Count < 2, - description: "title change to propagate via WS thread.updated and produce >=2 Updated invocations (participant-count preservation)"); + description: + "title change to propagate via WS thread.updated and produce >=2 Updated invocations (participant-count preservation)"); } finally { @@ -398,7 +401,8 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest var thread = await Client.GetThreadAsync(parent.Id, replyLimit: 5, participantLimit: 5); await WaitWhileTrueAsync(() => thread.CreatedAt == default || (thread.LastMessageAt ?? default) == default, - description: "thread.CreatedAt and LastMessageAt to be populated after GetThreadAsync (timestamp preservation)"); + description: + "thread.CreatedAt and LastMessageAt to be populated after GetThreadAsync (timestamp preservation)"); var createdAtBefore = thread.CreatedAt; var updatedAtBefore = thread.UpdatedAt; @@ -437,7 +441,8 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest }); await WaitWhileTrueAsync(() => !sawTitleUpdate || observations.Count < 2, - description: "title change to propagate via WS thread.updated and produce >=2 Updated invocations (timestamp preservation)"); + description: + "title change to propagate via WS thread.updated and produce >=2 Updated invocations (timestamp preservation)"); } finally { @@ -508,7 +513,8 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest var thread = await Client.GetThreadAsync(parent.Id, replyLimit: 5, participantLimit: 5); await WaitWhileTrueAsync(() => (thread.ParticipantCount ?? 0) == 0, - description: "thread.ParticipantCount to become non-zero after GetThreadAsync (mark-read preservation)"); + description: + "thread.ParticipantCount to become non-zero after GetThreadAsync (mark-read preservation)"); var participantsBefore = thread.ParticipantCount; var activeParticipantsBefore = thread.ActiveParticipantCount; @@ -599,7 +605,8 @@ await WaitWhileTrueAsync(() => !readSeen, maxSeconds: 5, /// [UnityTest] public IEnumerator When_notification_mark_read_event_received_expect_local_user_unread_count_cleared() - => ConnectAndExecute(When_notification_mark_read_event_received_expect_local_user_unread_count_cleared_Async); + => ConnectAndExecute( + When_notification_mark_read_event_received_expect_local_user_unread_count_cleared_Async); private async Task When_notification_mark_read_event_received_expect_local_user_unread_count_cleared_Async() { @@ -772,7 +779,8 @@ await otherClientChannel.SendNewMessageAsync(new StreamSendMessageRequest return thread.Read.FirstOrDefault(r => r.User != null && r.User.Id == localUserId); }, r => r != null && r.UnreadMessages > 0, - description: "local user's thread.Read entry to materialize with UnreadMessages > 0 (upsert-reply setup)"); + description: + "local user's thread.Read entry to materialize with UnreadMessages > 0 (upsert-reply setup)"); Assert.IsTrue( thread.ThreadParticipants.Any(p => (p.User?.Id ?? p.UserId) == otherUserId), @@ -830,9 +838,11 @@ await WaitWhileTrueAsync(() => !replyReceived, /// [UnityTest] public IEnumerator When_watcher_receives_message_new_for_thread_reply_expect_parent_reply_count_increments() - => ConnectAndExecute(When_watcher_receives_message_new_for_thread_reply_expect_parent_reply_count_increments_Async); + => ConnectAndExecute( + When_watcher_receives_message_new_for_thread_reply_expect_parent_reply_count_increments_Async); - private async Task When_watcher_receives_message_new_for_thread_reply_expect_parent_reply_count_increments_Async() + private async Task + When_watcher_receives_message_new_for_thread_reply_expect_parent_reply_count_increments_Async() { var otherClient = await GetConnectedOtherClientAsync(); @@ -852,7 +862,8 @@ private async Task When_watcher_receives_message_new_for_thread_reply_expect_par var localParent = await TryAsync( () => Task.FromResult(channel.Messages.SingleOrDefault(m => m.Id == otherParent.Id)), m => m != null, - description: "local watcher channel.Messages to contain the otherClient parent (watcher reply-count regression)"); + description: + "local watcher channel.Messages to contain the otherClient parent (watcher reply-count regression)"); var replyCountBefore = localParent.ReplyCount ?? 0; @@ -982,9 +993,11 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest /// [UnityTest] public IEnumerator When_watching_channel_with_existing_thread_expect_thread_tracked_and_ws_updates_propagate() - => ConnectAndExecute(When_watching_channel_with_existing_thread_expect_thread_tracked_and_ws_updates_propagate_Async); + => ConnectAndExecute( + When_watching_channel_with_existing_thread_expect_thread_tracked_and_ws_updates_propagate_Async); - private async Task When_watching_channel_with_existing_thread_expect_thread_tracked_and_ws_updates_propagate_Async() + private async Task + When_watching_channel_with_existing_thread_expect_thread_tracked_and_ws_updates_propagate_Async() { var otherClient = await GetConnectedOtherClientAsync(); @@ -1024,12 +1037,14 @@ await TryAsync( Filter = new IFieldFilterRule[] { ThreadFilter.ChannelCid.EqualsTo(channel) }, }), r => r != null && r.Threads != null && r.Threads.Any(t => t.ParentMessageId == parent.Id), - description: "QueryThreadsAsync to return the freshly-created thread (ThreadTracked-on-watch setup)"); + description: + "QueryThreadsAsync to return the freshly-created thread (ThreadTracked-on-watch setup)"); var otherClientChannel = await otherClient.GetOrCreateChannelWithIdAsync(channel.Type, channel.Id); await WaitWhileTrueAsync(() => trackedThreads.All(t => t.ParentMessageId != parent.Id), - description: "otherClient.ThreadTracked to fire for the thread carried by the channel watch response"); + description: + "otherClient.ThreadTracked to fire for the thread carried by the channel watch response"); var tracked = trackedThreads.First(t => t.ParentMessageId == parent.Id); @@ -1084,10 +1099,13 @@ await WaitWhileTrueAsync(() => tracked.Title != newTitle, /// documented on ICacheRepository.Tracked. /// [UnityTest] - public IEnumerator When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state() - => ConnectAndExecute(When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state_Async); + public IEnumerator + When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state() + => ConnectAndExecute( + When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state_Async); - private async Task When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state_Async() + private async Task + When_query_threads_called_twice_expect_thread_tracked_fires_exactly_once_with_hydrated_state_Async() { var channel = await CreateUniqueTempChannelAsync(); var parent = await channel.SendNewMessageAsync("thread parent for ThreadTracked-once test"); @@ -1120,6 +1138,7 @@ void OnTracked(IStreamThread t) firstEmissionChannelCidAtRaise = t.ChannelCid; } } + Client.ThreadTracked += OnTracked; try @@ -1131,7 +1150,8 @@ void OnTracked(IStreamThread t) Filter = new IFieldFilterRule[] { ThreadFilter.ChannelCid.EqualsTo(channel) }, }), r => r != null && r.Threads != null && r.Threads.Any(t => t.ParentMessageId == parent.Id), - description: "first QueryThreadsAsync to return the newly-created thread (ThreadTracked-once setup)"); + description: + "first QueryThreadsAsync to return the newly-created thread (ThreadTracked-once setup)"); await WaitWhileTrueAsync(() => emissionCount == 0, description: "ThreadTracked to fire for the first QueryThreadsAsync emission"); @@ -1197,9 +1217,11 @@ await TryAsync( Filter = new IFieldFilterRule[] { ThreadFilter.ChannelCid.EqualsTo(channel) }, }), r => r != null && r.Threads != null && r.Threads.Any(t => t.ParentMessageId == parent.Id), - description: "QueryThreadsAsync to return the thread before hard-deleting its parent (ThreadUntracked setup)"); + description: + "QueryThreadsAsync to return the thread before hard-deleting its parent (ThreadUntracked setup)"); IStreamThread untracked = null; + void OnUntracked(IStreamThread t) { if (t.ParentMessageId == parent.Id) @@ -1207,6 +1229,7 @@ void OnUntracked(IStreamThread t) untracked = t; } } + Client.ThreadUntracked += OnUntracked; try @@ -1254,7 +1277,8 @@ await WaitWhileTrueAsync(() => untracked == null, /// [UnityTest] public IEnumerator When_added_to_channel_with_existing_thread_expect_channel_watched_on_other_client() - => ConnectAndExecute(When_added_to_channel_with_existing_thread_expect_channel_watched_on_other_client_Async); + => ConnectAndExecute( + When_added_to_channel_with_existing_thread_expect_channel_watched_on_other_client_Async); private async Task When_added_to_channel_with_existing_thread_expect_channel_watched_on_other_client_Async() { @@ -1272,6 +1296,7 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest // No pre-watch here - we want the notification.added_to_channel handler to take // the wasCreated == true branch and exercise the previously-crashing fetch. IStreamChannel addedChannel = null; + void OnAddedToChannelAsMember(IStreamChannel ch, IStreamChannelMember _) { if (ch.Cid == channel.Cid) @@ -1279,6 +1304,7 @@ void OnAddedToChannelAsMember(IStreamChannel ch, IStreamChannelMember _) addedChannel = ch; } } + otherClient.AddedToChannelAsMember += OnAddedToChannelAsMember; try @@ -1286,7 +1312,8 @@ void OnAddedToChannelAsMember(IStreamChannel ch, IStreamChannelMember _) await channel.AddMembersAsync(new[] { otherClient.LocalUserData.User }); await WaitWhileTrueAsync(() => addedChannel == null, maxSeconds: 30, - description: "otherClient.AddedToChannelAsMember to fire after AddMembersAsync (channel-with-thread watch regression)"); + description: + "otherClient.AddedToChannelAsMember to fire after AddMembersAsync (channel-with-thread watch regression)"); } finally { @@ -1362,7 +1389,8 @@ await WaitWhileTrueAsync(() => !thread.LatestReplies.Any(r => r.Id == reply.Id), /// [UnityTest] public IEnumerator When_thread_reply_with_show_in_channel_received_expect_added_to_channel_messages() - => ConnectAndExecute(When_thread_reply_with_show_in_channel_received_expect_added_to_channel_messages_Async); + => ConnectAndExecute( + When_thread_reply_with_show_in_channel_received_expect_added_to_channel_messages_Async); private async Task When_thread_reply_with_show_in_channel_received_expect_added_to_channel_messages_Async() { @@ -1387,10 +1415,10 @@ await channel.SendNewMessageAsync(new StreamSendMessageRequest }); await WaitWhileTrueAsync( - () => !thread.LatestReplies.Any(r => r.Id == reply.Id) - || !channel.Messages.Any(m => m.Id == reply.Id), - maxSeconds: 15, - description: "reply with ShowInChannel=true to appear in BOTH thread.LatestReplies and channel.Messages"); + () => thread.LatestReplies.All(r => r.Id != reply.Id) || channel.Messages.All(m => m.Id != reply.Id), + maxSeconds: 30, + description: + "reply with ShowInChannel=true to appear in BOTH thread.LatestReplies and channel.Messages"); Assert.IsTrue(thread.LatestReplies.Any(r => r.Id == reply.Id), "Reply with ShowInChannel=true must appear in thread.LatestReplies."); @@ -1399,4 +1427,4 @@ await WaitWhileTrueAsync( } } } -#endif +#endif \ No newline at end of file