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..c1561052 100644 --- a/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs +++ b/Assets/Plugins/StreamChat/Tests/StatefulClient/ChannelMembersTests.cs @@ -507,58 +507,150 @@ void OnMessageReceived(IStreamChannel messageChannel, IStreamMessage message) } [UnityTest] - public IEnumerator When_user_removed_from_not_watched_channel_expect_user_removed_from_channel_event() + public IEnumerator + When_local_user_removed_from_unwatched_channel_expect_removed_from_channel_as_member_only() => 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() + 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 + /// . + /// 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() { var channel = await CreateUniqueTempChannelAsync(watch: false); - var receivedAddedEvent = false; - var receivedRemovedEvent = false; - IStreamChannelMember eventMember = null; - IStreamChannel eventChannel = null; + 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"); - void OnAddedToChannelAsMember(IStreamChannel channel2, IStreamChannelMember member) + 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; } - receivedAddedEvent = true; + removedFromChannelAsMemberFired = true; + removedMember = member; } - Client.AddedToChannelAsMember += OnAddedToChannelAsMember; + void OnMemberRemoved(IStreamChannel channel2, IStreamChannelMember member) + { + if (channel2.Cid != channel.Cid) + { + return; + } - await channel.AddMembersAsync(hideHistory: default, optionalMessage: default, Client.LocalUserData.User); - await WaitWhileFalseAsync(() => receivedAddedEvent); + 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"); - void OnRemovedFromChannelAsMember(IStreamChannel channel3, IStreamChannelMember member2) + 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.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] + 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 (channel3.Cid != channel.Cid) + if (channel2.Cid != channel.Cid) { return; } - receivedRemovedEvent = true; - eventMember = member2; - eventChannel = channel3; + 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 channel.RemoveMembersAsync(new IStreamUser[] { Client.LocalUserData.User }); - await WaitWhileFalseAsync(() => receivedRemovedEvent); + await otherClientChannel.RemoveMembersAsync(Client.LocalUserData.User); + await WaitWhileFalseAsync(() => memberRemovedFired, + description: "channel.MemberRemoved after other client removes local user from watched channel"); + + await Task.Delay(5000); - Client.AddedToChannelAsMember -= OnAddedToChannelAsMember; Client.RemovedFromChannelAsMember -= OnRemovedFromChannelAsMember; + channel.MemberRemoved -= OnMemberRemoved; - Assert.IsTrue(receivedRemovedEvent); - Assert.IsNotNull(eventChannel); - Assert.IsNotNull(eventMember); - Assert.AreEqual(Client.LocalUserData.User, eventMember.User); + 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.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); } } }