From 24c4a9b6416ab5a4ff28b99c48b68f37245bd696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:26:39 +0200 Subject: [PATCH 1/8] Fix OnRemovedFromChannelNotification trying to watch a channel (this fails because user may not have permissions to watch a channel that got removed from) + fix member.User being null in RemovedFromChannelAsMember --- .../StreamChat/Core/StreamChatClient.cs | 31 +- .../StatefulClient/ChannelMembersTests.cs | 362 ++++++++++++++++++ 2 files changed, 375 insertions(+), 18 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index 5d97928f..96d2162a 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -1441,7 +1441,7 @@ private void OnRemovedFromChannelNotification( sb.AppendLine($"{nameof(eventDto.Channel.Cid)}: {eventDto.Channel.Cid}"); _logs.Info(sb.ToString()); #endif - var channel = _cache.TryCreateOrUpdate(eventDto.Channel, out var wasCreated); + var channel = _cache.TryCreateOrUpdate(eventDto.Channel); #if STREAM_TESTS_ENABLED sb.Length = 0; @@ -1452,29 +1452,24 @@ private void OnRemovedFromChannelNotification( _logs.Info(sb.ToString()); #endif + _cache.TryCreateOrUpdate(eventDto.User); + if (eventDto.Member != null && eventDto.Member.User == null && eventDto.User != null) + { + eventDto.Member.User = eventDto.User; + } + var member = _cache.TryCreateOrUpdate(eventDto.Member); - _cache.TryCreateOrUpdate(eventDto.Member.User); - if (!wasCreated) + // Watched channels receive member.removed -> IStreamChannel.MemberRemoved instead. + // The server may still deliver notification.removed_from_channel to the removed user. + if (channel.IsWatched) { - RemovedFromChannelAsMember?.Invoke(channel, member); return; } - // Watch channel, otherwise WS events won't be received - InternalGetOrCreateChannelAsync(channel.Type, channel.Id).ContinueWith(t => - { - if (t.IsFaulted) - { - _logs.Error($"Failed to watch channel with type: {channel.Type} & id: {channel.Id} " + - $"before triggering the {nameof(RemovedFromChannelAsMember)} event. Inspect the following exception: " + - t.Exception); - _logs.Exception(t.Exception); - return; - } - - RemovedFromChannelAsMember?.Invoke(channel, member); - }, TaskScheduler.FromCurrentSynchronizationContext()); + // Unlike notification.added_to_channel, do not watch here — the user was just removed + // and no longer has ReadChannel. The notification payload is sufficient to raise the event. + RemovedFromChannelAsMember?.Invoke(channel, member); } private void OnInvitedNotification(NotificationInvitedEventInternalDTO eventDto) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs index 890cd1c9..2c6880ad 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs @@ -2,10 +2,12 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using StreamChat.Core; +using StreamChat.Core.InternalDTO.Events; using StreamChat.Core.Models; using StreamChat.Core.QueryBuilders.Filters; using StreamChat.Core.QueryBuilders.Filters.Users; @@ -560,6 +562,366 @@ void OnRemovedFromChannelAsMember(IStreamChannel channel3, IStreamChannelMember Assert.IsNotNull(eventMember); Assert.AreEqual(Client.LocalUserData.User, eventMember.User); } + + [UnityTest] + public IEnumerator + When_user_removed_from_not_watched_channel_expect_removed_member_user_populated_on_stateful_event() + => ConnectAndExecute( + When_user_removed_from_not_watched_channel_expect_removed_member_user_populated_on_stateful_event_Async); + + /// + /// notification.removed_from_channel is delivered only to the removed user. Customer payloads + /// include member.user_id and a top-level user, but omit member.user. + /// Our integration environment currently sends a fully populated member.user on the live + /// WS event, so after capturing that payload we strip member.user and replay it through + /// to reproduce the customer scenario. + /// + private async Task + When_user_removed_from_not_watched_channel_expect_removed_member_user_populated_on_stateful_event_Async() + { + var otherClient = await GetConnectedOtherClientAsync(); + var otherClientChannel = await CreateUniqueTempChannelAsync(watch: false, overrideClient: otherClient); + + var receivedAddedEvent = false; + + void OnAddedToChannelAsMember(IStreamChannel channel2, IStreamChannelMember member) + { + if (channel2.Cid != otherClientChannel.Cid) + { + return; + } + + receivedAddedEvent = true; + } + + Client.AddedToChannelAsMember += OnAddedToChannelAsMember; + + await otherClientChannel.AddMembersAsync(hideHistory: default, optionalMessage: default, + Client.LocalUserData.User); + await WaitWhileFalseAsync(() => receivedAddedEvent, + description: "Client.AddedToChannelAsMember before notification removal test"); + + Client.AddedToChannelAsMember -= OnAddedToChannelAsMember; + + NotificationRemovedFromChannelEventInternalDTO liveEventDto = null; + + void OnInternalRemovedFromChannel(NotificationRemovedFromChannelEventInternalDTO eventDto) + { + if (eventDto.Cid != otherClientChannel.Cid) + { + return; + } + + liveEventDto = eventDto; + } + + Client.InternalLowLevelClient.InternalNotificationRemovedFromChannel += OnInternalRemovedFromChannel; + + await otherClientChannel.RemoveMembersAsync(Client.LocalUserData.User); + await WaitWhileFalseAsync(() => liveEventDto != null, + description: "low-level notification.removed_from_channel payload"); + + Client.InternalLowLevelClient.InternalNotificationRemovedFromChannel -= OnInternalRemovedFromChannel; + + Assert.IsNotNull(liveEventDto.User, + "Expected top-level user on notification.removed_from_channel"); + Assert.AreEqual(Client.LocalUserData.UserId, liveEventDto.User.Id); + Assert.IsNotNull(liveEventDto.Member); + Assert.AreEqual(Client.LocalUserData.UserId, liveEventDto.Member.UserId); + Assert.IsNotNull(liveEventDto.Member.User, + "Precondition: integration env currently sends member.user on the live WS payload; " + + "stripping it below simulates the customer-reported payload shape"); + + liveEventDto.Member.User = null; + + var receivedRemovedEvent = false; + IStreamChannelMember statefulMember = null; + + void OnRemovedFromChannelAsMember(IStreamChannel channel3, IStreamChannelMember member2) + { + if (channel3.Cid != otherClientChannel.Cid) + { + return; + } + + receivedRemovedEvent = true; + statefulMember = member2; + } + + Client.RemovedFromChannelAsMember += OnRemovedFromChannelAsMember; + + var handler = typeof(StreamChatClient).GetMethod("OnRemovedFromChannelNotification", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(handler, "OnRemovedFromChannelNotification handler must exist"); + handler.Invoke(Client, new object[] { liveEventDto }); + + Client.RemovedFromChannelAsMember -= OnRemovedFromChannelAsMember; + + Assert.IsTrue(receivedRemovedEvent); + Assert.IsNotNull(statefulMember); + Assert.IsNotNull(statefulMember.User, + "IStreamChannelMember.User must be populated from the event top-level user when member.user is absent"); + Assert.AreEqual(Client.LocalUserData.UserId, statefulMember.User.Id); + } + + [UnityTest] + public IEnumerator + When_user_removed_from_not_watched_channel_not_in_cache_expect_removed_from_channel_as_member() + => ConnectAndExecute( + When_user_removed_from_not_watched_channel_not_in_cache_expect_removed_from_channel_as_member_Async); + + /// + /// When the channel is not yet in the local cache, notification.removed_from_channel must still + /// raise without attempting to watch + /// the channel (the removed user no longer has ReadChannel). + /// + private async Task + When_user_removed_from_not_watched_channel_not_in_cache_expect_removed_from_channel_as_member_Async() + { + var otherClient = await GetConnectedOtherClientAsync(); + var otherClientChannel = await CreateUniqueTempChannelAsync(watch: false, overrideClient: otherClient); + + var receivedAddedEvent = false; + + void OnAddedToChannelAsMember(IStreamChannel channel2, IStreamChannelMember member) + { + if (channel2.Cid != otherClientChannel.Cid) + { + return; + } + + receivedAddedEvent = true; + } + + Client.AddedToChannelAsMember += OnAddedToChannelAsMember; + + await otherClientChannel.AddMembersAsync(hideHistory: default, optionalMessage: default, + Client.LocalUserData.User); + await WaitWhileFalseAsync(() => receivedAddedEvent, + description: "Client.AddedToChannelAsMember before uncached removal test"); + + Client.AddedToChannelAsMember -= OnAddedToChannelAsMember; + + NotificationRemovedFromChannelEventInternalDTO liveEventDto = null; + + void OnInternalRemovedFromChannel(NotificationRemovedFromChannelEventInternalDTO eventDto) + { + if (eventDto.Cid != otherClientChannel.Cid) + { + return; + } + + liveEventDto = eventDto; + } + + Client.InternalLowLevelClient.InternalNotificationRemovedFromChannel += OnInternalRemovedFromChannel; + + await otherClientChannel.RemoveMembersAsync(Client.LocalUserData.User); + await WaitWhileFalseAsync(() => liveEventDto != null, + description: "low-level notification.removed_from_channel payload"); + + Client.InternalLowLevelClient.InternalNotificationRemovedFromChannel -= OnInternalRemovedFromChannel; + + Assert.IsNotNull(liveEventDto); + RemoveChannelFromClientCache(Client, liveEventDto.Cid); + + var receivedRemovedEvent = false; + IStreamChannel eventChannel = null; + IStreamChannelMember eventMember = null; + + void OnRemovedFromChannelAsMember(IStreamChannel channel3, IStreamChannelMember member2) + { + if (channel3.Cid != otherClientChannel.Cid) + { + return; + } + + receivedRemovedEvent = true; + eventChannel = channel3; + eventMember = member2; + } + + Client.RemovedFromChannelAsMember += OnRemovedFromChannelAsMember; + + var handler = typeof(StreamChatClient).GetMethod("OnRemovedFromChannelNotification", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(handler, "OnRemovedFromChannelNotification handler must exist"); + handler.Invoke(Client, new object[] { liveEventDto }); + + Client.RemovedFromChannelAsMember -= OnRemovedFromChannelAsMember; + + Assert.IsTrue(receivedRemovedEvent, + "RemovedFromChannelAsMember must fire without watching a channel that is not in the local cache"); + Assert.IsNotNull(eventChannel); + Assert.AreEqual(otherClientChannel.Cid, eventChannel.Cid); + Assert.IsNotNull(eventMember); + Assert.AreEqual(Client.LocalUserData.UserId, eventMember.User?.Id); + } + + [UnityTest] + public IEnumerator + When_local_user_removed_from_unwatched_channel_expect_removed_from_channel_as_member_only() + => ConnectAndExecute( + When_local_user_removed_from_unwatched_channel_expect_removed_from_channel_as_member_only_Async); + + /// + /// notification.removed_from_channel is delivered to the removed user when they are not + /// watching the channel. The stateful client surfaces that as + /// , not + /// . + /// + private async Task When_local_user_removed_from_unwatched_channel_expect_removed_from_channel_as_member_only_Async() + { + var channel = await CreateUniqueTempChannelAsync(watch: false); + + await channel.AddMembersAsync(hideHistory: default, optionalMessage: default, Client.LocalUserData.User); + await WaitWhileFalseAsync( + () => channel.Members.Any(m => m.User?.Id == Client.LocalUserData.UserId), + description: "local user to appear in unwatched channel members after add"); + + Assert.IsFalse(channel.IsWatched, + "Precondition: channel must not be watched so the server sends notification.removed_from_channel"); + + var removedFromChannelAsMemberFired = false; + var memberRemovedFired = false; + IStreamChannelMember removedMember = null; + + void OnRemovedFromChannelAsMember(IStreamChannel channel2, IStreamChannelMember member) + { + if (channel2.Cid != channel.Cid) + { + return; + } + + removedFromChannelAsMemberFired = true; + removedMember = member; + } + + void OnMemberRemoved(IStreamChannel channel2, IStreamChannelMember member) + { + if (channel2.Cid != channel.Cid) + { + return; + } + + memberRemovedFired = true; + } + + Client.RemovedFromChannelAsMember += OnRemovedFromChannelAsMember; + channel.MemberRemoved += OnMemberRemoved; + + await channel.RemoveMembersAsync(Client.LocalUserData.User); + await WaitWhileFalseAsync(() => removedFromChannelAsMemberFired, + description: "Client.RemovedFromChannelAsMember after removing local user from unwatched channel"); + + Client.RemovedFromChannelAsMember -= OnRemovedFromChannelAsMember; + channel.MemberRemoved -= OnMemberRemoved; + + Assert.IsTrue(removedFromChannelAsMemberFired); + Assert.IsFalse(memberRemovedFired, + "IStreamChannel.MemberRemoved must not fire when the local user is removed from an unwatched channel"); + Assert.IsNotNull(removedMember); + Assert.AreEqual(Client.LocalUserData.UserId, removedMember.User?.Id); + } + + [UnityTest] + public IEnumerator When_local_user_removed_from_watched_channel_expect_member_removed_only() + => ConnectAndExecute(When_local_user_removed_from_watched_channel_expect_member_removed_only_Async); + + /// + /// member.removed is delivered to clients watching the channel. When the local user is + /// removed while watching, the stateful client surfaces + /// instead of + /// . + /// The server may still send notification.removed_from_channel alongside member.removed; + /// the SDK suppresses the stateful notification event when the channel is watched locally. + /// + private async Task When_local_user_removed_from_watched_channel_expect_member_removed_only_Async() + { + var otherClient = await GetConnectedOtherClientAsync(); + var otherClientChannel = await CreateUniqueTempChannelAsync(watch: true, overrideClient: otherClient); + + await otherClientChannel.AddMembersAsync(hideHistory: default, optionalMessage: default, + Client.LocalUserData.User); + + var channel = await Client.GetOrCreateChannelWithIdAsync(otherClientChannel.Type, otherClientChannel.Id); + + await WaitWhileFalseAsync( + () => channel.IsWatched && channel.Members.Any(m => m.User?.Id == Client.LocalUserData.UserId), + description: "local client to watch channel and have local user membership before removal"); + + Assert.IsTrue(channel.IsWatched, + "Precondition: channel must be watched so the server sends member.removed"); + + var removedFromChannelAsMemberFired = false; + var memberRemovedFired = false; + IStreamChannelMember removedMember = null; + + void OnRemovedFromChannelAsMember(IStreamChannel channel2, IStreamChannelMember member) + { + if (channel2.Cid != channel.Cid) + { + return; + } + + removedFromChannelAsMemberFired = true; + } + + void OnMemberRemoved(IStreamChannel channel2, IStreamChannelMember member) + { + if (channel2.Cid != channel.Cid) + { + return; + } + + memberRemovedFired = true; + removedMember = member; + } + + Client.RemovedFromChannelAsMember += OnRemovedFromChannelAsMember; + channel.MemberRemoved += OnMemberRemoved; + + await otherClientChannel.RemoveMembersAsync(Client.LocalUserData.User); + await WaitWhileFalseAsync(() => memberRemovedFired, + description: "channel.MemberRemoved after other client removes local user from watched channel"); + + Client.RemovedFromChannelAsMember -= OnRemovedFromChannelAsMember; + channel.MemberRemoved -= OnMemberRemoved; + + Assert.IsTrue(memberRemovedFired); + Assert.IsFalse(removedFromChannelAsMemberFired, + "Client.RemovedFromChannelAsMember must not fire when the local user is removed from a watched channel"); + Assert.IsNotNull(removedMember); + Assert.AreEqual(Client.LocalUserData.UserId, removedMember.User?.Id); + } + + private static void RemoveChannelFromClientCache(StreamChatClient client, string cid) + { + var cacheField = typeof(StreamChatClient).GetField("_cache", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(cacheField, "StreamChatClient._cache field must exist"); + var cache = cacheField.GetValue(client); + Assert.IsNotNull(cache); + + var channelsProperty = cache.GetType().GetProperty("Channels"); + Assert.IsNotNull(channelsProperty, "ICache.Channels property must exist"); + var channels = channelsProperty.GetValue(cache); + Assert.IsNotNull(channels); + + var tryGet = channels.GetType().GetMethod("TryGet"); + Assert.IsNotNull(tryGet, "Channels.TryGet method must exist"); + var args = new object[] { cid, null }; + Assert.IsTrue((bool)tryGet.Invoke(channels, args), + "Precondition: channel must be in cache before eviction"); + + var remove = channels.GetType().GetMethod("Remove"); + Assert.IsNotNull(remove, "Channels.Remove method must exist"); + remove.Invoke(channels, new[] { args[1] }); + + args[1] = null; + Assert.IsFalse((bool)tryGet.Invoke(channels, args), + "Precondition: channel must not be in cache before replay"); + } } } #endif \ No newline at end of file From f409c7b2cb46618447c7c6cec4807495eb3c16b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:29:35 +0200 Subject: [PATCH 2/8] temp fix --- .../Core/StatefulModels/StreamThread.cs | 8 +++-- .../StreamChat/Core/StreamChatClient.cs | 19 +++++++--- .../StatefulClient/ChannelMembersTests.cs | 14 ++++++++ .../Tests/StatefulClient/ThreadsTests.cs | 35 +++---------------- 4 files changed, 40 insertions(+), 36 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs index f562e38a..e77dcb15 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs @@ -122,14 +122,18 @@ public async Task UpdatePartialAsync(IDictionary setFields = nul } } - public Task MarkReadAsync() + public async Task MarkReadAsync() { ResolveChannelTypeAndId(out var channelType, out var channelId); - return LowLevelClient.InternalChannelApi.MarkReadAsync(channelType, channelId, + await LowLevelClient.InternalChannelApi.MarkReadAsync(channelType, channelId, new MarkReadRequestInternalDTO { ThreadId = ParentMessageId, }); + + // REST success must clear local unread even when notification.mark_read is not echoed + // back to the caller. HandleMarkReadByUser is idempotent when the WS echo arrives later. + HandleMarkReadByUser(Client.LocalUserData.UserId, DateTimeOffset.UtcNow); } public Task MarkUnreadAsync() diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index 96d2162a..e1e15976 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -1208,12 +1208,19 @@ private void OnMessageReceived(MessageNewEventInternalDTO eventDto) // the parent counter on a true insert so duplicate or overlapping deliveries are safe. var isInsert = !string.IsNullOrEmpty(messageId) && !_cache.Messages.TryGet(messageId, out _); + var parentId = messageDto?.ParentId; + int? parentReplyCountBefore = null; + if (isInsert && !string.IsNullOrEmpty(parentId) + && _cache.Messages.TryGet(parentId, out var parentSnapshot)) + { + parentReplyCountBefore = parentSnapshot.ReplyCount ?? 0; + } + if (_cache.Channels.TryGet(eventDto.Cid, out var streamChannel)) { streamChannel.HandleMessageNewEvent(eventDto); } - var parentId = messageDto?.ParentId; if (string.IsNullOrEmpty(parentId)) { return; @@ -1221,11 +1228,15 @@ private void OnMessageReceived(MessageNewEventInternalDTO eventDto) // Watching clients receive message.new but not notification.thread_message_new, so without // this bump parent.ReplyCount drifts below the true value until the next REST refresh. - // Done unconditionally on the parent (independent of thread tracking) to match the - // notification.thread_message_new path. + // Skip the optimistic bump when message.updated (or another concurrent delivery) has + // already advanced the cached parent counter for this reply. if (isInsert && _cache.Messages.TryGet(parentId, out var parent)) { - parent.InternalIncrementReplyCount(); + var currentReplyCount = parent.ReplyCount ?? 0; + if (!parentReplyCountBefore.HasValue || currentReplyCount <= parentReplyCountBefore) + { + parent.InternalIncrementReplyCount(); + } } if (_cache.Threads.TryGet(parentId, out var thread) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs index 2c6880ad..81ba27ff 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs @@ -650,6 +650,20 @@ void OnRemovedFromChannelAsMember(IStreamChannel channel3, IStreamChannelMember Client.RemovedFromChannelAsMember += OnRemovedFromChannelAsMember; + // notification.added_to_channel auto-watches newly cached channels, but this test + // replays notification.removed_from_channel for the unwatched delivery path. + var cachedChannel = Client.Channels.FirstOrDefault(c => c.Cid == otherClientChannel.Cid); + Assert.IsNotNull(cachedChannel, + "Precondition: notification.added_to_channel must leave the channel in the local cache"); + Assert.IsTrue(cachedChannel.IsWatched, + "Precondition: notification.added_to_channel auto-watches newly cached channels"); + var markUnwatched = typeof(StreamChatClient).GetMethod("InternalMarkChannelUnwatched", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(markUnwatched, "InternalMarkChannelUnwatched must exist"); + markUnwatched.Invoke(Client, new object[] { cachedChannel }); + Assert.IsFalse(cachedChannel.IsWatched, + "Replay must simulate notification.removed_from_channel on an unwatched channel"); + var handler = typeof(StreamChatClient).GetMethod("OnRemovedFromChannelNotification", BindingFlags.Instance | BindingFlags.NonPublic); Assert.IsNotNull(handler, "OnRemovedFromChannelNotification handler must exist"); diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs index 8c5f3325..bb136367 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs @@ -775,41 +775,16 @@ await otherClientChannel.SendNewMessageAsync(new StreamSendMessageRequest var beforeLastRead = localRead.LastRead; - var readSeen = false; - StreamThreadReadHandler readHandler = _ => readSeen = true; - thread.ReadStateChanged += readHandler; - - try - { - await thread.MarkReadAsync(); - - // notification.mark_read may not be echoed back to the caller; if no event arrives - // the buggy code path is never exercised and there is nothing to assert. - try - { - await WaitWhileTrueAsync(() => !readSeen, maxSeconds: 5, - description: "notification.mark_read WS echo (best-effort, unread-clear)"); - } - catch (TimeoutException) - { - } - } - finally - { - thread.ReadStateChanged -= readHandler; - } - - if (!readSeen) - { - return; - } + await thread.MarkReadAsync(); + // MarkReadAsync clears local unread on REST success. Do not gate the assertion on + // ReadStateChanged — that event is also raised by unrelated thread reply deliveries. var afterRead = thread.Read.FirstOrDefault(r => r.User != null && r.User.Id == localUserId); Assert.NotNull(afterRead, "Local user's Read entry must still exist after mark-read"); Assert.AreEqual(0, afterRead.UnreadMessages, - "After notification.mark_read fires, the local user's UnreadMessages must be reset to 0"); + "After MarkReadAsync, the local user's UnreadMessages must be reset to 0"); Assert.GreaterOrEqual(afterRead.LastRead, beforeLastRead, - "After notification.mark_read fires, the local user's LastRead must advance"); + "After MarkReadAsync, the local user's LastRead must advance"); } /// From 122489adc4e056ccac2ea3c98a198bdac6707346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:10:41 +0200 Subject: [PATCH 3/8] fix compiler error --- .../StreamChat/Tests/StatefulClient/ChannelMembersTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs index 81ba27ff..60976a37 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs @@ -652,7 +652,7 @@ void OnRemovedFromChannelAsMember(IStreamChannel channel3, IStreamChannelMember // notification.added_to_channel auto-watches newly cached channels, but this test // replays notification.removed_from_channel for the unwatched delivery path. - var cachedChannel = Client.Channels.FirstOrDefault(c => c.Cid == otherClientChannel.Cid); + var cachedChannel = Client.WatchedChannels.FirstOrDefault(c => c.Cid == otherClientChannel.Cid); Assert.IsNotNull(cachedChannel, "Precondition: notification.added_to_channel must leave the channel in the local cache"); Assert.IsTrue(cachedChannel.IsWatched, From f1b3e4854d39da2551862352f3d7789bc84ed64f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:18:22 +0200 Subject: [PATCH 4/8] remove obsolete tests -> they wrongly test the internal details rather than the public interface --- .../StatefulClient/ChannelMembersTests.cs | 239 ------------------ 1 file changed, 239 deletions(-) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs index 60976a37..f87a11a3 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs @@ -2,12 +2,10 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using StreamChat.Core; -using StreamChat.Core.InternalDTO.Events; using StreamChat.Core.Models; using StreamChat.Core.QueryBuilders.Filters; using StreamChat.Core.QueryBuilders.Filters.Users; @@ -563,215 +561,6 @@ void OnRemovedFromChannelAsMember(IStreamChannel channel3, IStreamChannelMember Assert.AreEqual(Client.LocalUserData.User, eventMember.User); } - [UnityTest] - public IEnumerator - When_user_removed_from_not_watched_channel_expect_removed_member_user_populated_on_stateful_event() - => ConnectAndExecute( - When_user_removed_from_not_watched_channel_expect_removed_member_user_populated_on_stateful_event_Async); - - /// - /// notification.removed_from_channel is delivered only to the removed user. Customer payloads - /// include member.user_id and a top-level user, but omit member.user. - /// Our integration environment currently sends a fully populated member.user on the live - /// WS event, so after capturing that payload we strip member.user and replay it through - /// to reproduce the customer scenario. - /// - private async Task - When_user_removed_from_not_watched_channel_expect_removed_member_user_populated_on_stateful_event_Async() - { - var otherClient = await GetConnectedOtherClientAsync(); - var otherClientChannel = await CreateUniqueTempChannelAsync(watch: false, overrideClient: otherClient); - - var receivedAddedEvent = false; - - void OnAddedToChannelAsMember(IStreamChannel channel2, IStreamChannelMember member) - { - if (channel2.Cid != otherClientChannel.Cid) - { - return; - } - - receivedAddedEvent = true; - } - - Client.AddedToChannelAsMember += OnAddedToChannelAsMember; - - await otherClientChannel.AddMembersAsync(hideHistory: default, optionalMessage: default, - Client.LocalUserData.User); - await WaitWhileFalseAsync(() => receivedAddedEvent, - description: "Client.AddedToChannelAsMember before notification removal test"); - - Client.AddedToChannelAsMember -= OnAddedToChannelAsMember; - - NotificationRemovedFromChannelEventInternalDTO liveEventDto = null; - - void OnInternalRemovedFromChannel(NotificationRemovedFromChannelEventInternalDTO eventDto) - { - if (eventDto.Cid != otherClientChannel.Cid) - { - return; - } - - liveEventDto = eventDto; - } - - Client.InternalLowLevelClient.InternalNotificationRemovedFromChannel += OnInternalRemovedFromChannel; - - await otherClientChannel.RemoveMembersAsync(Client.LocalUserData.User); - await WaitWhileFalseAsync(() => liveEventDto != null, - description: "low-level notification.removed_from_channel payload"); - - Client.InternalLowLevelClient.InternalNotificationRemovedFromChannel -= OnInternalRemovedFromChannel; - - Assert.IsNotNull(liveEventDto.User, - "Expected top-level user on notification.removed_from_channel"); - Assert.AreEqual(Client.LocalUserData.UserId, liveEventDto.User.Id); - Assert.IsNotNull(liveEventDto.Member); - Assert.AreEqual(Client.LocalUserData.UserId, liveEventDto.Member.UserId); - Assert.IsNotNull(liveEventDto.Member.User, - "Precondition: integration env currently sends member.user on the live WS payload; " + - "stripping it below simulates the customer-reported payload shape"); - - liveEventDto.Member.User = null; - - var receivedRemovedEvent = false; - IStreamChannelMember statefulMember = null; - - void OnRemovedFromChannelAsMember(IStreamChannel channel3, IStreamChannelMember member2) - { - if (channel3.Cid != otherClientChannel.Cid) - { - return; - } - - receivedRemovedEvent = true; - statefulMember = member2; - } - - Client.RemovedFromChannelAsMember += OnRemovedFromChannelAsMember; - - // notification.added_to_channel auto-watches newly cached channels, but this test - // replays notification.removed_from_channel for the unwatched delivery path. - var cachedChannel = Client.WatchedChannels.FirstOrDefault(c => c.Cid == otherClientChannel.Cid); - Assert.IsNotNull(cachedChannel, - "Precondition: notification.added_to_channel must leave the channel in the local cache"); - Assert.IsTrue(cachedChannel.IsWatched, - "Precondition: notification.added_to_channel auto-watches newly cached channels"); - var markUnwatched = typeof(StreamChatClient).GetMethod("InternalMarkChannelUnwatched", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.IsNotNull(markUnwatched, "InternalMarkChannelUnwatched must exist"); - markUnwatched.Invoke(Client, new object[] { cachedChannel }); - Assert.IsFalse(cachedChannel.IsWatched, - "Replay must simulate notification.removed_from_channel on an unwatched channel"); - - var handler = typeof(StreamChatClient).GetMethod("OnRemovedFromChannelNotification", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.IsNotNull(handler, "OnRemovedFromChannelNotification handler must exist"); - handler.Invoke(Client, new object[] { liveEventDto }); - - Client.RemovedFromChannelAsMember -= OnRemovedFromChannelAsMember; - - Assert.IsTrue(receivedRemovedEvent); - Assert.IsNotNull(statefulMember); - Assert.IsNotNull(statefulMember.User, - "IStreamChannelMember.User must be populated from the event top-level user when member.user is absent"); - Assert.AreEqual(Client.LocalUserData.UserId, statefulMember.User.Id); - } - - [UnityTest] - public IEnumerator - When_user_removed_from_not_watched_channel_not_in_cache_expect_removed_from_channel_as_member() - => ConnectAndExecute( - When_user_removed_from_not_watched_channel_not_in_cache_expect_removed_from_channel_as_member_Async); - - /// - /// When the channel is not yet in the local cache, notification.removed_from_channel must still - /// raise without attempting to watch - /// the channel (the removed user no longer has ReadChannel). - /// - private async Task - When_user_removed_from_not_watched_channel_not_in_cache_expect_removed_from_channel_as_member_Async() - { - var otherClient = await GetConnectedOtherClientAsync(); - var otherClientChannel = await CreateUniqueTempChannelAsync(watch: false, overrideClient: otherClient); - - var receivedAddedEvent = false; - - void OnAddedToChannelAsMember(IStreamChannel channel2, IStreamChannelMember member) - { - if (channel2.Cid != otherClientChannel.Cid) - { - return; - } - - receivedAddedEvent = true; - } - - Client.AddedToChannelAsMember += OnAddedToChannelAsMember; - - await otherClientChannel.AddMembersAsync(hideHistory: default, optionalMessage: default, - Client.LocalUserData.User); - await WaitWhileFalseAsync(() => receivedAddedEvent, - description: "Client.AddedToChannelAsMember before uncached removal test"); - - Client.AddedToChannelAsMember -= OnAddedToChannelAsMember; - - NotificationRemovedFromChannelEventInternalDTO liveEventDto = null; - - void OnInternalRemovedFromChannel(NotificationRemovedFromChannelEventInternalDTO eventDto) - { - if (eventDto.Cid != otherClientChannel.Cid) - { - return; - } - - liveEventDto = eventDto; - } - - Client.InternalLowLevelClient.InternalNotificationRemovedFromChannel += OnInternalRemovedFromChannel; - - await otherClientChannel.RemoveMembersAsync(Client.LocalUserData.User); - await WaitWhileFalseAsync(() => liveEventDto != null, - description: "low-level notification.removed_from_channel payload"); - - Client.InternalLowLevelClient.InternalNotificationRemovedFromChannel -= OnInternalRemovedFromChannel; - - Assert.IsNotNull(liveEventDto); - RemoveChannelFromClientCache(Client, liveEventDto.Cid); - - var receivedRemovedEvent = false; - IStreamChannel eventChannel = null; - IStreamChannelMember eventMember = null; - - void OnRemovedFromChannelAsMember(IStreamChannel channel3, IStreamChannelMember member2) - { - if (channel3.Cid != otherClientChannel.Cid) - { - return; - } - - receivedRemovedEvent = true; - eventChannel = channel3; - eventMember = member2; - } - - Client.RemovedFromChannelAsMember += OnRemovedFromChannelAsMember; - - var handler = typeof(StreamChatClient).GetMethod("OnRemovedFromChannelNotification", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.IsNotNull(handler, "OnRemovedFromChannelNotification handler must exist"); - handler.Invoke(Client, new object[] { liveEventDto }); - - Client.RemovedFromChannelAsMember -= OnRemovedFromChannelAsMember; - - Assert.IsTrue(receivedRemovedEvent, - "RemovedFromChannelAsMember must fire without watching a channel that is not in the local cache"); - Assert.IsNotNull(eventChannel); - Assert.AreEqual(otherClientChannel.Cid, eventChannel.Cid); - Assert.IsNotNull(eventMember); - Assert.AreEqual(Client.LocalUserData.UserId, eventMember.User?.Id); - } - [UnityTest] public IEnumerator When_local_user_removed_from_unwatched_channel_expect_removed_from_channel_as_member_only() @@ -908,34 +697,6 @@ await WaitWhileFalseAsync(() => memberRemovedFired, Assert.IsNotNull(removedMember); Assert.AreEqual(Client.LocalUserData.UserId, removedMember.User?.Id); } - - private static void RemoveChannelFromClientCache(StreamChatClient client, string cid) - { - var cacheField = typeof(StreamChatClient).GetField("_cache", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.IsNotNull(cacheField, "StreamChatClient._cache field must exist"); - var cache = cacheField.GetValue(client); - Assert.IsNotNull(cache); - - var channelsProperty = cache.GetType().GetProperty("Channels"); - Assert.IsNotNull(channelsProperty, "ICache.Channels property must exist"); - var channels = channelsProperty.GetValue(cache); - Assert.IsNotNull(channels); - - var tryGet = channels.GetType().GetMethod("TryGet"); - Assert.IsNotNull(tryGet, "Channels.TryGet method must exist"); - var args = new object[] { cid, null }; - Assert.IsTrue((bool)tryGet.Invoke(channels, args), - "Precondition: channel must be in cache before eviction"); - - var remove = channels.GetType().GetMethod("Remove"); - Assert.IsNotNull(remove, "Channels.Remove method must exist"); - remove.Invoke(channels, new[] { args[1] }); - - args[1] = null; - Assert.IsFalse((bool)tryGet.Invoke(channels, args), - "Precondition: channel must not be in cache before replay"); - } } } #endif \ No newline at end of file From bc8c5415d2966c3d2d037b2254241eed208b921a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:21:58 +0200 Subject: [PATCH 5/8] remove obsolete test --- .../StatefulClient/ChannelMembersTests.cs | 67 +++---------------- 1 file changed, 10 insertions(+), 57 deletions(-) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs index f87a11a3..2f0a449a 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs @@ -506,61 +506,6 @@ void OnMessageReceived(IStreamChannel messageChannel, IStreamMessage message) Assert.AreEqual(MainThreadId, messageEventThreadId); } - [UnityTest] - public IEnumerator When_user_removed_from_not_watched_channel_expect_user_removed_from_channel_event() - => ConnectAndExecute( - When_user_removed_from_not_watched_channel_expect_user_removed_from_channel_event_Async); - - private async Task When_user_removed_from_not_watched_channel_expect_user_removed_from_channel_event_Async() - { - var channel = await CreateUniqueTempChannelAsync(watch: false); - - var receivedAddedEvent = false; - var receivedRemovedEvent = false; - IStreamChannelMember eventMember = null; - IStreamChannel eventChannel = null; - - void OnAddedToChannelAsMember(IStreamChannel channel2, IStreamChannelMember member) - { - if (channel2.Cid != channel.Cid) - { - return; - } - - receivedAddedEvent = true; - } - - Client.AddedToChannelAsMember += OnAddedToChannelAsMember; - - await channel.AddMembersAsync(hideHistory: default, optionalMessage: default, Client.LocalUserData.User); - await WaitWhileFalseAsync(() => receivedAddedEvent); - - void OnRemovedFromChannelAsMember(IStreamChannel channel3, IStreamChannelMember member2) - { - if (channel3.Cid != channel.Cid) - { - return; - } - - receivedRemovedEvent = true; - eventMember = member2; - eventChannel = channel3; - } - - Client.RemovedFromChannelAsMember += OnRemovedFromChannelAsMember; - - await channel.RemoveMembersAsync(new IStreamUser[] { Client.LocalUserData.User }); - await WaitWhileFalseAsync(() => receivedRemovedEvent); - - Client.AddedToChannelAsMember -= OnAddedToChannelAsMember; - Client.RemovedFromChannelAsMember -= OnRemovedFromChannelAsMember; - - Assert.IsTrue(receivedRemovedEvent); - Assert.IsNotNull(eventChannel); - Assert.IsNotNull(eventMember); - Assert.AreEqual(Client.LocalUserData.User, eventMember.User); - } - [UnityTest] public IEnumerator When_local_user_removed_from_unwatched_channel_expect_removed_from_channel_as_member_only() @@ -572,6 +517,8 @@ public IEnumerator /// watching the channel. The stateful client surfaces that as /// , not /// . + /// Exercises the real server response through the public client API; subscribers must be + /// able to read without it being null. /// private async Task When_local_user_removed_from_unwatched_channel_expect_removed_from_channel_as_member_only_Async() { @@ -624,7 +571,10 @@ await WaitWhileFalseAsync(() => removedFromChannelAsMemberFired, Assert.IsFalse(memberRemovedFired, "IStreamChannel.MemberRemoved must not fire when the local user is removed from an unwatched channel"); Assert.IsNotNull(removedMember); - Assert.AreEqual(Client.LocalUserData.UserId, removedMember.User?.Id); + Assert.IsNotNull(removedMember.User, + "IStreamChannelMember.User must be populated on RemovedFromChannelAsMember"); + Assert.AreEqual(Client.LocalUserData.UserId, removedMember.User.Id); + Assert.AreEqual(Client.LocalUserData.User, removedMember.User); } [UnityTest] @@ -695,7 +645,10 @@ await WaitWhileFalseAsync(() => memberRemovedFired, Assert.IsFalse(removedFromChannelAsMemberFired, "Client.RemovedFromChannelAsMember must not fire when the local user is removed from a watched channel"); Assert.IsNotNull(removedMember); - Assert.AreEqual(Client.LocalUserData.UserId, removedMember.User?.Id); + Assert.IsNotNull(removedMember.User, + "IStreamChannelMember.User must be populated on MemberRemoved"); + Assert.AreEqual(Client.LocalUserData.UserId, removedMember.User.Id); + Assert.AreEqual(Client.LocalUserData.User, removedMember.User); } } } From 7e96af9b04acb049d760c069071b0502d0274f15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:50:31 +0200 Subject: [PATCH 6/8] wait to ensure an event is not received --- .../StreamChat/Tests/StatefulClient/ChannelMembersTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs index 2f0a449a..c1561052 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs @@ -638,6 +638,8 @@ void OnMemberRemoved(IStreamChannel channel2, IStreamChannelMember member) await WaitWhileFalseAsync(() => memberRemovedFired, description: "channel.MemberRemoved after other client removes local user from watched channel"); + await Task.Delay(5000); + Client.RemovedFromChannelAsMember -= OnRemovedFromChannelAsMember; channel.MemberRemoved -= OnMemberRemoved; From c6a999143e14a4dbd7748422d3975deb5e089573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:58:43 +0200 Subject: [PATCH 7/8] Remove dead parent ReplyCount guard from OnMessageReceived --- .../StreamChat/Core/StreamChatClient.cs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs index e1e15976..96d2162a 100644 --- a/Assets/Plugins/StreamChat/Core/StreamChatClient.cs +++ b/Assets/Plugins/StreamChat/Core/StreamChatClient.cs @@ -1208,19 +1208,12 @@ private void OnMessageReceived(MessageNewEventInternalDTO eventDto) // the parent counter on a true insert so duplicate or overlapping deliveries are safe. var isInsert = !string.IsNullOrEmpty(messageId) && !_cache.Messages.TryGet(messageId, out _); - var parentId = messageDto?.ParentId; - int? parentReplyCountBefore = null; - if (isInsert && !string.IsNullOrEmpty(parentId) - && _cache.Messages.TryGet(parentId, out var parentSnapshot)) - { - parentReplyCountBefore = parentSnapshot.ReplyCount ?? 0; - } - if (_cache.Channels.TryGet(eventDto.Cid, out var streamChannel)) { streamChannel.HandleMessageNewEvent(eventDto); } + var parentId = messageDto?.ParentId; if (string.IsNullOrEmpty(parentId)) { return; @@ -1228,15 +1221,11 @@ private void OnMessageReceived(MessageNewEventInternalDTO eventDto) // Watching clients receive message.new but not notification.thread_message_new, so without // this bump parent.ReplyCount drifts below the true value until the next REST refresh. - // Skip the optimistic bump when message.updated (or another concurrent delivery) has - // already advanced the cached parent counter for this reply. + // Done unconditionally on the parent (independent of thread tracking) to match the + // notification.thread_message_new path. if (isInsert && _cache.Messages.TryGet(parentId, out var parent)) { - var currentReplyCount = parent.ReplyCount ?? 0; - if (!parentReplyCountBefore.HasValue || currentReplyCount <= parentReplyCountBefore) - { - parent.InternalIncrementReplyCount(); - } + parent.InternalIncrementReplyCount(); } if (_cache.Threads.TryGet(parentId, out var thread) From b66d61be4995df180df2c978cd43074b173af61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Sierpi=C5=84ski?= <33436839+sierpinskid@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:01:24 +0200 Subject: [PATCH 8/8] remove optimistic HandleMarkReadByUser and wait for the event --- .../Core/StatefulModels/StreamThread.cs | 8 ++--- .../Tests/StatefulClient/ThreadsTests.cs | 35 ++++++++++++++++--- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs index e77dcb15..f562e38a 100644 --- a/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs +++ b/Assets/Plugins/StreamChat/Core/StatefulModels/StreamThread.cs @@ -122,18 +122,14 @@ public async Task UpdatePartialAsync(IDictionary setFields = nul } } - public async Task MarkReadAsync() + public Task MarkReadAsync() { ResolveChannelTypeAndId(out var channelType, out var channelId); - await LowLevelClient.InternalChannelApi.MarkReadAsync(channelType, channelId, + return LowLevelClient.InternalChannelApi.MarkReadAsync(channelType, channelId, new MarkReadRequestInternalDTO { ThreadId = ParentMessageId, }); - - // REST success must clear local unread even when notification.mark_read is not echoed - // back to the caller. HandleMarkReadByUser is idempotent when the WS echo arrives later. - HandleMarkReadByUser(Client.LocalUserData.UserId, DateTimeOffset.UtcNow); } public Task MarkUnreadAsync() diff --git a/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs b/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs index bb136367..8c5f3325 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ThreadsTests.cs @@ -775,16 +775,41 @@ await otherClientChannel.SendNewMessageAsync(new StreamSendMessageRequest var beforeLastRead = localRead.LastRead; - await thread.MarkReadAsync(); + var readSeen = false; + StreamThreadReadHandler readHandler = _ => readSeen = true; + thread.ReadStateChanged += readHandler; + + try + { + await thread.MarkReadAsync(); + + // notification.mark_read may not be echoed back to the caller; if no event arrives + // the buggy code path is never exercised and there is nothing to assert. + try + { + await WaitWhileTrueAsync(() => !readSeen, maxSeconds: 5, + description: "notification.mark_read WS echo (best-effort, unread-clear)"); + } + catch (TimeoutException) + { + } + } + finally + { + thread.ReadStateChanged -= readHandler; + } + + if (!readSeen) + { + return; + } - // MarkReadAsync clears local unread on REST success. Do not gate the assertion on - // ReadStateChanged — that event is also raised by unrelated thread reply deliveries. var afterRead = thread.Read.FirstOrDefault(r => r.User != null && r.User.Id == localUserId); Assert.NotNull(afterRead, "Local user's Read entry must still exist after mark-read"); Assert.AreEqual(0, afterRead.UnreadMessages, - "After MarkReadAsync, the local user's UnreadMessages must be reset to 0"); + "After notification.mark_read fires, the local user's UnreadMessages must be reset to 0"); Assert.GreaterOrEqual(afterRead.LastRead, beforeLastRead, - "After MarkReadAsync, the local user's LastRead must advance"); + "After notification.mark_read fires, the local user's LastRead must advance"); } ///