diff --git a/.pubnub.yml b/.pubnub.yml index d6ff93c79..9c6bbb633 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -1,8 +1,13 @@ name: c-sharp -version: "8.0.3" +version: "8.0.4" schema: 1 scm: github.com/pubnub/c-sharp changelog: + - date: 2025-12-04 + version: v8.0.4 + changes: + - type: improvement + text: "Prevent resubscribe to previously subscribed entities." - date: 2025-11-20 version: v8.0.3 changes: @@ -967,7 +972,7 @@ features: - QUERY-PARAM supported-platforms: - - version: Pubnub 'C#' 8.0.3 + version: Pubnub 'C#' 8.0.4 platforms: - Windows 10 and up - Windows Server 2008 and up @@ -978,7 +983,7 @@ supported-platforms: - .Net Framework 4.6.1+ - .Net Framework 6.0 - - version: PubnubPCL 'C#' 8.0.3 + version: PubnubPCL 'C#' 8.0.4 platforms: - Xamarin.Android - Xamarin.iOS @@ -998,7 +1003,7 @@ supported-platforms: - .Net Core - .Net 6.0 - - version: PubnubUWP 'C#' 8.0.3 + version: PubnubUWP 'C#' 8.0.4 platforms: - Windows Phone 10 - Universal Windows Apps @@ -1022,7 +1027,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: Pubnub - location: https://github.com/pubnub/c-sharp/releases/tag/v8.0.3 + location: https://github.com/pubnub/c-sharp/releases/tag/v8.0.4 requires: - name: ".Net" @@ -1305,7 +1310,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: PubNubPCL - location: https://github.com/pubnub/c-sharp/releases/tag/v8.0.3 + location: https://github.com/pubnub/c-sharp/releases/tag/v8.0.4 requires: - name: ".Net Core" @@ -1664,7 +1669,7 @@ sdks: distribution-type: source distribution-repository: GitHub package-name: PubnubUWP - location: https://github.com/pubnub/c-sharp/releases/tag/v8.0.3 + location: https://github.com/pubnub/c-sharp/releases/tag/v8.0.4 requires: - name: "Universal Windows Platform Development" diff --git a/CHANGELOG b/CHANGELOG index 5ddb4ab12..7e8fcb9ae 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +v8.0.4 - December 04 2025 +----------------------------- +- Modified: prevent resubscribe to previously subscribed entities. + v8.0.3 - November 20 2025 ----------------------------- - Modified: refactor EmitStatus to prevent concurrent modification issue resulting InvalidOperationException. diff --git a/src/Api/PubnubApi/EventEngine/Common/EventEmitter.cs b/src/Api/PubnubApi/EventEngine/Common/EventEmitter.cs index c842679dc..2b7e174d1 100644 --- a/src/Api/PubnubApi/EventEngine/Common/EventEmitter.cs +++ b/src/Api/PubnubApi/EventEngine/Common/EventEmitter.cs @@ -158,21 +158,25 @@ public void EmitEvent(object e) { payload = eventData?.Payload; } - + if (currentMessageChannel.Contains("-pnpres") || currentMessageChannel.Contains(".*-pnpres")) { jsonFields.Add("payload", payload); } else if (eventData.MessageType == 2) //Objects Simplification events { - Dictionary appContextEventFields = payload as Dictionary ?? (payload as JObject)?.ToObject>(); + Dictionary appContextEventFields = payload as Dictionary ?? + (payload as JObject) + ?.ToObject>(); if (appContextEventFields != null && appContextEventFields.ContainsKey("source") && appContextEventFields.ContainsKey("version") && appContextEventFields["source"].ToString() == "objects" - && Double.TryParse(appContextEventFields["version"]?.ToString()?.Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var objectsVersion)) + && Double.TryParse(appContextEventFields["version"]?.ToString()?.Trim(), NumberStyles.Number, + CultureInfo.InvariantCulture, out var objectsVersion)) { - if (objectsVersion.CompareTo(2D) == 0) //Process only version=2 for Objects Simplification. Ignore 1. + if (objectsVersion.CompareTo(2D) == + 0) //Process only version=2 for Objects Simplification. Ignore 1. { jsonFields.Add("payload", payload); } @@ -180,10 +184,16 @@ public void EmitEvent(object e) } else { - if ((configuration.CryptoModule != null || configuration.CipherKey.Length > 0) && (eventData.MessageType == 0 || eventData.MessageType == 4)) //decrypt the subscriber message if cipherkey is available + if ((configuration.CryptoModule != null || configuration.CipherKey.Length > 0) && + (eventData.MessageType == 0 || + eventData.MessageType == 4)) //decrypt the subscriber message if cipherkey is available { string decryptMessage = ""; - configuration.CryptoModule ??= new CryptoModule(new AesCbcCryptor(configuration.CipherKey), new List() { new LegacyCryptor(configuration.CipherKey, configuration.UseRandomInitializationVector) }); + configuration.CryptoModule ??= new CryptoModule(new AesCbcCryptor(configuration.CipherKey), + new List() + { + new LegacyCryptor(configuration.CipherKey, configuration.UseRandomInitializationVector) + }); try { decryptMessage = configuration.CryptoModule.Decrypt(payload.ToString()); @@ -191,10 +201,13 @@ public void EmitEvent(object e) catch (Exception ex) { decryptMessage = "**DECRYPT ERROR**"; - configuration?.Logger?.Warn("Failed to decrypt message on channel {currentMessageChannel} due to exception={ex}.\n Message might be not encrypted"); + configuration?.Logger?.Warn( + "Failed to decrypt message on channel {currentMessageChannel} due to exception={ex}.\n Message might be not encrypted"); } - object decodeMessage = jsonLibrary.DeserializeToObject((decryptMessage == "**DECRYPT ERROR**") ? jsonLibrary.SerializeToJsonString(payload) : decryptMessage); + object decodeMessage = jsonLibrary.DeserializeToObject((decryptMessage == "**DECRYPT ERROR**") + ? jsonLibrary.SerializeToJsonString(payload) + : decryptMessage); jsonFields.Add("payload", decodeMessage); } else @@ -219,238 +232,276 @@ public void EmitEvent(object e) jsonFields.Add("currentMessageChannelGroup", currentMessageChannelGroup); jsonFields.Add("currentMessageChannel", currentMessageChannel); - - switch (eventData.MessageType) + try { - case 1: + switch (eventData.MessageType) { - jsonFields.Add("customMessageType", eventData.CustomMessageType); - ResponseBuilder responseBuilder = new ResponseBuilder(configuration, jsonLibrary); - PNMessageResult pnMessageResult = responseBuilder.GetEventResultObject>(jsonFields); - if (pnMessageResult != null) + case 1: { - PNSignalResult signalMessage = new PNSignalResult - { - Channel = pnMessageResult.Channel, - Message = pnMessageResult.Message, - Subscription = pnMessageResult.Subscription, - Timetoken = pnMessageResult.Timetoken, - UserMetadata = pnMessageResult.UserMetadata, - Publisher = pnMessageResult.Publisher - }; - foreach (var listener in listeners) - { - SafeInvokeListener(() => listener?.Signal(instance, signalMessage), "Signal"); - } - - if (!string.IsNullOrEmpty(signalMessage.Channel) && channelListenersMap.ContainsKey(signalMessage.Channel)) + jsonFields.Add("customMessageType", eventData.CustomMessageType); + ResponseBuilder responseBuilder = new ResponseBuilder(configuration, jsonLibrary); + PNMessageResult pnMessageResult = + responseBuilder.GetEventResultObject>(jsonFields); + if (pnMessageResult != null) { - foreach (var l in channelListenersMap[signalMessage.Channel]) + PNSignalResult signalMessage = new PNSignalResult { - SafeInvokeListener(() => l?.Signal(instance, signalMessage), "Signal"); - } - } - - if (!string.IsNullOrEmpty(signalMessage.Subscription) && channelGroupListenersMap.ContainsKey(signalMessage.Subscription)) - { - foreach (var l in channelGroupListenersMap[signalMessage.Subscription]) + Channel = pnMessageResult.Channel, + Message = pnMessageResult.Message, + Subscription = pnMessageResult.Subscription, + Timetoken = pnMessageResult.Timetoken, + UserMetadata = pnMessageResult.UserMetadata, + Publisher = pnMessageResult.Publisher + }; + foreach (var listener in listeners) { - SafeInvokeListener(() => l?.Signal(instance, signalMessage), "Signal"); + SafeInvokeListener(() => listener?.Signal(instance, signalMessage), "Signal"); } - } - } - break; - } - case 2: - { - ResponseBuilder responseBuilder =new ResponseBuilder(configuration, jsonLibrary); - PNObjectEventResult objectApiEvent = responseBuilder.GetEventResultObject(jsonFields); - if (objectApiEvent != null) - { - foreach (var listener in listeners) - { - SafeInvokeListener(() => listener?.ObjectEvent(instance, objectApiEvent), "ObjectEvent"); - } - - if (!string.IsNullOrEmpty(objectApiEvent.Channel) && channelListenersMap.ContainsKey(objectApiEvent.Channel)) - { - foreach (var l in channelListenersMap[objectApiEvent.Channel]) + if (!string.IsNullOrEmpty(signalMessage.Channel) && + channelListenersMap.ContainsKey(signalMessage.Channel)) { - SafeInvokeListener(() => l?.ObjectEvent(instance, objectApiEvent), "ObjectEvent"); + foreach (var l in channelListenersMap[signalMessage.Channel]) + { + SafeInvokeListener(() => l?.Signal(instance, signalMessage), "Signal"); + } } - } - if (!string.IsNullOrEmpty(objectApiEvent.Subscription) && channelGroupListenersMap.ContainsKey(objectApiEvent.Subscription)) - { - foreach (var l in channelGroupListenersMap[objectApiEvent.Subscription]) + if (!string.IsNullOrEmpty(signalMessage.Subscription) && + channelGroupListenersMap.ContainsKey(signalMessage.Subscription)) { - SafeInvokeListener(() => l?.ObjectEvent(instance, objectApiEvent), "ObjectEvent"); + foreach (var l in channelGroupListenersMap[signalMessage.Subscription]) + { + SafeInvokeListener(() => l?.Signal(instance, signalMessage), "Signal"); + } } } - } - break; - } - case 3: - { - ResponseBuilder responseBuilder =new ResponseBuilder(configuration, jsonLibrary); - PNMessageActionEventResult messageActionEvent = responseBuilder.GetEventResultObject(jsonFields); - if (messageActionEvent != null) + break; + } + case 2: { - foreach (var listener in listeners) + ResponseBuilder responseBuilder = new ResponseBuilder(configuration, jsonLibrary); + PNObjectEventResult objectApiEvent = + responseBuilder.GetEventResultObject(jsonFields); + if (objectApiEvent != null) { - SafeInvokeListener(() => listener?.MessageAction(instance, messageActionEvent), "MessageAction"); - } + foreach (var listener in listeners) + { + SafeInvokeListener(() => listener?.ObjectEvent(instance, objectApiEvent), + "ObjectEvent"); + } - if (!string.IsNullOrEmpty(messageActionEvent.Channel) && channelListenersMap.ContainsKey(messageActionEvent.Channel)) - { - foreach (var l in channelListenersMap[messageActionEvent.Channel]) + if (!string.IsNullOrEmpty(objectApiEvent.Channel) && + channelListenersMap.ContainsKey(objectApiEvent.Channel)) { - SafeInvokeListener(() => l?.MessageAction(instance, messageActionEvent), "MessageAction"); + foreach (var l in channelListenersMap[objectApiEvent.Channel]) + { + SafeInvokeListener(() => l?.ObjectEvent(instance, objectApiEvent), "ObjectEvent"); + } } - } - if (!string.IsNullOrEmpty(messageActionEvent.Subscription) && channelGroupListenersMap.ContainsKey(messageActionEvent.Subscription)) - { - foreach (var l in channelGroupListenersMap[messageActionEvent.Subscription]) + if (!string.IsNullOrEmpty(objectApiEvent.Subscription) && + channelGroupListenersMap.ContainsKey(objectApiEvent.Subscription)) { - SafeInvokeListener(() => l?.MessageAction(instance, messageActionEvent), "MessageAction"); + foreach (var l in channelGroupListenersMap[objectApiEvent.Subscription]) + { + SafeInvokeListener(() => l?.ObjectEvent(instance, objectApiEvent), "ObjectEvent"); + } } } - } - break; - } - case 4: - { - jsonFields.Add("customMessageType", eventData.CustomMessageType); - ResponseBuilder responseBuilder =new ResponseBuilder(configuration, jsonLibrary); - PNMessageResult filesEvent = responseBuilder.GetEventResultObject>(jsonFields); - if (filesEvent != null) + break; + } + case 3: { - PNFileEventResult fileMessage = new PNFileEventResult - { - Channel = filesEvent.Channel, - Subscription = filesEvent.Subscription, - Timetoken = filesEvent.Timetoken, - Publisher = filesEvent.Publisher, - CustomMessageType = filesEvent.CustomMessageType, - }; - Dictionary fileEventMessageField = jsonLibrary.ConvertToDictionaryObject(filesEvent.Message); - if (fileEventMessageField is { Count: > 0 }) + ResponseBuilder responseBuilder = new ResponseBuilder(configuration, jsonLibrary); + PNMessageActionEventResult messageActionEvent = + responseBuilder.GetEventResultObject(jsonFields); + if (messageActionEvent != null) { - if (fileEventMessageField.ContainsKey("message") && fileEventMessageField["message"] != null) + foreach (var listener in listeners) { - fileMessage.Message = fileEventMessageField["message"]; + SafeInvokeListener(() => listener?.MessageAction(instance, messageActionEvent), + "MessageAction"); } - if (fileEventMessageField.TryGetValue("file", out var fileField)) + if (!string.IsNullOrEmpty(messageActionEvent.Channel) && + channelListenersMap.ContainsKey(messageActionEvent.Channel)) { - Dictionary fileDetailFields = jsonLibrary.ConvertToDictionaryObject(fileField); - if (fileDetailFields != null && fileDetailFields.ContainsKey("id") && fileDetailFields.ContainsKey("name")) + foreach (var l in channelListenersMap[messageActionEvent.Channel]) { - fileMessage.File = new PNFile { Id = fileDetailFields["id"].ToString(), Name = fileDetailFields["name"].ToString() }; - fileMessage.File.Url = UriUtil.GetFileUrl(fileName: fileMessage.File.Name, fileId: fileMessage.File.Id, channel: fileMessage.Channel, - pnConfiguration: configuration, pubnub: instance, tokenmanager: tokenManager); + SafeInvokeListener(() => l?.MessageAction(instance, messageActionEvent), + "MessageAction"); } } - } - else - { - if (filesEvent.Message != null) + + if (!string.IsNullOrEmpty(messageActionEvent.Subscription) && + channelGroupListenersMap.ContainsKey(messageActionEvent.Subscription)) { - fileMessage.Message = filesEvent.Message; + foreach (var l in channelGroupListenersMap[messageActionEvent.Subscription]) + { + SafeInvokeListener(() => l?.MessageAction(instance, messageActionEvent), + "MessageAction"); + } } } - foreach (var listener in listeners) - { - SafeInvokeListener(() => listener?.File(instance, fileMessage), "File"); - } - - if (!string.IsNullOrEmpty(fileMessage.Channel) && channelListenersMap.ContainsKey(fileMessage.Channel)) + break; + } + case 4: + { + jsonFields.Add("customMessageType", eventData.CustomMessageType); + ResponseBuilder responseBuilder = new ResponseBuilder(configuration, jsonLibrary); + PNMessageResult filesEvent = + responseBuilder.GetEventResultObject>(jsonFields); + if (filesEvent != null) { - foreach (var l in channelListenersMap[fileMessage.Channel]) + PNFileEventResult fileMessage = new PNFileEventResult { - SafeInvokeListener(() => l?.File(instance, fileMessage), "File"); - } - } + Channel = filesEvent.Channel, + Subscription = filesEvent.Subscription, + Timetoken = filesEvent.Timetoken, + Publisher = filesEvent.Publisher, + CustomMessageType = filesEvent.CustomMessageType, + }; + Dictionary fileEventMessageField = + jsonLibrary.ConvertToDictionaryObject(filesEvent.Message); + if (fileEventMessageField is { Count: > 0 }) + { + if (fileEventMessageField.ContainsKey("message") && + fileEventMessageField["message"] != null) + { + fileMessage.Message = fileEventMessageField["message"]; + } - if (!string.IsNullOrEmpty(fileMessage.Subscription) && channelGroupListenersMap.ContainsKey(fileMessage.Subscription)) - { - foreach (var l in channelGroupListenersMap[fileMessage.Subscription]) + if (fileEventMessageField.TryGetValue("file", out var fileField)) + { + Dictionary fileDetailFields = + jsonLibrary.ConvertToDictionaryObject(fileField); + if (fileDetailFields != null && fileDetailFields.ContainsKey("id") && + fileDetailFields.ContainsKey("name")) + { + fileMessage.File = new PNFile + { + Id = fileDetailFields["id"].ToString(), + Name = fileDetailFields["name"].ToString() + }; + fileMessage.File.Url = UriUtil.GetFileUrl(fileName: fileMessage.File.Name, + fileId: fileMessage.File.Id, channel: fileMessage.Channel, + pnConfiguration: configuration, pubnub: instance, + tokenmanager: tokenManager); + } + } + } + else { - SafeInvokeListener(() => l?.File(instance, fileMessage), "File"); + if (filesEvent.Message != null) + { + fileMessage.Message = filesEvent.Message; + } } - } - } - break; - } - default: - { - if (currentMessageChannel.Contains("-pnpres")) - { - ResponseBuilder responseBuilder =new ResponseBuilder(configuration, jsonLibrary); - PNPresenceEventResult presenceEvent = responseBuilder.GetEventResultObject(jsonFields); - if (presenceEvent != null) - { foreach (var listener in listeners) { - SafeInvokeListener(() => listener?.Presence(instance, presenceEvent), "Presence"); + SafeInvokeListener(() => listener?.File(instance, fileMessage), "File"); } - if (!string.IsNullOrEmpty(presenceEvent.Channel) && channelListenersMap.ContainsKey(presenceEvent.Channel)) + if (!string.IsNullOrEmpty(fileMessage.Channel) && + channelListenersMap.ContainsKey(fileMessage.Channel)) { - foreach (var l in channelListenersMap[presenceEvent.Channel]) + foreach (var l in channelListenersMap[fileMessage.Channel]) { - SafeInvokeListener(() => l?.Presence(instance, presenceEvent), "Presence"); + SafeInvokeListener(() => l?.File(instance, fileMessage), "File"); } } - if (!string.IsNullOrEmpty(presenceEvent.Subscription) && channelGroupListenersMap.ContainsKey(presenceEvent.Subscription)) + if (!string.IsNullOrEmpty(fileMessage.Subscription) && + channelGroupListenersMap.ContainsKey(fileMessage.Subscription)) { - foreach (var l in channelGroupListenersMap[presenceEvent.Subscription]) + foreach (var l in channelGroupListenersMap[fileMessage.Subscription]) { - SafeInvokeListener(() => l?.Presence(instance, presenceEvent), "Presence"); + SafeInvokeListener(() => l?.File(instance, fileMessage), "File"); } } } + + break; } - else + default: { - jsonFields.Add("customMessageType", eventData.CustomMessageType); - ResponseBuilder responseBuilder =new ResponseBuilder(configuration, jsonLibrary); - PNMessageResult userMessage = responseBuilder.GetEventResultObject>(jsonFields); - if (userMessage != null) + if (currentMessageChannel.Contains("-pnpres")) { - foreach (var listener in listeners) + ResponseBuilder responseBuilder = new ResponseBuilder(configuration, jsonLibrary); + PNPresenceEventResult presenceEvent = + responseBuilder.GetEventResultObject(jsonFields); + if (presenceEvent != null) { - SafeInvokeListener(() => listener?.Message(instance, userMessage), "Message"); - } + foreach (var listener in listeners) + { + SafeInvokeListener(() => listener?.Presence(instance, presenceEvent), "Presence"); + } - if (!string.IsNullOrEmpty(userMessage.Channel) && channelListenersMap.ContainsKey(userMessage.Channel)) - { - foreach (var l in channelListenersMap[userMessage.Channel]) + if (!string.IsNullOrEmpty(presenceEvent.Channel) && + channelListenersMap.ContainsKey(presenceEvent.Channel)) { - SafeInvokeListener(() => l?.Message(instance, userMessage), "Message"); + foreach (var l in channelListenersMap[presenceEvent.Channel]) + { + SafeInvokeListener(() => l?.Presence(instance, presenceEvent), "Presence"); + } } - } - if (!string.IsNullOrEmpty(userMessage.Subscription) && channelGroupListenersMap.ContainsKey(userMessage.Subscription)) + if (!string.IsNullOrEmpty(presenceEvent.Subscription) && + channelGroupListenersMap.ContainsKey(presenceEvent.Subscription)) + { + foreach (var l in channelGroupListenersMap[presenceEvent.Subscription]) + { + SafeInvokeListener(() => l?.Presence(instance, presenceEvent), "Presence"); + } + } + } + } + else + { + jsonFields.Add("customMessageType", eventData.CustomMessageType); + ResponseBuilder responseBuilder = new ResponseBuilder(configuration, jsonLibrary); + PNMessageResult userMessage = + responseBuilder.GetEventResultObject>(jsonFields); + if (userMessage != null) { - foreach (var l in channelGroupListenersMap[userMessage.Subscription]) + foreach (var listener in listeners) { - SafeInvokeListener(() => l?.Message(instance, userMessage), "Message"); + SafeInvokeListener(() => listener?.Message(instance, userMessage), "Message"); + } + + if (!string.IsNullOrEmpty(userMessage.Channel) && + channelListenersMap.ContainsKey(userMessage.Channel)) + { + foreach (var l in channelListenersMap[userMessage.Channel]) + { + SafeInvokeListener(() => l?.Message(instance, userMessage), "Message"); + } + } + + if (!string.IsNullOrEmpty(userMessage.Subscription) && + channelGroupListenersMap.ContainsKey(userMessage.Subscription)) + { + foreach (var l in channelGroupListenersMap[userMessage.Subscription]) + { + SafeInvokeListener(() => l?.Message(instance, userMessage), "Message"); + } } } } - } - break; + break; + } } } + catch (Exception ex) + { + configuration?.Logger.Warn($"Emit Event failed: {ex.Message}"); + } } } } \ No newline at end of file diff --git a/src/Api/PubnubApi/EventEngine/Core/Engine.cs b/src/Api/PubnubApi/EventEngine/Core/Engine.cs index 8de04e224..20f173e5e 100644 --- a/src/Api/PubnubApi/EventEngine/Core/Engine.cs +++ b/src/Api/PubnubApi/EventEngine/Core/Engine.cs @@ -66,6 +66,12 @@ private async Task Transition(IEvent e) return; } + if (stateInvocationPair.State is null) + { + logger?.Warn($"TransitionResult returned null State for event {e?.GetType()?.Name}. Ignoring transition."); + return; + } + await ExecuteStateChange(currentState, stateInvocationPair.State, stateInvocationPair.Invocations).ConfigureAwait(false); this.currentState = stateInvocationPair.State; diff --git a/src/Api/PubnubApi/EventEngine/Subscribe/SubscribeEventEngine.cs b/src/Api/PubnubApi/EventEngine/Subscribe/SubscribeEventEngine.cs index 857a121a1..4e2aa7e09 100644 --- a/src/Api/PubnubApi/EventEngine/Subscribe/SubscribeEventEngine.cs +++ b/src/Api/PubnubApi/EventEngine/Subscribe/SubscribeEventEngine.cs @@ -6,6 +6,8 @@ using System; using PubnubApi.EventEngine.Subscribe.Events; using PubnubApi.EventEngine.Common; +using PubnubApi.EventEngine.Subscribe.Effects; +using PubnubApi.EventEngine.Subscribe.Invocations; namespace PubnubApi.EventEngine.Subscribe { @@ -18,6 +20,7 @@ public class SubscribeEventEngine : Engine public List Channels { get; } = []; public List ChannelGroups { get; } = []; + private readonly EmitStatusEffectHandler emitStatusHandler; internal SubscribeEventEngine(Pubnub pubnubInstance, PNConfiguration pubnubConfiguration, @@ -28,33 +31,37 @@ internal SubscribeEventEngine(Pubnub pubnubInstance, { this.subscribeManager = subscribeManager; this.jsonPluggableLibrary = jsonPluggableLibrary; - var handshakeHandler = new Effects.HandshakeEffectHandler(subscribeManager, EventQueue); - var handshakeReconnectHandler = new Effects.HandshakeReconnectEffectHandler(pubnubConfiguration, EventQueue, handshakeHandler); + var handshakeHandler = new HandshakeEffectHandler(subscribeManager, EventQueue); + var handshakeReconnectHandler = new HandshakeReconnectEffectHandler(pubnubConfiguration, EventQueue, handshakeHandler); - dispatcher.Register(handshakeHandler); - dispatcher.Register(handshakeHandler); - dispatcher.Register(handshakeReconnectHandler); - dispatcher.Register(handshakeReconnectHandler); + dispatcher.Register(handshakeHandler); + dispatcher.Register(handshakeHandler); + dispatcher.Register(handshakeReconnectHandler); + dispatcher.Register(handshakeReconnectHandler); - var receiveHandler = new Effects.ReceivingEffectHandler(subscribeManager, EventQueue); - var receiveReconnectHandler = new Effects.ReceivingReconnectEffectHandler(pubnubConfiguration, EventQueue, receiveHandler); + var receiveHandler = new ReceivingEffectHandler(subscribeManager, EventQueue); + var receiveReconnectHandler = new ReceivingReconnectEffectHandler(pubnubConfiguration, EventQueue, receiveHandler); - dispatcher.Register(receiveHandler); - dispatcher.Register(receiveHandler); - dispatcher.Register(receiveReconnectHandler); - dispatcher.Register(receiveReconnectHandler); + dispatcher.Register(receiveHandler); + dispatcher.Register(receiveHandler); + dispatcher.Register(receiveReconnectHandler); + dispatcher.Register(receiveReconnectHandler); - var emitMessageHandler = new Effects.EmitMessagesHandler(eventEmitter, jsonPluggableLibrary, channelTypeMap, channelGroupTypeMap); - dispatcher.Register(emitMessageHandler); + var emitMessageHandler = new EmitMessagesHandler(eventEmitter, jsonPluggableLibrary, channelTypeMap, channelGroupTypeMap); + dispatcher.Register(emitMessageHandler); - var emitStatusHandler = new Effects.EmitStatusEffectHandler(pubnubInstance, statusListener); - dispatcher.Register(emitStatusHandler); + emitStatusHandler = new EmitStatusEffectHandler(pubnubInstance, statusListener); + dispatcher.Register(emitStatusHandler); currentState = new UnsubscribedState(); logger = pubnubConfiguration.Logger; } public void Subscribe(string[] channels, string[] channelGroups, SubscriptionCursor cursor) { + bool allChannelsExist = channels.All(c => Channels.Contains(c)); + bool allChannelGroupsExist = channelGroups.All(cg => ChannelGroups.Contains(cg)); + bool isNewSubscription = !(allChannelsExist && allChannelGroupsExist); + Channels.AddRange(channels); ChannelGroups.AddRange(channelGroups); @@ -71,10 +78,16 @@ public void Subscribe(string[] channels, string[] channelGroups, Subscription Cursor = cursor }); } else { - EventQueue.Enqueue(new SubscriptionChangedEvent() { - Channels = Channels.Distinct(), - ChannelGroups = ChannelGroups.Distinct() - }); + if (isNewSubscription) { + EventQueue.Enqueue(new SubscriptionChangedEvent() { + Channels = Channels.Distinct(), + ChannelGroups = ChannelGroups.Distinct() + }); + } else { + var status = new PNStatus(null, PNOperationType.PNSubscribeOperation, PNStatusCategory.PNSubscriptionChangedCategory, Channels, ChannelGroups, Constants.HttpRequestSuccessStatusCode); + var invocation = new EmitStatusInvocation(status); + _ = emitStatusHandler.Run(invocation); + } } } @@ -104,4 +117,4 @@ public void Unsubscribe(string[] channels, string[] channelGroups) } } } -} \ No newline at end of file +} diff --git a/src/Api/PubnubApi/Helper/MobilePushHelper.cs b/src/Api/PubnubApi/Helper/MobilePushHelper.cs index 20872e75a..f97ca555f 100644 --- a/src/Api/PubnubApi/Helper/MobilePushHelper.cs +++ b/src/Api/PubnubApi/Helper/MobilePushHelper.cs @@ -109,7 +109,7 @@ public Dictionary GetPayload() Dictionary pnFcm = BuildFcmPayload(pushType); if (pnFcm != null) { - ret.Add("pn_gcm", pnFcm); + ret.Add("pn_fcm", pnFcm); } } } @@ -248,6 +248,10 @@ public class Apns2Data { public string collapseId { get; set; } public string expiration { get; set; } + + [JsonConverter(typeof(StringEnumConverter))] + public APNS2AuthMethod authMethod { get; set; } + public List targets { get; set; } public string version { get; } = "v2"; @@ -262,6 +266,16 @@ public class PushTarget public Environment environment { get; set; } } + public enum APNS2AuthMethod + { + [EnumMember(Value = "token")] + TOKEN, + [EnumMember(Value = "cert")] + CERT, + [EnumMember(Value = "certificate")] + CERTIFICATE + } + public enum Environment { [EnumMember(Value = "development")] diff --git a/src/Api/PubnubApi/Interface/IJsonPluggableLibrary.cs b/src/Api/PubnubApi/Interface/IJsonPluggableLibrary.cs index eac617e6c..53febadeb 100644 --- a/src/Api/PubnubApi/Interface/IJsonPluggableLibrary.cs +++ b/src/Api/PubnubApi/Interface/IJsonPluggableLibrary.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using Newtonsoft.Json.Linq; namespace PubnubApi { diff --git a/src/Api/PubnubApi/Properties/AssemblyInfo.cs b/src/Api/PubnubApi/Properties/AssemblyInfo.cs index 5490c3f94..2cac81845 100644 --- a/src/Api/PubnubApi/Properties/AssemblyInfo.cs +++ b/src/Api/PubnubApi/Properties/AssemblyInfo.cs @@ -11,8 +11,8 @@ [assembly: AssemblyProduct("Pubnub C# SDK")] [assembly: AssemblyCopyright("Copyright © 2021")] [assembly: AssemblyTrademark("")] -[assembly: AssemblyVersion("8.0.3")] -[assembly: AssemblyFileVersion("8.0.3")] +[assembly: AssemblyVersion("8.0.4")] +[assembly: AssemblyFileVersion("8.0.4")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from // COM, set the ComVisible attribute to true on that type. diff --git a/src/Api/PubnubApi/PubnubApi.csproj b/src/Api/PubnubApi/PubnubApi.csproj index f1de1ef01..5cc2b4ce7 100644 --- a/src/Api/PubnubApi/PubnubApi.csproj +++ b/src/Api/PubnubApi/PubnubApi.csproj @@ -14,7 +14,7 @@ Pubnub - 8.0.3 + 8.0.4 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub @@ -22,7 +22,7 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Refactor EmitStatus to prevent concurrent modification issue resulting InvalidOperationException. + Prevent resubscribe to previously subscribed entities. Web Data Push Real-time Notifications ESB Message Broadcasting Distributed Computing PubNub is a Massively Scalable Web Push Service for Web and Mobile Games. This is a cloud-based service for broadcasting messages to thousands of web and mobile clients simultaneously diff --git a/src/Api/PubnubApiPCL/PubnubApiPCL.csproj b/src/Api/PubnubApiPCL/PubnubApiPCL.csproj index 915f20a52..b48b11efe 100644 --- a/src/Api/PubnubApiPCL/PubnubApiPCL.csproj +++ b/src/Api/PubnubApiPCL/PubnubApiPCL.csproj @@ -14,7 +14,7 @@ PubnubPCL - 8.0.3 + 8.0.4 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub @@ -22,7 +22,7 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Refactor EmitStatus to prevent concurrent modification issue resulting InvalidOperationException. + Prevent resubscribe to previously subscribed entities. Web Data Push Real-time Notifications ESB Message Broadcasting Distributed Computing PubNub is a Massively Scalable Web Push Service for Web and Mobile Games. This is a cloud-based service for broadcasting messages to thousands of web and mobile clients simultaneously diff --git a/src/Api/PubnubApiUWP/PubnubApiUWP.csproj b/src/Api/PubnubApiUWP/PubnubApiUWP.csproj index 0a27a4bc5..f6f552c0b 100644 --- a/src/Api/PubnubApiUWP/PubnubApiUWP.csproj +++ b/src/Api/PubnubApiUWP/PubnubApiUWP.csproj @@ -16,7 +16,7 @@ PubnubUWP - 8.0.3 + 8.0.4 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub @@ -24,7 +24,7 @@ http://pubnub.s3.amazonaws.com/2011/powered-by-pubnub/pubnub-icon-600x600.png true https://github.com/pubnub/c-sharp/ - Refactor EmitStatus to prevent concurrent modification issue resulting InvalidOperationException. + Prevent resubscribe to previously subscribed entities. Web Data Push Real-time Notifications ESB Message Broadcasting Distributed Computing PubNub is a Massively Scalable Web Push Service for Web and Mobile Games. This is a cloud-based service for broadcasting messages to thousands of web and mobile clients simultaneously diff --git a/src/Api/PubnubApiUnity/PubnubApiUnity.csproj b/src/Api/PubnubApiUnity/PubnubApiUnity.csproj index f612c8298..6c1c07c27 100644 --- a/src/Api/PubnubApiUnity/PubnubApiUnity.csproj +++ b/src/Api/PubnubApiUnity/PubnubApiUnity.csproj @@ -15,7 +15,7 @@ PubnubApiUnity - 8.0.3 + 8.0.4 PubNub C# .NET - Web Data Push API Pandu Masabathula PubNub diff --git a/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs b/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs index cec92b40f..795eaf86e 100644 --- a/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs +++ b/src/UnitTests/PubnubApi.Tests/WhenAMessageIsPublished.cs @@ -1780,21 +1780,22 @@ public static void IfMobilePayloadThenPublishReturnSuccess() { collapseId = "sample collapse id", expiration = "xyzexpiration", + authMethod = APNS2AuthMethod.TOKEN, targets = new List() - { - new PushTarget() - { - environment= PubnubApi.Environment.Development, - exclude_devices = new List(){ "excl_d1", "excl_d2" }, - topic = "sample dev topic" - }, - new PushTarget() - { - environment= PubnubApi.Environment.Production, - exclude_devices = new List(){ "excl_d3", "excl_d4" }, - topic = "sample prod topic" - } - } + { + new PushTarget() + { + environment= PubnubApi.Environment.Development, + exclude_devices = new List(){ "excl_d1", "excl_d2" }, + topic = "sample dev topic" + }, + new PushTarget() + { + environment= PubnubApi.Environment.Production, + exclude_devices = new List(){ "excl_d3", "excl_d4" }, + topic = "sample prod topic" + } + } }; Dictionary> pushTypeCustomData = new Dictionary>(); @@ -1824,6 +1825,9 @@ public static void IfMobilePayloadThenPublishReturnSuccess() System.Diagnostics.Debug.WriteLine(pubnub.JsonPluggableLibrary.SerializeToJsonString(payload)); Assert.IsTrue(payload != null, "FAILED - IfMobilePayloadThenPublishReturnSuccess"); + Assert.True(payload.ContainsKey("pn_apns"), "FAILED - Push Payload should contain apns key"); + Assert.True(payload.ContainsKey("pn_fcm"), "FAILED - Push Payload should contain fcm key"); + Assert.False(payload.ContainsKey("pn_gcm"), "FAILED - Push Payload should NOT contain gcm key"); } [Test] diff --git a/src/UnitTests/PubnubApi.Tests/WhenSubscribeWithDuplicateChannels.cs b/src/UnitTests/PubnubApi.Tests/WhenSubscribeWithDuplicateChannels.cs index b1732cd28..c85ba4fc9 100644 --- a/src/UnitTests/PubnubApi.Tests/WhenSubscribeWithDuplicateChannels.cs +++ b/src/UnitTests/PubnubApi.Tests/WhenSubscribeWithDuplicateChannels.cs @@ -15,21 +15,19 @@ public class WhenSubscribeWithDuplicateChannels { private WireMockServer _mockServer; private Pubnub _pubnub; - private const int HEARTBEAT_INTERVAL = 3; // seconds - private const int PRESENCE_TIMEOUT = 10; // seconds + private const int HEARTBEAT_INTERVAL = 3; + private const int PRESENCE_TIMEOUT = 10; + private const int EVENT_TIMEOUT_MS = 2000; [SetUp] public void Setup() { - // Start WireMock server on random available port _mockServer = WireMockServer.Start(); - // Configure all required mocks SetupSubscribeMocks(); SetupHeartbeatMocks(); SetupLeaveMocks(); - // Create PubNub instance var config = new PNConfiguration(new UserId("csharp")) { SubscribeKey = "demo", @@ -46,39 +44,20 @@ public void Setup() [TearDown] public async Task TearDown() { - // Properly unsubscribe all channels before destroying to prevent collection modification errors if (_pubnub != null) { try { - // Get all subscribed channels and channel groups - var subscribedChannels = _pubnub.GetSubscribedChannels(); - var subscribedChannelGroups = _pubnub.GetSubscribedChannelGroups(); - - // Unsubscribe all if any subscriptions exist - if ((subscribedChannels != null && subscribedChannels.Count > 0) || - (subscribedChannelGroups != null && subscribedChannelGroups.Count > 0)) - { - // UnsubscribeAll constructor automatically performs unsubscribe - _pubnub.UnsubscribeAll(); - - // Wait for unsubscribe and event engine to finish processing - await Task.Delay(TimeSpan.FromSeconds(1)); - } - } - catch - { - // Ignore errors during cleanup + _pubnub.UnsubscribeAll(); + await Task.Delay(200); } + catch { } try { _pubnub.Destroy(); } - catch - { - // Ignore errors during destroy - } + catch { } } _mockServer?.Stop(); @@ -88,43 +67,38 @@ public async Task TearDown() [Test] public async Task ThenSubscribeWithDuplicateChannels_DeduplicatesAndSendsHeartbeats() { - // Act: First subscribe to ["c1"] - _pubnub.Subscribe().Channels(new[] { "c1" }).Execute(); + var connectedReceived = new TaskCompletionSource(); - // Wait 5 seconds - expect at least 1 heartbeat (at 3s) - await Task.Delay(TimeSpan.FromSeconds(5)); - - // Act: Second subscribe to ["c1", "c1"] - SDK deduplicates to ["c1"], no new request - _pubnub.Subscribe().Channels(new[] { "c1", "c1" }).Execute(); + var listener = new SubscribeCallbackExt( + (pn, msg) => { }, + (pn, presence) => { }, + (pn, status) => + { + if (status.Category == PNStatusCategory.PNConnectedCategory) + connectedReceived.TrySetResult(true); + } + ); + _pubnub.AddListener(listener); - // Wait 5 seconds - expect at least 1 heartbeat (at 3s) - await Task.Delay(TimeSpan.FromSeconds(5)); + _pubnub.Subscribe().Channels(new[] { "c1" }).Execute(); + await Task.WhenAny(connectedReceived.Task, Task.Delay(EVENT_TIMEOUT_MS)); - // Act: Third subscribe to ["c1", "c1"] - SDK deduplicates to ["c1"], no new request + // Subscribe with duplicates - should be deduplicated _pubnub.Subscribe().Channels(new[] { "c1", "c1" }).Execute(); + await Task.Delay(200); - // Wait 5 seconds - expect at least 1 heartbeat (at 3s) - await Task.Delay(TimeSpan.FromSeconds(5)); + _pubnub.Subscribe().Channels(new[] { "c1", "c1" }).Execute(); + + // Wait for at least 1 heartbeat + await Task.Delay(4000); - // Assert: Verify subscribe calls were made var subscribeRequests = _mockServer.LogEntries .Where(e => e.RequestMessage.Path.Contains("/v2/subscribe/demo/")) .ToList(); - Console.WriteLine($"Total subscribe requests: {subscribeRequests.Count}"); - foreach (var req in subscribeRequests) - { - Console.WriteLine($" Subscribe: {req.RequestMessage.Path}"); - } - - // Should have at least: - // - 1 initial handshake for c1 (tt=0) - // - 1+ long-poll requests (tt > 0) - // No additional requests since channels don't change Assert.That(subscribeRequests.Count, Is.GreaterThanOrEqualTo(1), "Should have at least 1 subscribe request (initial handshake)"); - // Verify first request is handshake with tt=0 var handshakeRequests = subscribeRequests .Cast() .Where(r => HasQueryParameter(r, "tt", "0")) @@ -133,36 +107,38 @@ public async Task ThenSubscribeWithDuplicateChannels_DeduplicatesAndSendsHeartbe Assert.That(handshakeRequests.Count, Is.EqualTo(1), "Should have exactly 1 handshake request with tt=0"); - // Assert: Verify heartbeat calls were made (approximately every 3 seconds) var heartbeatRequests = _mockServer.LogEntries .Where(e => e.RequestMessage.Path.Contains("/heartbeat")) .ToList(); - Console.WriteLine($"Total heartbeat requests: {heartbeatRequests.Count}"); - foreach (var req in heartbeatRequests) - { - Console.WriteLine($" Heartbeat: {req.RequestMessage.Path}"); - } - - // Total test duration: ~15 seconds - // Expected heartbeats: ~3-5 (one every 3 seconds, timing may vary) - Assert.That(heartbeatRequests.Count, Is.GreaterThanOrEqualTo(3), - "Should have at least 3 heartbeat requests over 15 seconds"); + Assert.That(heartbeatRequests.Count, Is.GreaterThanOrEqualTo(1), + "Should have at least 1 heartbeat request"); - // Assert: Verify all heartbeats are for single deduplicated channel "c1" VerifyHeartbeatsForSingleChannel(heartbeatRequests.Cast().ToList()); } [Test] public async Task ThenSubscribeToSingleChannel_SendsHeartbeatsAutomatically() { - // Act: Subscribe to single channel + var connectedReceived = new TaskCompletionSource(); + + var listener = new SubscribeCallbackExt( + (pn, msg) => { }, + (pn, presence) => { }, + (pn, status) => + { + if (status.Category == PNStatusCategory.PNConnectedCategory) + connectedReceived.TrySetResult(true); + } + ); + _pubnub.AddListener(listener); + _pubnub.Subscribe().Channels(new[] { "c1" }).Execute(); + await Task.WhenAny(connectedReceived.Task, Task.Delay(EVENT_TIMEOUT_MS)); - // Wait 10 seconds to observe multiple heartbeats - await Task.Delay(TimeSpan.FromSeconds(10)); + // Wait for at least 1 heartbeat (interval is 3s, wait 4s to be safe) + await Task.Delay(4000); - // Assert: Verify subscribe call was made var subscribeRequests = _mockServer.LogEntries .Where(e => e.RequestMessage.Path.Contains("/v2/subscribe/demo/c1/0")) .ToList(); @@ -170,39 +146,43 @@ public async Task ThenSubscribeToSingleChannel_SendsHeartbeatsAutomatically() Assert.That(subscribeRequests.Count, Is.GreaterThanOrEqualTo(1), "Should have at least 1 subscribe request for c1"); - // Assert: Verify heartbeats were sent approximately every 3 seconds var heartbeatRequests = _mockServer.LogEntries .Where(e => e.RequestMessage.Path.Contains("/channel/c1/heartbeat")) .ToList(); - Console.WriteLine($"Heartbeat requests for c1: {heartbeatRequests.Count}"); - - // In 10 seconds, expect at least 3 heartbeats (at 3s, 6s, 9s) - Assert.That(heartbeatRequests.Count, Is.GreaterThanOrEqualTo(2), - "Should have at least 2 heartbeat requests in 10 seconds"); + Assert.That(heartbeatRequests.Count, Is.GreaterThanOrEqualTo(1), + "Should have at least 1 heartbeat request"); } [Test] public async Task ThenSubscribeWithDifferentChannels_MakesNewSubscribeRequest() { - // Act: First subscribe to ["c1"] - _pubnub.Subscribe().Channels(new[] { "c1" }).Execute(); + var connectedReceived = new TaskCompletionSource(); + var subscriptionChangedReceived = new TaskCompletionSource(); - await Task.Delay(TimeSpan.FromSeconds(3)); + var listener = new SubscribeCallbackExt( + (pn, msg) => { }, + (pn, presence) => { }, + (pn, status) => + { + if (status.Category == PNStatusCategory.PNConnectedCategory && !connectedReceived.Task.IsCompleted) + connectedReceived.TrySetResult(true); + else if (status.Category == PNStatusCategory.PNSubscriptionChangedCategory) + subscriptionChangedReceived.TrySetResult(true); + } + ); + _pubnub.AddListener(listener); - // Act: Subscribe to different channels ["c1", "c2"] - adds c2 - _pubnub.Subscribe().Channels(new[] { "c1", "c2" }).Execute(); + _pubnub.Subscribe().Channels(new[] { "c1" }).Execute(); + await Task.WhenAny(connectedReceived.Task, Task.Delay(EVENT_TIMEOUT_MS)); - // Wait longer to ensure new subscribe request is made - await Task.Delay(TimeSpan.FromSeconds(5)); + _pubnub.Subscribe().Channels(new[] { "c1", "c2" }).Execute(); + await Task.WhenAny(subscriptionChangedReceived.Task, Task.Delay(EVENT_TIMEOUT_MS)); - // Assert: Verify subscribe calls were made for both channel sets var subscribeRequests = _mockServer.LogEntries .Where(e => e.RequestMessage.Path.Contains("/v2/subscribe/demo/")) .ToList(); - // With EventEngine enabled, changing channels triggers new subscription - // Should have at least one request for initial c1 var c1OnlyRequests = subscribeRequests .Where(r => r.RequestMessage.Path.Contains("/c1/0") && !r.RequestMessage.Path.Contains("/c1,c2/")) @@ -211,26 +191,126 @@ public async Task ThenSubscribeWithDifferentChannels_MakesNewSubscribeRequest() Assert.That(c1OnlyRequests.Count, Is.GreaterThanOrEqualTo(1), "Should have at least one request for c1 only"); - // After adding c2, SDK should make a request with both channels - // Note: This may or may not happen immediately depending on SDK implementation - var c1c2Requests = subscribeRequests - .Where(r => r.RequestMessage.Path.Contains("/c1,c2/0")) - .ToList(); - - // Verify heartbeats reflect the channel change var heartbeatRequests = _mockServer.LogEntries .Where(e => e.RequestMessage.Path.Contains("/heartbeat")) .ToList(); - // At minimum, we should have heartbeats Assert.That(heartbeatRequests.Count, Is.GreaterThanOrEqualTo(1), "Should have at least one heartbeat request"); } + /// + /// Tests that subscribing to the same channel twice does NOT trigger a new handshake. + /// It emits a status with PNSubscriptionChangedCategory without making additional network requests. + /// + [Test] + public async Task ThenSubscribeToSameChannelTwice_EmitsStatusWithoutNewHandshake() + { + var statusEvents = new System.Collections.Generic.List(); + var connectedReceived = new TaskCompletionSource(); + var secondStatusReceived = new TaskCompletionSource(); + int statusCount = 0; + + var listener = new SubscribeCallbackExt( + (pn, msg) => { }, + (pn, presence) => { }, + (pn, status) => + { + statusEvents.Add(status); + statusCount++; + + if (statusCount == 1 && status.Category == PNStatusCategory.PNConnectedCategory) + connectedReceived.TrySetResult(true); + else if (statusCount == 2 && status.Category == PNStatusCategory.PNSubscriptionChangedCategory) + secondStatusReceived.TrySetResult(true); + } + ); + _pubnub.AddListener(listener); + + _pubnub.Subscribe().Channels(new[] { "c1" }).Execute(); + + var firstConnectedTask = await Task.WhenAny(connectedReceived.Task, Task.Delay(EVENT_TIMEOUT_MS)); + Assert.That(firstConnectedTask == connectedReceived.Task, Is.True, + "Should receive PNConnectedCategory status after first subscribe"); + + var handshakeCountAfterFirst = _mockServer.LogEntries + .Where(e => e.RequestMessage.Path.Contains("/v2/subscribe/demo/c1/0") && + HasQueryParameter((WireMock.Logging.LogEntry)e, "tt", "0")) + .Count(); + + Assert.That(handshakeCountAfterFirst, Is.EqualTo(1), + "Should have exactly 1 handshake request after first subscribe"); + + _pubnub.Subscribe().Channels(new[] { "c1" }).Execute(); + + var secondStatusTask = await Task.WhenAny(secondStatusReceived.Task, Task.Delay(EVENT_TIMEOUT_MS)); + Assert.That(secondStatusTask == secondStatusReceived.Task, Is.True, + "Should receive PNSubscriptionChangedCategory status callback after second subscribe to same channel"); + + var handshakeCountAfterSecond = _mockServer.LogEntries + .Where(e => e.RequestMessage.Path.Contains("/v2/subscribe/demo/c1/0") && + HasQueryParameter((WireMock.Logging.LogEntry)e, "tt", "0")) + .Count(); + + Assert.That(handshakeCountAfterSecond, Is.EqualTo(1), + "Should have exactly 1 handshake request - second subscribe to same channel should NOT trigger new handshake"); + + Assert.That(statusEvents.Count, Is.GreaterThanOrEqualTo(2), + "Should have at least 2 status events (one for each subscribe call)"); + + Assert.That(statusEvents[0].Category, Is.EqualTo(PNStatusCategory.PNConnectedCategory), + "First status should be PNConnectedCategory (from handshake success)"); + Assert.That(statusEvents[1].Category, Is.EqualTo(PNStatusCategory.PNSubscriptionChangedCategory), + "Second status should be PNSubscriptionChangedCategory (emitted directly without state transition)"); + } + + /// + /// Tests that subscribing to a mix of existing and new channels DOES trigger a state transition. + /// + [Test] + public async Task ThenSubscribeWithMixOfExistingAndNewChannels_TriggersSubscriptionChange() + { + var statusEvents = new System.Collections.Generic.List(); + var connectedReceived = new TaskCompletionSource(); + var subscriptionChangedReceived = new TaskCompletionSource(); + + var listener = new SubscribeCallbackExt( + (pn, msg) => { }, + (pn, presence) => { }, + (pn, status) => + { + statusEvents.Add(status); + + if (status.Category == PNStatusCategory.PNConnectedCategory && !connectedReceived.Task.IsCompleted) + connectedReceived.TrySetResult(true); + else if (status.Category == PNStatusCategory.PNSubscriptionChangedCategory) + subscriptionChangedReceived.TrySetResult(true); + } + ); + _pubnub.AddListener(listener); + + _pubnub.Subscribe().Channels(new[] { "c1" }).Execute(); + + var connectedTask = await Task.WhenAny(connectedReceived.Task, Task.Delay(EVENT_TIMEOUT_MS)); + Assert.That(connectedTask == connectedReceived.Task, Is.True, + "Should receive PNConnectedCategory after first subscribe"); + + _pubnub.Subscribe().Channels(new[] { "c1", "c2" }).Execute(); + + var changedTask = await Task.WhenAny(subscriptionChangedReceived.Task, Task.Delay(EVENT_TIMEOUT_MS)); + Assert.That(changedTask == subscriptionChangedReceived.Task, Is.True, + "Should receive PNSubscriptionChangedCategory when adding new channel"); + + Assert.That(statusEvents[0].Category, Is.EqualTo(PNStatusCategory.PNConnectedCategory), + "First status should be PNConnectedCategory"); + + Assert.That(statusEvents.Any(s => s.Category == PNStatusCategory.PNSubscriptionChangedCategory), Is.True, + "Should have received PNSubscriptionChangedCategory when adding new channel"); + } + private void SetupSubscribeMocks() { - // Mock 1: Initial handshake for any channel with tt=0 - // This catches all initial subscribe requests + // Generic handshake mock (tt=0) _mockServer .Given(Request.Create() .WithPath(new RegexMatcher(@"/v2/subscribe/demo/.*/0")) @@ -244,14 +324,13 @@ private void SetupSubscribeMocks() t = new { t = "17000000000000001", r = 12 }, m = new object[] { } }) - .WithDelay(TimeSpan.FromMilliseconds(100))); + .WithDelay(TimeSpan.FromMilliseconds(50))); - // Mock 2: Long-polling subscribe with timetoken > 0 - // This simulates the continuous subscribe loop + // Long-poll mock (tt > 0) - keep connection alive _mockServer .Given(Request.Create() .WithPath(new RegexMatcher(@"/v2/subscribe/demo/.*/0")) - .WithParam("tt", new RegexMatcher(@"^(?!0$).*")) // tt != "0" + .WithParam("tt", new RegexMatcher(@"^(?!0$).*")) .UsingGet()) .RespondWith(Response.Create() .WithStatusCode(200) @@ -261,28 +340,9 @@ private void SetupSubscribeMocks() t = new { t = "17000000000000002", r = 12 }, m = new object[] { } }) - .WithDelay(TimeSpan.FromSeconds(180))); // Long-poll delay + .WithDelay(TimeSpan.FromSeconds(5))); - // Mock 3: Specific mock for c1 with tt=0 - _mockServer - .Given(Request.Create() - .WithPath("/v2/subscribe/demo/c1/0") - .WithParam("tt", "0") - .WithParam("heartbeat", "10") - .UsingGet()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(@"{ - ""t"": { - ""t"": ""17000000000000001"", - ""r"": 12 - }, - ""m"": [] - }") - .WithDelay(TimeSpan.FromMilliseconds(100))); - - // Mock 4: Specific mock for c1,c1 with tt=0 + // Specific c1 handshake _mockServer .Given(Request.Create() .WithPath("/v2/subscribe/demo/c1/0") @@ -292,16 +352,10 @@ private void SetupSubscribeMocks() .RespondWith(Response.Create() .WithStatusCode(200) .WithHeader("Content-Type", "application/json") - .WithBody(@"{ - ""t"": { - ""t"": ""17000000000000003"", - ""r"": 12 - }, - ""m"": [] - }") - .WithDelay(TimeSpan.FromMilliseconds(100))); - - // Mock 5: Long-poll for c1 with specific timetoken + .WithBody(@"{""t"":{""t"":""17000000000000001"",""r"":12},""m"":[]}") + .WithDelay(TimeSpan.FromMilliseconds(50))); + + // c1 long-poll _mockServer .Given(Request.Create() .WithPath("/v2/subscribe/demo/c1/0") @@ -310,34 +364,10 @@ private void SetupSubscribeMocks() .RespondWith(Response.Create() .WithStatusCode(200) .WithHeader("Content-Type", "application/json") - .WithBody(@"{ - ""t"": { - ""t"": ""17000000000000002"", - ""r"": 12 - }, - ""m"": [] - }") - .WithDelay(TimeSpan.FromSeconds(180))); - - // Mock 6: Long-poll for c1 with specific timetoken - _mockServer - .Given(Request.Create() - .WithPath("/v2/subscribe/demo/c1/0") - .WithParam("tt", "17000000000000003") - .UsingGet()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(@"{ - ""t"": { - ""t"": ""17000000000000004"", - ""r"": 12 - }, - ""m"": [] - }") - .WithDelay(TimeSpan.FromSeconds(180))); - - // Mock 7: Handshake for c1,c2 with tt=0 + .WithBody(@"{""t"":{""t"":""17000000000000002"",""r"":12},""m"":[]}") + .WithDelay(TimeSpan.FromSeconds(5))); + + // c1,c2 handshake _mockServer .Given(Request.Create() .WithPath("/v2/subscribe/demo/c1,c2/0") @@ -347,16 +377,10 @@ private void SetupSubscribeMocks() .RespondWith(Response.Create() .WithStatusCode(200) .WithHeader("Content-Type", "application/json") - .WithBody(@"{ - ""t"": { - ""t"": ""17000000000000005"", - ""r"": 12 - }, - ""m"": [] - }") - .WithDelay(TimeSpan.FromMilliseconds(100))); - - // Mock 8: Long-poll for c1,c2 with specific timetoken + .WithBody(@"{""t"":{""t"":""17000000000000005"",""r"":12},""m"":[]}") + .WithDelay(TimeSpan.FromMilliseconds(50))); + + // c1,c2 long-poll _mockServer .Given(Request.Create() .WithPath("/v2/subscribe/demo/c1,c2/0") @@ -365,20 +389,12 @@ private void SetupSubscribeMocks() .RespondWith(Response.Create() .WithStatusCode(200) .WithHeader("Content-Type", "application/json") - .WithBody(@"{ - ""t"": { - ""t"": ""17000000000000006"", - ""r"": 12 - }, - ""m"": [] - }") - .WithDelay(TimeSpan.FromSeconds(180))); + .WithBody(@"{""t"":{""t"":""17000000000000006"",""r"":12},""m"":[]}") + .WithDelay(TimeSpan.FromSeconds(5))); } private void SetupHeartbeatMocks() { - // Setup heartbeat mocks with wildcards for any channel combination - // This catches all heartbeat requests regardless of channel _mockServer .Given(Request.Create() .WithPath(new RegexMatcher(@"/v2/presence/sub_key/demo/channel/.*/heartbeat")) @@ -392,44 +408,11 @@ private void SetupHeartbeatMocks() message = "OK", service = "Presence" }) - .WithDelay(TimeSpan.FromMilliseconds(50))); - - // Specific mock for c1 heartbeat - _mockServer - .Given(Request.Create() - .WithPath("/v2/presence/sub_key/demo/channel/c1/heartbeat") - .WithParam("heartbeat", "10") - .UsingGet()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(@"{ - ""status"": 200, - ""message"": ""OK"", - ""service"": ""Presence"" - }") - .WithDelay(TimeSpan.FromMilliseconds(50))); - - // Specific mock for c1,c2 heartbeat - _mockServer - .Given(Request.Create() - .WithPath("/v2/presence/sub_key/demo/channel/c1,c2/heartbeat") - .WithParam("heartbeat", "10") - .UsingGet()) - .RespondWith(Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody(@"{ - ""status"": 200, - ""message"": ""OK"", - ""service"": ""Presence"" - }") - .WithDelay(TimeSpan.FromMilliseconds(50))); + .WithDelay(TimeSpan.FromMilliseconds(20))); } private void SetupLeaveMocks() { - // Mock leave/unsubscribe requests with wildcard for any channel combination _mockServer .Given(Request.Create() .WithPath(new RegexMatcher(@"/v2/presence/sub_key/demo/channel/.*/leave")) @@ -444,7 +427,7 @@ private void SetupLeaveMocks() action = "leave", service = "Presence" }) - .WithDelay(TimeSpan.FromMilliseconds(50))); + .WithDelay(TimeSpan.FromMilliseconds(20))); } private bool HasQueryParameter(WireMock.Logging.LogEntry entry, string key, string value) @@ -454,23 +437,19 @@ private bool HasQueryParameter(WireMock.Logging.LogEntry entry, string key, stri foreach (var param in entry.RequestMessage.Query) { if (param.Key == key && param.Value.Contains(value)) - { return true; - } } return false; } private void VerifyHeartbeatsForSingleChannel(System.Collections.Generic.List heartbeatRequests) { - // Verify heartbeat for c1 was called (should never contain duplicates like c1,c1) var heartbeatC1 = heartbeatRequests .Where(r => r.RequestMessage.Path.Contains("/channel/c1/heartbeat")) .ToList(); Assert.That(heartbeatC1.Count, Is.GreaterThan(0), "Should have at least one heartbeat request for c1"); - // Verify NO heartbeat requests contain duplicate channels var heartbeatWithDuplicates = heartbeatRequests .Where(r => r.RequestMessage.Path.Contains("/channel/c1,c1/heartbeat")) .ToList(); @@ -479,4 +458,3 @@ private void VerifyHeartbeatsForSingleChannel(System.Collections.Generic.List