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");
}
///