diff --git a/Examples/Sources/MasterDetailTableViewController.swift b/Examples/Sources/MasterDetailTableViewController.swift index 96e7c78a..e4b5cd1e 100644 --- a/Examples/Sources/MasterDetailTableViewController.swift +++ b/Examples/Sources/MasterDetailTableViewController.swift @@ -250,26 +250,14 @@ class MasterDetailTableViewController: UITableViewController { print("The signal is \(signal.payload) and was sent by \(signal.publisher ?? "")") case let .connectionStatusChanged(connectionChange): switch connectionChange { - case .connecting: - print("Status connecting...") case .connected: print("Status connected!") - case .reconnecting: - print("Status reconnecting...") + case .connectionError: + print("Error while attempting to initialize connection") case .disconnected: print("Status disconnected") case .disconnectedUnexpectedly: - print("Status disconnected unexpectedly!") - } - case let .subscriptionChanged(subscribeChange): - switch subscribeChange { - case let .subscribed(channels, groups): - print("\(channels) and \(groups) were added to subscription") - case let .responseHeader(channels, groups, previous, next): - print("\(channels) and \(groups) recevied a response at \(previous?.timetoken ?? 0)") - print("\(next?.timetoken ?? 0) will be used as the new timetoken") - case let .unsubscribed(channels, groups): - print("\(channels) and \(groups) were removed from subscription") + print("Disconnected unexpectedly") } case let .presenceChanged(presenceChange): print("The channel \(presenceChange.channel) has an updated occupancy of \(presenceChange.occupancy)") diff --git a/Podfile.lock b/Podfile.lock index 00bd0c60..b1d2328e 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -19,4 +19,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 61a40240486621bb01f596fdd5bc632504940fab -COCOAPODS: 1.12.1 +COCOAPODS: 1.14.3 diff --git a/PubNub.xcodeproj/project.pbxproj b/PubNub.xcodeproj/project.pbxproj index 8351d92a..4690cb80 100644 --- a/PubNub.xcodeproj/project.pbxproj +++ b/PubNub.xcodeproj/project.pbxproj @@ -216,7 +216,6 @@ 35A66A8322F861BA00AC67A9 /* PubNubMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A66A7C22F861BA00AC67A9 /* PubNubMessage.swift */; }; 35A66A8E22F911DB00AC67A9 /* SubscribeSessionFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A66A8D22F911DB00AC67A9 /* SubscribeSessionFactory.swift */; }; 35A66A9022F913B200AC67A9 /* ConnectionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A66A8F22F913B200AC67A9 /* ConnectionStatus.swift */; }; - 35A66A9422F91B2A00AC67A9 /* SubscriptionSession+Presence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A66A9322F91B2A00AC67A9 /* SubscriptionSession+Presence.swift */; }; 35A66A9622F9B71200AC67A9 /* setState_missing_state.json in Resources */ = {isa = PBXBuildFile; fileRef = 35A66A9522F9B71200AC67A9 /* setState_missing_state.json */; }; 35A66A9722F9B72200AC67A9 /* setState_success.json in Resources */ = {isa = PBXBuildFile; fileRef = 35A66A8C22F9084000AC67A9 /* setState_success.json */; }; 35A66A9822F9B72500AC67A9 /* getState_success.json in Resources */ = {isa = PBXBuildFile; fileRef = 35A66A8B22F9080A00AC67A9 /* getState_success.json */; }; @@ -375,6 +374,57 @@ 35FE941822EFCB7F0051C455 /* SessionStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FE941722EFCB7F0051C455 /* SessionStreamTests.swift */; }; 35FE941B22EFE5400051C455 /* EventStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FE941A22EFE5400051C455 /* EventStreamTests.swift */; }; 35FE941F22F0929A0051C455 /* RequestRetrierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35FE941E22F0929A0051C455 /* RequestRetrierTests.swift */; }; + 3D389FE12B35AF4A006928E7 /* TransitionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FC32B35AF4A006928E7 /* TransitionProtocol.swift */; }; + 3D389FE22B35AF4A006928E7 /* Dispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FC42B35AF4A006928E7 /* Dispatcher.swift */; }; + 3D389FE32B35AF4A006928E7 /* EffectHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FC52B35AF4A006928E7 /* EffectHandler.swift */; }; + 3D389FE42B35AF4A006928E7 /* EventEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FC62B35AF4A006928E7 /* EventEngine.swift */; }; + 3D389FE52B35AF4A006928E7 /* EventEngineFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FC72B35AF4A006928E7 /* EventEngineFactory.swift */; }; + 3D389FE62B35AF4A006928E7 /* EmitMessagesEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FCA2B35AF4A006928E7 /* EmitMessagesEffect.swift */; }; + 3D389FE72B35AF4A006928E7 /* EmitStatusEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FCB2B35AF4A006928E7 /* EmitStatusEffect.swift */; }; + 3D389FE82B35AF4A006928E7 /* SubscribeEffects.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FCC2B35AF4A006928E7 /* SubscribeEffects.swift */; }; + 3D389FE92B35AF4A006928E7 /* SubscribeEffectFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FCD2B35AF4A006928E7 /* SubscribeEffectFactory.swift */; }; + 3D389FEA2B35AF4A006928E7 /* SubscribeError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FCF2B35AF4A006928E7 /* SubscribeError.swift */; }; + 3D389FEB2B35AF4A006928E7 /* SubscribeInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FD02B35AF4A006928E7 /* SubscribeInput.swift */; }; + 3D389FEC2B35AF4A006928E7 /* SubscribeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FD12B35AF4A006928E7 /* SubscribeRequest.swift */; }; + 3D389FED2B35AF4A006928E7 /* Subscribe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FD22B35AF4A006928E7 /* Subscribe.swift */; }; + 3D389FEE2B35AF4A006928E7 /* SubscribeTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FD32B35AF4A006928E7 /* SubscribeTransition.swift */; }; + 3D389FEF2B35AF4A006928E7 /* Presence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FD52B35AF4A006928E7 /* Presence.swift */; }; + 3D389FF02B35AF4A006928E7 /* PresenceTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FD62B35AF4A006928E7 /* PresenceTransition.swift */; }; + 3D389FF12B35AF4A006928E7 /* HeartbeatEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FD82B35AF4A006928E7 /* HeartbeatEffect.swift */; }; + 3D389FF22B35AF4A006928E7 /* LeaveEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FD92B35AF4A006928E7 /* LeaveEffect.swift */; }; + 3D389FF32B35AF4A006928E7 /* WaitEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FDA2B35AF4A006928E7 /* WaitEffect.swift */; }; + 3D389FF42B35AF4A006928E7 /* PresenceEffectFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FDB2B35AF4A006928E7 /* PresenceEffectFactory.swift */; }; + 3D389FF52B35AF4A006928E7 /* DelayedHeartbeatEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FDC2B35AF4A006928E7 /* DelayedHeartbeatEffect.swift */; }; + 3D389FF62B35AF4A006928E7 /* PresenceLeaveRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FDE2B35AF4A006928E7 /* PresenceLeaveRequest.swift */; }; + 3D389FF72B35AF4A006928E7 /* PresenceHeartbeatRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FDF2B35AF4A006928E7 /* PresenceHeartbeatRequest.swift */; }; + 3D389FF82B35AF4A006928E7 /* PresenceInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FE02B35AF4A006928E7 /* PresenceInput.swift */; }; + 3D38A00B2B35AF6A006928E7 /* DispatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FFA2B35AF6A006928E7 /* DispatcherTests.swift */; }; + 3D38A00C2B35AF6A006928E7 /* SubscribeInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FFC2B35AF6A006928E7 /* SubscribeInputTests.swift */; }; + 3D38A00D2B35AF6A006928E7 /* EmitMessagesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FFD2B35AF6A006928E7 /* EmitMessagesTests.swift */; }; + 3D38A00E2B35AF6A006928E7 /* SubscribeRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FFE2B35AF6A006928E7 /* SubscribeRequestTests.swift */; }; + 3D38A00F2B35AF6A006928E7 /* EmitStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D389FFF2B35AF6A006928E7 /* EmitStatusTests.swift */; }; + 3D38A0102B35AF6B006928E7 /* SubscribeEffectsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0002B35AF6A006928E7 /* SubscribeEffectsTests.swift */; }; + 3D38A0112B35AF6B006928E7 /* SubscribeTransitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0012B35AF6A006928E7 /* SubscribeTransitionTests.swift */; }; + 3D38A0122B35AF6B006928E7 /* WaitEffectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0032B35AF6A006928E7 /* WaitEffectTests.swift */; }; + 3D38A0132B35AF6B006928E7 /* HeartbeatEffectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0042B35AF6A006928E7 /* HeartbeatEffectTests.swift */; }; + 3D38A0142B35AF6B006928E7 /* PresenceTransitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0052B35AF6A006928E7 /* PresenceTransitionTests.swift */; }; + 3D38A0152B35AF6B006928E7 /* LeaveEffectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0062B35AF6A006928E7 /* LeaveEffectTests.swift */; }; + 3D38A0162B35AF6B006928E7 /* DelayedHeartbeatEffectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0072B35AF6A006928E7 /* DelayedHeartbeatEffectTests.swift */; }; + 3D38A0172B35AF6B006928E7 /* EffectInvocation+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0092B35AF6A006928E7 /* EffectInvocation+Equatable.swift */; }; + 3D38A0182B35AF6B006928E7 /* EventEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A00A2B35AF6A006928E7 /* EventEngineTests.swift */; }; + 3D38A01D2B35AFBE006928E7 /* PubNubSubscribeEngineContractTestsSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A01A2B35AFBE006928E7 /* PubNubSubscribeEngineContractTestsSteps.swift */; }; + 3D38A01E2B35AFBE006928E7 /* PubNubSubscribeEngineContractTestsSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A01A2B35AFBE006928E7 /* PubNubSubscribeEngineContractTestsSteps.swift */; }; + 3D38A01F2B35AFBE006928E7 /* PubNubPresenceEngineContractTestSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A01B2B35AFBE006928E7 /* PubNubPresenceEngineContractTestSteps.swift */; }; + 3D38A0202B35AFBE006928E7 /* PubNubPresenceEngineContractTestSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A01B2B35AFBE006928E7 /* PubNubPresenceEngineContractTestSteps.swift */; }; + 3D38A0212B35AFBE006928E7 /* PubNubEventEngineContractTestSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A01C2B35AFBE006928E7 /* PubNubEventEngineContractTestSteps.swift */; }; + 3D38A0222B35AFBE006928E7 /* PubNubEventEngineContractTestSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A01C2B35AFBE006928E7 /* PubNubEventEngineContractTestSteps.swift */; }; + 3D38A0242B35B00D006928E7 /* PubNubEventEngineTestsHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0232B35B00D006928E7 /* PubNubEventEngineTestsHelpers.swift */; }; + 3D38A0252B35B00D006928E7 /* PubNubEventEngineTestsHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0232B35B00D006928E7 /* PubNubEventEngineTestsHelpers.swift */; }; + 3D38A02B2B35B087006928E7 /* EventEngineSubscriptionSessionStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0272B35B087006928E7 /* EventEngineSubscriptionSessionStrategy.swift */; }; + 3D38A02C2B35B087006928E7 /* LegacySubscriptionSessionStrategy+Presence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0282B35B087006928E7 /* LegacySubscriptionSessionStrategy+Presence.swift */; }; + 3D38A02D2B35B087006928E7 /* SubscriptionSessionStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A0292B35B087006928E7 /* SubscriptionSessionStrategy.swift */; }; + 3D38A02E2B35B087006928E7 /* LegacySubscriptionSessionStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D38A02A2B35B087006928E7 /* LegacySubscriptionSessionStrategy.swift */; }; + 3D38A0302B35B208006928E7 /* subscription_handshake_success.json in Resources */ = {isa = PBXBuildFile; fileRef = 3D38A02F2B35B208006928E7 /* subscription_handshake_success.json */; }; 3D6265D72ABCA79100FDD5E6 /* CryptorUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D6265D62ABCA79100FDD5E6 /* CryptorUtils.swift */; }; 3D758DBF2AAA1C49005D2B36 /* CryptoModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D758DBE2AAA1C49005D2B36 /* CryptoModule.swift */; }; 3D758DC82AB06A12005D2B36 /* CryptoInputStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D758DC62AB06A12005D2B36 /* CryptoInputStream.swift */; }; @@ -756,7 +806,6 @@ 35A66A8C22F9084000AC67A9 /* setState_success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = setState_success.json; sourceTree = ""; }; 35A66A8D22F911DB00AC67A9 /* SubscribeSessionFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscribeSessionFactory.swift; sourceTree = ""; }; 35A66A8F22F913B200AC67A9 /* ConnectionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStatus.swift; sourceTree = ""; }; - 35A66A9322F91B2A00AC67A9 /* SubscriptionSession+Presence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SubscriptionSession+Presence.swift"; sourceTree = ""; }; 35A66A9522F9B71200AC67A9 /* setState_missing_state.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = setState_missing_state.json; sourceTree = ""; }; 35A6C77C22FB159F00E97CC5 /* PresenceRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceRouter.swift; sourceTree = ""; }; 35A6C78022FB2E4C00E97CC5 /* herenow_success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = herenow_success.json; sourceTree = ""; }; @@ -911,6 +960,53 @@ 35FE941722EFCB7F0051C455 /* SessionStreamTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStreamTests.swift; sourceTree = ""; }; 35FE941A22EFE5400051C455 /* EventStreamTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventStreamTests.swift; sourceTree = ""; }; 35FE941E22F0929A0051C455 /* RequestRetrierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestRetrierTests.swift; sourceTree = ""; }; + 3D389FC32B35AF4A006928E7 /* TransitionProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransitionProtocol.swift; sourceTree = ""; }; + 3D389FC42B35AF4A006928E7 /* Dispatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Dispatcher.swift; sourceTree = ""; }; + 3D389FC52B35AF4A006928E7 /* EffectHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EffectHandler.swift; sourceTree = ""; }; + 3D389FC62B35AF4A006928E7 /* EventEngine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventEngine.swift; sourceTree = ""; }; + 3D389FC72B35AF4A006928E7 /* EventEngineFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventEngineFactory.swift; sourceTree = ""; }; + 3D389FCA2B35AF4A006928E7 /* EmitMessagesEffect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmitMessagesEffect.swift; sourceTree = ""; }; + 3D389FCB2B35AF4A006928E7 /* EmitStatusEffect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmitStatusEffect.swift; sourceTree = ""; }; + 3D389FCC2B35AF4A006928E7 /* SubscribeEffects.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscribeEffects.swift; sourceTree = ""; }; + 3D389FCD2B35AF4A006928E7 /* SubscribeEffectFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscribeEffectFactory.swift; sourceTree = ""; }; + 3D389FCF2B35AF4A006928E7 /* SubscribeError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscribeError.swift; sourceTree = ""; }; + 3D389FD02B35AF4A006928E7 /* SubscribeInput.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscribeInput.swift; sourceTree = ""; }; + 3D389FD12B35AF4A006928E7 /* SubscribeRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscribeRequest.swift; sourceTree = ""; }; + 3D389FD22B35AF4A006928E7 /* Subscribe.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscribe.swift; sourceTree = ""; }; + 3D389FD32B35AF4A006928E7 /* SubscribeTransition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscribeTransition.swift; sourceTree = ""; }; + 3D389FD52B35AF4A006928E7 /* Presence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Presence.swift; sourceTree = ""; }; + 3D389FD62B35AF4A006928E7 /* PresenceTransition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresenceTransition.swift; sourceTree = ""; }; + 3D389FD82B35AF4A006928E7 /* HeartbeatEffect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartbeatEffect.swift; sourceTree = ""; }; + 3D389FD92B35AF4A006928E7 /* LeaveEffect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LeaveEffect.swift; sourceTree = ""; }; + 3D389FDA2B35AF4A006928E7 /* WaitEffect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WaitEffect.swift; sourceTree = ""; }; + 3D389FDB2B35AF4A006928E7 /* PresenceEffectFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresenceEffectFactory.swift; sourceTree = ""; }; + 3D389FDC2B35AF4A006928E7 /* DelayedHeartbeatEffect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DelayedHeartbeatEffect.swift; sourceTree = ""; }; + 3D389FDE2B35AF4A006928E7 /* PresenceLeaveRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresenceLeaveRequest.swift; sourceTree = ""; }; + 3D389FDF2B35AF4A006928E7 /* PresenceHeartbeatRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresenceHeartbeatRequest.swift; sourceTree = ""; }; + 3D389FE02B35AF4A006928E7 /* PresenceInput.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresenceInput.swift; sourceTree = ""; }; + 3D389FFA2B35AF6A006928E7 /* DispatcherTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DispatcherTests.swift; sourceTree = ""; }; + 3D389FFC2B35AF6A006928E7 /* SubscribeInputTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscribeInputTests.swift; sourceTree = ""; }; + 3D389FFD2B35AF6A006928E7 /* EmitMessagesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmitMessagesTests.swift; sourceTree = ""; }; + 3D389FFE2B35AF6A006928E7 /* SubscribeRequestTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscribeRequestTests.swift; sourceTree = ""; }; + 3D389FFF2B35AF6A006928E7 /* EmitStatusTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmitStatusTests.swift; sourceTree = ""; }; + 3D38A0002B35AF6A006928E7 /* SubscribeEffectsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscribeEffectsTests.swift; sourceTree = ""; }; + 3D38A0012B35AF6A006928E7 /* SubscribeTransitionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscribeTransitionTests.swift; sourceTree = ""; }; + 3D38A0032B35AF6A006928E7 /* WaitEffectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WaitEffectTests.swift; sourceTree = ""; }; + 3D38A0042B35AF6A006928E7 /* HeartbeatEffectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartbeatEffectTests.swift; sourceTree = ""; }; + 3D38A0052B35AF6A006928E7 /* PresenceTransitionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PresenceTransitionTests.swift; sourceTree = ""; }; + 3D38A0062B35AF6A006928E7 /* LeaveEffectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LeaveEffectTests.swift; sourceTree = ""; }; + 3D38A0072B35AF6A006928E7 /* DelayedHeartbeatEffectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DelayedHeartbeatEffectTests.swift; sourceTree = ""; }; + 3D38A0092B35AF6A006928E7 /* EffectInvocation+Equatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "EffectInvocation+Equatable.swift"; sourceTree = ""; }; + 3D38A00A2B35AF6A006928E7 /* EventEngineTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventEngineTests.swift; sourceTree = ""; }; + 3D38A01A2B35AFBE006928E7 /* PubNubSubscribeEngineContractTestsSteps.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PubNubSubscribeEngineContractTestsSteps.swift; sourceTree = ""; }; + 3D38A01B2B35AFBE006928E7 /* PubNubPresenceEngineContractTestSteps.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PubNubPresenceEngineContractTestSteps.swift; sourceTree = ""; }; + 3D38A01C2B35AFBE006928E7 /* PubNubEventEngineContractTestSteps.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PubNubEventEngineContractTestSteps.swift; sourceTree = ""; }; + 3D38A0232B35B00D006928E7 /* PubNubEventEngineTestsHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PubNubEventEngineTestsHelpers.swift; sourceTree = ""; }; + 3D38A0272B35B087006928E7 /* EventEngineSubscriptionSessionStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventEngineSubscriptionSessionStrategy.swift; sourceTree = ""; }; + 3D38A0282B35B087006928E7 /* LegacySubscriptionSessionStrategy+Presence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "LegacySubscriptionSessionStrategy+Presence.swift"; sourceTree = ""; }; + 3D38A0292B35B087006928E7 /* SubscriptionSessionStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionSessionStrategy.swift; sourceTree = ""; }; + 3D38A02A2B35B087006928E7 /* LegacySubscriptionSessionStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacySubscriptionSessionStrategy.swift; sourceTree = ""; }; + 3D38A02F2B35B208006928E7 /* subscription_handshake_success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscription_handshake_success.json; sourceTree = ""; }; 3D6265D62ABCA79100FDD5E6 /* CryptorUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptorUtils.swift; sourceTree = ""; }; 3D758DBE2AAA1C49005D2B36 /* CryptoModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoModule.swift; sourceTree = ""; }; 3D758DC62AB06A12005D2B36 /* CryptoInputStream.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CryptoInputStream.swift; sourceTree = ""; }; @@ -1321,6 +1417,7 @@ 357AEB7E22E693DD00C18250 /* Subscribe */ = { isa = PBXGroup; children = ( + 3D38A02F2B35B208006928E7 /* subscription_handshake_success.json */, 359287C423185EEE0046F7A2 /* subscription_success.json */, 3DFB01932B0E30EE00146B57 /* subscription_encrypted_message_success.json */, 35C6B6DF22F513D80054F242 /* subscription_mixed_success.json */, @@ -1594,9 +1691,9 @@ children = ( 35A66A8D22F911DB00AC67A9 /* SubscribeSessionFactory.swift */, 35A66A7422F861BA00AC67A9 /* SubscriptionSession.swift */, - 35A66A9322F91B2A00AC67A9 /* SubscriptionSession+Presence.swift */, 35C829DB23147AC000F59D3C /* SubscriptionState.swift */, 35A66A8F22F913B200AC67A9 /* ConnectionStatus.swift */, + 3D38A0262B35B087006928E7 /* Strategy */, ); path = Subscription; sourceTree = ""; @@ -1912,6 +2009,160 @@ path = EndpointError; sourceTree = ""; }; + 3D389FC12B35AF4A006928E7 /* EventEngine */ = { + isa = PBXGroup; + children = ( + 3D389FC22B35AF4A006928E7 /* Core */, + 3D389FC82B35AF4A006928E7 /* Subscribe */, + 3D389FD42B35AF4A006928E7 /* Presence */, + ); + path = EventEngine; + sourceTree = ""; + }; + 3D389FC22B35AF4A006928E7 /* Core */ = { + isa = PBXGroup; + children = ( + 3D389FC32B35AF4A006928E7 /* TransitionProtocol.swift */, + 3D389FC42B35AF4A006928E7 /* Dispatcher.swift */, + 3D389FC52B35AF4A006928E7 /* EffectHandler.swift */, + 3D389FC62B35AF4A006928E7 /* EventEngine.swift */, + 3D389FC72B35AF4A006928E7 /* EventEngineFactory.swift */, + ); + path = Core; + sourceTree = ""; + }; + 3D389FC82B35AF4A006928E7 /* Subscribe */ = { + isa = PBXGroup; + children = ( + 3D389FC92B35AF4A006928E7 /* Effects */, + 3D389FCE2B35AF4A006928E7 /* Helpers */, + 3D389FD22B35AF4A006928E7 /* Subscribe.swift */, + 3D389FD32B35AF4A006928E7 /* SubscribeTransition.swift */, + ); + path = Subscribe; + sourceTree = ""; + }; + 3D389FC92B35AF4A006928E7 /* Effects */ = { + isa = PBXGroup; + children = ( + 3D389FCA2B35AF4A006928E7 /* EmitMessagesEffect.swift */, + 3D389FCB2B35AF4A006928E7 /* EmitStatusEffect.swift */, + 3D389FCC2B35AF4A006928E7 /* SubscribeEffects.swift */, + 3D389FCD2B35AF4A006928E7 /* SubscribeEffectFactory.swift */, + ); + path = Effects; + sourceTree = ""; + }; + 3D389FCE2B35AF4A006928E7 /* Helpers */ = { + isa = PBXGroup; + children = ( + 3D389FCF2B35AF4A006928E7 /* SubscribeError.swift */, + 3D389FD02B35AF4A006928E7 /* SubscribeInput.swift */, + 3D389FD12B35AF4A006928E7 /* SubscribeRequest.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 3D389FD42B35AF4A006928E7 /* Presence */ = { + isa = PBXGroup; + children = ( + 3D389FD52B35AF4A006928E7 /* Presence.swift */, + 3D389FD62B35AF4A006928E7 /* PresenceTransition.swift */, + 3D389FD72B35AF4A006928E7 /* Effects */, + 3D389FDD2B35AF4A006928E7 /* Helpers */, + ); + path = Presence; + sourceTree = ""; + }; + 3D389FD72B35AF4A006928E7 /* Effects */ = { + isa = PBXGroup; + children = ( + 3D389FD82B35AF4A006928E7 /* HeartbeatEffect.swift */, + 3D389FD92B35AF4A006928E7 /* LeaveEffect.swift */, + 3D389FDA2B35AF4A006928E7 /* WaitEffect.swift */, + 3D389FDB2B35AF4A006928E7 /* PresenceEffectFactory.swift */, + 3D389FDC2B35AF4A006928E7 /* DelayedHeartbeatEffect.swift */, + ); + path = Effects; + sourceTree = ""; + }; + 3D389FDD2B35AF4A006928E7 /* Helpers */ = { + isa = PBXGroup; + children = ( + 3D389FDE2B35AF4A006928E7 /* PresenceLeaveRequest.swift */, + 3D389FDF2B35AF4A006928E7 /* PresenceHeartbeatRequest.swift */, + 3D389FE02B35AF4A006928E7 /* PresenceInput.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 3D389FF92B35AF6A006928E7 /* EventEngine */ = { + isa = PBXGroup; + children = ( + 3D38A00A2B35AF6A006928E7 /* EventEngineTests.swift */, + 3D389FFA2B35AF6A006928E7 /* DispatcherTests.swift */, + 3D389FFB2B35AF6A006928E7 /* Subscribe */, + 3D38A0022B35AF6A006928E7 /* Presence */, + 3D38A0082B35AF6A006928E7 /* Helpers */, + ); + path = EventEngine; + sourceTree = ""; + }; + 3D389FFB2B35AF6A006928E7 /* Subscribe */ = { + isa = PBXGroup; + children = ( + 3D389FFC2B35AF6A006928E7 /* SubscribeInputTests.swift */, + 3D389FFD2B35AF6A006928E7 /* EmitMessagesTests.swift */, + 3D389FFE2B35AF6A006928E7 /* SubscribeRequestTests.swift */, + 3D389FFF2B35AF6A006928E7 /* EmitStatusTests.swift */, + 3D38A0002B35AF6A006928E7 /* SubscribeEffectsTests.swift */, + 3D38A0012B35AF6A006928E7 /* SubscribeTransitionTests.swift */, + ); + path = Subscribe; + sourceTree = ""; + }; + 3D38A0022B35AF6A006928E7 /* Presence */ = { + isa = PBXGroup; + children = ( + 3D38A0032B35AF6A006928E7 /* WaitEffectTests.swift */, + 3D38A0042B35AF6A006928E7 /* HeartbeatEffectTests.swift */, + 3D38A0052B35AF6A006928E7 /* PresenceTransitionTests.swift */, + 3D38A0062B35AF6A006928E7 /* LeaveEffectTests.swift */, + 3D38A0072B35AF6A006928E7 /* DelayedHeartbeatEffectTests.swift */, + ); + path = Presence; + sourceTree = ""; + }; + 3D38A0082B35AF6A006928E7 /* Helpers */ = { + isa = PBXGroup; + children = ( + 3D38A0092B35AF6A006928E7 /* EffectInvocation+Equatable.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 3D38A0192B35AFBE006928E7 /* EventEngine */ = { + isa = PBXGroup; + children = ( + 3D38A0232B35B00D006928E7 /* PubNubEventEngineTestsHelpers.swift */, + 3D38A01C2B35AFBE006928E7 /* PubNubEventEngineContractTestSteps.swift */, + 3D38A01A2B35AFBE006928E7 /* PubNubSubscribeEngineContractTestsSteps.swift */, + 3D38A01B2B35AFBE006928E7 /* PubNubPresenceEngineContractTestSteps.swift */, + ); + path = EventEngine; + sourceTree = ""; + }; + 3D38A0262B35B087006928E7 /* Strategy */ = { + isa = PBXGroup; + children = ( + 3D38A0272B35B087006928E7 /* EventEngineSubscriptionSessionStrategy.swift */, + 3D38A0282B35B087006928E7 /* LegacySubscriptionSessionStrategy+Presence.swift */, + 3D38A0292B35B087006928E7 /* SubscriptionSessionStrategy.swift */, + 3D38A02A2B35B087006928E7 /* LegacySubscriptionSessionStrategy.swift */, + ); + path = Strategy; + sourceTree = ""; + }; 3D6265D22ABC8E6900FDD5E6 /* CryptorModule */ = { isa = PBXGroup; children = ( @@ -2003,6 +2254,7 @@ 79407BC1271D4CFA0032076C /* Steps */ = { isa = PBXGroup; children = ( + 3D38A0192B35AFBE006928E7 /* EventEngine */, 3D6265D22ABC8E6900FDD5E6 /* CryptorModule */, A5F88ECF2906A9DE00F49D5C /* Objects */, 79407BC2271D4CFA0032076C /* Access */, @@ -2117,6 +2369,7 @@ OBJ_12 /* Tests */ = { isa = PBXGroup; children = ( + 3D389FF92B35AF6A006928E7 /* EventEngine */, 79407BBD271D4CDA0032076C /* Contract */, 3558073823145A48005CDD92 /* Integration */, 3580A5A322F14D5200B12E5E /* Mocking */, @@ -2176,6 +2429,7 @@ children = ( OBJ_11 /* PubNub.swift */, 359152A022BA9AA30048842D /* PubNubConfiguration.swift */, + 3D389FC12B35AF4A006928E7 /* EventEngine */, 35B0ACE4252BE37C00537A18 /* APIs */, 35DB0C49287475F9001E1F76 /* Core */, 3580A59B22F128A300B12E5E /* Errors */, @@ -2566,6 +2820,7 @@ 3562DBB923428961006DFFBC /* objects_uuid_remove_success.json in Resources */, 359287CF232880660046F7A2 /* herenow_success_stateful.json in Resources */, 35293A7D2369EF740049A71F /* fetchMessageAction_success.json in Resources */, + 3D38A0302B35B208006928E7 /* subscription_handshake_success.json in Resources */, 3559977F23078A7C000BCFD1 /* message_counts_error_invalid_arguments.json in Resources */, 35FE93F222EF93A90051C455 /* cannotDecodeContentData.json in Resources */, 35FE93BE22EE9C4A0051C455 /* couldNotParseRequest.json in Resources */, @@ -3029,6 +3284,8 @@ files = ( A5F19EE329126D8200F185A9 /* PubNubObjectsUUIDMetadataContractTestSteps.swift in Sources */, 79407BDC271D4CFA0032076C /* PubNubSubscribeContractTestSteps.swift in Sources */, + 3D38A0242B35B00D006928E7 /* PubNubEventEngineTestsHelpers.swift in Sources */, + 3D38A01D2B35AFBE006928E7 /* PubNubSubscribeEngineContractTestsSteps.swift in Sources */, 79407BD2271D4CFA0032076C /* PubNubContractTestCase.swift in Sources */, 79407BDE271D4CFA0032076C /* PubNubPushContractTestSteps.swift in Sources */, A5115F2B291D5C2700F6ADA1 /* PubNubObjectsContractTests.swift in Sources */, @@ -3042,7 +3299,9 @@ 79407BD4271D4CFA0032076C /* PubNubContractCucumberTest.m in Sources */, 79407BD6271D4CFA0032076C /* PubNubAccessContractTestSteps.swift in Sources */, A56445F22907D9FD0085B310 /* PubNubObjectsChannelMetadataContractTestSteps.swift in Sources */, + 3D38A0212B35AFBE006928E7 /* PubNubEventEngineContractTestSteps.swift in Sources */, 79407BE0271D4CFA0032076C /* PubNubPublishContractTestSteps.swift in Sources */, + 3D38A01F2B35AFBE006928E7 /* PubNubPresenceEngineContractTestSteps.swift in Sources */, 79407BE2271D4CFA0032076C /* PubNubHistoryContractTestSteps.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3053,6 +3312,8 @@ files = ( A5F19EE429126D8200F185A9 /* PubNubObjectsUUIDMetadataContractTestSteps.swift in Sources */, 79407BDD271D4CFA0032076C /* PubNubSubscribeContractTestSteps.swift in Sources */, + 3D38A0252B35B00D006928E7 /* PubNubEventEngineTestsHelpers.swift in Sources */, + 3D38A01E2B35AFBE006928E7 /* PubNubSubscribeEngineContractTestsSteps.swift in Sources */, 79407BD3271D4CFA0032076C /* PubNubContractTestCase.swift in Sources */, 79407BDF271D4CFA0032076C /* PubNubPushContractTestSteps.swift in Sources */, A5115F2C291D5C2700F6ADA1 /* PubNubObjectsContractTests.swift in Sources */, @@ -3066,7 +3327,9 @@ 79407BD5271D4CFA0032076C /* PubNubContractCucumberTest.m in Sources */, 79407BD7271D4CFA0032076C /* PubNubAccessContractTestSteps.swift in Sources */, A56445F32907D9FD0085B310 /* PubNubObjectsChannelMetadataContractTestSteps.swift in Sources */, + 3D38A0222B35AFBE006928E7 /* PubNubEventEngineContractTestSteps.swift in Sources */, 79407BE1271D4CFA0032076C /* PubNubPublishContractTestSteps.swift in Sources */, + 3D38A0202B35AFBE006928E7 /* PubNubPresenceEngineContractTestSteps.swift in Sources */, 79407BE3271D4CFA0032076C /* PubNubHistoryContractTestSteps.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3083,6 +3346,8 @@ 3D758DD62AB48A6A005D2B36 /* CryptorHeaderWithinStreamFinder.swift in Sources */, 35A66A8E22F911DB00AC67A9 /* SubscribeSessionFactory.swift in Sources */, 3D758DBF2AAA1C49005D2B36 /* CryptoModule.swift in Sources */, + 3D389FEC2B35AF4A006928E7 /* SubscribeRequest.swift in Sources */, + 3D389FE72B35AF4A006928E7 /* EmitStatusEffect.swift in Sources */, 3D758DD02AB0A8C6005D2B36 /* CryptorVector.swift in Sources */, 3D6265D72ABCA79100FDD5E6 /* CryptorUtils.swift in Sources */, 35D8D4C522EB4600001B07D9 /* AnyJSON.swift in Sources */, @@ -3091,17 +3356,23 @@ 3585033B22CD545400A11D9A /* URLRequest+PubNub.swift in Sources */, 35C6B6E622F51A060054F242 /* AnyJSONType.swift in Sources */, 3585033422CD139400A11D9A /* String+PubNub.swift in Sources */, + 3D389FE12B35AF4A006928E7 /* TransitionProtocol.swift in Sources */, 354ADA8C22D923F20093EFFB /* Replaceables+PubNub.swift in Sources */, + 3D389FF62B35AF4A006928E7 /* PresenceLeaveRequest.swift in Sources */, 35CF549D248D73500099FE81 /* SubscribeObjectPayload.swift in Sources */, 35277A7022D6B3F90083B9B6 /* URL+PubNub.swift in Sources */, 3D758DC92AB06A12005D2B36 /* CryptoStream.swift in Sources */, 3534D4E222C56533008E89FA /* TimeRouter.swift in Sources */, 35CDFEAF22E7664D00F3B9F2 /* URLQueryItem+PubNub.swift in Sources */, + 3D389FF12B35AF4A006928E7 /* HeartbeatEffect.swift in Sources */, 35304F8A22FE5425006A02CA /* Validated.swift in Sources */, 35EE358822E247B200E3F081 /* URLSessionConfiguration+PubNub.swift in Sources */, + 3D389FF22B35AF4A006928E7 /* LeaveEffect.swift in Sources */, 35A6C7A822FBCC8B00E97CC5 /* PushRouter.swift in Sources */, + 3D389FF72B35AF4A006928E7 /* PresenceHeartbeatRequest.swift in Sources */, 3D758DCE2AB0A835005D2B36 /* LegacyCryptor.swift in Sources */, 35A66A7E22F861BA00AC67A9 /* SubscriptionSession.swift in Sources */, + 3D389FE42B35AF4A006928E7 /* EventEngine.swift in Sources */, 356D48B32360BD6B00C65C40 /* EventStream.swift in Sources */, 35C6B6E322F515760054F242 /* SubscribeRouter.swift in Sources */, 35C6B6DD22F501780054F242 /* Encodable+PubNub.swift in Sources */, @@ -3122,7 +3393,9 @@ 35CF5490248971DD0099FE81 /* ObjectsMembershipsRouter.swift in Sources */, 35CF54922489912F0099FE81 /* PubNubUUIDMetadata.swift in Sources */, 35B6FBAF22F226F4005EE490 /* NSNumber+PubNub.swift in Sources */, + 3D38A02E2B35B087006928E7 /* LegacySubscriptionSessionStrategy.swift in Sources */, 357024BF283C07C900567EE8 /* Objects+PubNub.swift in Sources */, + 3D389FEA2B35AF4A006928E7 /* SubscribeError.swift in Sources */, 35B0ACE3252BE36D00537A18 /* File+PubNub.swift in Sources */, 3D758DD52AB48A6A005D2B36 /* CryptorHeader.swift in Sources */, 35CF549C248ABE8B0099FE81 /* PubNubObjectMetadataPatcher.swift in Sources */, @@ -3130,7 +3403,10 @@ 35E71C3C2490678E0032A991 /* PubNubPresence.swift in Sources */, 35D8D4CD22EB90F1001B07D9 /* Int+PubNub.swift in Sources */, 35B3824A233AAB8C0028803F /* JSONCodable.swift in Sources */, + 3D389FF52B35AF4A006928E7 /* DelayedHeartbeatEffect.swift in Sources */, 35599792230A3F11000BCFD1 /* Thread+PubNub.swift in Sources */, + 3D389FEF2B35AF4A006928E7 /* Presence.swift in Sources */, + 3D389FF82B35AF4A006928E7 /* PresenceInput.swift in Sources */, 3567434822E1E4F700BF2639 /* Collection+PubNub.swift in Sources */, 354FC4C122D04D3600318932 /* DispatchQueue+PubNub.swift in Sources */, 359512102301DCAB00C9D3AE /* Crypto.swift in Sources */, @@ -3138,6 +3414,8 @@ 3534D4E822C67D0E008E89FA /* OperationQueue+PubNub.swift in Sources */, 3585A02423C63EE900FDA860 /* CBORSerialization.swift in Sources */, 359152A122BA9AA30048842D /* PubNubConfiguration.swift in Sources */, + 3D389FE62B35AF4A006928E7 /* EmitMessagesEffect.swift in Sources */, + 3D38A02C2B35B087006928E7 /* LegacySubscriptionSessionStrategy+Presence.swift in Sources */, 358C641F238C5FCA009CE354 /* FCMWebpushPayload.swift in Sources */, 352DBFEA237CCB9D00A0106E /* EndpointResponse.swift in Sources */, 350EFBE422C95FED00FA33AA /* Atomic.swift in Sources */, @@ -3145,14 +3423,20 @@ 35AC162B2485B1DA00A66030 /* SubscribeMessageActionPayload.swift in Sources */, 35599796230B6FFA000BCFD1 /* FileManager+PubNub.swift in Sources */, 35012EC528500BA800CF7E0A /* PubNubEntityEvent.swift in Sources */, + 3D389FE92B35AF4A006928E7 /* SubscribeEffectFactory.swift in Sources */, 355F213722DECFCD004DEFBF /* Typealias+PubNub.swift in Sources */, 3585A02623C63F3900FDA860 /* DecodingError+PubNub.swift in Sources */, 3557CE0723886434004BBACC /* PubNubAPNSPayload.swift in Sources */, + 3D389FED2B35AF4A006928E7 /* Subscribe.swift in Sources */, + 3D389FE22B35AF4A006928E7 /* Dispatcher.swift in Sources */, + 3D389FF02B35AF4A006928E7 /* PresenceTransition.swift in Sources */, 35DB0C4D287476BF001E1F76 /* OptionalChange.swift in Sources */, 35458BAB230F369A0085B502 /* InstanceIdOperator.swift in Sources */, 7951954E26C955CE001E308C /* PAMToken.swift in Sources */, + 3D389FEB2B35AF4A006928E7 /* SubscribeInput.swift in Sources */, 35F0259922BBFA85007BD7D3 /* HTTPSession.swift in Sources */, 353F78C42527934500FFB72C /* InputStream+PubNub.swift in Sources */, + 3D38A02D2B35B087006928E7 /* SubscriptionSessionStrategy.swift in Sources */, 353E8CE423C68F01003FBFF5 /* Float32+PubNub.swift in Sources */, 352DBFE9237C937F00A0106E /* HTTPSessionDelegate.swift in Sources */, 35CF548E248971CD0099FE81 /* ObjectsChannelRouter.swift in Sources */, @@ -3161,6 +3445,8 @@ 3585033722CD4A1A00A11D9A /* RequestOperator.swift in Sources */, 354ADA8822D909A30093EFFB /* Convertibles+PubNub.swift in Sources */, 3556E3762485936B004FDC25 /* SubscribePresencePayload.swift in Sources */, + 3D389FF42B35AF4A006928E7 /* PresenceEffectFactory.swift in Sources */, + 3D38A02B2B35B087006928E7 /* EventEngineSubscriptionSessionStrategy.swift in Sources */, 350EFBDC22C951F700FA33AA /* Request.swift in Sources */, 3D758DD22AB0A91C005D2B36 /* AESCBCCryptor.swift in Sources */, 35A66A7F22F861BA00AC67A9 /* WeakBox.swift in Sources */, @@ -3177,16 +3463,20 @@ 35CF54962489B3760099FE81 /* PubNubMembershipMetadata.swift in Sources */, 3556E370248023B2004FDC25 /* BoundedValue.swift in Sources */, 35270C0323AC124800501388 /* CBORDecoder.swift in Sources */, + 3D389FE82B35AF4A006928E7 /* SubscribeEffects.swift in Sources */, 3559978C230A02B7000BCFD1 /* PubNubLogger.swift in Sources */, 35AE6A3224FD6CEE00BBFA37 /* FileManagementRouter.swift in Sources */, + 3D389FF32B35AF4A006928E7 /* WaitEffect.swift in Sources */, 35089A0B22E56F1F002BCC94 /* Constants.swift in Sources */, 358C6421238C6787009CE354 /* PubNubPushMessage.swift in Sources */, 3D758DC82AB06A12005D2B36 /* CryptoInputStream.swift in Sources */, 35E4604F234B8B9D005D04AE /* ErrorDescription.swift in Sources */, + 3D389FE52B35AF4A006928E7 /* EventEngineFactory.swift in Sources */, 35089A0922E3C08D002BCC94 /* Error+PubNub.swift in Sources */, 3534D4E422C57659008E89FA /* PublishRouter.swift in Sources */, 35EE358C22E26A4D00E3F081 /* HTTPURLResponse+PubNub.swift in Sources */, - 35A66A9422F91B2A00AC67A9 /* SubscriptionSession+Presence.swift in Sources */, + 3D389FEE2B35AF4A006928E7 /* SubscribeTransition.swift in Sources */, + 3D389FE32B35AF4A006928E7 /* EffectHandler.swift in Sources */, 350EFBE022C9573F00FA33AA /* NSLocking+PubNub.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3195,6 +3485,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 0; files = ( + 3D38A0162B35AF6B006928E7 /* DelayedHeartbeatEffectTests.swift in Sources */, 3558069C231303D9005CDD92 /* AutomaticRetryTests.swift in Sources */, 35D973542857BBFE001A44DC /* FlatJSONCodable+Test.swift in Sources */, 35FE93B922EE44F70051C455 /* MockURLSession.swift in Sources */, @@ -3203,6 +3494,7 @@ 35CDFEA922E75DA800F3B9F2 /* Set+PubNubTests.swift in Sources */, 359152AB22BAA6730048842D /* PubNubConfigurationTests.swift in Sources */, 35FE941B22EFE5400051C455 /* EventStreamTests.swift in Sources */, + 3D38A00D2B35AF6A006928E7 /* EmitMessagesTests.swift in Sources */, 35FE93C322EF57FA0051C455 /* Session+URLErrorTests.swift in Sources */, 35FE940122EF983A0051C455 /* Session+EndpointErrorTests.swift in Sources */, 357AEB8422E6954600C18250 /* Collection+PubNubTests.swift in Sources */, @@ -3213,9 +3505,12 @@ 35D0615A2303A61500FDB2F9 /* ValidatedTests.swift in Sources */, 35CDFEBC22E789B200F3B9F2 /* ConstantsTests.swift in Sources */, 35CF54A0248D96320099FE81 /* SubscribeRouterTests.swift in Sources */, + 3D38A0122B35AF6B006928E7 /* WaitEffectTests.swift in Sources */, 35CF54A1248DA6430099FE81 /* ObjectsChannelRouterTests.swift in Sources */, 359C2C1422EBB56A009C3B4B /* Int+PubNubTests.swift in Sources */, 35CDFEC022E7B48000F3B9F2 /* ImportTestResource.swift in Sources */, + 3D38A00C2B35AF6A006928E7 /* SubscribeInputTests.swift in Sources */, + 3D38A0132B35AF6B006928E7 /* HeartbeatEffectTests.swift in Sources */, 35403F8A253617A8004B978E /* XMLCodingTests.swift in Sources */, 3557CDF8237F4611004BBACC /* MessageActionsRouterTests.swift in Sources */, 35CDFEAD22E7655700F3B9F2 /* URL+PubNubTests.swift in Sources */, @@ -3225,6 +3520,7 @@ 35CDFEA722E75BE800F3B9F2 /* OperationQueue+PubNubTests.swift in Sources */, 357AEB8A22E6A02F00C18250 /* Error+PubNubTests.swift in Sources */, 35C8FDC625000BC80069E89E /* FileManagementRouterTests.swift in Sources */, + 3D38A00F2B35AF6A006928E7 /* EmitStatusTests.swift in Sources */, 35A6C7BA22FC5BFB00E97CC5 /* Data+PubNubTests.swift in Sources */, 35AB218D22E7D72200BD3049 /* AnyJSON+CodableTests.swift in Sources */, 3557CDFC237F59F6004BBACC /* PublishRouterTests.swift in Sources */, @@ -3233,8 +3529,13 @@ 3559977B23073D53000BCFD1 /* WeakBoxTests.swift in Sources */, 35CDFEB622E76DC200F3B9F2 /* URLQueryItem+PubNubTests.swift in Sources */, 3557CDF9237F4ABB004BBACC /* PresenceRouterTests.swift in Sources */, + 3D38A0152B35AF6B006928E7 /* LeaveEffectTests.swift in Sources */, 3D9134972A1216F7000A5124 /* PubNubPushTargetTests.swift in Sources */, 35D8D4CB22EB84B4001B07D9 /* AtomicTests.swift in Sources */, + 3D38A0172B35AF6B006928E7 /* EffectInvocation+Equatable.swift in Sources */, + 3D38A0102B35AF6B006928E7 /* SubscribeEffectsTests.swift in Sources */, + 3D38A00E2B35AF6A006928E7 /* SubscribeRequestTests.swift in Sources */, + 3D38A0142B35AF6B006928E7 /* PresenceTransitionTests.swift in Sources */, OBJ_49 /* PubNubTests.swift in Sources */, 3558068A230F4C99005CDD92 /* InstanceIdOperatorTests.swift in Sources */, 35CF549E248D913A0099FE81 /* ObjectsUUIDRouterTests.swift in Sources */, @@ -3249,7 +3550,10 @@ 35458BA7230D91BB0085B502 /* TestSetup.swift in Sources */, 357AEB8C22E6A12400C18250 /* HTTPURLResponse+PubNubTests.swift in Sources */, 3580A59422F0C74100B12E5E /* RequestMutatorTests.swift in Sources */, + 3D38A0112B35AF6B006928E7 /* SubscribeTransitionTests.swift in Sources */, + 3D38A00B2B35AF6A006928E7 /* DispatcherTests.swift in Sources */, 35721576252FA675005A0144 /* XMLEncoder.swift in Sources */, + 3D38A0182B35AF6B006928E7 /* EventEngineTests.swift in Sources */, 3557CDF6237F189E004BBACC /* ChannelGroupEndpointTests.swift in Sources */, 35FE941822EFCB7F0051C455 /* SessionStreamTests.swift in Sources */, 35CDFEB822E7776400F3B9F2 /* URLRequest+PubNubTests.swift in Sources */, diff --git a/PubNubMembership/Sources/Membership+PubNub.swift b/PubNubMembership/Sources/Membership+PubNub.swift index 2c7f79f0..a0ffc89a 100644 --- a/PubNubMembership/Sources/Membership+PubNub.swift +++ b/PubNubMembership/Sources/Membership+PubNub.swift @@ -18,7 +18,6 @@ import PubNubUser public protocol PubNubMembershipInterface { /// A copy of the configuration object used for this session var configuration: PubNubConfiguration { get } - /// Session used for performing request/response REST calls var networkSession: SessionReplaceable { get } @@ -268,6 +267,7 @@ public extension PubNubMembershipInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchMultipleValueResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in @@ -320,6 +320,7 @@ public extension PubNubMembershipInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchMultipleValueResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in @@ -365,6 +366,7 @@ public extension PubNubMembershipInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchStatusResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in @@ -401,6 +403,7 @@ public extension PubNubMembershipInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchStatusResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in @@ -463,6 +466,7 @@ public extension PubNubMembershipInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchStatusResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in @@ -499,6 +503,7 @@ public extension PubNubMembershipInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchStatusResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in diff --git a/PubNubSpace/Sources/Space+PubNub.swift b/PubNubSpace/Sources/Space+PubNub.swift index 8954f8ae..7cc07d4e 100644 --- a/PubNubSpace/Sources/Space+PubNub.swift +++ b/PubNubSpace/Sources/Space+PubNub.swift @@ -16,7 +16,6 @@ import PubNub public protocol PubNubSpaceInterface { /// A copy of the configuration object used for this session var configuration: PubNubConfiguration { get } - /// Session used for performing request/response REST calls var networkSession: SessionReplaceable { get } @@ -213,6 +212,7 @@ public extension PubNubSpaceInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchMultipleValueResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in @@ -237,6 +237,7 @@ public extension PubNubSpaceInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchSingleValueResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in @@ -273,6 +274,7 @@ public extension PubNubSpaceInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchSingleValueResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in @@ -317,6 +319,7 @@ public extension PubNubSpaceInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchStatusResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in diff --git a/PubNubUser/Sources/User+PubNub.swift b/PubNubUser/Sources/User+PubNub.swift index 2ebf20a4..adde78d7 100644 --- a/PubNubUser/Sources/User+PubNub.swift +++ b/PubNubUser/Sources/User+PubNub.swift @@ -9,14 +9,12 @@ // import Foundation - import PubNub /// Protocol interface to manage `PubNubUser` entities using closures public protocol PubNubUserInterface { /// A copy of the configuration object used for this session var configuration: PubNubConfiguration { get } - /// Session used for performing request/response REST calls var networkSession: SessionReplaceable { get } @@ -221,6 +219,7 @@ public extension PubNubUserInterface { (requestConfig.customSession ?? networkSession)? .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchMultipleValueResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in @@ -248,6 +247,7 @@ public extension PubNubUserInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchSingleValueResponseDecoder(), responseQueue: requestConfig.responseQueue ) { @@ -288,6 +288,7 @@ public extension PubNubUserInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchSingleValueResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in @@ -336,6 +337,7 @@ public extension PubNubUserInterface { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: configuration.automaticRetry?[.appContext], responseDecoder: FetchStatusResponseDecoder(), responseQueue: requestConfig.responseQueue ) { result in diff --git a/Sources/PubNub/APIs/File+PubNub.swift b/Sources/PubNub/APIs/File+PubNub.swift index f99b1d14..a4d108a7 100644 --- a/Sources/PubNub/APIs/File+PubNub.swift +++ b/Sources/PubNub/APIs/File+PubNub.swift @@ -29,6 +29,7 @@ public extension PubNub { ) { route( FileManagementRouter(.list(channel: channel, limit: limit, next: next), configuration: configuration), + requestOperator: configuration.automaticRetry?[.files], responseDecoder: FileListResponseDecoder(), custom: requestConfig ) { result in @@ -60,6 +61,7 @@ public extension PubNub { ) { route( FileManagementRouter(.delete(channel: channel, fileId: fileId, filename: filename), configuration: configuration), + requestOperator: configuration.automaticRetry?[.files], responseDecoder: FileGeneralSuccessResponseDecoder(), custom: requestConfig ) { result in @@ -137,6 +139,7 @@ public extension PubNub { .generateURL(channel: channel, body: .init(name: remoteFilename)), configuration: configuration ), + requestOperator: configuration.automaticRetry?[.files], responseDecoder: FileGenerateResponseDecoder(), custom: requestConfig ) { [configuration] result in @@ -225,9 +228,12 @@ public extension PubNub { configuration: configuration ) - route(router, - responseDecoder: PublishResponseDecoder(), - custom: request.customRequestConfig) { result in + route( + router, + requestOperator: configuration.automaticRetry?[.files], + responseDecoder: PublishResponseDecoder(), + custom: request.customRequestConfig + ) { result in completion?(result.map { $0.payload.timetoken }) } } diff --git a/Sources/PubNub/EventEngine/Core/Dispatcher.swift b/Sources/PubNub/EventEngine/Core/Dispatcher.swift new file mode 100644 index 00000000..ffc5232c --- /dev/null +++ b/Sources/PubNub/EventEngine/Core/Dispatcher.swift @@ -0,0 +1,113 @@ +// +// Dispatcher.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +// MARK: - DispatcherListener + +struct DispatcherListener { + let onAnyInvocationCompleted: (([Event]) -> Void) +} + +// MARK: - Dispatcher + +protocol Dispatcher { + associatedtype Invocation: AnyEffectInvocation + associatedtype Event + associatedtype Dependencies + + func dispatch( + invocations: [EffectInvocation], + with dependencies: EventEngineDependencies, + notify listener: DispatcherListener + ) +} + +// MARK: - EffectDispatcher + +class EffectDispatcher: Dispatcher { + private let factory: any EffectHandlerFactory + private let effectsCache = EffectsCache() + + init(factory: some EffectHandlerFactory) { + self.factory = factory + } + + func hasPendingInvocation(_ invocation: Invocation) -> Bool { + effectsCache.hasPendingEffect(with: invocation.id) + } + + func dispatch( + invocations: [EffectInvocation], + with dependencies: EventEngineDependencies, + notify listener: DispatcherListener + ) { + invocations.forEach { + switch $0 { + case .managed(let invocation): + executeEffect( + effect: factory.effect(for: invocation, with: dependencies), + storageId: invocation.id, + notify: listener + ) + case .regular(let invocation): + executeEffect( + effect: factory.effect(for: invocation, with: dependencies), + storageId: UUID().uuidString, + notify: listener + ) + case .cancel(let cancelInvocation): + effectsCache.getEffect(with: cancelInvocation.id)?.cancelTask() + effectsCache.removeEffect(id: cancelInvocation.id) + } + } + } + + private func executeEffect( + effect: some EffectHandler, + storageId id: String, + notify listener: DispatcherListener + ) { + effectsCache.put(effect: effect, with: id) + effect.performTask { [weak effectsCache] results in + effectsCache?.removeEffect(id: id) + listener.onAnyInvocationCompleted(results) + } + } +} + +// MARK: - EffectsCache + +fileprivate class EffectsCache { + private var managedEffects: Atomic<[String: EffectWrapper]> = Atomic([:]) + + func hasPendingEffect(with id: String) -> Bool { + managedEffects.lockedRead { $0[id] } != nil + } + + func put(effect: some EffectHandler, with id: String) { + managedEffects.lockedWrite { $0[id] = EffectWrapper(id: id, effect: effect) } + } + + func getEffect(with id: String) -> (any EffectHandler)? { + managedEffects.lockedRead() { $0[id] }?.effect + } + + func removeEffect(id: String) { + managedEffects.lockedWrite { $0[id] = nil } + } +} + +// MARK: - EffectWrapper + +fileprivate struct EffectWrapper { + let id: String + let effect: any EffectHandler +} diff --git a/Sources/PubNub/EventEngine/Core/EffectHandler.swift b/Sources/PubNub/EventEngine/Core/EffectHandler.swift new file mode 100644 index 00000000..fa1866d5 --- /dev/null +++ b/Sources/PubNub/EventEngine/Core/EffectHandler.swift @@ -0,0 +1,66 @@ +// +// EffectHandler.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// +import Foundation + +// MARK: - EffectHandlerFactory + +protocol EffectHandlerFactory { + associatedtype Invocation + associatedtype Event + associatedtype Dependencies + + func effect( + for invocation: Invocation, + with dependencies: EventEngineDependencies + ) -> any EffectHandler +} + +// MARK: - EffectHandler + +protocol EffectHandler { + associatedtype Event + + func performTask(completionBlock: @escaping ([Event]) -> Void) + func cancelTask() +} + +extension EffectHandler { + func cancelTask() {} +} + +// MARK: - Delayed Effect Handler + +protocol DelayedEffectHandler: AnyObject, EffectHandler { + var workItem: DispatchWorkItem? { get set } + + func delayInterval() -> TimeInterval? + func onEarlyExit(notify completionBlock: @escaping ([Event]) -> Void) + func onDelayExpired(notify completionBlock: @escaping ([Event]) -> Void) +} + +extension DelayedEffectHandler { + func performTask(completionBlock: @escaping ([Event]) -> Void) { + guard let delay = delayInterval() else { + onEarlyExit(notify: completionBlock); return + } + let workItem = DispatchWorkItem() { [weak self] in + self?.onDelayExpired(notify: completionBlock) + } + DispatchQueue.global(qos: .default).asyncAfter( + deadline: .now() + delay, + execute: workItem + ) + self.workItem = workItem + } + + func cancelTask() { + workItem?.cancel() + } +} diff --git a/Sources/PubNub/EventEngine/Core/EventEngine.swift b/Sources/PubNub/EventEngine/Core/EventEngine.swift new file mode 100644 index 00000000..4f8a3b99 --- /dev/null +++ b/Sources/PubNub/EventEngine/Core/EventEngine.swift @@ -0,0 +1,71 @@ +// +// EventEngine.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +struct EventEngineDependencies { + let value: Dependencies +} + +class EventEngine { + private let transition: any TransitionProtocol + private let dispatcher: any Dispatcher + private(set) var state: State + + var dependencies: EventEngineDependencies + var onStateUpdated: ((State) -> Void)? + + init( + state: State, + transition: some TransitionProtocol, + onStateUpdated: ((State) -> Void)? = nil, + dispatcher: some Dispatcher, + dependencies: EventEngineDependencies + ) { + self.state = state + self.onStateUpdated = onStateUpdated + self.transition = transition + self.dispatcher = dispatcher + self.dependencies = dependencies + } + + func send(event: Event) { + objc_sync_enter(self) + + defer { + objc_sync_exit(self) + } + guard transition.canTransition( + from: state, + dueTo: event + ) else { + return + } + + let transitionResult = transition.transition(from: state, event: event) + let invocations = transitionResult.invocations + + state = transitionResult.state + onStateUpdated?(state) + + let listener = DispatcherListener( + onAnyInvocationCompleted: { [weak self] results in + results.forEach { + self?.send(event: $0) + } + } + ) + dispatcher.dispatch( + invocations: invocations, + with: dependencies, + notify: listener + ) + } +} diff --git a/Sources/PubNub/EventEngine/Core/EventEngineFactory.swift b/Sources/PubNub/EventEngine/Core/EventEngineFactory.swift new file mode 100644 index 00000000..0efb70ec --- /dev/null +++ b/Sources/PubNub/EventEngine/Core/EventEngineFactory.swift @@ -0,0 +1,47 @@ +// +// EventEngineFactory.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +typealias SubscribeEngine = EventEngine<(any SubscribeState), Subscribe.Event, Subscribe.Invocation, Subscribe.Dependencies> +typealias PresenceEngine = EventEngine<(any PresenceState), Presence.Event, Presence.Invocation, Presence.Dependencies> + +typealias SubscribeTransitions = TransitionProtocol<(any SubscribeState), Subscribe.Event, Subscribe.Invocation> +typealias PresenceTransitions = TransitionProtocol<(any PresenceState), Presence.Event, Presence.Invocation> +typealias SubscribeDispatcher = Dispatcher +typealias PresenceDispatcher = Dispatcher + +class EventEngineFactory { + func subscribeEngine( + with configuration: PubNubConfiguration, + dispatcher: some SubscribeDispatcher, + transition: some SubscribeTransitions + ) -> SubscribeEngine { + EventEngine( + state: Subscribe.UnsubscribedState(), + transition: transition, + dispatcher: dispatcher, + dependencies: EventEngineDependencies(value: Subscribe.Dependencies(configuration: configuration)) + ) + } + + func presenceEngine( + with configuration: PubNubConfiguration, + dispatcher: some PresenceDispatcher, + transition: some PresenceTransitions + ) -> PresenceEngine { + EventEngine( + state: Presence.HeartbeatInactive(), + transition: transition, + dispatcher: dispatcher, + dependencies: EventEngineDependencies(value: Presence.Dependencies(configuration: configuration)) + ) + } +} diff --git a/Sources/PubNub/EventEngine/Core/TransitionProtocol.swift b/Sources/PubNub/EventEngine/Core/TransitionProtocol.swift new file mode 100644 index 00000000..2a0446bf --- /dev/null +++ b/Sources/PubNub/EventEngine/Core/TransitionProtocol.swift @@ -0,0 +1,48 @@ +// +// TransitionProtocol.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +protocol AnyIdentifiableInvocation { + var id: String { get } +} + +protocol AnyCancellableInvocation: AnyIdentifiableInvocation { + +} + +protocol AnyEffectInvocation: AnyIdentifiableInvocation { + associatedtype Cancellable: AnyCancellableInvocation +} + +struct TransitionResult { + let state: State + let invocations: [EffectInvocation] + + init(state: State, invocations: [EffectInvocation] = []) { + self.state = state + self.invocations = invocations + } +} + +enum EffectInvocation { + case managed(_ invocation: Invocation) + case regular(_ invocation: Invocation) + case cancel(_ invocation: Invocation.Cancellable) +} + +protocol TransitionProtocol { + associatedtype State + associatedtype Event + associatedtype Invocation: AnyEffectInvocation + + func canTransition(from state: State, dueTo event: Event) -> Bool + func transition(from state: State, event: Event) -> TransitionResult +} diff --git a/Sources/PubNub/EventEngine/Presence/Effects/DelayedHeartbeatEffect.swift b/Sources/PubNub/EventEngine/Presence/Effects/DelayedHeartbeatEffect.swift new file mode 100644 index 00000000..d989c0da --- /dev/null +++ b/Sources/PubNub/EventEngine/Presence/Effects/DelayedHeartbeatEffect.swift @@ -0,0 +1,83 @@ +// +// DelayedHeartbeatEffect.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class DelayedHeartbeatEffect: DelayedEffectHandler { + typealias Event = Presence.Event + + private let request: PresenceHeartbeatRequest + private let configuration: PubNubConfiguration + private let retryAttempt: Int + private let reason: PubNubError + + var workItem: DispatchWorkItem? + + init( + request: PresenceHeartbeatRequest, + retryAttempt: Int, + reason: PubNubError, + configuration: PubNubConfiguration + ) { + self.request = request + self.retryAttempt = retryAttempt + self.reason = reason + self.configuration = configuration + } + + func delayInterval() -> TimeInterval? { + guard let automaticRetry = configuration.automaticRetry else { + return nil + } + guard automaticRetry[.presence] != nil else { + return nil + } + guard automaticRetry.retryLimit > retryAttempt else { + return nil + } + guard let underlyingError = reason.underlying else { + return automaticRetry.policy.delay(for: retryAttempt) + } + guard let urlResponse = reason.affected.findFirst(by: PubNubError.AffectedValue.response) else { + return nil + } + + let shouldRetry = automaticRetry.shouldRetry( + response: urlResponse, + error: underlyingError + ) + + return shouldRetry ? automaticRetry.policy.delay(for: retryAttempt) : nil + } + + func onEarlyExit(notify completionBlock: @escaping ([Presence.Event]) -> Void) { + completionBlock([.heartbeatGiveUp(error: reason)]) + } + + func onDelayExpired(notify completionBlock: @escaping ([Presence.Event]) -> Void) { + request.execute() { result in + switch result { + case .success(_): + completionBlock([.heartbeatSuccess]) + case .failure(let error): + completionBlock([.heartbeatFailed(error: error)]) + } + } + } + + func cancelTask() { + workItem?.cancel() + request.cancel() + } + + deinit { + cancelTask() + } +} diff --git a/Sources/PubNub/EventEngine/Presence/Effects/HeartbeatEffect.swift b/Sources/PubNub/EventEngine/Presence/Effects/HeartbeatEffect.swift new file mode 100644 index 00000000..986df121 --- /dev/null +++ b/Sources/PubNub/EventEngine/Presence/Effects/HeartbeatEffect.swift @@ -0,0 +1,34 @@ +// +// HeartbeatEffect.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class HeartbeatEffect: EffectHandler { + private let request: PresenceHeartbeatRequest + + init(request: PresenceHeartbeatRequest) { + self.request = request + } + + func performTask(completionBlock: @escaping ([Presence.Event]) -> Void) { + request.execute() { result in + switch result { + case .success(_): + completionBlock([.heartbeatSuccess]) + case .failure(let error): + completionBlock([.heartbeatFailed(error: error)]) + } + } + } + + deinit { + request.cancel() + } +} diff --git a/Sources/PubNub/EventEngine/Presence/Effects/LeaveEffect.swift b/Sources/PubNub/EventEngine/Presence/Effects/LeaveEffect.swift new file mode 100644 index 00000000..0f87a1e9 --- /dev/null +++ b/Sources/PubNub/EventEngine/Presence/Effects/LeaveEffect.swift @@ -0,0 +1,34 @@ +// +// LeaveEffect.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class LeaveEffect: EffectHandler { + private let request: PresenceLeaveRequest + + init(request: PresenceLeaveRequest) { + self.request = request + } + + func performTask(completionBlock: @escaping ([Presence.Event]) -> Void) { + request.execute() { result in + switch result { + case .success(_): + completionBlock([]) + case .failure(_): + completionBlock([]) + } + } + } + + deinit { + request.cancel() + } +} diff --git a/Sources/PubNub/EventEngine/Presence/Effects/PresenceEffectFactory.swift b/Sources/PubNub/EventEngine/Presence/Effects/PresenceEffectFactory.swift new file mode 100644 index 00000000..bbec2c93 --- /dev/null +++ b/Sources/PubNub/EventEngine/Presence/Effects/PresenceEffectFactory.swift @@ -0,0 +1,76 @@ +// +// PresenceEffectFactory.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class PresenceEffectFactory: EffectHandlerFactory { + private let session: SessionReplaceable + private let sessionResponseQueue: DispatchQueue + private let presenceStateContainer: PresenceStateContainer + + init( + session: SessionReplaceable, + sessionResponseQueue: DispatchQueue = .global(qos: .default), + presenceStateContainer: PresenceStateContainer + ) { + self.session = session + self.sessionResponseQueue = sessionResponseQueue + self.presenceStateContainer = presenceStateContainer + } + + func effect( + for invocation: Presence.Invocation, + with dependencies: EventEngineDependencies + ) -> any EffectHandler { + switch invocation { + case .heartbeat(let channels, let groups): + return HeartbeatEffect( + request: PresenceHeartbeatRequest( + channels: channels, + groups: groups, + channelStates: presenceStateContainer.getStates(forChannels: channels), + configuration: dependencies.value.configuration, + session: session, + sessionResponseQueue: sessionResponseQueue + ) + ) + case .delayedHeartbeat(let channels, let groups, let retryAttempt, let reason): + return DelayedHeartbeatEffect( + request: PresenceHeartbeatRequest( + channels: channels, + groups: groups, + channelStates: presenceStateContainer.getStates(forChannels: channels), + configuration: dependencies.value.configuration, + session: session, + sessionResponseQueue: sessionResponseQueue + ), + retryAttempt: retryAttempt, + reason: reason, + configuration: dependencies.value.configuration + ) + case .leave(let channels, let groups): + return LeaveEffect( + request: PresenceLeaveRequest( + channels: channels, + groups: groups, + configuration: dependencies.value.configuration, + session: session, + sessionResponseQueue: sessionResponseQueue + ) + ) + case .wait: + return WaitEffect(configuration: dependencies.value.configuration) + } + } + + deinit { + session.invalidateAndCancel() + } +} diff --git a/Sources/PubNub/EventEngine/Presence/Effects/WaitEffect.swift b/Sources/PubNub/EventEngine/Presence/Effects/WaitEffect.swift new file mode 100644 index 00000000..979c4700 --- /dev/null +++ b/Sources/PubNub/EventEngine/Presence/Effects/WaitEffect.swift @@ -0,0 +1,38 @@ +// +// WaitEffect.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class WaitEffect: DelayedEffectHandler { + typealias Event = Presence.Event + + private let configuration: SubscriptionConfiguration + var workItem: DispatchWorkItem? + + init(configuration: SubscriptionConfiguration) { + self.configuration = configuration + } + + func delayInterval() -> TimeInterval? { + configuration.heartbeatInterval > 0 ? TimeInterval(configuration.heartbeatInterval) : nil + } + + func onEarlyExit(notify completionBlock: @escaping ([Presence.Event]) -> Void) { + completionBlock([]) + } + + func onDelayExpired(notify completionBlock: @escaping ([Presence.Event]) -> Void) { + completionBlock([.timesUp]) + } + + func cancelTask() { + workItem?.cancel() + } +} diff --git a/Sources/PubNub/EventEngine/Presence/Helpers/PresenceHeartbeatRequest.swift b/Sources/PubNub/EventEngine/Presence/Helpers/PresenceHeartbeatRequest.swift new file mode 100644 index 00000000..d8e0f2d2 --- /dev/null +++ b/Sources/PubNub/EventEngine/Presence/Helpers/PresenceHeartbeatRequest.swift @@ -0,0 +1,123 @@ +// +// PresenceHeartbeatRequest.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +// MARK: - PresenceStateContainer + +class PresenceStateContainer { + private var channelStates: Atomic<[String: [String: JSONCodableScalar]]> = Atomic([:]) + private var channelGroupStates: Atomic<[String: [String: JSONCodableScalar]]> = Atomic([:]) + + static var shared: PresenceStateContainer = PresenceStateContainer() + private init() {} + + func registerState(_ state: [String: JSONCodableScalar], forChannels channels: [String]) { + channelStates.lockedWrite { channelStates in + channels.forEach { + channelStates[$0] = state + } + } + } + + func registerState(_ state: [String: JSONCodableScalar], forChannelGroups groups: [String]) { + channelGroupStates.lockedWrite { channelGroupStates in + groups.forEach { + channelGroupStates[$0] = state + } + } + } + + func removeState(forChannels channels: [String]) { + channelStates.lockedWrite { channelStates in + channels.map { + channelStates[$0] = nil + } + } + } + + func removeState(forGroups groups: [String]) { + channelGroupStates.lockedWrite { channelGroupStates in + groups.map { + channelGroupStates[$0] = nil + } + } + } + + func getStates(forChannels channels: [String]) -> [String: [String: JSONCodableScalar]] { + channelStates.lockedRead { + $0.filter { + channels.contains($0.key) + } + } + } + + func getStates(forGroups channelGroups: [String]) -> [String: [String: JSONCodableScalar]] { + channelGroupStates.lockedRead { + $0.filter { + channelGroups.contains($0.key) + } + } + } +} + +// MARK: - PresenceHeartbeatRequest + +class PresenceHeartbeatRequest { + let channels: [String] + let groups: [String] + let configuration: PubNubConfiguration + + private let session: SessionReplaceable + private let sessionResponseQueue: DispatchQueue + private let channelStates: [String: [String: JSONCodableScalar]] + private var request: RequestReplaceable? + + init( + channels: [String], + groups: [String], + channelStates: [String: [String: JSONCodableScalar]], + configuration: PubNubConfiguration, + session: SessionReplaceable, + sessionResponseQueue: DispatchQueue + ) { + self.channels = channels + self.groups = groups + self.channelStates = channelStates + self.configuration = configuration + self.session = session + self.sessionResponseQueue = sessionResponseQueue + } + + func execute(completionBlock: @escaping (Result) -> Void) { + let endpoint = PresenceRouter.Endpoint.heartbeat( + channels: channels, + groups: groups, + channelStates: channelStates, + presenceTimeout: configuration.durationUntilTimeout + ) + request = session.request( + with: PresenceRouter(endpoint, configuration: configuration), + requestOperator: nil + ) + request?.validate().response(on: sessionResponseQueue, decoder: GenericServiceResponseDecoder()) { result in + switch result { + case .success(_): + completionBlock(.success(())) + case .failure(let error): + completionBlock(.failure(error as? PubNubError ?? PubNubError(.unknown, underlying: error))) + } + } + } + + func cancel() { + request?.cancel(PubNubError(.clientCancelled)) + } +} diff --git a/Sources/PubNub/EventEngine/Presence/Helpers/PresenceInput.swift b/Sources/PubNub/EventEngine/Presence/Helpers/PresenceInput.swift new file mode 100644 index 00000000..3a2f780c --- /dev/null +++ b/Sources/PubNub/EventEngine/Presence/Helpers/PresenceInput.swift @@ -0,0 +1,58 @@ +// +// PresenceInput.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// +import Foundation + +struct PresenceInput: Equatable { + fileprivate let channelsSet: Set + fileprivate let groupsSet: Set + + init(channels: [String] = [], groups: [String] = []) { + channelsSet = Set(channels) + groupsSet = Set(groups) + } + + fileprivate init(channels: Set, groups: Set) { + channelsSet = channels + groupsSet = groups + } + + var channels: [String] { + channelsSet.map { $0 } + } + + var groups: [String] { + groupsSet.map { $0 } + } + + var isEmpty: Bool { + channelsSet.isEmpty && groupsSet.isEmpty + } + + static func +(lhs: PresenceInput, rhs: PresenceInput) -> PresenceInput { + PresenceInput( + channels: lhs.channelsSet.union(rhs.channelsSet), + groups: lhs.groupsSet.union(rhs.groupsSet) + ) + } + + static func -(lhs: PresenceInput, rhs: PresenceInput) -> PresenceInput { + PresenceInput( + channels: lhs.channelsSet.subtracting(rhs.channelsSet), + groups: lhs.groupsSet.subtracting(rhs.groupsSet) + ) + } + + static func ==(lhs: PresenceInput, rhs: PresenceInput) -> Bool { + let equalChannels = lhs.channels.sorted(by: <) == rhs.channels.sorted(by: <) + let equalGroups = lhs.groups.sorted(by: <) == rhs.groups.sorted(by: <) + + return equalChannels && equalGroups + } +} diff --git a/Sources/PubNub/EventEngine/Presence/Helpers/PresenceLeaveRequest.swift b/Sources/PubNub/EventEngine/Presence/Helpers/PresenceLeaveRequest.swift new file mode 100644 index 00000000..9e7f44e3 --- /dev/null +++ b/Sources/PubNub/EventEngine/Presence/Helpers/PresenceLeaveRequest.swift @@ -0,0 +1,58 @@ +// +// PresenceLeaveRequest.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class PresenceLeaveRequest { + let channels: [String] + let groups: [String] + let configuration: PubNubConfiguration + + private let session: SessionReplaceable + private let sessionResponseQueue: DispatchQueue + private var request: RequestReplaceable? + + init( + channels: [String], + groups: [String], + configuration: PubNubConfiguration, + session: SessionReplaceable, + sessionResponseQueue: DispatchQueue + ) { + self.channels = channels + self.groups = groups + self.configuration = configuration + self.session = session + self.sessionResponseQueue = sessionResponseQueue + } + + func execute(completionBlock: @escaping (Result) -> Void) { + let endpoint = PresenceRouter.Endpoint.leave( + channels: channels, + groups: groups + ) + request = session.request( + with: PresenceRouter(endpoint, configuration: configuration), + requestOperator: nil + ) + request?.validate().response(on: sessionResponseQueue, decoder: GenericServiceResponseDecoder()) { result in + switch result { + case .success(_): + completionBlock(.success(())) + case .failure(let error): + completionBlock(.failure(error as? PubNubError ?? PubNubError(.unknown, underlying: error))) + } + } + } + + func cancel() { + request?.cancel(PubNubError(.clientCancelled)) + } +} diff --git a/Sources/PubNub/EventEngine/Presence/Presence.swift b/Sources/PubNub/EventEngine/Presence/Presence.swift new file mode 100644 index 00000000..ad9f35fe --- /dev/null +++ b/Sources/PubNub/EventEngine/Presence/Presence.swift @@ -0,0 +1,122 @@ +// +// Presence.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +// MARK: - PresenceState + +protocol PresenceState: Equatable { + var input: PresenceInput { get } +} + +extension PresenceState { + var channels: [String] { + input.channels + } + var groups: [String] { + input.groups + } +} + +// +// A namespace for Events, concrete State types and Invocations used in Presence EE +// +enum Presence {} + +// MARK: - Presence States + +extension Presence { + struct Heartbeating: PresenceState { + let input: PresenceInput + } + + struct HeartbeatCooldown: PresenceState { + let input: PresenceInput + } + + struct HeartbeatReconnecting: PresenceState { + let input: PresenceInput + let retryAttempt: Int + let error: PubNubError + } + + struct HeartbeatFailed: PresenceState { + let input: PresenceInput + let error: PubNubError + } + + struct HeartbeatStopped: PresenceState { + let input: PresenceInput + } + + struct HeartbeatInactive: PresenceState { + let input: PresenceInput = PresenceInput() + } +} + +// MARK: - Presence Events + +extension Presence { + enum Event { + case joined(channels: [String], groups: [String]) + case left(channels: [String], groups: [String]) + case leftAll + case reconnect + case disconnect + case timesUp + case heartbeatSuccess + case heartbeatFailed(error: PubNubError) + case heartbeatGiveUp(error: PubNubError) + } +} + +extension Presence { + struct Dependencies { + let configuration: PubNubConfiguration + } +} + +// MARK: - Presence Effect Invocations + +extension Presence { + enum Invocation: AnyEffectInvocation { + case heartbeat(channels: [String], groups: [String]) + case leave(channels: [String], groups: [String]) + case delayedHeartbeat(channels: [String], groups: [String], retryAttempt: Int, error: PubNubError) + case wait + + enum Cancellable: AnyCancellableInvocation { + case wait + case delayedHeartbeat + + var id: String { + switch self { + case .wait: + return "Presence.ScheduleNextHeartbeat" + case .delayedHeartbeat: + return "Presence.HeartbeatReconnect" + } + } + } + + var id: String { + switch self { + case .heartbeat(_,_): + return "Presence.Heartbeat" + case .wait: + return Cancellable.wait.id + case .delayedHeartbeat: + return Cancellable.delayedHeartbeat.id + case .leave(_,_): + return "Presence.Leave" + } + } + } +} diff --git a/Sources/PubNub/EventEngine/Presence/PresenceTransition.swift b/Sources/PubNub/EventEngine/Presence/PresenceTransition.swift new file mode 100644 index 00000000..0d8bb704 --- /dev/null +++ b/Sources/PubNub/EventEngine/Presence/PresenceTransition.swift @@ -0,0 +1,213 @@ +// +// PresenceTransition.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class PresenceTransition: TransitionProtocol { + typealias State = (any PresenceState) + typealias Event = Presence.Event + typealias Invocation = Presence.Invocation + + private let configuration: PubNubConfiguration + + init(configuration: PubNubConfiguration) { + self.configuration = configuration + } + + func canTransition(from state: State, dueTo event: Event) -> Bool { + switch event { + case .joined(_,_): + return configuration.heartbeatInterval > 0 + case .left(_,_): + return !(state is Presence.HeartbeatInactive) + case .heartbeatSuccess: + return state is Presence.Heartbeating || state is Presence.HeartbeatReconnecting + case .heartbeatFailed(_): + return state is Presence.Heartbeating || state is Presence.HeartbeatReconnecting + case .heartbeatGiveUp(_): + return state is Presence.HeartbeatReconnecting + case .timesUp: + return state is Presence.HeartbeatCooldown + case .leftAll: + return !(state is Presence.HeartbeatInactive) + case .disconnect: + return true + case .reconnect: + return state is Presence.HeartbeatStopped || state is Presence.HeartbeatFailed + } + } + + private func onEntry(to state: State) -> [EffectInvocation] { + switch state { + case is Presence.Heartbeating: + return [.regular(.heartbeat( + channels: state.channels, + groups: state.input.groups + ))] + case let state as Presence.HeartbeatReconnecting: + return [.managed(.delayedHeartbeat( + channels: state.channels, groups: state.groups, + retryAttempt: state.retryAttempt, error: state.error + ))] + case is Presence.HeartbeatCooldown: + return [.managed(.wait)] + default: + return [] + } + } + + private func onExit(from state: State) -> [EffectInvocation] { + switch state { + case is Presence.HeartbeatCooldown: + return [.cancel(.wait)] + case is Presence.HeartbeatReconnecting: + return [.cancel(.delayedHeartbeat)] + default: + return [] + } + } + + func transition(from state: State, event: Event) -> TransitionResult { + var results: TransitionResult + + switch event { + case .joined(let channels, let groups): + results = heartbeatingTransition(from: state, joining: (channels: channels, groups: groups)) + case .left(let channels, let groups): + results = heartbeatingTransition(from: state, leaving: (channels: channels, groups: groups)) + case .heartbeatSuccess: + results = heartbeatSuccessTransition(from: state) + case .heartbeatFailed(let error): + results = heartbeatReconnectingTransition(from: state, dueTo: error) + case .heartbeatGiveUp(let error): + results = heartbeatReconnectingGiveUpTransition(from: state, dueTo: error) + case .timesUp: + results = heartbeatingTransition(from: state) + case .leftAll: + results = heartbeatInactiveTransition(from: state) + case .reconnect: + results = heartbeatingTransition(from: state) + case .disconnect: + results = heartbeatStoppedTransition(from: state) + } + + return TransitionResult( + state: results.state, + invocations: onExit(from: state) + results.invocations + onEntry(to: results.state) + ) + } +} + +fileprivate extension PresenceTransition { + func heartbeatingTransition( + from state: State, + joining: (channels: [String], groups: [String]) + ) -> TransitionResult { + let newInput = state.input + PresenceInput( + channels: joining.channels, + groups: joining.groups + ) + if state is Presence.HeartbeatStopped { + return TransitionResult(state: Presence.HeartbeatStopped(input: newInput)) + } else { + return TransitionResult(state: Presence.Heartbeating(input: newInput)) + } + } +} + +fileprivate extension PresenceTransition { + func heartbeatingTransition( + from state: State, + leaving: (channels: [String], groups: [String]) + ) -> TransitionResult { + let newInput = state.input - PresenceInput( + channels: leaving.channels, + groups: leaving.groups + ) + if state is Presence.HeartbeatStopped { + return TransitionResult( + state: Presence.HeartbeatStopped(input: newInput), + invocations: [] + ) + } else { + let leaveInvocation = EffectInvocation.regular(Presence.Invocation.leave( + channels: leaving.channels, + groups: leaving.groups + )) + return TransitionResult( + state: newInput.isEmpty ? Presence.HeartbeatInactive() : Presence.Heartbeating(input: newInput), + invocations: configuration.supressLeaveEvents ? [] : [leaveInvocation] + ) + } + } +} + +fileprivate extension PresenceTransition { + func heartbeatSuccessTransition(from state: State) -> TransitionResult { + return TransitionResult(state: Presence.HeartbeatCooldown(input: state.input)) + } +} + +fileprivate extension PresenceTransition { + func heartbeatReconnectingTransition( + from state: State, + dueTo error: PubNubError + ) -> TransitionResult { + return TransitionResult( + state: Presence.HeartbeatReconnecting( + input: state.input, + retryAttempt: ((state as? Presence.HeartbeatReconnecting)?.retryAttempt ?? -1) + 1, + error: error + ) + ) + } +} + +fileprivate extension PresenceTransition { + func heartbeatReconnectingGiveUpTransition( + from state: State, + dueTo error: PubNubError + ) -> TransitionResult { + return TransitionResult( + state: Presence.HeartbeatFailed( + input: state.input, + error: error + ) + ) + } +} + +fileprivate extension PresenceTransition { + func heartbeatingTransition(from state: State) -> TransitionResult { + return TransitionResult(state: Presence.Heartbeating(input: state.input)) + } +} + +fileprivate extension PresenceTransition { + func heartbeatStoppedTransition(from state: State) -> TransitionResult { + return TransitionResult( + state: Presence.HeartbeatStopped(input: state.input), + invocations: [.regular(.leave(channels: state.input.channels, groups: state.input.groups))] + ) + } +} + +fileprivate extension PresenceTransition { + func heartbeatInactiveTransition(from state: State) -> TransitionResult { + let leaveInvocation = EffectInvocation.regular(Presence.Invocation.leave( + channels: state.input.channels, + groups: state.input.groups + )) + return TransitionResult( + state: Presence.HeartbeatInactive(), + invocations: configuration.supressLeaveEvents ? []: [leaveInvocation] + ) + } +} diff --git a/Sources/PubNub/EventEngine/Subscribe/Effects/EmitMessagesEffect.swift b/Sources/PubNub/EventEngine/Subscribe/Effects/EmitMessagesEffect.swift new file mode 100644 index 00000000..541219ef --- /dev/null +++ b/Sources/PubNub/EventEngine/Subscribe/Effects/EmitMessagesEffect.swift @@ -0,0 +1,76 @@ +// +// EmitMessagesEffect.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class MessageCache { + private(set) var messagesArray = [SubscribeMessagePayload?].init(repeating: nil, count: 100) + + init(messagesArray: [SubscribeMessagePayload?] = .init(repeating: nil, count: 100)) { + self.messagesArray = messagesArray + } + + var isOverflowed: Bool { + return messagesArray.count >= 100 + } + + func contains(_ message: SubscribeMessagePayload) -> Bool { + messagesArray.contains(message) + } + + func append(_ message: SubscribeMessagePayload) { + messagesArray.append(message) + } + + func dropTheOldest() { + messagesArray.remove(at: 0) + } +} + +struct EmitMessagesEffect: EffectHandler { + let messages: [SubscribeMessagePayload] + let cursor: SubscribeCursor + let listeners: [BaseSubscriptionListener] + let messageCache: MessageCache + + func performTask(completionBlock: @escaping ([Subscribe.Event]) -> Void) { + // Attempt to detect missed messages due to queue overflow + if messages.count >= 100 { + listeners.forEach { + $0.emit(subscribe: .errorReceived( + PubNubError( + .messageCountExceededMaximum, + router: nil, + affected: [.subscribe(cursor)] + )) + ) + } + } + + let filteredMessages = messages.filter { message in // Dedupe the message + // Update cache and notify if not a duplicate message + if !messageCache.contains(message) { + messageCache.append(message) + // Remove the oldest value if we're at max capacity + if messageCache.isOverflowed { + messageCache.dropTheOldest() + } + return true + } + return false + } + + listeners.forEach { + $0.emit(batch: filteredMessages) + } + + completionBlock([]) + } +} diff --git a/Sources/PubNub/EventEngine/Subscribe/Effects/EmitStatusEffect.swift b/Sources/PubNub/EventEngine/Subscribe/Effects/EmitStatusEffect.swift new file mode 100644 index 00000000..9197a992 --- /dev/null +++ b/Sources/PubNub/EventEngine/Subscribe/Effects/EmitStatusEffect.swift @@ -0,0 +1,27 @@ +// +// EmitStatusEffect.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// +import Foundation + +struct EmitStatusEffect: EffectHandler { + let statusChange: Subscribe.ConnectionStatusChange + let listeners: [BaseSubscriptionListener] + + func performTask(completionBlock: @escaping ([Subscribe.Event]) -> Void) { + if let error = statusChange.error { + listeners.forEach { + $0.emit(subscribe: .errorReceived(error.underlying)) + } + } + listeners.forEach { + $0.emit(subscribe: .connectionChanged(statusChange.newStatus)) + } + completionBlock([]) + } +} diff --git a/Sources/PubNub/EventEngine/Subscribe/Effects/SubscribeEffectFactory.swift b/Sources/PubNub/EventEngine/Subscribe/Effects/SubscribeEffectFactory.swift new file mode 100644 index 00000000..977ec10a --- /dev/null +++ b/Sources/PubNub/EventEngine/Subscribe/Effects/SubscribeEffectFactory.swift @@ -0,0 +1,108 @@ +// +// SubscribeEffectFactory.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class SubscribeEffectFactory: EffectHandlerFactory { + private let session: SessionReplaceable + private let sessionResponseQueue: DispatchQueue + private let messageCache: MessageCache + private let presenceStateContainer: PresenceStateContainer + + init( + session: SessionReplaceable, + sessionResponseQueue: DispatchQueue = .global(qos: .default), + messageCache: MessageCache = MessageCache(), + presenceStateContainer: PresenceStateContainer + ) { + self.session = session + self.sessionResponseQueue = sessionResponseQueue + self.messageCache = messageCache + self.presenceStateContainer = presenceStateContainer + } + + func effect( + for invocation: Subscribe.Invocation, + with dependencies: EventEngineDependencies + ) -> any EffectHandler { + switch invocation { + case .handshakeRequest(let channels, let groups): + return HandshakeEffect( + request: SubscribeRequest( + configuration: dependencies.value.configuration, + channels: channels, + groups: groups, + channelStates: presenceStateContainer.getStates(forChannels: channels), + timetoken: 0, + session: session, + sessionResponseQueue: sessionResponseQueue + ) + ) + case .handshakeReconnect(let channels, let groups, let retryAttempt, let reason): + return HandshakeReconnectEffect( + request: SubscribeRequest( + configuration: dependencies.value.configuration, + channels: channels, + groups: groups, + channelStates: presenceStateContainer.getStates(forChannels: channels), + timetoken: 0, + session: session, + sessionResponseQueue: sessionResponseQueue + ), + error: reason, + retryAttempt: retryAttempt + ) + case .receiveMessages(let channels, let groups, let cursor): + return ReceivingEffect( + request: SubscribeRequest( + configuration: dependencies.value.configuration, + channels: channels, + groups: groups, + channelStates: [:], + timetoken: cursor.timetoken, + region: cursor.region, + session: session, + sessionResponseQueue: sessionResponseQueue + ) + ) + case .receiveReconnect(let channels, let groups, let cursor, let retryAttempt, let reason): + return ReceiveReconnectEffect( + request: SubscribeRequest( + configuration: dependencies.value.configuration, + channels: channels, + groups: groups, + channelStates: [:], + timetoken: cursor.timetoken, + region: cursor.region, + session: session, + sessionResponseQueue: sessionResponseQueue + ), + error: reason, + retryAttempt: retryAttempt + ) + case .emitMessages(let messages, let cursor): + return EmitMessagesEffect( + messages: messages, + cursor: cursor, + listeners: dependencies.value.listeners, + messageCache: messageCache + ) + case .emitStatus(let statusChange): + return EmitStatusEffect( + statusChange: statusChange, + listeners: dependencies.value.listeners + ) + } + } + + deinit { + session.invalidateAndCancel() + } +} diff --git a/Sources/PubNub/EventEngine/Subscribe/Effects/SubscribeEffects.swift b/Sources/PubNub/EventEngine/Subscribe/Effects/SubscribeEffects.swift new file mode 100644 index 00000000..2892ebca --- /dev/null +++ b/Sources/PubNub/EventEngine/Subscribe/Effects/SubscribeEffects.swift @@ -0,0 +1,163 @@ +// +// SubscribeEffects.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +// MARK: - Handshake Effect + +class HandshakeEffect: EffectHandler { + let request: SubscribeRequest + + init(request: SubscribeRequest) { + self.request = request + } + + func performTask(completionBlock: @escaping ([Subscribe.Event]) -> Void) { + request.execute(onCompletion: { [weak self] in + guard let _ = self else { return } + switch $0 { + case .success(let response): + completionBlock([.handshakeSuccess(cursor: response.cursor)]) + case .failure(let error): + completionBlock([.handshakeFailure(error: error)]) + } + }) + } + + func cancelTask() { + request.cancel() + } + + deinit { + cancelTask() + } +} + +// MARK: - Receiving Effect + +class ReceivingEffect: EffectHandler { + let request: SubscribeRequest + + init(request: SubscribeRequest) { + self.request = request + } + + func performTask(completionBlock: @escaping ([Subscribe.Event]) -> Void) { + request.execute(onCompletion: { [weak self] in + guard let _ = self else { return } + switch $0 { + case .success(let response): + completionBlock([.receiveSuccess(cursor: response.cursor, messages: response.messages)]) + case .failure(let error): + completionBlock([.receiveFailure(error: error)]) + } + }) + } + + func cancelTask() { + request.cancel() + } + + deinit { + cancelTask() + } +} + +// MARK: - Handshake Reconnect Effect + +class HandshakeReconnectEffect: DelayedEffectHandler { + typealias Event = Subscribe.Event + + let request: SubscribeRequest + let retryAttempt: Int + let error: SubscribeError + var workItem: DispatchWorkItem? + + init(request: SubscribeRequest, error: SubscribeError, retryAttempt: Int) { + self.request = request + self.error = error + self.retryAttempt = retryAttempt + } + + func delayInterval() -> TimeInterval? { + request.reconnectionDelay(dueTo: error, with: retryAttempt) + } + + func onEarlyExit(notify completionBlock: @escaping ([Subscribe.Event]) -> Void) { + completionBlock([.handshakeReconnectGiveUp(error: error)]) + } + + func onDelayExpired(notify completionBlock: @escaping ([Subscribe.Event]) -> Void) { + request.execute(onCompletion: { [weak self] in + guard let _ = self else { return } + switch $0 { + case .success(let response): + completionBlock([.handshakeReconnectSuccess(cursor: response.cursor)]) + case .failure(let error): + completionBlock([.handshakeReconnectFailure(error: error)]) + } + }) + } + + func cancelTask() { + request.cancel() + workItem?.cancel() + } + + deinit { + cancelTask() + } +} + +// MARK: - Receiving Reconnect Effect + +class ReceiveReconnectEffect: DelayedEffectHandler { + typealias Event = Subscribe.Event + + let request: SubscribeRequest + let retryAttempt: Int + let error: SubscribeError + var workItem: DispatchWorkItem? + + init(request: SubscribeRequest, error: SubscribeError, retryAttempt: Int) { + self.request = request + self.error = error + self.retryAttempt = retryAttempt + } + + func delayInterval() -> TimeInterval? { + request.reconnectionDelay(dueTo: error, with: retryAttempt) + } + + func onEarlyExit(notify completionBlock: @escaping ([Subscribe.Event]) -> Void) { + completionBlock([.receiveReconnectGiveUp(error: error)]) + } + + func onDelayExpired(notify completionBlock: @escaping ([Subscribe.Event]) -> Void) { + request.execute(onCompletion: { [weak self] in + guard let _ = self else { return } + switch $0 { + case .success(let response): + completionBlock([.receiveReconnectSuccess(cursor: response.cursor, messages: response.messages)]) + case .failure(let error): + completionBlock([.receiveReconnectFailure(error: error)]) + } + }) + } + + func cancelTask() { + request.cancel() + workItem?.cancel() + } + + deinit { + cancelTask() + } +} diff --git a/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeError.swift b/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeError.swift new file mode 100644 index 00000000..883c7bd6 --- /dev/null +++ b/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeError.swift @@ -0,0 +1,42 @@ +// +// SubscribeError.swift +// +// PubNub Real-time Cloud-Hosted Push API and Push Notification Client Frameworks +// Copyright © 2023 PubNub Inc. +// https://www.pubnub.com/ +// https://www.pubnub.com/terms +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +struct SubscribeError: Error, Equatable { + let underlying: PubNubError + let urlResponse: HTTPURLResponse? + + init(underlying: PubNubError, urlResponse: HTTPURLResponse? = nil) { + self.underlying = underlying + self.urlResponse = urlResponse + } + + static func == (lhs: SubscribeError, rhs: SubscribeError) -> Bool { + lhs.underlying == rhs.underlying + } +} diff --git a/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeInput.swift b/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeInput.swift new file mode 100644 index 00000000..89885ca0 --- /dev/null +++ b/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeInput.swift @@ -0,0 +1,163 @@ +// +// SubscribeInput.swift +// +// PubNub Real-time Cloud-Hosted Push API and Push Notification Client Frameworks +// Copyright © 2023 PubNub Inc. +// https://www.pubnub.com/ +// https://www.pubnub.com/terms +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +struct SubscribeInput: Equatable { + private let channels: [String: PubNubChannel] + private let groups: [String: PubNubChannel] + + init(channels: [PubNubChannel] = [], groups: [PubNubChannel] = []) { + self.channels = channels.reduce(into: [String: PubNubChannel]()) { r, channel in _ = r.insert(channel) } + self.groups = groups.reduce(into: [String: PubNubChannel]()) { r, channel in _ = r.insert(channel) } + } + + private init(channels: [String: PubNubChannel], groups: [String: PubNubChannel]) { + self.channels = channels + self.groups = groups + } + + var isEmpty: Bool { + channels.isEmpty && groups.isEmpty + } + + var subscribedChannels: [String] { + channels.map { $0.key } + } + + var subscribedGroups: [String] { + groups.map { $0.key } + } + + var allSubscribedChannels: [String] { + channels.reduce(into: [String]()) { result, entry in + result.append(entry.value.id) + if entry.value.isPresenceSubscribed { + result.append(entry.value.presenceId) + } + } + } + + var allSubscribedGroups: [String] { + groups.reduce(into: [String]()) { result, entry in + result.append(entry.value.id) + if entry.value.isPresenceSubscribed { + result.append(entry.value.presenceId) + } + } + } + + var presenceSubscribedChannels: [String] { + channels.compactMap { + if $0.value.isPresenceSubscribed { + return $0.value.id + } else { + return nil + } + } + } + + var presenceSubscribedGroups: [String] { + groups.compactMap { + if $0.value.isPresenceSubscribed { + return $0.value.id + } else { + return nil + } + } + } + + var totalSubscribedCount: Int { + channels.count + groups.count + } + + static func +(lhs: SubscribeInput, rhs: SubscribeInput) -> SubscribeInput { + var currentChannels = lhs.channels + var currentGroups = rhs.groups + + rhs.channels.values.forEach { _ = currentChannels.insert($0) } + lhs.groups.values.forEach { _ = currentGroups.insert($0) } + + return SubscribeInput( + channels: currentChannels, + groups: currentGroups + ) + } + + static func -(lhs: SubscribeInput, rhs: (channels: [String], groups: [String])) -> SubscribeInput { + var currentChannels = lhs.channels + var currentGroups = lhs.groups + + rhs.channels.forEach { + if $0.isPresenceChannelName { + currentChannels.unsubscribePresence($0.trimmingPresenceChannelSuffix) + } else { + currentChannels.removeValue(forKey: $0) + } + } + rhs.groups.forEach { + if $0.isPresenceChannelName { + currentGroups.unsubscribePresence($0.trimmingPresenceChannelSuffix) + } else { + currentGroups.removeValue(forKey: $0) + } + } + return SubscribeInput( + channels: currentChannels, + groups: currentGroups + ) + } + + static func ==(lhs: SubscribeInput, rhs: SubscribeInput) -> Bool { + let equalChannels = lhs.allSubscribedChannels.sorted(by: <) == rhs.allSubscribedChannels.sorted(by: <) + let equalGroups = lhs.allSubscribedGroups.sorted(by: <) == rhs.allSubscribedGroups.sorted(by: <) + + return equalChannels && equalGroups + } +} + +extension Dictionary where Key == String, Value == PubNubChannel { + // Inserts and returns the provided channel if that channel doesn't already exist + mutating func insert(_ channel: Value) -> Bool { + if let match = self[channel.id], match == channel { + return false + } + self[channel.id] = channel + return true + } + + // Updates current Dictionary with the new channel value unsubscribed from Presence. + // Returns the updated value if the corresponding entry matching the passed `id:` was found, otherwise `nil` + @discardableResult mutating func unsubscribePresence(_ id: String) -> Value? { + if let match = self[id], match.isPresenceSubscribed { + let updatedChannel = PubNubChannel(id: match.id, withPresence: false) + self[match.id] = updatedChannel + return updatedChannel + } + return nil + } +} diff --git a/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeRequest.swift b/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeRequest.swift new file mode 100644 index 00000000..3ef60fd7 --- /dev/null +++ b/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeRequest.swift @@ -0,0 +1,128 @@ +// +// SubscribeRequest.swift +// +// PubNub Real-time Cloud-Hosted Push API and Push Notification Client Frameworks +// Copyright © 2023 PubNub Inc. +// https://www.pubnub.com/ +// https://www.pubnub.com/terms +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +class SubscribeRequest { + let channels: [String] + let groups: [String] + let timetoken: Timetoken? + let region: Int? + + private let configuration: PubNubConfiguration + private let session: SessionReplaceable + private let sessionResponseQueue: DispatchQueue + private let channelStates: [String: [String: JSONCodableScalar]] + + private var request: RequestReplaceable? + + var retryLimit: UInt { + configuration.automaticRetry?.retryLimit ?? 0 + } + + init( + configuration: PubNubConfiguration, + channels: [String], + groups: [String], + channelStates: [String: [String: JSONCodableScalar]], + timetoken: Timetoken? = nil, + region: Int? = nil, + session: SessionReplaceable, + sessionResponseQueue: DispatchQueue + ) { + self.configuration = configuration + self.channels = channels + self.groups = groups + self.channelStates = channelStates + self.timetoken = timetoken + self.region = region + self.session = session + self.sessionResponseQueue = sessionResponseQueue + } + + func reconnectionDelay(dueTo error: SubscribeError, with retryAttempt: Int) -> TimeInterval? { + guard let automaticRetry = configuration.automaticRetry else { + return nil + } + guard automaticRetry[.subscribe] != nil else { + return nil + } + guard automaticRetry.retryLimit > retryAttempt else { + return nil + } + guard let underlyingError = error.underlying.underlying else { + return automaticRetry.policy.delay(for: retryAttempt) + } + let shouldRetry = automaticRetry.shouldRetry( + response: error.urlResponse, + error: underlyingError + ) + return shouldRetry ? automaticRetry.policy.delay(for: retryAttempt) : nil + } + + func execute(onCompletion: @escaping (Result) -> Void) { + let router = SubscribeRouter( + .subscribe( + channels: channels, + groups: groups, + channelStates: channelStates, + timetoken: timetoken, + region: region?.description ?? nil, + heartbeat: configuration.durationUntilTimeout, + filter: configuration.filterExpression + ), + configuration: configuration + ) + request = session.request( + with: router, + requestOperator: nil + ) + request?.validate().response( + on: sessionResponseQueue, + decoder: SubscribeDecoder(), + completion: { [weak self] result in + switch result { + case .success(let response): + onCompletion(.success(response.payload)) + case .failure(let error): + onCompletion(.failure(SubscribeError( + underlying: error as? PubNubError ?? PubNubError(.unknown, underlying: error), + urlResponse: self?.request?.urlResponse + ))) + } + } + ) + } + + func cancel() { + request?.cancel(PubNubError(.clientCancelled)) + } + + deinit { + cancel() + } +} diff --git a/Sources/PubNub/EventEngine/Subscribe/Subscribe.swift b/Sources/PubNub/EventEngine/Subscribe/Subscribe.swift new file mode 100644 index 00000000..e425a629 --- /dev/null +++ b/Sources/PubNub/EventEngine/Subscribe/Subscribe.swift @@ -0,0 +1,186 @@ +// +// Subscribe.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +// MARK: - SubscribeState + +protocol SubscribeState: Equatable { + var input: SubscribeInput { get } + var cursor: SubscribeCursor { get } + var connectionStatus: ConnectionStatus { get } +} + +extension SubscribeState { + var hasTimetoken: Bool { + cursor.timetoken != 0 + } +} + +// +// A namespace for Events, concrete State types and Invocations used in Subscribe EE +// +enum Subscribe {} + +// MARK: - Subscribe States + +extension Subscribe { + struct HandshakingState: SubscribeState { + let input: SubscribeInput + let cursor: SubscribeCursor + let connectionStatus = ConnectionStatus.disconnected + } + + struct HandshakeStoppedState: SubscribeState { + let input: SubscribeInput + let cursor: SubscribeCursor + let connectionStatus = ConnectionStatus.disconnected + } + + struct HandshakeReconnectingState: SubscribeState { + let input: SubscribeInput + let cursor: SubscribeCursor + let retryAttempt: Int + let reason: SubscribeError + let connectionStatus = ConnectionStatus.disconnected + } + + struct HandshakeFailedState: SubscribeState { + let input: SubscribeInput + let cursor: SubscribeCursor + let error: SubscribeError + let connectionStatus = ConnectionStatus.disconnected + } + + struct ReceivingState: SubscribeState { + let input: SubscribeInput + let cursor: SubscribeCursor + let connectionStatus = ConnectionStatus.connected + } + + struct ReceiveReconnectingState: SubscribeState { + let input: SubscribeInput + let cursor: SubscribeCursor + let retryAttempt: Int + let reason: SubscribeError + let connectionStatus = ConnectionStatus.connected + } + + struct ReceiveStoppedState: SubscribeState { + let input: SubscribeInput + let cursor: SubscribeCursor + let connectionStatus = ConnectionStatus.disconnected + } + + struct ReceiveFailedState: SubscribeState { + let input: SubscribeInput + let cursor: SubscribeCursor + let error: SubscribeError + let connectionStatus = ConnectionStatus.disconnected + } + + struct UnsubscribedState: SubscribeState { + let cursor: SubscribeCursor = SubscribeCursor(timetoken: 0)! + let input: SubscribeInput = SubscribeInput() + let connectionStatus = ConnectionStatus.disconnected + } +} + +// MARK: - Subscribe Events + +extension Subscribe { + enum Event { + case subscriptionChanged(channels: [String], groups: [String]) + case subscriptionRestored(channels: [String], groups: [String], cursor: SubscribeCursor) + case handshakeSuccess(cursor: SubscribeCursor) + case handshakeFailure(error: SubscribeError) + case handshakeReconnectSuccess(cursor: SubscribeCursor) + case handshakeReconnectFailure(error: SubscribeError) + case handshakeReconnectGiveUp(error: SubscribeError) + case receiveSuccess(cursor: SubscribeCursor, messages: [SubscribeMessagePayload]) + case receiveFailure(error: SubscribeError) + case receiveReconnectSuccess(cursor: SubscribeCursor, messages: [SubscribeMessagePayload]) + case receiveReconnectFailure(error: SubscribeError) + case receiveReconnectGiveUp(error: SubscribeError) + case disconnect + case reconnect + case unsubscribeAll + } +} + +extension Subscribe { + struct ConnectionStatusChange: Equatable { + let oldStatus: ConnectionStatus + let newStatus: ConnectionStatus + let error: SubscribeError? + } +} + +extension Subscribe { + struct Dependencies { + let configuration: PubNubConfiguration + let listeners: [BaseSubscriptionListener] + + init(configuration: PubNubConfiguration, listeners: [BaseSubscriptionListener] = []) { + self.configuration = configuration + self.listeners = listeners + } + } +} + +// MARK: - Subscribe Effect Invocations + +extension Subscribe { + enum Invocation: AnyEffectInvocation { + case handshakeRequest(channels: [String], groups: [String]) + case handshakeReconnect(channels: [String], groups: [String], retryAttempt: Int, reason: SubscribeError) + case receiveMessages(channels: [String], groups: [String], cursor: SubscribeCursor) + case receiveReconnect(channels: [String], groups: [String], cursor: SubscribeCursor, retryAttempt: Int, reason: SubscribeError) + case emitStatus(change: Subscribe.ConnectionStatusChange) + case emitMessages(events: [SubscribeMessagePayload], forCursor: SubscribeCursor) + + enum Cancellable: AnyCancellableInvocation { + case handshakeRequest + case handshakeReconnect + case receiveMessages + case receiveReconnect + + var id: String { + switch self { + case .handshakeRequest: + return "Subscribe.HandshakeRequest" + case .handshakeReconnect: + return "Subscribe.HandshakeReconnect" + case .receiveMessages: + return "Subscribe.ReceiveMessages" + case .receiveReconnect: + return "Subscribe.ReceiveReconnect" + } + } + } + + var id: String { + switch self { + case .handshakeRequest(_, _): + return Cancellable.handshakeRequest.id + case .handshakeReconnect(_, _, _, _): + return Cancellable.handshakeReconnect.id + case .receiveMessages(_, _, _): + return Cancellable.receiveMessages.id + case .receiveReconnect(_, _, _, _, _): + return Cancellable.receiveReconnect.id + case .emitMessages(_,_): + return "Subscribe.EmitMessages" + case .emitStatus(_): + return "Subscribe.EmitStatus" + } + } + } +} diff --git a/Sources/PubNub/EventEngine/Subscribe/SubscribeTransition.swift b/Sources/PubNub/EventEngine/Subscribe/SubscribeTransition.swift new file mode 100644 index 00000000..e9bf10dd --- /dev/null +++ b/Sources/PubNub/EventEngine/Subscribe/SubscribeTransition.swift @@ -0,0 +1,377 @@ +// +// SubscribeTransition.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class SubscribeTransition: TransitionProtocol { + typealias State = (any SubscribeState) + typealias Event = Subscribe.Event + typealias Invocation = Subscribe.Invocation + + func canTransition(from state: State, dueTo event: Event) -> Bool { + switch event { + case .handshakeSuccess(_): + return state is Subscribe.HandshakingState + case .handshakeFailure(_): + return state is Subscribe.HandshakingState + case .handshakeReconnectSuccess(_): + return state is Subscribe.HandshakeReconnectingState + case .handshakeReconnectFailure(_): + return state is Subscribe.HandshakeReconnectingState + case .handshakeReconnectGiveUp(_): + return state is Subscribe.HandshakeReconnectingState + case .receiveSuccess(_,_): + return state is Subscribe.ReceivingState + case .receiveFailure(_): + return state is Subscribe.ReceivingState + case .receiveReconnectSuccess(_,_): + return state is Subscribe.ReceiveReconnectingState + case .receiveReconnectFailure(_): + return state is Subscribe.ReceiveReconnectingState + case .receiveReconnectGiveUp(_): + return state is Subscribe.ReceiveReconnectingState + case .subscriptionChanged(_, _): + return true + case .subscriptionRestored(_, _, _): + return true + case .unsubscribeAll: + return true + case .disconnect: + return !( + state is Subscribe.HandshakeStoppedState || state is Subscribe.ReceiveStoppedState || + state is Subscribe.HandshakeFailedState || state is Subscribe.ReceiveFailedState + ) + case .reconnect: + return ( + state is Subscribe.HandshakeStoppedState || state is Subscribe.HandshakeFailedState || + state is Subscribe.ReceiveFailedState || state is Subscribe.ReceiveStoppedState + ) + } + } + + private func onExit(from state: State) -> [EffectInvocation] { + switch state { + case is Subscribe.HandshakingState: + return [.cancel(.handshakeRequest)] + case is Subscribe.HandshakeReconnectingState: + return [.cancel(.handshakeReconnect)] + case is Subscribe.ReceivingState: + return [.cancel(.receiveMessages)] + case is Subscribe.ReceiveReconnectingState: + return [.cancel(.receiveReconnect)] + default: + return [] + } + } + + private func onEntry(to state: State) -> [EffectInvocation] { + switch state { + case let state as Subscribe.HandshakingState: + return [ + .managed( + .handshakeRequest( + channels: state.input.allSubscribedChannels, + groups: state.input.allSubscribedGroups + ) + ) + ] + case let state as Subscribe.HandshakeReconnectingState: + return [ + .managed( + .handshakeReconnect( + channels: state.input.allSubscribedChannels, + groups: state.input.allSubscribedGroups, + retryAttempt: state.retryAttempt, + reason: state.reason + ) + ) + ] + case let state as Subscribe.ReceivingState: + return [ + .managed( + .receiveMessages( + channels: state.input.allSubscribedChannels, + groups: state.input.allSubscribedGroups, + cursor: state.cursor + ) + ) + ] + case let state as Subscribe.ReceiveReconnectingState: + return [ + .managed( + .receiveReconnect( + channels: state.input.allSubscribedChannels, + groups: state.input.allSubscribedGroups, + cursor: state.cursor, + retryAttempt: state.retryAttempt, + reason: state.reason + ) + ) + ] + default: + return [] + } + } + + func transition(from state: State, event: Event) -> TransitionResult { + var results: TransitionResult + + switch event { + case .handshakeSuccess(let cursor): + results = setReceivingState(from: state, cursor: resolveCursor(for: state, new: cursor)) + case .handshakeFailure(let error): + results = setHandshakeReconnectingState(from: state, error: error) + case .handshakeReconnectSuccess(let cursor): + results = setReceivingState(from: state, cursor: resolveCursor(for: state, new: cursor)) + case .handshakeReconnectFailure(let error): + results = setHandshakeReconnectingState(from: state, error: error) + case .handshakeReconnectGiveUp(let error): + results = setHandshakeFailedState(from: state, error: error) + case .receiveSuccess(let cursor, let messages): + results = setReceivingState(from: state, cursor: cursor, messages: messages) + case .receiveFailure(let error): + results = setReceiveReconnectingState(from: state, error: error) + case .receiveReconnectSuccess(let cursor, let messages): + results = setReceivingState(from: state, cursor: cursor, messages: messages) + case .receiveReconnectFailure(let error): + results = setReceiveReconnectingState(from: state, error: error) + case .receiveReconnectGiveUp(let error): + results = setReceiveFailedState(from: state, error: error) + case .subscriptionChanged(let channels, let groups): + results = onSubscriptionAltered(from: state, channels: channels, groups: groups, cursor: state.cursor) + case .subscriptionRestored(let channels, let groups, let cursor): + results = onSubscriptionAltered(from: state, channels: channels, groups: groups, cursor: cursor) + case .disconnect: + results = setStoppedState(from: state) + case .unsubscribeAll: + results = setUnsubscribedState(from: state) + case .reconnect: + results = setHandshakingState(from: state) + } + + return TransitionResult( + state: results.state, + invocations: onExit(from: state) + results.invocations + onEntry(to: results.state) + ) + } + + private func resolveCursor( + for currentState: State, + new cursor: SubscribeCursor + ) -> SubscribeCursor { + if currentState.hasTimetoken { + return SubscribeCursor( + timetoken: currentState.cursor.timetoken, + region: cursor.region + ) + } + return cursor + } +} + +fileprivate extension SubscribeTransition { + func onSubscriptionAltered( + from state: State, + channels: [String], + groups: [String], + cursor: SubscribeCursor + ) -> TransitionResult { + let newInput = SubscribeInput( + channels: channels.map { PubNubChannel(channel: $0) }, + groups: groups.map { PubNubChannel(channel: $0) } + ) + + if newInput.isEmpty { + return setUnsubscribedState(from: state) + } else { + switch state { + case is Subscribe.HandshakingState: + return TransitionResult(state: Subscribe.HandshakingState(input: newInput, cursor: cursor)) + case is Subscribe.HandshakeReconnectingState: + return TransitionResult(state: Subscribe.HandshakingState(input: newInput, cursor: cursor)) + case is Subscribe.HandshakeStoppedState: + return TransitionResult(state: Subscribe.HandshakeStoppedState(input: newInput, cursor: cursor)) + case is Subscribe.HandshakeFailedState: + return TransitionResult(state: Subscribe.HandshakingState(input: newInput, cursor: cursor)) + case is Subscribe.ReceivingState: + return TransitionResult(state: Subscribe.ReceivingState(input: newInput, cursor: cursor)) + case is Subscribe.ReceiveReconnectingState: + return TransitionResult(state: Subscribe.ReceivingState(input: newInput, cursor: cursor)) + case is Subscribe.ReceiveStoppedState: + return TransitionResult(state: Subscribe.ReceiveStoppedState(input: newInput, cursor: cursor)) + case is Subscribe.ReceiveFailedState: + return TransitionResult(state: Subscribe.HandshakingState(input: newInput, cursor: cursor)) + case is Subscribe.UnsubscribedState: + return TransitionResult(state: Subscribe.HandshakingState(input: newInput, cursor: cursor)) + default: + return TransitionResult(state: state) + } + } + } +} + +fileprivate extension SubscribeTransition { + func setHandshakingState(from state: State) -> TransitionResult { + TransitionResult(state: Subscribe.HandshakingState(input: state.input, cursor: state.cursor)) + } +} + +fileprivate extension SubscribeTransition { + func setHandshakeReconnectingState( + from state: State, + error: SubscribeError + ) -> TransitionResult { + return TransitionResult( + state: Subscribe.HandshakeReconnectingState( + input: state.input, + cursor: state.cursor, + retryAttempt: ((state as? Subscribe.HandshakeReconnectingState)?.retryAttempt ?? -1) + 1, + reason: error + ) + ) + } +} + +fileprivate extension SubscribeTransition { + func setHandshakeFailedState( + from state: State, + error: SubscribeError + ) -> TransitionResult { + return TransitionResult( + state: Subscribe.HandshakeFailedState( + input: state.input, + cursor: state.cursor, + error: error + ), invocations: [ + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: state.connectionStatus, + newStatus: .connectionError, + error: error + ))) + ] + ) + } +} + +fileprivate extension SubscribeTransition { + func setReceivingState( + from state: State, + cursor: SubscribeCursor, + messages: [SubscribeMessagePayload] = [] + ) -> TransitionResult { + let emitMessagesInvocation = EffectInvocation.managed( + Subscribe.Invocation.emitMessages(events: messages, forCursor: cursor) + ) + let emitStatusInvocation = EffectInvocation.managed( + Subscribe.Invocation.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: state.connectionStatus, + newStatus: .connected, + error: nil + )) + ) + let finalInvocations = [ + !messages.isEmpty ? emitMessagesInvocation : nil, + state.connectionStatus != .connected ? emitStatusInvocation : nil + ].compactMap { $0 } + + return TransitionResult( + state: Subscribe.ReceivingState(input: state.input, cursor: cursor), + invocations: finalInvocations + ) + } +} + +fileprivate extension SubscribeTransition { + func setReceiveReconnectingState( + from state: State, + error: SubscribeError + ) -> TransitionResult { + return TransitionResult( + state: Subscribe.ReceiveReconnectingState( + input: state.input, + cursor: state.cursor, + retryAttempt: ((state as? Subscribe.ReceiveReconnectingState)?.retryAttempt ?? -1) + 1, + reason: error + ) + ) + } +} + +fileprivate extension SubscribeTransition { + func setReceiveFailedState( + from state: State, + error: SubscribeError + ) -> TransitionResult { + guard let state = state as? Subscribe.ReceiveReconnectingState else { + return TransitionResult(state: state) + } + return TransitionResult( + state: Subscribe.ReceiveFailedState( + input: state.input, + cursor: state.cursor, + error: error + ), invocations: [ + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: state.connectionStatus, + newStatus: .disconnectedUnexpectedly, + error: error + ))) + ] + ) + } +} + +fileprivate extension SubscribeTransition { + func setStoppedState(from state: State) -> TransitionResult { + let invocations: [EffectInvocation] = [ + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: state.connectionStatus, + newStatus: .disconnected, + error: nil + ))) + ] + let handshakeStoppedTransition: TransitionResult = TransitionResult( + state: Subscribe.HandshakeStoppedState(input: state.input, cursor: state.cursor), + invocations: invocations + ) + let receiveStoppedTransition: TransitionResult = TransitionResult( + state: Subscribe.ReceiveStoppedState(input: state.input, cursor: state.cursor), + invocations: invocations + ) + + switch state { + case is Subscribe.HandshakingState: + return handshakeStoppedTransition + case is Subscribe.HandshakeReconnectingState: + return handshakeStoppedTransition + case is Subscribe.ReceivingState: + return receiveStoppedTransition + case is Subscribe.ReceiveReconnectingState: + return receiveStoppedTransition + default: + return TransitionResult(state: state) + } + } +} + +fileprivate extension SubscribeTransition { + func setUnsubscribedState(from state: State) -> TransitionResult { + return TransitionResult( + state: Subscribe.UnsubscribedState(), + invocations: [ + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: state.connectionStatus, + newStatus: .disconnected, + error: nil + ))) + ] + ) + } +} diff --git a/Sources/PubNub/Events/Subscription/SubscriptionStream.swift b/Sources/PubNub/Events/Subscription/SubscriptionStream.swift index e05ef765..b2310374 100644 --- a/Sources/PubNub/Events/Subscription/SubscriptionStream.swift +++ b/Sources/PubNub/Events/Subscription/SubscriptionStream.swift @@ -34,36 +34,8 @@ public enum SubscriptionChangeEvent { } } -/// The header of a PubNub subscribe response for zero or more events -public struct SubscribeResponseHeader { - /// The channels that are actively subscribed - public let channels: [PubNubChannel] - /// The groups that are actively subscribed - public let groups: [PubNubChannel] - /// The most recent successful Timetoken used in subscriptionstatus - public let previous: SubscribeCursor? - /// Timetoken that will be used on the next subscription cycle - public let next: SubscribeCursor? - - public init( - channels: [PubNubChannel], - groups: [PubNubChannel], - previous: SubscribeCursor?, - next: SubscribeCursor? - ) { - self.channels = channels - self.groups = groups - self.previous = previous - self.next = next - } -} - /// Local events emitted from the Subscribe method public enum PubNubSubscribeEvent { - /// A change in the Channel or Group state occured - case subscriptionChanged(SubscriptionChangeEvent) - /// A subscribe response was received - case responseReceived(SubscribeResponseHeader) /// The connection status of the PubNub subscription was changed case connectionChanged(ConnectionStatus) /// An error was received @@ -79,16 +51,10 @@ public enum PubNubCoreEvent { case messageReceived(PubNubMessage) /// A signal has been received case signalReceived(PubNubMessage) - /// A change in the subscription connection has occurred case connectionStatusChanged(ConnectionStatus) - - /// A change in the subscribed channels or groups has occurred - case subscriptionChanged(SubscriptionChangeEvent) - /// A presence change has been received case presenceChanged(PubNubPresenceChange) - /// A User object has been updated case uuidMetadataSet(PubNubUUIDMetadataChangeset) /// A User object has been deleted @@ -101,15 +67,12 @@ public enum PubNubCoreEvent { case membershipMetadataSet(PubNubMembershipMetadata) /// A Membership object has been deleted case membershipMetadataRemoved(PubNubMembershipMetadata) - /// A MessageAction was added to a published message case messageActionAdded(PubNubMessageAction) /// A MessageAction was removed from a published message case messageActionRemoved(PubNubMessageAction) - /// A File was uploaded to storage case fileUploaded(PubNubFileEvent) - /// A subscription error has occurred case subscribeError(PubNubError) @@ -162,9 +125,6 @@ public final class CoreListener: BaseSubscriptionListener { public var didReceiveBatchSubscription: (([SubscriptionEvent]) -> Void)? /// Receiver for all subscription events public var didReceiveSubscription: ((SubscriptionEvent) -> Void)? - - /// Receiver for changes in the subscribe/unsubscribe status of channels/groups - public var didReceiveSubscriptionChange: ((SubscriptionChangeEvent) -> Void)? /// Receiver for status (Connection & Error) events public var didReceiveStatus: ((StatusEvent) -> Void)? /// Receiver for presence events @@ -173,13 +133,10 @@ public final class CoreListener: BaseSubscriptionListener { public var didReceiveMessage: ((PubNubMessage) -> Void)? /// Receiver for signal events public var didReceiveSignal: ((PubNubMessage) -> Void)? - /// Receiver for Object Metadata Events public var didReceiveObjectMetadataEvent: ((ObjectMetadataChangeEvents) -> Void)? - /// Receiver for message action events public var didReceiveMessageAction: ((MessageActionEvent) -> Void)? - /// Receiver for File Upload events public var didReceiveFileUpload: ((PubNubFileEvent) -> Void)? @@ -187,17 +144,6 @@ public final class CoreListener: BaseSubscriptionListener { override public func emit(subscribe event: PubNubSubscribeEvent) { switch event { - case let .subscriptionChanged(changeEvent): - emitDidReceive(subscription: [.subscriptionChanged(changeEvent)]) - case let .responseReceived(header): - emitDidReceive(subscription: [.subscriptionChanged( - .responseHeader( - channels: header.channels, - groups: header.groups, - previous: header.previous, - next: header.next - ) - )]) case let .connectionChanged(status): emitDidReceive(subscription: [.connectionStatusChanged(status)]) case let .errorReceived(error): @@ -275,14 +221,10 @@ public final class CoreListener: BaseSubscriptionListener { self?.didReceiveMessage?(message) case let .signalReceived(signal): self?.didReceiveSignal?(signal) - case let .connectionStatusChanged(status): self?.didReceiveStatus?(.success(status)) - case let .subscriptionChanged(change): - self?.didReceiveSubscriptionChange?(change) case let .presenceChanged(presence): self?.didReceivePresence?(presence) - case let .uuidMetadataSet(metadata): self?.didReceiveObjectMetadataEvent?(.setUUID(metadata)) case let .uuidMetadataRemoved(metadataId): @@ -295,7 +237,6 @@ public final class CoreListener: BaseSubscriptionListener { self?.didReceiveObjectMetadataEvent?(.setMembership(membership)) case let .membershipMetadataRemoved(membership): self?.didReceiveObjectMetadataEvent?(.removedMembership(membership)) - case let .messageActionAdded(action): self?.didReceiveMessageAction?(.added(action)) case let .messageActionRemoved(action): diff --git a/Sources/PubNub/Extensions/URLQueryItem+PubNub.swift b/Sources/PubNub/Extensions/URLQueryItem+PubNub.swift index 5544e3c2..d309d8fb 100644 --- a/Sources/PubNub/Extensions/URLQueryItem+PubNub.swift +++ b/Sources/PubNub/Extensions/URLQueryItem+PubNub.swift @@ -52,7 +52,22 @@ public extension Array where Element == URLQueryItem { internal mutating func appendIfPresent(key: QueryKey, value: String?) { appendIfPresent(name: key.rawValue, value: value) } - + + internal mutating func append(key: QueryKey, value: @autoclosure () -> String?, when condition: Bool) { + if condition { + append(URLQueryItem(name: key.rawValue, value: value())) + } + } + + internal mutating func appendIfPresent(key: QueryKey, value: @autoclosure () -> String?, when condition: Bool) { + guard condition else { + return + } + if let value = value() { + append(URLQueryItem(name: key.rawValue, value: value)) + } + } + /// Creates a new query item with a csv string value and appends only if the value is not empty mutating func appendIfNotEmpty(name: String, value: [String]) { if !value.isEmpty { diff --git a/Sources/PubNub/Helpers/Constants.swift b/Sources/PubNub/Helpers/Constants.swift index aa29d619..e1cd1ac8 100644 --- a/Sources/PubNub/Helpers/Constants.swift +++ b/Sources/PubNub/Helpers/Constants.swift @@ -162,6 +162,10 @@ public extension Constant { /// Produces a `User-Agent` header according to /// [RFC7231 section 5.5.3](https://tools.ietf.org/html/rfc7231#section-5.5.3) static let userAgentHeaderKey = "User-Agent" + + /// A header indicating how long to wait before making a new request + /// [RFC6585 section 4](https://datatracker.ietf.org/doc/html/rfc6585#section-4) + static let retryAfterHeaderKey = "Retry-After" internal static let defaultUserAgentHeader: String = { let userAgent: String = { diff --git a/Sources/PubNub/Networking/HTTPRouter.swift b/Sources/PubNub/Networking/HTTPRouter.swift index 7c7ccf7a..7b70d043 100644 --- a/Sources/PubNub/Networking/HTTPRouter.swift +++ b/Sources/PubNub/Networking/HTTPRouter.swift @@ -32,6 +32,10 @@ public protocol RouterConfiguration { var useRequestId: Bool { get } /// Ordered list of key-value pairs which identify various consumers. var consumerIdentifiers: [String: String] { get } + /// This controls whether to enable a new, experimental implementation of Subscription and Presence handling + var enableEventEngine: Bool { get } + /// When `true` the SDK will resend the last channel state that was set using `PubNub.setPresence` + var maintainPresenceState: Bool { get } } public extension RouterConfiguration { @@ -117,6 +121,7 @@ enum QueryKey: String { case filter case sort case descending = "desc" + case eventEngine = "ee" } /// The PubNub Key requirement for a given Endpoint diff --git a/Sources/PubNub/Networking/Replaceables+PubNub.swift b/Sources/PubNub/Networking/Replaceables+PubNub.swift index ae597ec8..f86ed392 100644 --- a/Sources/PubNub/Networking/Replaceables+PubNub.swift +++ b/Sources/PubNub/Networking/Replaceables+PubNub.swift @@ -126,6 +126,7 @@ public protocol SessionReplaceable { func route( _ router: HTTPRouter, + requestOperator: RequestOperator?, responseDecoder: Decoder, responseQueue: DispatchQueue, completion: @escaping (Result, Error>) -> Void @@ -135,11 +136,12 @@ public protocol SessionReplaceable { public extension SessionReplaceable { func route( _ router: HTTPRouter, + requestOperator: RequestOperator? = nil, responseDecoder: Decoder, responseQueue: DispatchQueue = .main, completion: @escaping (Result, Error>) -> Void ) where Decoder: ResponseDecoder { - request(with: router, requestOperator: nil) + request(with: router, requestOperator: requestOperator) .validate() .response( on: responseQueue, diff --git a/Sources/PubNub/Networking/Request/Operators/AutomaticRetry.swift b/Sources/PubNub/Networking/Request/Operators/AutomaticRetry.swift index 0c433b87..8fca00a0 100644 --- a/Sources/PubNub/Networking/Request/Operators/AutomaticRetry.swift +++ b/Sources/PubNub/Networking/Request/Operators/AutomaticRetry.swift @@ -16,43 +16,71 @@ public struct AutomaticRetry: RequestOperator, Hashable { public static var `default` = AutomaticRetry() /// No retry will be performed public static var none = AutomaticRetry(retryLimit: 1) - /// Retry immediately twice on lost network connection - public static var connectionLost = AutomaticRetry(policy: .immediately, - retryableURLErrorCodes: [.networkConnectionLost]) + /// Retry on lost network connection + public static var connectionLost = AutomaticRetry( + policy: .defaultLinear, + retryableURLErrorCodes: [.networkConnectionLost] + ) /// Exponential backoff twice when no internet connection is detected - public static var noInternet = AutomaticRetry(policy: .defaultExponential, - retryableURLErrorCodes: [.notConnectedToInternet]) - + public static var noInternet = AutomaticRetry( + policy: .defaultExponential, + retryableURLErrorCodes: [.notConnectedToInternet] + ) + // The minimum value allowed between retries + static let minDelay: UInt = 2 + /// Provides the action taken when a retry is to be performed public enum ReconnectionPolicy: Hashable { - /// Exponential backoff with base/scale factor of 2, and a 300s max delay - public static let defaultExponential: ReconnectionPolicy = .exponential(base: 2, scale: 2, maxDelay: 300) - - /// Linear reconnect every 3 seconds - public static let defaultLinear: ReconnectionPolicy = .linear(delay: 3) + /// Exponential backoff with base/scale factor of 2, and a 150s max delay + public static let defaultExponential: ReconnectionPolicy = .exponential(minDelay: minDelay, maxDelay: 150) + /// Linear reconnect every 2 seconds + public static let defaultLinear: ReconnectionPolicy = .linear(delay: Double(minDelay)) - /// Attempt to reconnect immediately - case immediately /// Reconnect with an exponential backoff - case exponential(base: UInt, scale: Double, maxDelay: UInt) + case exponential(minDelay: UInt, maxDelay: UInt) /// Attempt to reconnect every X seconds case linear(delay: Double) func delay(for retryAttempt: Int) -> TimeInterval { + /// Generates a random interval that's added to the final value + /// Mitigates receiving 429 status code that's the result of too many requests in a given amount of time + let randomDelay = Double.random(in: 0...1) + switch self { - case .immediately: - return 0.0 - case let .exponential(base, scale, maxDelay): - return exponentialBackoffDelay(for: base, scale: scale, maxDelay: maxDelay, current: retryAttempt) + case let .exponential(minDelay, maxDelay): + return exponentialBackoffDelay(minDelay: minDelay, maxDelay: maxDelay, current: retryAttempt) + randomDelay case let .linear(delay): - return delay + return delay + randomDelay } } - func exponentialBackoffDelay(for base: UInt, scale: Double, maxDelay: UInt, current retryCount: Int) -> Double { - return min(pow(Double(base), Double(retryCount)) * scale, Double(maxDelay)) + func exponentialBackoffDelay(minDelay: UInt, maxDelay: UInt, current retryCount: Int) -> Double { + return min(Double(maxDelay), Double(minDelay) * pow(2, Double(retryCount))) } } + + /// List of known endpoint groups (by context) + public enum Endpoint { + /// Sending a message + case messageSend + /// Subscribing to channels and channel groups to receive realtime updates + case subscribe + /// Groups Presence related methods + case presence + /// Groups Files related methods + /// - Important: Downloading and uploading a File isn't included + case files + /// History related methods + case messageStorage + /// Managing channel groups + case channelGroups + /// Managing devices to receive push notifications + case devicePushNotifications + /// Accessing and managing AppContext objects + case appContext + /// Accessing and managing Message Actions + case messageActions + } /// Collection of default `URLError.Code` objects that will trigger a retry public static let defaultRetryableURLErrorCodes: Set = [ @@ -80,43 +108,64 @@ public struct AutomaticRetry: RequestOperator, Hashable { public let retryableHTTPStatusCodes: Set /// Collection of returned `URLError.Code` objects that will trigger a retry public let retryableURLErrorCodes: Set + /// The list of endpoints excluded from retrying + public let excluded: [AutomaticRetry.Endpoint] public init( - retryLimit: UInt = 2, + retryLimit: UInt = 6, policy: ReconnectionPolicy = .defaultExponential, - retryableHTTPStatusCodes: Set = [500], - retryableURLErrorCodes: Set = AutomaticRetry.defaultRetryableURLErrorCodes + retryableHTTPStatusCodes: Set = [500, 429], + retryableURLErrorCodes: Set = AutomaticRetry.defaultRetryableURLErrorCodes, + excluded endpoints: [AutomaticRetry.Endpoint] = [ + .messageSend, + .files, + .messageStorage, + .channelGroups, + .devicePushNotifications, + .appContext, + .messageActions + ] ) { switch policy { - case let .exponential(base, scale, max): - switch (true, true) { - case (base < 2, scale < 0): - PubNub.log.warn("The `exponential.base` must be a minimum of 2.") - PubNub.log.warn("The `exponential.scale` must be a positive value.") - self.policy = .exponential(base: 2, scale: 0, maxDelay: max) - case (base < 2, scale >= 0): - PubNub.log.warn("The `exponential.base` must be a minimum of 2.") - self.policy = .exponential(base: 2, scale: scale, maxDelay: max) - case (base >= 2, scale < 0): - PubNub.log.warn("The `exponential.scale` must be a positive value.") - self.policy = .exponential(base: base, scale: 0, maxDelay: max) - default: - self.policy = policy + case let .exponential(minDelay, maxDelay): + var finalMinDelay: UInt = minDelay + var finalMaxDelay: UInt = maxDelay + var finalRetryLimit: UInt = retryLimit + + if finalRetryLimit > 10 { + PubNub.log.warn("The `retryLimit` for exponential policy must be less than or equal 10") + finalRetryLimit = 10 } + if finalMinDelay < Self.minDelay { + PubNub.log.warn("The `minDelay` must be a minimum of \(Self.minDelay)") + finalMinDelay = Self.minDelay + } + if finalMinDelay > finalMaxDelay { + PubNub.log.warn("The `minDelay` \"\(minDelay)\" must be greater or equal `maxDelay` \"\(maxDelay)\"") + finalMaxDelay = minDelay + } + self.retryLimit = finalRetryLimit + self.policy = .exponential(minDelay: finalMinDelay, maxDelay: finalMaxDelay) + case let .linear(delay): - if delay < 0 { - PubNub.log.warn("The `linear.delay` must be a positive value.") - self.policy = .linear(delay: 0) - } else { - self.policy = policy + var finalRetryLimit = retryLimit + var finalDelay = delay + + if finalRetryLimit > 10 { + PubNub.log.warn("The `retryLimit` for linear policy must be less than or equal 10") + finalRetryLimit = 10 + } + if finalDelay < 0 || UInt(finalDelay) < Self.minDelay { + PubNub.log.warn("The `linear.delay` must be greater than or equal \(Self.minDelay).") + finalDelay = Double(Self.minDelay) } - case .immediately: - self.policy = policy + self.retryLimit = finalRetryLimit + self.policy = .linear(delay: finalDelay) } - - self.retryLimit = retryLimit + self.retryableHTTPStatusCodes = retryableHTTPStatusCodes self.retryableURLErrorCodes = retryableURLErrorCodes + self.excluded = endpoints } public func retry( @@ -129,20 +178,29 @@ public struct AutomaticRetry: RequestOperator, Hashable { completion(.failure(error)) return } - - return completion(.success(policy.delay(for: request.retryCount))) + + let urlResponse = request.urlResponse + let retryAfterValue = urlResponse?.allHeaderFields[Constant.retryAfterHeaderKey] + + if let retryAfterValue = retryAfterValue as? TimeInterval { + return completion(.success(retryAfterValue)) + } else { + return completion(.success(policy.delay(for: request.retryCount))) + } + } + + public subscript(endpoint: AutomaticRetry.Endpoint) -> RequestOperator? { + excluded.contains(endpoint) ? nil : self } func shouldRetry(response: HTTPURLResponse?, error: Error) -> Bool { - if let statusCode = response?.statusCode, retryableHTTPStatusCodes.contains(statusCode) { - return true + if let statusCode = response?.statusCode { + return retryableHTTPStatusCodes.contains(statusCode) } else if let errorCode = error.urlError?.code, retryableURLErrorCodes.contains(errorCode) { return true - } else if let errorCode = error.pubNubError?.underlying?.urlError?.code, - retryableURLErrorCodes.contains(errorCode) { + } else if let errorCode = error.pubNubError?.underlying?.urlError?.code, retryableURLErrorCodes.contains(errorCode) { return true } - return false } } diff --git a/Sources/PubNub/Networking/Request/Request.swift b/Sources/PubNub/Networking/Request/Request.swift index 276890b8..261a9278 100644 --- a/Sources/PubNub/Networking/Request/Request.swift +++ b/Sources/PubNub/Networking/Request/Request.swift @@ -317,7 +317,7 @@ final class Request { if let error = state.error { return .failure(error) } - + if let request = state.urlRequests.last, let response = state.tasks.last?.httpResponse, let data = state.responesData { diff --git a/Sources/PubNub/Networking/Routers/PresenceRouter.swift b/Sources/PubNub/Networking/Routers/PresenceRouter.swift index 007cee70..ac842bb6 100644 --- a/Sources/PubNub/Networking/Routers/PresenceRouter.swift +++ b/Sources/PubNub/Networking/Routers/PresenceRouter.swift @@ -15,7 +15,7 @@ import Foundation struct PresenceRouter: HTTPRouter { // Nested Endpoint enum Endpoint: CustomStringConvertible { - case heartbeat(channels: [String], groups: [String], presenceTimeout: UInt?) + case heartbeat(channels: [String], groups: [String], channelStates: [String: [String:JSONCodableScalar]], presenceTimeout: UInt?) case leave(channels: [String], groups: [String]) case hereNow(channels: [String], groups: [String], includeUUIDs: Bool, includeState: Bool) case hereNowGlobal(includeUUIDs: Bool, includeState: Bool) @@ -44,7 +44,7 @@ struct PresenceRouter: HTTPRouter { var channels: [String] { switch self { - case let .heartbeat(channels, _, _): + case let .heartbeat(channels, _, _, _): return channels case let .leave(channels, _): return channels @@ -61,7 +61,7 @@ struct PresenceRouter: HTTPRouter { var groups: [String] { switch self { - case let .heartbeat(_, groups, _): + case let .heartbeat(_, groups, _, _): return groups case let .leave(_, groups): return groups @@ -85,7 +85,7 @@ struct PresenceRouter: HTTPRouter { var endpoint: Endpoint var configuration: RouterConfiguration - + // Protocol Properties var service: PubNubService { return .presence @@ -99,10 +99,10 @@ struct PresenceRouter: HTTPRouter { let path: String switch endpoint { - case let .heartbeat(channels, _, _): + case let .heartbeat(channels, _, _, _): path = "/v2/presence/sub-key/\(subscribeKey)/channel/\(channels.commaOrCSVString.urlEncodeSlash)/heartbeat" case let .leave(channels, _): - path = "/v2/presence/sub_key/\(subscribeKey)/channel/\(channels.commaOrCSVString.urlEncodeSlash)/leave" + path = "/v2/presence/sub-key/\(subscribeKey)/channel/\(channels.commaOrCSVString.urlEncodeSlash)/leave" case let .hereNow(channels, _, _, _): path = "/v2/presence/sub-key/\(subscribeKey)/channel/\(channels.commaOrCSVString.urlEncodeSlash)" case .hereNowGlobal: @@ -123,11 +123,28 @@ struct PresenceRouter: HTTPRouter { var query = defaultQueryItems switch endpoint { - case let .heartbeat(_, groups, presenceTimeout): - query.appendIfNotEmpty(key: .channelGroup, value: groups) - query.appendIfPresent(key: .heartbeat, value: presenceTimeout?.description) + case let .heartbeat(_, groups, channelStates, presenceTimeout): + query.appendIfNotEmpty( + key: .channelGroup, + value: groups + ) + query.appendIfPresent( + key: .heartbeat, + value: presenceTimeout?.description + ) + query.append( + key: .eventEngine, + value: nil, + when: configuration.enableEventEngine + ) + query.appendIfPresent( + key: .state, + value: try? channelStates.mapValues { $0.mapValues { $0.codableValue } }.encodableJSONString.get(), + when: configuration.enableEventEngine && configuration.maintainPresenceState && !channelStates.isEmpty + ) case let .leave(_, groups): query.appendIfNotEmpty(key: .channelGroup, value: groups) + query.append(key: .eventEngine, value: nil, when: configuration.enableEventEngine) case let .hereNow(_, groups, includeUUIDs, includeState): query.appendIfNotEmpty(key: .channelGroup, value: groups) query.append(URLQueryItem(key: .disableUUIDs, value: (!includeUUIDs).stringNumber)) @@ -157,7 +174,7 @@ struct PresenceRouter: HTTPRouter { // Validated var validationErrorDetail: String? { switch endpoint { - case let .heartbeat(channels, groups, _): + case let .heartbeat(channels, groups, _, _): return isInvalidForReason( (channels.isEmpty && groups.isEmpty, ErrorDescription.missingChannelsAnyGroups)) case let .leave(channels, groups): @@ -197,7 +214,7 @@ struct AnyPresencePayload: Codable where Payload: Codable { let payload: Payload } -// MARK: - Heree Now Response +// MARK: - Here Now Response struct HereNowResponseDecoder: ResponseDecoder { typealias Payload = [String: HereNowChannelsPayload] diff --git a/Sources/PubNub/Networking/Routers/SubscribeRouter.swift b/Sources/PubNub/Networking/Routers/SubscribeRouter.swift index e1212fd8..c657c4f8 100644 --- a/Sources/PubNub/Networking/Routers/SubscribeRouter.swift +++ b/Sources/PubNub/Networking/Routers/SubscribeRouter.swift @@ -15,9 +15,11 @@ import Foundation struct SubscribeRouter: HTTPRouter { // Nested Endpoint enum Endpoint: CaseAccessible, CustomStringConvertible { - case subscribe(channels: [String], groups: [String], - timetoken: Timetoken?, region: String?, - heartbeat: UInt?, filter: String?) + case subscribe( + channels: [String], groups: [String], channelStates: [String: [String: JSONCodableScalar]], + timetoken: Timetoken?, region: String?, + heartbeat: UInt?, filter: String? + ) var description: String { switch self { @@ -35,7 +37,7 @@ struct SubscribeRouter: HTTPRouter { var endpoint: Endpoint var configuration: RouterConfiguration - + // Protocol Properties var service: PubNubService { return .subscribe @@ -49,7 +51,7 @@ struct SubscribeRouter: HTTPRouter { let path: String switch endpoint { - case let .subscribe(channels, _, _, _, _, _): + case let .subscribe(channels, _, _, _, _, _, _): path = "/v2/subscribe/\(subscribeKey)/\(channels.commaOrCSVString.urlEncodeSlash)/0" } @@ -60,12 +62,37 @@ struct SubscribeRouter: HTTPRouter { var query = defaultQueryItems switch endpoint { - case let .subscribe(_, groups, timetoken, region, heartbeat, filter): - query.appendIfNotEmpty(key: .channelGroup, value: groups) - query.appendIfPresent(key: .timetokenShort, value: timetoken?.description) - query.appendIfPresent(key: .regionShort, value: region?.description) - query.appendIfPresent(key: .filterExpr, value: filter) - query.appendIfPresent(key: .heartbeat, value: heartbeat?.description) + case let .subscribe(_, groups, channelStates, timetoken, region, heartbeat, filter): + query.appendIfNotEmpty( + key: .channelGroup, + value: groups + ) + query.appendIfPresent( + key: .timetokenShort, + value: timetoken?.description + ) + query.appendIfPresent( + key: .regionShort, + value: region?.description + ) + query.appendIfPresent( + key: .filterExpr, + value: filter + ) + query.appendIfPresent( + key: .heartbeat, + value: heartbeat?.description + ) + query.append( + key: .eventEngine, + value: nil, + when: configuration.enableEventEngine + ) + query.appendIfPresent( + key: .state, + value: try? channelStates.mapValues { $0.mapValues { $0.codableValue } }.encodableJSONString.get(), + when: configuration.enableEventEngine && configuration.maintainPresenceState && !channelStates.isEmpty + ) } return .success(query) @@ -74,7 +101,7 @@ struct SubscribeRouter: HTTPRouter { // Validated var validationErrorDetail: String? { switch endpoint { - case let .subscribe(channels, groups, _, _, _, _): + case let .subscribe(channels, groups, _, _, _, _, _): return isInvalidForReason( (channels.isEmpty && groups.isEmpty, ErrorDescription.missingChannelsAnyGroups)) } diff --git a/Sources/PubNub/PubNub.swift b/Sources/PubNub/PubNub.swift index 9f05505e..798dc95b 100644 --- a/Sources/PubNub/PubNub.swift +++ b/Sources/PubNub/PubNub.swift @@ -33,22 +33,24 @@ public class PubNub { public static var log = PubNubLogger(levels: [.event, .warn, .error], writers: [ConsoleLogWriter(), FileLogWriter()]) // Global log instance for Logging issues/events public static var logLog = PubNubLogger(levels: [.log], writers: [ConsoleLogWriter()]) - + // Container that holds current Presence states for given channels/channel groups + internal let presenceStateContainer = PresenceStateContainer.shared + /// Creates a PubNub session with the specified configuration /// /// - Parameters: /// - configuration: The default configurations that will be used /// - session: Session used for performing request/response REST calls /// - subscribeSession: The network session used for Subscription only - public init( + /// - fileSession: The network session used for File uploading/downloading only + public convenience init( configuration: PubNubConfiguration, session: SessionReplaceable? = nil, subscribeSession: SessionReplaceable? = nil, fileSession: URLSessionReplaceable? = nil ) { - instanceID = UUID() - self.configuration = configuration - + let instanceID = UUID() + // Default operators based on config var operators = [RequestOperator]() if let retryOperator = configuration.automaticRetry { @@ -70,30 +72,46 @@ public class PubNub { .defaultRequestOperator? .merge(requestOperator: MultiplexRequestOperator(operators: operators)) } - - // Immutable session - self.networkSession = networkSession - + + let fileSession = fileSession ?? URLSession( + configuration: .pubnubBackground, + delegate: FileSessionManager(), + delegateQueue: .main + ) + // Set initial session also based on configuration - subscription = SubscribeSessionFactory.shared.getSession( + let subscriptionSession = SubscribeSessionFactory.shared.getSession( from: configuration, with: subscribeSession, presenceSession: session ) - - if let fileSession = fileSession { - fileURLSession = fileSession - } else { - fileURLSession = URLSession( - configuration: .pubnubBackground, - delegate: FileSessionManager(), - delegateQueue: .main - ) - } + + self.init( + instanceID: instanceID, + configuration: configuration, + session: networkSession, + fileSession: fileSession, + subscriptionSession: subscriptionSession + ) + } + + init( + instanceID: UUID = UUID(), + configuration: PubNubConfiguration, + session: SessionReplaceable, + fileSession: URLSessionReplaceable, + subscriptionSession: SubscriptionSession + ) { + self.instanceID = instanceID + self.configuration = configuration + self.subscription = subscriptionSession + self.networkSession = session + self.fileURLSession = fileSession } func route( _ router: HTTPRouter, + requestOperator: RequestOperator? = nil, responseDecoder: Decoder, custom requestConfig: RequestConfiguration, completion: @escaping (Result, Error>) -> Void @@ -101,6 +119,7 @@ public class PubNub { (requestConfig.customSession ?? networkSession) .route( router, + requestOperator: requestOperator, responseDecoder: responseDecoder, responseQueue: requestConfig.responseQueue, completion: completion @@ -199,9 +218,11 @@ public extension PubNub { custom requestConfig: RequestConfiguration = RequestConfiguration(), completion: ((Result) -> Void)? ) { - route(TimeRouter(.time, configuration: requestConfig.customConfiguration ?? configuration), - responseDecoder: TimeResponseDecoder(), - custom: requestConfig) { result in + route( + TimeRouter(.time, configuration: requestConfig.customConfiguration ?? configuration), + responseDecoder: TimeResponseDecoder(), + custom: requestConfig + ) { result in completion?(result.map { $0.payload.timetoken }) } } @@ -242,27 +263,34 @@ public extension PubNub { let router: PublishRouter if shouldCompress { router = PublishRouter( - .compressedPublish(message: message.codableValue, - channel: channel, - shouldStore: shouldStore, - ttl: storeTTL, - meta: meta?.codableValue), + .compressedPublish( + message: message.codableValue, + channel: channel, + shouldStore: shouldStore, + ttl: storeTTL, + meta: meta?.codableValue + ), configuration: requestConfig.customConfiguration ?? configuration ) } else { router = PublishRouter( - .publish(message: message.codableValue, - channel: channel, - shouldStore: shouldStore, - ttl: storeTTL, - meta: meta?.codableValue), + .publish( + message: message.codableValue, + channel: channel, + shouldStore: shouldStore, + ttl: storeTTL, + meta: meta?.codableValue + ), configuration: requestConfig.customConfiguration ?? configuration ) } - route(router, - responseDecoder: PublishResponseDecoder(), - custom: requestConfig) { result in + route( + router, + requestOperator: configuration.automaticRetry?[.messageSend], + responseDecoder: PublishResponseDecoder(), + custom: requestConfig + ) { result in completion?(result.map { $0.payload.timetoken }) } } @@ -294,10 +322,15 @@ public extension PubNub { custom requestConfig: RequestConfiguration = RequestConfiguration(), completion: ((Result) -> Void)? ) { - route(PublishRouter(.fire(message: message.codableValue, channel: channel, meta: meta?.codableValue), - configuration: requestConfig.customConfiguration ?? configuration), - responseDecoder: PublishResponseDecoder(), - custom: requestConfig) { result in + route( + PublishRouter( + .fire(message: message.codableValue, channel: channel, meta: meta?.codableValue), + configuration: requestConfig.customConfiguration ?? configuration + ), + requestOperator: configuration.automaticRetry?[.messageSend], + responseDecoder: PublishResponseDecoder(), + custom: requestConfig + ) { result in completion?(result.map { $0.payload.timetoken }) } } @@ -318,10 +351,15 @@ public extension PubNub { custom requestConfig: RequestConfiguration = RequestConfiguration(), completion: ((Result) -> Void)? ) { - route(PublishRouter(.signal(message: message.codableValue, channel: channel), - configuration: requestConfig.customConfiguration ?? configuration), - responseDecoder: PublishResponseDecoder(), - custom: requestConfig) { result in + route( + PublishRouter( + .signal(message: message.codableValue, channel: channel), + configuration: requestConfig.customConfiguration ?? configuration + ), + requestOperator: configuration.automaticRetry?[.messageSend], + responseDecoder: PublishResponseDecoder(), + custom: requestConfig + ) { result in completion?(result.map { $0.payload.timetoken }) } } @@ -338,20 +376,18 @@ public extension PubNub { /// - at: The initial timetoken to subscribe with /// - withPresence: If true it also subscribes to presence events on the specified channels. /// - region: The region code from a previous `SubscribeCursor` - /// - filterOverride: Overrides the previous filter on the next successful request func subscribe( to channels: [String], and channelGroups: [String] = [], at timetoken: Timetoken? = nil, - withPresence: Bool = false, - filterOverride: String? = nil + withPresence: Bool = false ) { - subscription.filterExpression = filterOverride - - subscription.subscribe(to: channels, - and: channelGroups, - at: SubscribeCursor(timetoken: timetoken), - withPresence: withPresence) + subscription.subscribe( + to: channels, + and: channelGroups, + at: SubscribeCursor(timetoken: timetoken), + withPresence: withPresence + ) } /// Unsubscribe from channels and/or channel groups @@ -412,14 +448,6 @@ public extension PubNub { var connectionStatus: ConnectionStatus { return subscription.connectionStatus } - - /// An override for the default filter expression set during initialization - internal var subscribeFilterExpression: String? { - get { return subscription.filterExpression } - set { - subscription.filterExpression = newValue - } - } } // MARK: - Presence Management @@ -444,10 +472,17 @@ public extension PubNub { .setState(channels: channels, groups: groups, state: state), configuration: requestConfig.customConfiguration ?? configuration ) + if configuration.enableEventEngine && configuration.maintainPresenceState { + presenceStateContainer.registerState(state, forChannels: channels) + presenceStateContainer.registerState(state, forChannelGroups: groups) + } - route(router, - responseDecoder: PresenceResponseDecoder>(), - custom: requestConfig) { result in + route( + router, + requestOperator: configuration.automaticRetry?[.presence], + responseDecoder: PresenceResponseDecoder>(), + custom: requestConfig + ) { result in completion?(result.map { $0.payload.payload }) } } @@ -472,9 +507,12 @@ public extension PubNub { configuration: requestConfig.customConfiguration ?? configuration ) - route(router, - responseDecoder: GetPresenceStateResponseDecoder(), - custom: requestConfig) { result in + route( + router, + requestOperator: configuration.automaticRetry?[.presence], + responseDecoder: GetPresenceStateResponseDecoder(), + custom: requestConfig + ) { result in completion?(result.map { (uuid: $0.payload.uuid, stateByChannel: $0.payload.channels) }) } } @@ -504,8 +542,10 @@ public extension PubNub { ) { let router: PresenceRouter if channels.isEmpty, groups.isEmpty { - router = PresenceRouter(.hereNowGlobal(includeUUIDs: includeUUIDs, includeState: includeState), - configuration: requestConfig.customConfiguration ?? configuration) + router = PresenceRouter( + .hereNowGlobal(includeUUIDs: includeUUIDs, includeState: includeState), + configuration: requestConfig.customConfiguration ?? configuration + ) } else { router = PresenceRouter( .hereNow(channels: channels, groups: groups, includeUUIDs: includeUUIDs, includeState: includeState), @@ -515,9 +555,12 @@ public extension PubNub { let decoder = HereNowResponseDecoder(channels: channels, groups: groups) - route(router, - responseDecoder: decoder, - custom: requestConfig) { result in + route( + router, + requestOperator: configuration.automaticRetry?[.presence], + responseDecoder: decoder, + custom: requestConfig + ) { result in completion?(result.map { $0.payload.asPubNubPresenceBase }) } } @@ -534,9 +577,12 @@ public extension PubNub { custom requestConfig: RequestConfiguration = RequestConfiguration(), completion: ((Result<[String: [String]], Error>) -> Void)? ) { - route(PresenceRouter(.whereNow(uuid: uuid), configuration: requestConfig.customConfiguration ?? configuration), - responseDecoder: PresenceResponseDecoder>(), - custom: requestConfig) { result in + route( + PresenceRouter(.whereNow(uuid: uuid), configuration: requestConfig.customConfiguration ?? configuration), + requestOperator: configuration.automaticRetry?[.presence], + responseDecoder: PresenceResponseDecoder>(), + custom: requestConfig + ) { result in completion?(result.map { [uuid: $0.payload.payload.channels] }) } } @@ -555,9 +601,12 @@ public extension PubNub { custom requestConfig: RequestConfiguration = RequestConfiguration(), completion: ((Result<[String], Error>) -> Void)? ) { - route(ChannelGroupsRouter(.channelGroups, configuration: requestConfig.customConfiguration ?? configuration), - responseDecoder: ChannelGroupResponseDecoder(), - custom: requestConfig) { result in + route( + ChannelGroupsRouter(.channelGroups, configuration: requestConfig.customConfiguration ?? configuration), + requestOperator: configuration.automaticRetry?[.channelGroups], + responseDecoder: ChannelGroupResponseDecoder(), + custom: requestConfig + ) { result in completion?(result.map { $0.payload.payload.groups }) } } @@ -580,6 +629,7 @@ public extension PubNub { .deleteGroup(group: channelGroup), configuration: requestConfig.customConfiguration ?? configuration ), + requestOperator: configuration.automaticRetry?[.channelGroups], responseDecoder: GenericServiceResponseDecoder(), custom: requestConfig ) { result in @@ -604,6 +654,7 @@ public extension PubNub { .channelsForGroup(group: group), configuration: requestConfig.customConfiguration ?? configuration ), + requestOperator: configuration.automaticRetry?[.channelGroups], responseDecoder: ChannelGroupResponseDecoder(), custom: requestConfig ) { result in @@ -630,6 +681,7 @@ public extension PubNub { .addChannelsToGroup(group: group, channels: channels), configuration: requestConfig.customConfiguration ?? configuration ), + requestOperator: configuration.automaticRetry?[.channelGroups], responseDecoder: GenericServiceResponseDecoder(), custom: requestConfig ) { result in @@ -637,7 +689,7 @@ public extension PubNub { } } - /// Rremoves the channels from the channel group. + /// Removes the channels from the channel group. /// - Parameters: /// - channels: List of channels to remove from the group /// - from: The Channel Group to remove the list of channels from @@ -656,6 +708,7 @@ public extension PubNub { .removeChannelsForGroup(group: group, channels: channels), configuration: requestConfig.customConfiguration ?? configuration ), + requestOperator: configuration.automaticRetry?[.channelGroups], responseDecoder: GenericServiceResponseDecoder(), custom: requestConfig ) { result in @@ -686,6 +739,7 @@ public extension PubNub { .listPushChannels(pushToken: deviceToken, pushType: pushType), configuration: requestConfig.customConfiguration ?? configuration ), + requestOperator: configuration.automaticRetry?[.devicePushNotifications], responseDecoder: RegisteredPushChannelsResponseDecoder(), custom: requestConfig ) { result in @@ -716,9 +770,12 @@ public extension PubNub { configuration: requestConfig.customConfiguration ?? configuration ) - route(router, - responseDecoder: ModifyPushResponseDecoder(), - custom: requestConfig) { result in + route( + router, + requestOperator: configuration.automaticRetry?[.devicePushNotifications], + responseDecoder: ModifyPushResponseDecoder(), + custom: requestConfig + ) { result in completion?(result.map { (added: $0.payload.added, removed: $0.payload.removed) }) } } @@ -788,6 +845,7 @@ public extension PubNub { .removeAllPushChannels(pushToken: deviceToken, pushType: pushType), configuration: requestConfig.customConfiguration ?? configuration ), + requestOperator: configuration.automaticRetry?[.devicePushNotifications], responseDecoder: ModifyPushResponseDecoder(), custom: requestConfig ) { result in @@ -813,11 +871,10 @@ public extension PubNub { ) { route( PushRouter( - .manageAPNS( - pushToken: deviceToken, environment: environment, topic: topic, adding: [], removing: [] - ), + .manageAPNS(pushToken: deviceToken, environment: environment, topic: topic, adding: [], removing: []), configuration: requestConfig.customConfiguration ?? configuration ), + requestOperator: configuration.automaticRetry?[.devicePushNotifications], responseDecoder: RegisteredPushChannelsResponseDecoder(), custom: requestConfig ) { result in @@ -846,20 +903,28 @@ public extension PubNub { completion: ((Result<(added: [String], removed: [String]), Error>) -> Void)? ) { let router = PushRouter( - .manageAPNS(pushToken: token, environment: environment, - topic: topic, adding: additions, removing: removals), + .manageAPNS( + pushToken: token, environment: environment, topic: topic, + adding: additions, removing: removals + ), configuration: requestConfig.customConfiguration ?? configuration ) if removals.isEmpty, additions.isEmpty { completion?( - .failure(PubNubError(.missingRequiredParameter, - router: router, - additional: [ErrorDescription.missingChannelsAnyGroups]))) + .failure(PubNubError( + .missingRequiredParameter, + router: router, + additional: [ErrorDescription.missingChannelsAnyGroups] + )) + ) } else { - route(router, - responseDecoder: ModifyPushResponseDecoder(), - custom: requestConfig) { result in + route( + router, + requestOperator: configuration.automaticRetry?[.devicePushNotifications], + responseDecoder: ModifyPushResponseDecoder(), + custom: requestConfig + ) { result in completion?(result.map { (added: $0.payload.added, removed: $0.payload.removed) }) } } @@ -931,10 +996,15 @@ public extension PubNub { custom requestConfig: RequestConfiguration = RequestConfiguration(), completion: ((Result) -> Void)? ) { - route(PushRouter(.removeAllAPNS(pushToken: deviceToken, environment: environment, topic: topic), - configuration: requestConfig.customConfiguration ?? configuration), - responseDecoder: ModifyPushResponseDecoder(), - custom: requestConfig) { result in + route( + PushRouter( + .removeAllAPNS(pushToken: deviceToken, environment: environment, topic: topic), + configuration: requestConfig.customConfiguration ?? configuration + ), + requestOperator: configuration.automaticRetry?[.devicePushNotifications], + responseDecoder: ModifyPushResponseDecoder(), + custom: requestConfig + ) { result in completion?(result.map { _ in () }) } } @@ -1009,6 +1079,7 @@ public extension PubNub { route( router, + requestOperator: configuration.automaticRetry?[.messageStorage], responseDecoder: MessageHistoryResponseDecoder(), custom: requestConfig ) { result in @@ -1042,6 +1113,7 @@ public extension PubNub { .delete(channel: channel, start: start, end: end), configuration: requestConfig.customConfiguration ?? configuration ), + requestOperator: configuration.automaticRetry?[.messageStorage], responseDecoder: GenericServiceResponseDecoder(), custom: requestConfig ) { result in @@ -1066,9 +1138,12 @@ public extension PubNub { configuration: requestConfig.customConfiguration ?? configuration ) - route(router, - responseDecoder: MessageCountsResponseDecoder(), - custom: requestConfig) { result in + route( + router, + requestOperator: configuration.automaticRetry?[.messageStorage], + responseDecoder: MessageCountsResponseDecoder(), + custom: requestConfig + ) { result in completion?(result.map { $0.payload.channels }) } } @@ -1092,9 +1167,12 @@ public extension PubNub { configuration: requestConfig.customConfiguration ?? configuration ) - route(router, - responseDecoder: MessageCountsResponseDecoder(), - custom: requestConfig) { result in + route( + router, + requestOperator: configuration.automaticRetry?[.messageStorage], + responseDecoder: MessageCountsResponseDecoder(), + custom: requestConfig + ) { result in completion?(result.map { $0.payload.channels }) } } @@ -1122,6 +1200,7 @@ public extension PubNub { .fetch(channel: channel, start: page?.start, end: page?.end, limit: page?.limit), configuration: requestConfig.customConfiguration ?? configuration ), + requestOperator: configuration.automaticRetry?[.messageActions], responseDecoder: MessageActionsResponseDecoder(), custom: requestConfig ) { result in @@ -1162,12 +1241,14 @@ public extension PubNub { configuration: requestConfig.customConfiguration ?? configuration ) - route(router, - responseDecoder: MessageActionResponseDecoder(), - custom: requestConfig) { result in + route( + router, + requestOperator: configuration.automaticRetry?[.messageActions], + responseDecoder: MessageActionResponseDecoder(), + custom: requestConfig + ) { result in switch result { case let .success(response): - if let errorPayload = response.payload.error { let error = PubNubError( reason: errorPayload.message.pubnubReason, router: router, @@ -1205,9 +1286,12 @@ public extension PubNub { configuration: requestConfig.customConfiguration ?? configuration ) - route(router, - responseDecoder: DeleteResponseDecoder(), - custom: requestConfig) { result in + route( + router, + requestOperator: configuration.automaticRetry?[.messageActions], + responseDecoder: DeleteResponseDecoder(), + custom: requestConfig + ) { result in switch result { case let .success(response): if let errorPayload = response.payload.error { diff --git a/Sources/PubNub/PubNubConfiguration.swift b/Sources/PubNub/PubNubConfiguration.swift index 4e7818f1..f15b9047 100644 --- a/Sources/PubNub/PubNubConfiguration.swift +++ b/Sources/PubNub/PubNubConfiguration.swift @@ -71,6 +71,9 @@ public struct PubNubConfiguration: Hashable { /// - supressLeaveEvents: Whether to send out the leave requests /// - requestMessageCountThreshold: The number of messages into the payload before emitting `RequestMessageCountExceeded` /// - filterExpression: PSV2 feature to subscribe with a custom filter expression. + /// - enableEventEngine: Whether to enable a new, experimental implementation of Subscription and Presence handling + /// - maintainPresenceState: Whether to automatically resend the last Presence channel state, + /// applies only if `heartbeatInterval` is greater than 0 and `enableEventEngine` is true public init( publishKey: String?, subscribeKey: String, @@ -89,7 +92,9 @@ public struct PubNubConfiguration: Hashable { heartbeatInterval: UInt = 0, supressLeaveEvents: Bool = false, requestMessageCountThreshold: UInt = 100, - filterExpression: String? = nil + filterExpression: String? = nil, + enableEventEngine: Bool = false, + maintainPresenceState: Bool = false ) { guard userId.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else { preconditionFailure("UserId should not be empty.") @@ -120,6 +125,8 @@ public struct PubNubConfiguration: Hashable { self.supressLeaveEvents = supressLeaveEvents self.requestMessageCountThreshold = requestMessageCountThreshold self.filterExpression = filterExpression + self.enableEventEngine = enableEventEngine + self.maintainPresenceState = maintainPresenceState } // swiftlint:disable:next line_length @@ -216,6 +223,14 @@ public struct PubNubConfiguration: Hashable { public var useInstanceId: Bool /// Whether a request identifier should be included on outgoing requests public var useRequestId: Bool + /// This controls whether to enable a new, experimental implementation of Subscription and Presence handling. + /// + /// This switch can help you verify the behavior of the PubNub SDK with the new engine enabled + /// in your app. It will default to true in a future SDK release. + public var enableEventEngine: Bool = false + /// When `true` the SDK will resend the last channel state that was set using `PubNub.setPresence`. + /// Applies only if `heartbeatInterval` is greater than 0 and `enableEventEngine` is true + public var maintainPresenceState: Bool = false /// Reconnection policy which will be used if/when a request fails public var automaticRetry: AutomaticRetry? /// URLSessionConfiguration used for URLSession network events diff --git a/Sources/PubNub/Subscription/ConnectionStatus.swift b/Sources/PubNub/Subscription/ConnectionStatus.swift index 72f93b68..a10f4ce0 100644 --- a/Sources/PubNub/Subscription/ConnectionStatus.swift +++ b/Sources/PubNub/Subscription/ConnectionStatus.swift @@ -11,22 +11,20 @@ import Foundation /// Status of a connection to a remote system -public enum ConnectionStatus { - /// Attempting to connect to a remote system - case connecting +public enum ConnectionStatus: Equatable { /// Successfully connected to a remote system case connected - /// Attempting to reconnect to a remote system - case reconnecting /// Explicit disconnect from a remote system case disconnected /// Unexpected disconnect from a remote system case disconnectedUnexpectedly + /// Unable to establish initial connection + case connectionError /// If the connection is connected or attempting to connect public var isActive: Bool { switch self { - case .connecting, .connected, .reconnecting: + case .connected: return true default: return false @@ -35,30 +33,24 @@ public enum ConnectionStatus { /// If the connection is connected public var isConnected: Bool { - return self == .connected + if case .connected = self { + return true + } else { + return false + } } - + func canTransition(to state: ConnectionStatus) -> Bool { switch (self, state) { - case (.connecting, .reconnecting): - return false - case (.connecting, _): + case (.connected, .disconnected): return true - case (.connected, .connecting): - return false - case (.connected, _): + case (.disconnected, .connected): return true - case (.reconnecting, .connecting): - return false - case (.reconnecting, _): + case (.connected, .disconnectedUnexpectedly): return true - case (.disconnected, .connecting): + case (.disconnected, .connectionError): return true - case (.disconnected, _): - return false - case (.disconnectedUnexpectedly, .connecting): - return true - case (.disconnectedUnexpectedly, _): + default: return false } } diff --git a/Sources/PubNub/Subscription/Strategy/EventEngineSubscriptionSessionStrategy.swift b/Sources/PubNub/Subscription/Strategy/EventEngineSubscriptionSessionStrategy.swift new file mode 100644 index 00000000..880dd585 --- /dev/null +++ b/Sources/PubNub/Subscription/Strategy/EventEngineSubscriptionSessionStrategy.swift @@ -0,0 +1,195 @@ +// +// EventEngineSubscriptionSessionStrategy.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class EventEngineSubscriptionSessionStrategy: SubscriptionSessionStrategy { + let uuid = UUID() + let subscribeEngine: SubscribeEngine + let presenceEngine: PresenceEngine + let presenceStateContainer: PresenceStateContainer + + var privateListeners: WeakSet = WeakSet([]) + var configuration: PubNubConfiguration + var previousTokenResponse: SubscribeCursor? + + internal init( + configuration: PubNubConfiguration, + subscribeEngine: SubscribeEngine, + presenceEngine: PresenceEngine, + presenceStateContainer: PresenceStateContainer + ) { + self.subscribeEngine = subscribeEngine + self.configuration = configuration + self.presenceEngine = presenceEngine + self.presenceStateContainer = presenceStateContainer + self.listenForStateUpdates() + } + + var subscribedChannels: [String] { + subscribeEngine.state.input.subscribedChannels + } + + var subscribedChannelGroups: [String] { + subscribeEngine.state.input.subscribedGroups + } + + var subscriptionCount: Int { + subscribeEngine.state.input.totalSubscribedCount + } + + var connectionStatus: ConnectionStatus { + subscribeEngine.state.connectionStatus + } + + deinit { + PubNub.log.debug("SubscriptionSession Destroyed") + // Poke the session factory to clean up nil values + SubscribeSessionFactory.shared.sessionDestroyed() + } + + private func listenForStateUpdates() { + subscribeEngine.onStateUpdated = { [weak self] state in + if state is Subscribe.ReceivingState && state.hasTimetoken { + self?.previousTokenResponse = state.cursor + } + } + } + + private func updateSubscribeEngineDependencies() { + subscribeEngine.dependencies = EventEngineDependencies( + value: Subscribe.Dependencies( + configuration: configuration, + listeners: privateListeners.allObjects + ) + ) + } + + private func sendSubscribeEvent(event: Subscribe.Event) { + updateSubscribeEngineDependencies() + subscribeEngine.send(event: event) + } + + private func updatePresenceEngineDependencies() { + presenceEngine.dependencies = EventEngineDependencies( + value: Presence.Dependencies( + configuration: configuration + ) + ) + } + + private func sendPresenceEvent(event: Presence.Event) { + updatePresenceEngineDependencies() + presenceEngine.send(event: event) + } + + // MARK: - Subscription Loop + + func subscribe( + to channels: [String], + and groups: [String], + at cursor: SubscribeCursor?, + withPresence: Bool + ) { + let newInput = subscribeEngine.state.input + SubscribeInput( + channels: channels.map { PubNubChannel(id: $0, withPresence: withPresence) }, + groups: groups.map { PubNubChannel(id: $0, withPresence: withPresence) } + ) + if let cursor = cursor, cursor.timetoken != 0 { + sendSubscribeEvent(event: .subscriptionRestored( + channels: newInput.allSubscribedChannels, + groups: newInput.allSubscribedGroups, + cursor: cursor + )) + } else { + sendSubscribeEvent(event: .subscriptionChanged( + channels: newInput.allSubscribedChannels, + groups: newInput.allSubscribedGroups + )) + } + sendPresenceEvent(event: .joined( + channels: newInput.subscribedChannels, + groups: newInput.subscribedGroups + )) + } + + func reconnect(at cursor: SubscribeCursor?) { + let input = subscribeEngine.state.input + let channels = input.allSubscribedChannels + let groups = input.allSubscribedGroups + + if let cursor = cursor { + sendSubscribeEvent(event: .subscriptionRestored( + channels: channels, + groups: groups, + cursor: cursor + )) + } else { + sendSubscribeEvent(event: .reconnect) + } + } + + func disconnect() { + sendSubscribeEvent(event: .disconnect) + sendPresenceEvent(event: .disconnect) + } + + // MARK: - Unsubscribe + + func unsubscribe(from channels: [String], and groups: [String], presenceOnly: Bool) { + let newInput = subscribeEngine.state.input - ( + channels: channels.map { presenceOnly ? $0.presenceChannelName : $0 }, + groups: groups.map { presenceOnly ? $0.presenceChannelName : $0 } + ) + + presenceStateContainer.removeState(forChannels: channels) + presenceStateContainer.removeState(forGroups: groups) + + sendSubscribeEvent(event: .subscriptionChanged( + channels: newInput.allSubscribedChannels, + groups: newInput.allSubscribedGroups + )) + sendPresenceEvent(event: .left( + channels: channels, + groups: groups + )) + } + + func unsubscribeAll() { + sendSubscribeEvent(event: .unsubscribeAll) + sendPresenceEvent(event: .leftAll) + } +} + +extension EventEngineSubscriptionSessionStrategy: EventStreamEmitter { + typealias ListenerType = BaseSubscriptionListener + + var listeners: [ListenerType] { + privateListeners.allObjects + } + + func add(_ listener: ListenerType) { + // Ensure that we cancel the previously attached token + listener.token?.cancel() + // Add new token to the listener + listener.token = ListenerToken { [weak self, weak listener] in + if let listener = listener { + self?.privateListeners.remove(listener) + self?.updateSubscribeEngineDependencies() + } + } + privateListeners.update(listener) + updateSubscribeEngineDependencies() + } + + func notify(listeners closure: (ListenerType) -> Void) { + listeners.forEach { closure($0) } + } +} diff --git a/Sources/PubNub/Subscription/SubscriptionSession+Presence.swift b/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy+Presence.swift similarity index 91% rename from Sources/PubNub/Subscription/SubscriptionSession+Presence.swift rename to Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy+Presence.swift index 8a752602..23e3b9f3 100644 --- a/Sources/PubNub/Subscription/SubscriptionSession+Presence.swift +++ b/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy+Presence.swift @@ -1,5 +1,5 @@ // -// SubscriptionSession+Presence.swift +// LegacySubscriptionSessionStrategy+Presence.swift // // Copyright (c) PubNub Inc. // All rights reserved. @@ -10,7 +10,8 @@ import Foundation -extension SubscriptionSession { +extension LegacySubscriptionSessionStrategy { + // MARK: - Heartbeat Loop func registerHeartbeatTimer() { @@ -50,12 +51,12 @@ extension SubscriptionSession { // Perform Heartbeat let router = PresenceRouter( - .heartbeat(channels: channels, groups: groups, presenceTimeout: configuration.durationUntilTimeout), + .heartbeat(channels: channels, groups: groups, channelStates: [:], presenceTimeout: configuration.durationUntilTimeout), configuration: configuration ) nonSubscribeSession - .request(with: router, requestOperator: configuration.automaticRetry) + .request(with: router, requestOperator: configuration.automaticRetry?[.presence]) .validate() .response(on: .main, decoder: GenericServiceResponseDecoder()) { [weak self] result in switch result { diff --git a/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy.swift b/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy.swift new file mode 100644 index 00000000..9e33d125 --- /dev/null +++ b/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy.swift @@ -0,0 +1,391 @@ +// +// LegacySubscriptionSessionStrategy.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +// swiftlint:disable:next type_body_length +class LegacySubscriptionSessionStrategy: SubscriptionSessionStrategy { + let uuid = UUID() + let longPollingSession: SessionReplaceable + let sessionStream: SessionListener + let responseQueue: DispatchQueue + + var configuration: PubNubConfiguration + var privateListeners: WeakSet = WeakSet([]) + var filterExpression: String? + var messageCache = [SubscribeMessagePayload?].init(repeating: nil, count: 100) + var presenceTimer: Timer? + + /// Session used for performing request/response REST calls + let nonSubscribeSession: SessionReplaceable + // These allow for better tracking of outstanding subscribe loop request status + var request: RequestReplaceable? + var previousTokenResponse: SubscribeCursor? + + var subscribedChannels: [String] { + return internalState.lockedRead { $0.subscribedChannels } + } + + var subscribedChannelGroups: [String] { + return internalState.lockedRead { $0.subscribedGroups } + } + + var subscriptionCount: Int { + return internalState.lockedRead { $0.totalSubscribedCount } + } + + private(set) var connectionStatus: ConnectionStatus { + get { + return internalState.lockedRead { $0.connectionState } + } + set { + // Set internal state + let (oldState, didTransition) = internalState.lockedWrite { state -> (ConnectionStatus, Bool) in + let oldState = state.connectionState + if oldState.canTransition(to: newValue) { + state.connectionState = newValue + return (oldState, true) + } + return (oldState, false) + } + + // Update any listeners if value changed + if oldState != newValue, didTransition { + notify { + $0.emit(subscribe: .connectionChanged(newValue)) + } + } + } + } + + var internalState = Atomic(SubscriptionState()) + + internal init( + configuration: PubNubConfiguration, + network subscribeSession: SessionReplaceable, + presenceSession: SessionReplaceable + ) { + self.configuration = configuration + var mutableSession = subscribeSession + + filterExpression = configuration.filterExpression + + nonSubscribeSession = presenceSession + + responseQueue = DispatchQueue(label: "com.pubnub.subscription.response", qos: .default) + sessionStream = SessionListener(queue: responseQueue) + + mutableSession.sessionStream = sessionStream + longPollingSession = mutableSession + } + + deinit { + PubNub.log.debug("SubscriptionSession Destroyed") + longPollingSession.invalidateAndCancel() + nonSubscribeSession.invalidateAndCancel() + // Poke the session factory to clean up nil values + SubscribeSessionFactory.shared.sessionDestroyed() + } + + // MARK: - Subscription Loop + + func subscribe( + to channels: [String], + and groups: [String], + at cursor: SubscribeCursor?, + withPresence: Bool + ) { + if channels.isEmpty, groups.isEmpty { + return + } + + let channelObject = channels.map { PubNubChannel(id: $0, withPresence: withPresence) } + let groupObjects = groups.map { PubNubChannel(id: $0, withPresence: withPresence) } + + // Don't attempt to start subscription if there are no changes + let subscribeChange = internalState.lockedWrite { state -> SubscriptionChangeEvent in + + let newChannels = channelObject.filter { state.channels.insert($0) } + let newGroups = groupObjects.filter { state.groups.insert($0) } + + return .subscribed(channels: newChannels, groups: newGroups) + } + + if subscribeChange.didChange { + // notify { $0.emit(subscribe: .subscriptionChanged(subscribeChange)) } + } + + if subscribeChange.didChange || !connectionStatus.isActive { + reconnect(at: cursor) + } + } + + func reconnect(at cursor: SubscribeCursor?) { + if !connectionStatus.isActive { + // Start subscribe loop + performSubscribeLoop(at: cursor) + + // Start presence heartbeat + registerHeartbeatTimer() + } else { + // Start subscribe loop + performSubscribeLoop(at: cursor) + } + } + + /// Disconnect the subscription stream + func disconnect() { + stopSubscribeLoop(.clientCancelled) + stopHeartbeatTimer() + } + + @discardableResult + func stopSubscribeLoop(_ reason: PubNubError.Reason) -> Bool { + // Cancel subscription requests + request?.cancel(PubNubError(reason, router: request?.router)) + + return connectionStatus.isActive + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + func performSubscribeLoop(at cursor: SubscribeCursor?) { + let (channels, groups) = internalState.lockedWrite { state -> ([String], [String]) in + (state.allSubscribedChannels, state.allSubscribedGroups) + } + + // Don't start subscription if there no channels/groups + if channels.isEmpty, groups.isEmpty { + return + } + + // Create Endpoing + let router = SubscribeRouter( + .subscribe( + channels: channels, groups: groups, channelStates: [:], timetoken: cursor?.timetoken, + region: cursor?.region.description, heartbeat: configuration.durationUntilTimeout, + filter: filterExpression + ), configuration: configuration + ) + + // Cancel previous request before starting new one + stopSubscribeLoop(.longPollingRestart) + + // Will compre this in the error response to see if we need to restart + let nextSubscribe = longPollingSession + .request(with: router, requestOperator: configuration.automaticRetry) + let currentSubscribeID = nextSubscribe.requestID + request = nextSubscribe + + request? + .validate() + .response(on: .main, decoder: SubscribeDecoder()) { [weak self] result in + switch result { + case let .success(response): + guard let strongSelf = self else { + return + } + + // Reset heartbeat timer + self?.registerHeartbeatTimer() + + // Ensure that we're connected now the response has been processed + self?.connectionStatus = .connected + + // Emit the header of the reponse + self?.notify { listener in + var pubnubChannels = [String: PubNubChannel]() + channels.forEach { + if $0.isPresenceChannelName { + let channel = PubNubChannel(channel: $0) + pubnubChannels[channel.id] = channel + } else if pubnubChannels[$0] == nil { + pubnubChannels[$0] = PubNubChannel(channel: $0) + } + } + + var pubnubGroups = [String: PubNubChannel]() + groups.forEach { + if $0.isPresenceChannelName { + let group = PubNubChannel(channel: $0) + pubnubGroups[group.id] = group + } else if pubnubChannels[$0] == nil { + pubnubGroups[$0] = PubNubChannel(channel: $0) + } + } + } + + // Attempt to detect missed messages due to queue overflow + if response.payload.messages.count >= 100 { + self?.notify { + $0.emit(subscribe: .errorReceived(PubNubError( + .messageCountExceededMaximum, + router: router, + affected: [.subscribe(response.payload.cursor)] + ))) + } + } + + let events = response.payload.messages + .filter { message in // Dedupe the message + // Update Cache and notify if not a duplicate message + if !strongSelf.messageCache.contains(message) { + self?.messageCache.append(message) + + // Remove oldest value if we're at max capacity + if strongSelf.messageCache.count >= 100 { + self?.messageCache.remove(at: 0) + } + + return true + } + + return false + } + + self?.notify { $0.emit(batch: events) } + + self?.previousTokenResponse = response.payload.cursor + + // Repeat the request + self?.performSubscribeLoop(at: response.payload.cursor) + case let .failure(error): + self?.notify { [unowned self] in + $0.emit(subscribe: + .errorReceived(PubNubError.event(error, router: self?.request?.router)) + ) + } + + if error.pubNubError?.reason == .clientCancelled || error.pubNubError?.reason == .longPollingRestart || + error.pubNubError?.reason == .longPollingReset { + if self?.subscriptionCount == 0 { + self?.connectionStatus = .disconnected + } else if self?.request?.requestID == currentSubscribeID { + // No new request has been created so we'll reconnect here + self?.reconnect(at: self?.previousTokenResponse) + } + } else if let cursor = error.pubNubError?.affected.findFirst(by: PubNubError.AffectedValue.subscribe) { + self?.previousTokenResponse = cursor + + // Repeat the request + self?.performSubscribeLoop(at: cursor) + } else { + self?.connectionStatus = .disconnectedUnexpectedly + } + } + } + } + + // MARK: - Unsubscribe + + func unsubscribe(from channels: [String], and groups: [String], presenceOnly: Bool) { + // Update Channel List + let subscribeChange = internalState.lockedWrite { state -> SubscriptionChangeEvent in + if presenceOnly { + let presenceChannelsRemoved = channels.compactMap { state.channels.unsubscribePresence($0) } + let presenceGroupsRemoved = groups.compactMap { state.groups.unsubscribePresence($0) } + + return .unsubscribed(channels: presenceChannelsRemoved, groups: presenceGroupsRemoved) + } else { + let removedChannels = channels.compactMap { state.channels.removeValue(forKey: $0) } + let removedGroups = groups.compactMap { state.groups.removeValue(forKey: $0) } + + return .unsubscribed(channels: removedChannels, groups: removedGroups) + } + } + + if subscribeChange.didChange { + // Call unsubscribe to cleanup remaining state items + unsubscribeCleanup(subscribeChange: subscribeChange) + } + } + + /// Unsubscribe from all channels and channel groups + func unsubscribeAll() { + // Remove All Channels & Groups + let subscribeChange = internalState.lockedWrite { mutableState -> SubscriptionChangeEvent in + + let removedChannels = mutableState.channels + mutableState.channels.removeAll(keepingCapacity: true) + + let removedGroups = mutableState.groups + mutableState.groups.removeAll(keepingCapacity: true) + + return .unsubscribed(channels: removedChannels.map { $0.value }, groups: removedGroups.map { $0.value }) + } + + if subscribeChange.didChange { + // Cancel previous subscribe request. + stopSubscribeLoop(.longPollingReset) + // Call unsubscribe to cleanup remaining state items + unsubscribeCleanup(subscribeChange: subscribeChange) + } + } + + func unsubscribeCleanup(subscribeChange: SubscriptionChangeEvent) { + // Call Leave on channels/groups + if !configuration.supressLeaveEvents { + switch subscribeChange { + case let .unsubscribed(channels, groups): + presenceLeave(for: configuration.uuid, + on: channels.map { $0.id }, + and: groups.map { $0.id }) { [weak self] result in + switch result { + case .success: + if !channels.isEmpty { + PubNub.log.info("Presence Leave Successful on channels \(channels.map { $0.id })") + } + if !groups.isEmpty { + PubNub.log.info("Presence Leave Successful on groups \(groups.map { $0.id })") + } + case let .failure(error): + self?.notify { + $0.emit(subscribe: .errorReceived(PubNubError.event(error, router: nil))) + } + } + } + default: + break + } + } + + // Reset all timetokens and regions if we've unsubscribed from all channels/groups + if internalState.lockedRead({ $0.totalSubscribedCount == 0 }) { + previousTokenResponse = nil + disconnect() + } else { + reconnect(at: previousTokenResponse) + } + } +} + +extension LegacySubscriptionSessionStrategy: EventStreamEmitter { + typealias ListenerType = BaseSubscriptionListener + + var listeners: [ListenerType] { + return privateListeners.allObjects + } + + func add(_ listener: ListenerType) { + // Ensure that we cancel the previously attached token + listener.token?.cancel() + + // Add new token to the listener + listener.token = ListenerToken { [weak self, weak listener] in + if let listener = listener { + self?.privateListeners.remove(listener) + } + } + privateListeners.update(listener) + } + + func notify(listeners closure: (ListenerType) -> Void) { + listeners.forEach { closure($0) } + } +} diff --git a/Sources/PubNub/Subscription/Strategy/SubscriptionSessionStrategy.swift b/Sources/PubNub/Subscription/Strategy/SubscriptionSessionStrategy.swift new file mode 100644 index 00000000..beee2113 --- /dev/null +++ b/Sources/PubNub/Subscription/Strategy/SubscriptionSessionStrategy.swift @@ -0,0 +1,27 @@ +// +// SubscriptionSessionStrategy.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +protocol SubscriptionSessionStrategy: EventStreamEmitter where ListenerType == BaseSubscriptionListener { + var uuid: UUID { get } + var configuration: PubNubConfiguration { get set } + var subscribedChannels: [String] { get } + var subscribedChannelGroups: [String] { get } + var subscriptionCount: Int { get } + var connectionStatus: ConnectionStatus { get } + var previousTokenResponse: SubscribeCursor? { get set } + + func subscribe(to channels: [String], and groups: [String], at cursor: SubscribeCursor?, withPresence: Bool) + func unsubscribe(from channels: [String], and groups: [String], presenceOnly: Bool) + func reconnect(at cursor: SubscribeCursor?) + func disconnect() + func unsubscribeAll() +} diff --git a/Sources/PubNub/Subscription/SubscribeSessionFactory.swift b/Sources/PubNub/Subscription/SubscribeSessionFactory.swift index 92b63048..34445732 100644 --- a/Sources/PubNub/Subscription/SubscribeSessionFactory.swift +++ b/Sources/PubNub/Subscription/SubscribeSessionFactory.swift @@ -22,6 +22,7 @@ import Foundation /// /// - Important: Having multiple `SubscriptionSession` instances will result in /// increase network usage and battery drain. +@available(*, deprecated) public class SubscribeSessionFactory { private typealias SessionMap = [Int: WeakBox] @@ -45,27 +46,83 @@ public class SubscribeSessionFactory { with subscribeSession: SessionReplaceable? = nil, presenceSession: SessionReplaceable? = nil ) -> SubscriptionSession { + guard let config = config as? PubNubConfiguration else { + preconditionFailure("Unexpected configuration that doesn't match PubNubConfiguration") + } + // The hash value for the given configuration let configHash = config.subscriptionHashValue + // Returns a session (if any) that matches the hash value if let session = sessions.lockedRead({ $0[configHash]?.underlying }) { PubNub.log.debug("Found existing session for config hash \(config.subscriptionHashValue)") return session } - + PubNub.log.debug("Creating new session for with hash value \(config.subscriptionHashValue)") + return sessions.lockedWrite { dictionary in - let subscribeSession = subscribeSession ?? HTTPSession(configuration: URLSessionConfiguration.subscription, - sessionQueue: subscribeQueue) - - let presenceSession = presenceSession ?? HTTPSession(configuration: URLSessionConfiguration.pubnub, - sessionQueue: subscribeSession.sessionQueue) - - let subscriptionSession = SubscriptionSession(configuration: config, - network: subscribeSession, - presenceSession: presenceSession) - - dictionary.updateValue(WeakBox(subscriptionSession), forKey: configHash) + let subscriptionSession = SubscriptionSession( + strategy: resolveStrategy( + configuration: config, + subscribeSession: subscribeSession, + presenceSession: presenceSession + ) + ) + dictionary.updateValue( + WeakBox(subscriptionSession), + forKey: configHash + ) return subscriptionSession } + + func resolveStrategy( + configuration: PubNubConfiguration, + subscribeSession: SessionReplaceable?, + presenceSession: SessionReplaceable? + ) -> any SubscriptionSessionStrategy { + // Creates default network session objects if they're not provided + let subscribeSession = subscribeSession ?? HTTPSession( + configuration: URLSessionConfiguration.subscription, + sessionQueue: subscribeQueue, + sessionStream: SessionListener(queue: subscribeQueue) + ) + let presenceSession = presenceSession ?? HTTPSession( + configuration: URLSessionConfiguration.pubnub, + sessionQueue: subscribeQueue, + sessionStream: SessionListener(queue: subscribeQueue) + ) + + if configuration.enableEventEngine { + let subscribeEffectFactory = SubscribeEffectFactory( + session: subscribeSession, + presenceStateContainer: .shared + ) + let subscribeEngine = EventEngineFactory().subscribeEngine( + with: configuration, + dispatcher: EffectDispatcher(factory: subscribeEffectFactory), + transition: SubscribeTransition() + ) + let presenceEffectFactory = PresenceEffectFactory( + session: presenceSession, + presenceStateContainer: .shared + ) + let presenceEngine = EventEngineFactory().presenceEngine( + with: configuration, + dispatcher: EffectDispatcher(factory: presenceEffectFactory), + transition: PresenceTransition(configuration: configuration) + ) + return EventEngineSubscriptionSessionStrategy( + configuration: configuration, + subscribeEngine: subscribeEngine, + presenceEngine: presenceEngine, + presenceStateContainer: .shared + ) + } + return LegacySubscriptionSessionStrategy( + configuration: configuration, + network: subscribeSession, + presenceSession: presenceSession + ) + } } /// Clean-up method that can be used to poke each weakbox to see if its nil diff --git a/Sources/PubNub/Subscription/SubscriptionSession.swift b/Sources/PubNub/Subscription/SubscriptionSession.swift index c8164aa6..9f57d37e 100644 --- a/Sources/PubNub/Subscription/SubscriptionSession.swift +++ b/Sources/PubNub/Subscription/SubscriptionSession.swift @@ -10,110 +10,53 @@ import Foundation -// swiftlint:disable:next type_body_length +@available(*, deprecated) public class SubscriptionSession { - var privateListeners: WeakSet = WeakSet([]) - - public let uuid = UUID() - let longPollingSession: SessionReplaceable - var configuration: SubscriptionConfiguration - let sessionStream: SessionListener - - /// PSV2 feature to subscribe with a custom filter expression. - public var filterExpression: String? - - var messageCache = [SubscribeMessagePayload?].init(repeating: nil, count: 100) - var presenceTimer: Timer? - - /// Session used for performing request/response REST calls - let nonSubscribeSession: SessionReplaceable - - // These allow for better tracking of outstanding subscribe loop request status - var request: RequestReplaceable? - - let responseQueue: DispatchQueue - - var previousTokenResponse: SubscribeCursor? + /// An unique identifier for subscription session + public var uuid: UUID { + strategy.uuid + } + + private let strategy: any SubscriptionSessionStrategy + + var previousTokenResponse: SubscribeCursor? { + strategy.previousTokenResponse + } + + var configuration: PubNubConfiguration { + get { + strategy.configuration + } set { + strategy.configuration = newValue + } + } + + internal init(strategy: any SubscriptionSessionStrategy) { + self.strategy = strategy + } + /// Names of all subscribed channels + /// + /// This list includes both regular and presence channel names public var subscribedChannels: [String] { - return internalState.lockedRead { $0.subscribedChannels } + strategy.subscribedChannels } - + + /// List of actively subscribed groups public var subscribedChannelGroups: [String] { - return internalState.lockedRead { $0.subscribedGroups } + strategy.subscribedChannelGroups } + /// Combined value of all subscribed channels and groups public var subscriptionCount: Int { - return internalState.lockedRead { $0.totalSubscribedCount } - } - - public private(set) var connectionStatus: ConnectionStatus { - get { - return internalState.lockedRead { $0.connectionState } - } - set { - // Set internal state - let (oldState, didTransition) = internalState.lockedWrite { state -> (ConnectionStatus, Bool) in - let oldState = state.connectionState - if oldState.canTransition(to: newValue) { - state.connectionState = newValue - return (oldState, true) - } - return (oldState, false) - } - - // Update any listeners if value changed - if oldState != newValue, didTransition { - notify { - $0.emit(subscribe: .connectionChanged(newValue)) - } - } - } - } - - var internalState = Atomic(SubscriptionState()) - - internal init( - configuration: SubscriptionConfiguration, - network subscribeSession: SessionReplaceable, - presenceSession: SessionReplaceable - ) { - self.configuration = configuration - var mutableSession = subscribeSession - - filterExpression = configuration.filterExpression - - nonSubscribeSession = presenceSession - - responseQueue = DispatchQueue(label: "com.pubnub.subscription.response", qos: .default) - sessionStream = SessionListener(queue: responseQueue) - - // Add listener to session - mutableSession.sessionStream = sessionStream - longPollingSession = mutableSession - - sessionStream.didRetryRequest = { [weak self] _ in - self?.connectionStatus = .reconnecting - } - - sessionStream.sessionDidReceiveChallenge = { [weak self] _, _ in - if self?.connectionStatus == .reconnecting { - // Delay time for server to process connection after TLS handshake - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.05) { - self?.connectionStatus = .connected - } - } - } + strategy.subscriptionCount } - - deinit { - PubNub.log.debug("SubscriptionSession Destroyed") - longPollingSession.invalidateAndCancel() - nonSubscribeSession.invalidateAndCancel() - // Poke the session factory to clean up nil values - SubscribeSessionFactory.shared.sessionDestroyed() + + /// Current connection status + public var connectionStatus: ConnectionStatus { + strategy.connectionStatus } - + // MARK: - Subscription Loop /// Subscribe to channels and/or channel groups @@ -130,195 +73,23 @@ public class SubscriptionSession { at cursor: SubscribeCursor? = nil, withPresence: Bool = false ) { - if channels.isEmpty, groups.isEmpty { - return - } - - let channelObject = channels.map { PubNubChannel(id: $0, withPresence: withPresence) } - let groupObjects = groups.map { PubNubChannel(id: $0, withPresence: withPresence) } - - // Don't attempt to start subscription if there are no changes - let subscribeChange = internalState.lockedWrite { state -> SubscriptionChangeEvent in - - let newChannels = channelObject.filter { state.channels.insert($0) } - let newGroups = groupObjects.filter { state.groups.insert($0) } - - return .subscribed(channels: newChannels, groups: newGroups) - } - - if subscribeChange.didChange { - notify { $0.emit(subscribe: .subscriptionChanged(subscribeChange)) } - } - - if subscribeChange.didChange || !connectionStatus.isActive { - reconnect(at: cursor) - } + strategy.subscribe( + to: channels, + and: groups, + at: cursor, + withPresence: withPresence + ) } /// Reconnect a disconnected subscription stream /// - parameter timetoken: The timetoken to subscribe with public func reconnect(at cursor: SubscribeCursor? = nil) { - if !connectionStatus.isActive { - connectionStatus = .connecting - - // Start subscribe loop - performSubscribeLoop(at: cursor) - - // Start presence heartbeat - registerHeartbeatTimer() - } else { - // Start subscribe loop - performSubscribeLoop(at: cursor) - } + strategy.reconnect(at: cursor) } /// Disconnect the subscription stream public func disconnect() { - stopSubscribeLoop(.clientCancelled) - stopHeartbeatTimer() - } - - @discardableResult - func stopSubscribeLoop(_ reason: PubNubError.Reason) -> Bool { - // Cancel subscription requests - request?.cancel(PubNubError(reason, router: request?.router)) - - return connectionStatus.isActive - } - - // swiftlint:disable:next cyclomatic_complexity function_body_length - func performSubscribeLoop(at cursor: SubscribeCursor?) { - let (channels, groups) = internalState.lockedWrite { state -> ([String], [String]) in - (state.allSubscribedChannels, state.allSubscribedGroups) - } - - // Don't start subscription if there no channels/groups - if channels.isEmpty, groups.isEmpty { - return - } - - // Create Endpoing - let router = SubscribeRouter(.subscribe(channels: channels, groups: groups, timetoken: cursor?.timetoken, - region: cursor?.region.description, - heartbeat: configuration.durationUntilTimeout, - filter: filterExpression), - configuration: configuration) - - // Cancel previous request before starting new one - stopSubscribeLoop(.longPollingRestart) - - // Will compre this in the error response to see if we need to restart - let nextSubscribe = longPollingSession - .request(with: router, requestOperator: configuration.automaticRetry) - let currentSubscribeID = nextSubscribe.requestID - request = nextSubscribe - - request? - .validate() - .response(on: .main, decoder: SubscribeDecoder()) { [weak self] result in - switch result { - case let .success(response): - guard let strongSelf = self else { - return - } - - // Reset heartbeat timer - self?.registerHeartbeatTimer() - - // Ensure that we're connected now the response has been processed - self?.connectionStatus = .connected - - // Emit the header of the reponse - self?.notify { listener in - var pubnubChannels = [String: PubNubChannel]() - channels.forEach { - if $0.isPresenceChannelName { - let channel = PubNubChannel(channel: $0) - pubnubChannels[channel.id] = channel - } else if pubnubChannels[$0] == nil { - pubnubChannels[$0] = PubNubChannel(channel: $0) - } - } - - var pubnubGroups = [String: PubNubChannel]() - groups.forEach { - if $0.isPresenceChannelName { - let group = PubNubChannel(channel: $0) - pubnubGroups[group.id] = group - } else if pubnubChannels[$0] == nil { - pubnubGroups[$0] = PubNubChannel(channel: $0) - } - } - - listener.emit(subscribe: - .responseReceived(SubscribeResponseHeader( - channels: pubnubChannels.values.map { $0 }, - groups: pubnubGroups.values.map { $0 }, - previous: cursor, - next: response.payload.cursor - )) - ) - } - - // Attempt to detect missed messages due to queue overflow - if response.payload.messages.count >= 100 { - self?.notify { - $0.emit(subscribe: .errorReceived(PubNubError( - .messageCountExceededMaximum, - router: router, - affected: [.subscribe(response.payload.cursor)] - ))) - } - } - - let events = response.payload.messages - .filter { message in // Dedupe the message - // Update Cache and notify if not a duplicate message - if !strongSelf.messageCache.contains(message) { - self?.messageCache.append(message) - - // Remove oldest value if we're at max capacity - if strongSelf.messageCache.count >= 100 { - self?.messageCache.remove(at: 0) - } - - return true - } - - return false - } - - self?.notify { $0.emit(batch: events) } - - self?.previousTokenResponse = response.payload.cursor - - // Repeat the request - self?.performSubscribeLoop(at: response.payload.cursor) - case let .failure(error): - self?.notify { [unowned self] in - $0.emit(subscribe: - .errorReceived(PubNubError.event(error, router: self?.request?.router)) - ) - } - - if error.pubNubError?.reason == .clientCancelled || error.pubNubError?.reason == .longPollingRestart || - error.pubNubError?.reason == .longPollingReset { - if self?.subscriptionCount == 0 { - self?.connectionStatus = .disconnected - } else if self?.request?.requestID == currentSubscribeID { - // No new request has been created so we'll reconnect here - self?.reconnect(at: self?.previousTokenResponse) - } - } else if let cursor = error.pubNubError?.affected.findFirst(by: PubNubError.AffectedValue.subscribe) { - self?.previousTokenResponse = cursor - - // Repeat the request - self?.performSubscribeLoop(at: cursor) - } else { - self?.connectionStatus = .disconnectedUnexpectedly - } - } - } + strategy.disconnect() } // MARK: - Unsubscribe @@ -330,90 +101,16 @@ public class SubscriptionSession { /// - and: List of channel groups to unsubscribe from /// - presenceOnly: If true, it only unsubscribes from presence events on the specified channels. public func unsubscribe(from channels: [String], and groups: [String] = [], presenceOnly: Bool = false) { - // Update Channel List - let subscribeChange = internalState.lockedWrite { state -> SubscriptionChangeEvent in - if presenceOnly { - let presenceChannelsRemoved = channels.compactMap { state.channels.unsubscribePresence($0) } - let presenceGroupsRemoved = groups.compactMap { state.groups.unsubscribePresence($0) } - - return .unsubscribed(channels: presenceChannelsRemoved, groups: presenceGroupsRemoved) - } else { - let removedChannels = channels.compactMap { state.channels.removeValue(forKey: $0) } - let removedGroups = groups.compactMap { state.groups.removeValue(forKey: $0) } - - return .unsubscribed(channels: removedChannels, groups: removedGroups) - } - } - - if subscribeChange.didChange { - notify { - $0.emit(subscribe: .subscriptionChanged(subscribeChange)) - } - // Call unsubscribe to cleanup remaining state items - unsubscribeCleanup(subscribeChange: subscribeChange) - } + strategy.unsubscribe( + from: channels, + and: groups, + presenceOnly: presenceOnly + ) } /// Unsubscribe from all channels and channel groups public func unsubscribeAll() { - // Remove All Channels & Groups - let subscribeChange = internalState.lockedWrite { mutableState -> SubscriptionChangeEvent in - - let removedChannels = mutableState.channels - mutableState.channels.removeAll(keepingCapacity: true) - - let removedGroups = mutableState.groups - mutableState.groups.removeAll(keepingCapacity: true) - - return .unsubscribed(channels: removedChannels.map { $0.value }, groups: removedGroups.map { $0.value }) - } - - if subscribeChange.didChange { - notify { - $0.emit(subscribe: .subscriptionChanged(subscribeChange)) - } - // Cancel previous subscribe request. - stopSubscribeLoop(.longPollingReset) - - // Call unsubscribe to cleanup remaining state items - unsubscribeCleanup(subscribeChange: subscribeChange) - } - } - - func unsubscribeCleanup(subscribeChange: SubscriptionChangeEvent) { - // Call Leave on channels/groups - if !configuration.supressLeaveEvents { - switch subscribeChange { - case let .unsubscribed(channels, groups): - presenceLeave(for: configuration.uuid, - on: channels.map { $0.id }, - and: groups.map { $0.id }) { [weak self] result in - switch result { - case .success: - if !channels.isEmpty { - PubNub.log.info("Presence Leave Successful on channels \(channels.map { $0.id })") - } - if !groups.isEmpty { - PubNub.log.info("Presence Leave Successful on groups \(groups.map { $0.id })") - } - case let .failure(error): - self?.notify { - $0.emit(subscribe: .errorReceived(PubNubError.event(error, router: nil))) - } - } - } - default: - break - } - } - - // Reset all timetokens and regions if we've unsubscribed from all channels/groups - if internalState.lockedRead({ $0.totalSubscribedCount == 0 }) { - previousTokenResponse = nil - disconnect() - } else { - reconnect(at: previousTokenResponse) - } + strategy.unsubscribeAll() } } @@ -421,30 +118,21 @@ extension SubscriptionSession: EventStreamEmitter { public typealias ListenerType = BaseSubscriptionListener public var listeners: [ListenerType] { - return privateListeners.allObjects + strategy.listeners } public func add(_ listener: ListenerType) { - // Ensure that we cancel the previously attached token - listener.token?.cancel() - - // Add new token to the listener - listener.token = ListenerToken { [weak self, weak listener] in - if let listener = listener { - self?.privateListeners.remove(listener) - } - } - privateListeners.update(listener) + strategy.add(listener) } public func notify(listeners closure: (ListenerType) -> Void) { - listeners.forEach { closure($0) } + strategy.notify(listeners: closure) } } extension SubscriptionSession: Hashable, CustomStringConvertible { public static func == (lhs: SubscriptionSession, rhs: SubscriptionSession) -> Bool { - return lhs.uuid == rhs.uuid + lhs.uuid == rhs.uuid } public func hash(into hasher: inout Hasher) { @@ -452,8 +140,6 @@ extension SubscriptionSession: Hashable, CustomStringConvertible { } public var description: String { - return uuid.uuidString + uuid.uuidString } - - // swiftlint:disable:next file_length } diff --git a/Sources/PubNub/Subscription/SubscriptionState.swift b/Sources/PubNub/Subscription/SubscriptionState.swift index a798db54..677bae8d 100644 --- a/Sources/PubNub/Subscription/SubscriptionState.swift +++ b/Sources/PubNub/Subscription/SubscriptionState.swift @@ -74,7 +74,7 @@ public struct PubNubChannel: Hashable { /// The presence channel name public let presenceId: String /// If the channel is currently subscribed with presence - public var isPresenceSubscribed: Bool + public let isPresenceSubscribed: Bool public init(id: String, withPresence: Bool = false) { self.id = id @@ -119,25 +119,3 @@ extension PubNubChannel: Codable { try container.encode(isPresenceSubscribed, forKey: .isPresenceSubscribed) } } - -public extension Dictionary where Key == String, Value == PubNubChannel { - /// Inserts the provided channel if that channel doesn't already exist - mutating func insert(_ channel: Value) -> Bool { - if let match = self[channel.id], match == channel { - return false - } - - self[channel.id] = channel - return true - } - - /// Updates the subscribedPresence state on the channel matching the provided name - mutating func unsubscribePresence(_ id: String) -> Value? { - if var match = self[id], match.isPresenceSubscribed { - match.isPresenceSubscribed = false - self[match.id] = match - return match - } - return nil - } -} diff --git a/Tests/PubNubContractTest/PubNubContractCucumberTest.m b/Tests/PubNubContractTest/PubNubContractCucumberTest.m index 54ce7118..cc8ac961 100644 --- a/Tests/PubNubContractTest/PubNubContractCucumberTest.m +++ b/Tests/PubNubContractTest/PubNubContractCucumberTest.m @@ -1,28 +1,11 @@ // -// PubNubContractTest.m +// PubNubContractCucumberTest.swift // -// PubNub Real-time Cloud-Hosted Push API and Push Notification Client Frameworks -// Copyright © 2021 PubNub Inc. -// https://www.pubnub.com/ -// https://www.pubnub.com/terms +// Copyright (c) PubNub Inc. +// All rights reserved. // -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. // #import "PubNubContractTests-Swift.h" @@ -94,8 +77,6 @@ void CucumberishInit(void) { @"contract=removeAliceMembership", @"contract=manageAliceMemberships" ]; - - NSBundle * bundle = [NSBundle bundleForClass:[PubNubContractTestCase class]]; [Cucumberish executeFeaturesInDirectory:@"Features" diff --git a/Tests/PubNubContractTest/PubNubContractTestCase.swift b/Tests/PubNubContractTest/PubNubContractTestCase.swift index f7c4048a..628aefec 100644 --- a/Tests/PubNubContractTest/PubNubContractTestCase.swift +++ b/Tests/PubNubContractTest/PubNubContractTestCase.swift @@ -23,20 +23,31 @@ let defaultPublishKey = "demo-36" public var messageReceivedHandler: ((PubNubMessage, [PubNubMessage]) -> Void)? public var statusReceivedHandler: ((SubscriptionListener.StatusEvent, [SubscriptionListener.StatusEvent]) -> Void)? + public var presenceChangeReceivedHandler: ((PubNubPresenceChange, [PubNubPresenceChange]) -> Void)? + fileprivate static var _receivedErrorStatuses: [SubscriptionListener.StatusEvent] = [] fileprivate static var _receivedStatuses: [SubscriptionListener.StatusEvent] = [] fileprivate static var _receivedMessages: [PubNubMessage] = [] + fileprivate static var _receivedPresenceChanges: [PubNubPresenceChange] = [] + fileprivate static var _currentScenario: CCIScenarioDefinition? fileprivate static var _apiCallResults: [Any] = [] - fileprivate var currentConfiguration = PubNubConfiguration(publishKey: defaultPublishKey, - subscribeKey: defaultSubscribeKey, - userId: UUID().uuidString, - useSecureConnections: false, - origin: mockServerAddress, - supressLeaveEvents: true) + + fileprivate static var _currentConfiguration = PubNubContractTestCase._defaultConfiguration + fileprivate static var _defaultConfiguration: PubNubConfiguration { + PubNubConfiguration( + publishKey: defaultPublishKey, + subscribeKey: defaultSubscribeKey, + userId: UUID().uuidString, + useSecureConnections: false, + origin: mockServerAddress, + supressLeaveEvents: true + ) + } + fileprivate static var currentClient: PubNub? - public var configuration: PubNubConfiguration { currentConfiguration } + public var configuration: PubNubConfiguration { PubNubContractTestCase._currentConfiguration } public var expectSubscribeFailure: Bool { false } @@ -62,6 +73,11 @@ let defaultPublishKey = "demo-36" get { PubNubContractTestCase._receivedMessages } set { PubNubContractTestCase._receivedMessages = newValue } } + + public var receivedPresenceChanges: [PubNubPresenceChange] { + get { PubNubContractTestCase._receivedPresenceChanges } + set { PubNubContractTestCase._receivedPresenceChanges = newValue } + } public var apiCallResults: [Any] { get { PubNubContractTestCase._apiCallResults } @@ -70,11 +86,21 @@ let defaultPublishKey = "demo-36" public var client: PubNub { if PubNubContractTestCase.currentClient == nil { - PubNubContractTestCase.currentClient = PubNub(configuration: configuration) + PubNubContractTestCase.currentClient = createPubNubClient() } - return PubNubContractTestCase.currentClient! } + + func replacePubNubConfiguration(with configuration: PubNubConfiguration) { + if PubNubContractTestCase.currentClient != nil { + preconditionFailure("Cannot replace configuration when PubNub instance was already created") + } + PubNubContractTestCase._currentConfiguration = configuration + } + + func createPubNubClient() -> PubNub { + PubNub(configuration: configuration) + } public func startCucumberHookEventsListening() { NotificationCenter.default.addObserver(forName: .cucumberBeforeHook, object: nil, queue: nil) { [weak self] _ in @@ -90,19 +116,14 @@ let defaultPublishKey = "demo-36" } public func handleAfterHook() { - currentConfiguration = PubNubConfiguration(publishKey: defaultPublishKey, - subscribeKey: defaultSubscribeKey, - userId: UUID().uuidString, - useSecureConnections: false, - origin: mockServerAddress, - supressLeaveEvents: true) - + PubNubContractTestCase._currentConfiguration = PubNubContractTestCase._defaultConfiguration PubNubContractTestCase.currentClient?.unsubscribeAll() PubNubContractTestCase.currentClient = nil receivedErrorStatuses.removeAll() receivedStatuses.removeAll() receivedMessages.removeAll() + receivedPresenceChanges.removeAll() apiCallResults.removeAll() } @@ -210,6 +231,8 @@ let defaultPublishKey = "demo-36" PubNubPushContractTestSteps().setup() PubNubPublishContractTestSteps().setup() PubNubSubscribeContractTestSteps().setup() + PubNubSubscribeEngineContractTestsSteps().setup() + PubNubPresenceEngineContractTestsSteps().setup() PubNubTimeContractTestSteps().setup() PubNubCryptoModuleContractTestSteps().setup() @@ -284,6 +307,15 @@ let defaultPublishKey = "demo-36" handler(message, strongSelf.receivedMessages) } } + + listener.didReceivePresence = { [weak self] presenceChange in + guard let strongSelf = self else { return } + strongSelf.receivedPresenceChanges.append(presenceChange) + + if let handler = strongSelf.presenceChangeReceivedHandler { + handler(presenceChange, strongSelf.receivedPresenceChanges) + } + } client.add(listener) client.subscribe(to: channels, and: groups, at: timetoken, withPresence: presence) @@ -310,6 +342,33 @@ let defaultPublishKey = "demo-36" return receivedMessages.count > 0 ? receivedMessages : nil } } + + // MARK: - Presence + + @discardableResult + public func waitForPresenceChanges(_: PubNub, count: Int) -> [PubNubPresenceChange]? { + if receivedPresenceChanges.count < count { + let receivedPresenceChangeExpectation = expectation(description: "Subscribe messages") + receivedPresenceChangeExpectation.assertForOverFulfill = false + presenceChangeReceivedHandler = { _, presenceChanges in + if presenceChanges.count >= count { + receivedPresenceChangeExpectation.fulfill() + } + } + + wait(for: [receivedPresenceChangeExpectation], timeout: 10.0) + } + + defer { + receivedPresenceChanges.removeAll() + } + + if receivedPresenceChanges.count > count { + return Array(receivedPresenceChanges[.. 0 ? receivedPresenceChanges : nil + } + } // MARK: - Results handling diff --git a/Tests/PubNubContractTest/Steps/EventEngine/PubNubEventEngineContractTestSteps.swift b/Tests/PubNubContractTest/Steps/EventEngine/PubNubEventEngineContractTestSteps.swift new file mode 100644 index 00000000..05ead904 --- /dev/null +++ b/Tests/PubNubContractTest/Steps/EventEngine/PubNubEventEngineContractTestSteps.swift @@ -0,0 +1,24 @@ +// +// PubNubEventEngineContractTestsSteps.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import Cucumberish + +@testable import PubNub + +class PubNubEventEngineContractTestsSteps: PubNubContractTestCase { + func extractExpectedResults(from: [AnyHashable: Any]?) -> (events: [String], invocations: [String]) { + let dataTable = from?["DataTable"] as? Array> ?? [] + let events = dataTable.compactMap { $0.first == "event" ? $0.last : nil } + let invocations = dataTable.compactMap { $0.first == "invocation" ? $0.last : nil } + + return (events: events, invocations: invocations) + } +} diff --git a/Tests/PubNubContractTest/Steps/EventEngine/PubNubEventEngineTestsHelpers.swift b/Tests/PubNubContractTest/Steps/EventEngine/PubNubEventEngineTestsHelpers.swift new file mode 100644 index 00000000..04259833 --- /dev/null +++ b/Tests/PubNubContractTest/Steps/EventEngine/PubNubEventEngineTestsHelpers.swift @@ -0,0 +1,68 @@ +// +// PubNubEventEngineTestHelpers.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +@testable import PubNub + +protocol ContractTestIdentifiable { + var contractTestIdentifier: String { get } +} + +extension EffectInvocation: ContractTestIdentifiable where Invocation: ContractTestIdentifiable, Invocation.Cancellable: ContractTestIdentifiable { + var contractTestIdentifier: String { + switch self { + case .managed(let invocation): + return invocation.contractTestIdentifier + case .cancel(let cancellable): + return cancellable.contractTestIdentifier + case .regular(let invocation): + return invocation.contractTestIdentifier + } + } +} + +class DispatcherDecorator: Dispatcher { + private let wrappedInstance: any Dispatcher + private(set) var recordedInvocations: [EffectInvocation] + + init(wrappedInstance: some Dispatcher) { + self.wrappedInstance = wrappedInstance + self.recordedInvocations = [] + } + + func dispatch( + invocations: [EffectInvocation], + with dependencies: EventEngineDependencies, + notify listener: DispatcherListener + ) { + recordedInvocations += invocations + wrappedInstance.dispatch(invocations: invocations, with: dependencies, notify: listener) + } +} + +class TransitionDecorator: TransitionProtocol { + private let wrappedInstance: any TransitionProtocol + private(set) var recordedEvents: [Event] + + init(wrappedInstance: some TransitionProtocol) { + self.wrappedInstance = wrappedInstance + self.recordedEvents = [] + } + + func canTransition(from state: State, dueTo event: Event) -> Bool { + wrappedInstance.canTransition(from: state, dueTo: event) + } + + func transition(from state: State, event: Event) -> TransitionResult { + recordedEvents.append(event) + return wrappedInstance.transition(from: state, event: event) + } +} diff --git a/Tests/PubNubContractTest/Steps/EventEngine/PubNubPresenceEngineContractTestSteps.swift b/Tests/PubNubContractTest/Steps/EventEngine/PubNubPresenceEngineContractTestSteps.swift new file mode 100644 index 00000000..c876c444 --- /dev/null +++ b/Tests/PubNubContractTest/Steps/EventEngine/PubNubPresenceEngineContractTestSteps.swift @@ -0,0 +1,227 @@ +// +// PubNubPresenceEngineContractTestsSteps.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import Cucumberish + +@testable import PubNub + +extension Presence.Invocation: ContractTestIdentifiable { + var contractTestIdentifier: String { + switch self { + case .heartbeat(_, _): + return "HEARTBEAT" + case .leave(_, _): + return "LEAVE" + case .delayedHeartbeat(_, _, _, _): + return "DELAYED_HEARTBEAT" + case .wait: + return "WAIT" + } + } +} + +extension Presence.Invocation.Cancellable: ContractTestIdentifiable { + var contractTestIdentifier: String { + switch self { + case .wait: + return "CANCEL_WAIT" + case .delayedHeartbeat: + return "CANCEL_DELAYED_HEARTBEAT" + } + } +} + +extension Presence.Event: ContractTestIdentifiable { + var contractTestIdentifier: String { + switch self { + case .joined(_, _): + return "JOINED" + case .left(_, _): + return "LEFT" + case .leftAll: + return "LEFT_ALL" + case .reconnect: + return "RECONNECT" + case .disconnect: + return "DISCONNECT" + case .timesUp: + return "TIMES_UP" + case .heartbeatSuccess: + return "HEARTBEAT_SUCCESS" + case .heartbeatFailed(_): + return "HEARTBEAT_FAILURE" + case .heartbeatGiveUp(_): + return "HEARTBEAT_GIVEUP" + } + } +} + +class PubNubPresenceEngineContractTestsSteps: PubNubEventEngineContractTestsSteps { + // A decorator that records Invocations and forwards all calls to the original instance + private var dispatcherDecorator: DispatcherDecorator! + // A decorator that records Events and forwards all calls to the original instance + private var transitionDecorator: TransitionDecorator! + + override func handleAfterHook() { + dispatcherDecorator = nil + transitionDecorator = nil + super.handleAfterHook() + } + + override func createPubNubClient() -> PubNub { + let configuration = self.configuration + let factory = EventEngineFactory() + + /// Wraps original EffectDispatcher with Decorator that allows recording incoming Invocations + dispatcherDecorator = DispatcherDecorator( + wrappedInstance: EffectDispatcher( + factory: PresenceEffectFactory( + session: HTTPSession( + configuration: .pubnub, + sessionQueue: .global(qos: .default), + sessionStream: SessionListener(queue: .global(qos: .default)) + ), presenceStateContainer: .shared + ) + ) + ) + /// Wraps original Transition with Decorator that allows recording incoming Events + transitionDecorator = TransitionDecorator( + wrappedInstance: PresenceTransition(configuration: configuration) + ) + + let subscribeEffectFactory = SubscribeEffectFactory( + session: HTTPSession( + configuration: URLSessionConfiguration.subscription, + sessionQueue: .global(qos: .default), + sessionStream: SessionListener(queue: .global(qos: .default)) + ), presenceStateContainer: .shared + ) + let subscribeEngine = EventEngineFactory().subscribeEngine( + with: configuration, + dispatcher: EffectDispatcher(factory: subscribeEffectFactory), + transition: SubscribeTransition() + ) + let presenceEngine = factory.presenceEngine( + with: configuration, + dispatcher: dispatcherDecorator, + transition: transitionDecorator + ) + let subscriptionSession = SubscriptionSession( + strategy: EventEngineSubscriptionSessionStrategy( + configuration: configuration, + subscribeEngine: subscribeEngine, + presenceEngine: presenceEngine, + presenceStateContainer: .shared + ) + ) + return PubNub( + configuration: configuration, + session: HTTPSession(configuration: configuration.urlSessionConfiguration), + fileSession: URLSession(configuration: .pubnubBackground), + subscriptionSession: subscriptionSession + ) + } + + override public func setup() { + startCucumberHookEventsListening() + + Given("^the demo keyset with Presence Event Engine enabled$") { args, _ in + self.replacePubNubConfiguration(with: PubNubConfiguration( + publishKey: self.configuration.publishKey, + subscribeKey: self.configuration.subscribeKey, + userId: self.configuration.userId, + useSecureConnections: self.configuration.useSecureConnections, + origin: self.configuration.origin, + enableEventEngine: true + )) + } + + Given("a linear reconnection policy with 3 retries") { args, _ in + self.replacePubNubConfiguration(with: PubNubConfiguration( + publishKey: self.configuration.publishKey, + subscribeKey: self.configuration.subscribeKey, + userId: self.configuration.userId, + useSecureConnections: self.configuration.useSecureConnections, + origin: self.configuration.origin, + automaticRetry: AutomaticRetry(retryLimit: 3, policy: .linear(delay: 0.5)), + heartbeatInterval: 30, + supressLeaveEvents: true, + enableEventEngine: true + )) + } + + Given("^heartbeatInterval set to '([0-9]+)', timeout set to '([0-9]+)' and suppressLeaveEvents set to '(.*)'$") { args, _ in + self.replacePubNubConfiguration(with: PubNubConfiguration( + publishKey: self.configuration.publishKey, + subscribeKey: self.configuration.subscribeKey, + userId: self.configuration.userId, + useSecureConnections: self.configuration.useSecureConnections, + origin: self.configuration.origin, + durationUntilTimeout: UInt(args![1])!, + heartbeatInterval: UInt(args![0])!, + supressLeaveEvents: args![2] == "true", + enableEventEngine: self.configuration.enableEventEngine + )) + } + + When("^I join '(.*)', '(.*)', '(.*)' channels$") { args, _ in + let firstChannel = args?[0] ?? "" + let secondChannel = args?[1] ?? "" + let thirdChannel = args?[2] ?? "" + + self.subscribeSynchronously(self.client, to: [firstChannel, secondChannel, thirdChannel], with: false) + } + + When("^I join '(.*)', '(.*)', '(.*)' channels with presence$") { args, _ in + let firstChannel = args?[0] ?? "" + let secondChannel = args?[1] ?? "" + let thirdChannel = args?[2] ?? "" + + self.subscribeSynchronously(self.client, to: [firstChannel, secondChannel, thirdChannel], with: true) + } + + Then("^I wait for getting Presence joined events$") { args, _ in + XCTAssertNotNil(self.waitForPresenceChanges(self.client, count: 3)) + } + + Then("^I wait '([0-9]+)' seconds$") { args, _ in + self.waitFor(delay: TimeInterval(args!.first!)!) + } + + Then("^I wait for getting Presence left events$") { args, _ in + XCTAssertNotNil(self.waitForPresenceChanges(self.client, count: 2)) + } + + Then("^I leave '(.*)' and '(.*)' channels with presence$") { args, _ in + let firstChannel = args?[0] ?? "" + let secondChannel = args?[1] ?? "" + + self.client.unsubscribe(from: [firstChannel, secondChannel]) + } + + Then("^I receive an error in my heartbeat response$") { _, _ in + self.waitFor(delay: 9.5) + } + + Match(["And", "Then"], "^I observe the following Events and Invocations of the Presence EE:$") { args, value in + let recordedEvents = self.transitionDecorator.recordedEvents.map { $0.contractTestIdentifier } + let recordedInvocations = self.dispatcherDecorator.recordedInvocations.map { $0.contractTestIdentifier } + + XCTAssertTrue(recordedEvents.elementsEqual(self.extractExpectedResults(from: value).events)) + XCTAssertTrue(recordedInvocations.elementsEqual(self.extractExpectedResults(from: value).invocations)) + } + + Then("^I don't observe any Events and Invocations of the Presence EE") { args, value in + XCTAssertTrue(self.transitionDecorator.recordedEvents.isEmpty) + XCTAssertTrue(self.dispatcherDecorator.recordedInvocations.isEmpty) + } + } +} diff --git a/Tests/PubNubContractTest/Steps/EventEngine/PubNubSubscribeEngineContractTestsSteps.swift b/Tests/PubNubContractTest/Steps/EventEngine/PubNubSubscribeEngineContractTestsSteps.swift new file mode 100644 index 00000000..64263bde --- /dev/null +++ b/Tests/PubNubContractTest/Steps/EventEngine/PubNubSubscribeEngineContractTestsSteps.swift @@ -0,0 +1,217 @@ +// +// PubNubSubscribeEngineContractTestsSteps.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import Cucumberish + +@testable import PubNub + +extension Subscribe.Invocation: ContractTestIdentifiable { + var contractTestIdentifier: String { + switch self { + case .handshakeRequest(_, _): + return "HANDSHAKE" + case .handshakeReconnect(_, _, _, _): + return "HANDSHAKE_RECONNECT" + case .receiveMessages(_, _, _): + return "RECEIVE_MESSAGES" + case .receiveReconnect(_, _, _, _, _): + return "RECEIVE_RECONNECT" + case .emitMessages(_,_): + return "EMIT_MESSAGES" + case .emitStatus(_): + return "EMIT_STATUS" + } + } +} + +extension Subscribe.Invocation.Cancellable: ContractTestIdentifiable { + var contractTestIdentifier: String { + switch self { + case .handshakeRequest: + return "CANCEL_HANDSHAKE" + case .handshakeReconnect: + return "CANCEL_HANDSHAKE_RECONNECT" + case .receiveMessages: + return "CANCEL_RECEIVE_MESSAGES" + case .receiveReconnect: + return "CANCEL_RECEIVE_RECONNECT" + } + } +} + +extension Subscribe.Event: ContractTestIdentifiable { + var contractTestIdentifier: String { + switch self { + case .handshakeSuccess(_): + return "HANDSHAKE_SUCCESS" + case .handshakeFailure(_): + return "HANDSHAKE_FAILURE" + case .handshakeReconnectSuccess(_): + return "HANDSHAKE_RECONNECT_SUCCESS" + case .handshakeReconnectFailure(_): + return "HANDSHAKE_RECONNECT_FAILURE" + case .handshakeReconnectGiveUp(_): + return "HANDSHAKE_RECONNECT_GIVEUP" + case .receiveSuccess(_,_): + return "RECEIVE_SUCCESS" + case .receiveFailure(_): + return "RECEIVE_FAILURE" + case .receiveReconnectSuccess(_,_): + return "RECEIVE_RECONNECT_SUCCESS" + case .receiveReconnectFailure(_): + return "RECEIVE_RECONNECT_FAILURE" + case .receiveReconnectGiveUp(_): + return "RECEIVE_RECONNECT_GIVEUP" + case .subscriptionChanged(_, _): + return "SUBSCRIPTION_CHANGED" + case .subscriptionRestored(_, _, _): + return "SUBSCRIPTION_RESTORED" + case .unsubscribeAll: + return "UNSUBSCRIBE_ALL" + case .disconnect: + return "DISCONNECT" + case .reconnect: + return "RECONNECT" + } + } +} + +class PubNubSubscribeEngineContractTestsSteps: PubNubEventEngineContractTestsSteps { + // A decorator that records Invocations and forwards all calls to the original instance + private var dispatcherDecorator: DispatcherDecorator! + // A decorator that records Events and forwards all calls to the original instance + private var transitionDecorator: TransitionDecorator! + + override func handleAfterHook() { + dispatcherDecorator = nil + transitionDecorator = nil + super.handleAfterHook() + } + + override var expectSubscribeFailure: Bool { + [ + "Successfully restore subscribe with failures", + "Complete handshake failure", + "Handshake failure recovery", + "Receiving failure recovery" + ].contains(currentScenario?.name ?? "") + } + + override func createPubNubClient() -> PubNub { + /// Wraps original EffectDispatcher with Decorator that allows recording incoming Invocations + dispatcherDecorator = DispatcherDecorator( + wrappedInstance: EffectDispatcher( + factory: SubscribeEffectFactory( + session: HTTPSession( + configuration: URLSessionConfiguration.subscription, + sessionQueue: .global(qos: .default), + sessionStream: SessionListener(queue: .global(qos: .default)) + ), presenceStateContainer: .shared + ) + ) + ) + /// Wraps original Transition with Decorator that allows recording incoming Events + transitionDecorator = TransitionDecorator( + wrappedInstance: SubscribeTransition() + ) + + let factory = EventEngineFactory() + let configuration = self.configuration + + let subscribeEngine = factory.subscribeEngine( + with: configuration, + dispatcher: self.dispatcherDecorator, + transition: self.transitionDecorator + ) + let presenceEffectFactory = PresenceEffectFactory( + session: HTTPSession( + configuration: .pubnub, + sessionQueue: .global(qos: .default), + sessionStream: SessionListener(queue: .global(qos: .default)) + ), presenceStateContainer: .shared + ) + let presenceEngine = factory.presenceEngine( + with: configuration, + dispatcher: EffectDispatcher(factory: presenceEffectFactory), + transition: PresenceTransition(configuration: configuration) + ) + let subscriptionSession = SubscriptionSession( + strategy: EventEngineSubscriptionSessionStrategy( + configuration: configuration, + subscribeEngine: subscribeEngine, + presenceEngine: presenceEngine, + presenceStateContainer: .shared + ) + ) + return PubNub( + configuration: configuration, + session: HTTPSession(configuration: configuration.urlSessionConfiguration), + fileSession: URLSession(configuration: .pubnubBackground), + subscriptionSession: subscriptionSession + ) + } + + override public func setup() { + startCucumberHookEventsListening() + + Given("a linear reconnection policy with 3 retries") { args, _ in + self.replacePubNubConfiguration(with: PubNubConfiguration( + publishKey: self.configuration.publishKey, + subscribeKey: self.configuration.subscribeKey, + userId: self.configuration.userId, + useSecureConnections: self.configuration.useSecureConnections, + origin: self.configuration.origin, + automaticRetry: AutomaticRetry(retryLimit: 3, policy: .linear(delay: 0.5)), + heartbeatInterval: 0, + supressLeaveEvents: true, + enableEventEngine: true + )) + } + + Given("the demo keyset with event engine enabled") { _, _ in + self.replacePubNubConfiguration(with: PubNubConfiguration( + publishKey: self.configuration.publishKey, + subscribeKey: self.configuration.subscribeKey, + userId: self.configuration.userId, + useSecureConnections: self.configuration.useSecureConnections, + origin: self.configuration.origin, + heartbeatInterval: 0, + supressLeaveEvents: true, + enableEventEngine: true + )) + } + + When("I subscribe") { _, _ in + self.subscribeSynchronously(self.client, to: ["test"]) + } + + When("I subscribe with timetoken 42") { _, _ in + self.subscribeSynchronously(self.client, to: ["test"], timetoken: 42) + } + + Then("I receive an error in my subscribe response") { _, _ in + XCTAssertNotNil(self.receivedErrorStatuses.first) + } + + Then("I receive the message in my subscribe response") { _, userInfo in + let messages = self.waitForMessages(self.client, count: 1) ?? [] + XCTAssertNotNil(messages.first) + } + + Match(["And"], "I observe the following:") { args, value in + let recordedEvents = self.transitionDecorator.recordedEvents.map { $0.contractTestIdentifier } + let recordedInvocations = self.dispatcherDecorator.recordedInvocations.map { $0.contractTestIdentifier } + + XCTAssertTrue(recordedEvents.elementsEqual(self.extractExpectedResults(from: value).events)) + XCTAssertTrue(recordedInvocations.elementsEqual(self.extractExpectedResults(from: value).invocations)) + } + } +} diff --git a/Tests/PubNubTests/EventEngine/DispatcherTests.swift b/Tests/PubNubTests/EventEngine/DispatcherTests.swift new file mode 100644 index 00000000..c1989c5d --- /dev/null +++ b/Tests/PubNubTests/EventEngine/DispatcherTests.swift @@ -0,0 +1,176 @@ +// +// DispatcherTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +class DispatcherTests: XCTestCase { + func testDispatcher_FinishingAnyInvocationNotifiesListener() { + let onResultReceivedExpectation = XCTestExpectation(description: "onResultReceived") + onResultReceivedExpectation.expectedFulfillmentCount = 4 + onResultReceivedExpectation.assertForOverFulfill = true + + let dispatcher = EffectDispatcher(factory: MockEffectHandlerFactory()) + let listener = DispatcherListener(onAnyInvocationCompleted: { _ in + onResultReceivedExpectation.fulfill() + }) + + dispatcher.dispatch( + invocations: [ + .managed(.first), + .managed(.second), + .managed(.third), + .managed(.fourth) + ], + with: EventEngineDependencies(value: Void()), + notify: listener + ) + + wait(for: [onResultReceivedExpectation], timeout: 1.0) + } + + func testDispatcher_CancelInvocationsWithManagedInvocationsNotifiesListener() { + let onResultReceivedExpectation = XCTestExpectation(description: "onResultReceived") + onResultReceivedExpectation.expectedFulfillmentCount = 2 + onResultReceivedExpectation.assertForOverFulfill = true + + let dispatcher = EffectDispatcher(factory: MockEffectHandlerFactory()) + let listener = DispatcherListener(onAnyInvocationCompleted: { _ in + onResultReceivedExpectation.fulfill() + }) + + dispatcher.dispatch( + invocations: [ + .cancel(.firstCancellable), + .managed(.second), + .cancel(.thirdCancellable), + .managed(.fourth) + ], + with: EventEngineDependencies(value: Void()), + notify: listener + ) + + wait(for: [onResultReceivedExpectation], timeout: 1.0) + } + + func testDispatcher_NotifiesListenerWithExpectedEvents() { + let dispatcher = EffectDispatcher(factory: StubEffectHandlerFactory()) + let listener = DispatcherListener(onAnyInvocationCompleted: { events in + XCTAssertEqual(events, [.event1, .event3]) + }) + + dispatcher.dispatch( + invocations: [.managed(.first)], + with: EventEngineDependencies(value: Void()), + notify: listener + ) + } + + func testDispatcher_RemovesEffectsOnFinish() { + let onResultReceivedExpectation = XCTestExpectation(description: "onResultReceived") + onResultReceivedExpectation.expectedFulfillmentCount = 3 + onResultReceivedExpectation.assertForOverFulfill = true + + let dispatcher = EffectDispatcher(factory: MockEffectHandlerFactory()) + let listener = DispatcherListener(onAnyInvocationCompleted: { results in + onResultReceivedExpectation.fulfill() + }) + + dispatcher.dispatch( + invocations: [ + .managed(.first), + .managed(.second), + .cancel(.thirdCancellable), + .managed(.fourth) + ], + with: EventEngineDependencies(value: Void()), + notify: listener + ) + + wait(for: [onResultReceivedExpectation], timeout: 2.0) + + XCTAssertFalse(dispatcher.hasPendingInvocation(.first)) + XCTAssertFalse(dispatcher.hasPendingInvocation(.second)) + XCTAssertFalse(dispatcher.hasPendingInvocation(.third)) + XCTAssertFalse(dispatcher.hasPendingInvocation(.fourth)) + } +} + +fileprivate enum TestEvent { + case event1 + case event2 + case event3 +} + +fileprivate enum TestInvocation: String, AnyEffectInvocation { + case first = "first" + case second = "second" + case third = "third" + case fourth = "fourth" + + var id: String { + rawValue + } + + enum Cancellable: AnyCancellableInvocation { + var id: String { + switch self { + case .firstCancellable: + return TestInvocation.first.rawValue + case .secondCancellable: + return TestInvocation.second.rawValue + case .thirdCancellable: + return TestInvocation.third.rawValue + case .fourthCancellable: + return TestInvocation.fourth.rawValue + } + } + + case firstCancellable + case secondCancellable + case thirdCancellable + case fourthCancellable + } +} + +fileprivate struct MockEffectHandlerFactory: EffectHandlerFactory { + func effect( + for invocation: TestInvocation, + with dependencies: EventEngineDependencies + ) -> any EffectHandler { + MockEffectHandler() + } +} + +fileprivate struct MockEffectHandler: EffectHandler { + func performTask(completionBlock: @escaping ([TestEvent]) -> Void) { + // Added an artificial delay to simulate network latency or other asynchronous computations + DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + 0.35) { + completionBlock([]) + } + } +} + +fileprivate class StubEffectHandlerFactory: EffectHandlerFactory { + func effect( + for invocation: TestInvocation, + with dependencies: EventEngineDependencies + ) -> any EffectHandler { + StubEffectHandler() + } +} + +fileprivate class StubEffectHandler: EffectHandler { + func performTask(completionBlock: @escaping ([TestEvent]) -> Void) { + completionBlock([.event1, .event3]) + } +} diff --git a/Tests/PubNubTests/EventEngine/EventEngineTests.swift b/Tests/PubNubTests/EventEngine/EventEngineTests.swift new file mode 100644 index 00000000..fb5311f1 --- /dev/null +++ b/Tests/PubNubTests/EventEngine/EventEngineTests.swift @@ -0,0 +1,154 @@ +// +// EventEngineTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +fileprivate var initialState: ExampleState { + ExampleState( + x: 1000, + y: 2000, + z: 3000 + ) +} + +fileprivate var stateAfterSendingEvent1: ExampleState { + ExampleState( + x: 50, + y: 100, + z: 150 + ) +} + +fileprivate var stateAfterSendingEvent3: ExampleState { + ExampleState( + x: 99, + y: 999, + z: 9999 + ) +} + +fileprivate var stateAfterSendingEvent4: ExampleState { + ExampleState( + x: 0, + y: 0, + z: 0 + ) +} + +// MARK: - EventEngineTests + +class EventEngineTests: XCTestCase { + func testEventEngineTransitions() { + let eventEngine = EventEngine( + state: initialState, + transition: StubTransition(), + dispatcher: StubDispatcher(), + dependencies: EventEngineDependencies(value: Void()) + ) + + eventEngine.send(event: .event2) + XCTAssertTrue(eventEngine.state == initialState) + eventEngine.send(event: .event3) + XCTAssertTrue(eventEngine.state == stateAfterSendingEvent3) + eventEngine.send(event: .event1) + XCTAssertTrue(eventEngine.state == stateAfterSendingEvent1) + eventEngine.send(event: .event4) + XCTAssertTrue(eventEngine.state == stateAfterSendingEvent3) + } +} + +// MARK: - Helpers + +fileprivate struct ExampleState: Equatable { + let x: Int + let y: Int + let z: Int +} + +fileprivate enum ExampleEvent { + case event1 + case event2 + case event3 + case event4 +} + +fileprivate enum ExampleInvocation: AnyEffectInvocation { + case invocation + + var id: String { + "invocation" + } + + enum Cancellable: AnyCancellableInvocation { + case invocation + + var id: String { + "invocation" + } + } +} + +fileprivate class StubTransition: TransitionProtocol { + typealias State = ExampleState + typealias Event = ExampleEvent + typealias Invocation = ExampleInvocation + + func canTransition(from state: ExampleState, dueTo event: ExampleEvent) -> Bool { + switch event { + case .event1: + return true + case .event2: + return false + case .event3: + return true + case .event4: + return true + } + } + + func transition(from state: ExampleState, event: ExampleEvent) -> TransitionResult { + switch event { + case .event1: + return TransitionResult(state: stateAfterSendingEvent1, invocations: []) + case .event3: + return TransitionResult(state: stateAfterSendingEvent3, invocations: []) + case .event4: + return TransitionResult(state: state, invocations: [.managed(.invocation)]) + default: + fatalError("Unexpected condition") + } + } +} + +fileprivate struct StubDispatcher: Dispatcher { + typealias Invocation = ExampleInvocation + typealias Event = ExampleEvent + typealias Dependencies = Void + + func dispatch( + invocations: [EffectInvocation], + with dependencies: EventEngineDependencies, + notify listener: DispatcherListener + ) { + invocations.forEach { + switch $0 { + case .managed(_): + // Simulates that a hypothethical Effect returns an event back to EventEngine. + // The result of processing this event might be the new State, see implementation for Transition function + listener.onAnyInvocationCompleted([.event3]) + default: + fatalError("Unexpected test condition") + } + } + } +} diff --git a/Tests/PubNubTests/EventEngine/Helpers/EffectInvocation+Equatable.swift b/Tests/PubNubTests/EventEngine/Helpers/EffectInvocation+Equatable.swift new file mode 100644 index 00000000..d62cad9b --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Helpers/EffectInvocation+Equatable.swift @@ -0,0 +1,28 @@ +// +// EffectInvocation+Equatable.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +@testable import PubNub + +extension EffectInvocation: Equatable where Invocation: Equatable { + public static func ==(lhs: EffectInvocation, rhs: EffectInvocation) -> Bool { + switch (lhs, rhs) { + case (let .managed(lhsInvocation), let .managed(rhsInvocation)): + return lhsInvocation == rhsInvocation + case (let .regular(lhsInvocation), let .regular(rhsInvocation)): + return lhsInvocation == rhsInvocation + case (let .cancel(lhsId), let .cancel(rhsId)): + return lhsId.id == rhsId.id + default: + return false + } + } +} diff --git a/Tests/PubNubTests/EventEngine/Presence/DelayedHeartbeatEffectTests.swift b/Tests/PubNubTests/EventEngine/Presence/DelayedHeartbeatEffectTests.swift new file mode 100644 index 00000000..d25fedeb --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Presence/DelayedHeartbeatEffectTests.swift @@ -0,0 +1,130 @@ +// +// DelayedHeartbeatEffectTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +class DelayedHeartbeatEffectTests: XCTestCase { + private var mockUrlSession: MockURLSession! + private var httpSession: HTTPSession! + private var delegate: HTTPSessionDelegate! + private var factory: PresenceEffectFactory! + + override func setUp() { + delegate = HTTPSessionDelegate() + mockUrlSession = MockURLSession(delegate: delegate) + httpSession = HTTPSession(session: mockUrlSession, delegate: delegate, sessionQueue: .main) + factory = PresenceEffectFactory(session: httpSession, presenceStateContainer: .shared) + super.setUp() + } + + override func tearDown() { + mockUrlSession = nil + delegate = nil + httpSession = nil + super.tearDown() + } + + func test_DelayedHeartbeatEffect() { + let expectation = XCTestExpectation() + expectation.expectationDescription = "Effect Completion Expectation" + expectation.assertForOverFulfill = true + + mockResponse(GenericServicePayloadResponse(status: 200)) + + let delayRange = 2.0...3.0 + let automaticRetry = AutomaticRetry(retryLimit: 3, policy: .linear(delay: delayRange.lowerBound), excluded: []) + let effect = configureEffect(attempt: 0, automaticRetry: automaticRetry, error: PubNubError(.unknown)) + let startDate = Date() + + effect.performTask { returnedEvents in + XCTAssertTrue(returnedEvents.elementsEqual([.heartbeatSuccess])) + XCTAssertTrue(Int(Date().timeIntervalSince(startDate)) <= Int(delayRange.upperBound)) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 2 * delayRange.upperBound) + } + + func test_DelayedHeartbeatEffectFailure() { + let expectation = XCTestExpectation() + expectation.expectationDescription = "Effect Completion Expectation" + expectation.assertForOverFulfill = true + + mockResponse(GenericServicePayloadResponse(status: 500)) + + let delayRange = 2.0...3.0 + let automaticRetry = AutomaticRetry(retryLimit: 3, policy: .linear(delay: delayRange.lowerBound), excluded: []) + let error = PubNubError(.unknown) + let effect = configureEffect(attempt: 0, automaticRetry: automaticRetry, error: error) + + effect.performTask { returnedEvents in + let expectedError = PubNubError(.internalServiceError) + let expectedRes = Presence.Event.heartbeatFailed(error: expectedError) + XCTAssertTrue(returnedEvents.elementsEqual([expectedRes])) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 2 * delayRange.upperBound) + } + + func test_DelayedHeartbeatEffectGiveUp() { + let expectation = XCTestExpectation() + expectation.expectationDescription = "Effect Completion Expectation" + expectation.assertForOverFulfill = true + + let automaticRetry = AutomaticRetry(retryLimit: 3, policy: .linear(delay: 2.0), excluded: []) + let error = PubNubError(.unknown) + let effect = configureEffect(attempt: 3, automaticRetry: automaticRetry, error: error) + + mockResponse(GenericServicePayloadResponse(status: 200)) + + effect.performTask { returnedEvents in + let expectedRes = Presence.Event.heartbeatGiveUp(error: PubNubError(.unknown)) + XCTAssertTrue(returnedEvents.elementsEqual([expectedRes])) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 0.5) + } +} + +fileprivate extension DelayedHeartbeatEffectTests { + func mockResponse(_ response: GenericServicePayloadResponse) { + mockUrlSession.responseForDataTask = { task, id in + task.mockError = nil + task.mockData = try? Constant.jsonEncoder.encode(response) + task.mockResponse = HTTPURLResponse(statusCode: response.status) + return task + } + } + + func configureEffect( + attempt: Int, automaticRetry: AutomaticRetry?, + error: PubNubError + ) -> any EffectHandler { + factory.effect( + for: .delayedHeartbeat( + channels: ["channel-1", "channel-2"], groups: ["group-1", "group-2"], + retryAttempt: attempt, error: error + ), + with: EventEngineDependencies(value: Presence.Dependencies( + configuration: PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + automaticRetry: automaticRetry + )) + ) + ) + } +} diff --git a/Tests/PubNubTests/EventEngine/Presence/HeartbeatEffectTests.swift b/Tests/PubNubTests/EventEngine/Presence/HeartbeatEffectTests.swift new file mode 100644 index 00000000..4d78ef8f --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Presence/HeartbeatEffectTests.swift @@ -0,0 +1,93 @@ +// +// HeartbeatEffectTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +class HeartbeatEffectTests: XCTestCase { + private var mockUrlSession: MockURLSession! + private var httpSession: HTTPSession! + private var delegate: HTTPSessionDelegate! + private var factory: PresenceEffectFactory! + + private let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + heartbeatInterval: 30 + ) + + override func setUp() { + delegate = HTTPSessionDelegate() + mockUrlSession = MockURLSession(delegate: delegate) + httpSession = HTTPSession(session: mockUrlSession, delegate: delegate, sessionQueue: .main) + factory = PresenceEffectFactory(session: httpSession, presenceStateContainer: .shared) + + super.setUp() + } + + override func tearDown() { + mockUrlSession = nil + delegate = nil + httpSession = nil + super.tearDown() + } + + func test_HeartbeatingEffectWithSuccessResponse() { + let expectation = XCTestExpectation() + expectation.expectationDescription = "Effect Completion Expectation" + expectation.assertForOverFulfill = true + + mockResponse(GenericServicePayloadResponse(status: 200)) + + let effect = factory.effect( + for: .heartbeat(channels: ["channel-1", "channel-2"], groups: ["group-1", "group-2"]), + with: EventEngineDependencies(value: Presence.Dependencies(configuration: config)) + ) + effect.performTask { returnedEvents in + XCTAssertTrue(returnedEvents.elementsEqual([.heartbeatSuccess])) + expectation.fulfill() + } + wait(for: [expectation], timeout: 0.5) + } + + func test_HeartbeatingEffectWithFailedResponse() { + let expectation = XCTestExpectation() + expectation.expectationDescription = "Effect Completion Expectation" + expectation.assertForOverFulfill = true + + mockResponse(GenericServicePayloadResponse(status: 500)) + + let effect = factory.effect( + for: .heartbeat(channels: ["channel-1", "channel-2"], groups: ["group-1", "group-2"]), + with: EventEngineDependencies(value: Presence.Dependencies(configuration: config)) + ) + effect.performTask { returnedEvents in + let expectedError = PubNubError(.internalServiceError) + let expectedEvent = Presence.Event.heartbeatFailed(error: expectedError) + XCTAssertTrue(returnedEvents.elementsEqual([expectedEvent])) + expectation.fulfill() + } + wait(for: [expectation], timeout: 0.5) + } +} + +fileprivate extension HeartbeatEffectTests { + func mockResponse(_ response: GenericServicePayloadResponse) { + mockUrlSession.responseForDataTask = { task, id in + task.mockError = nil + task.mockData = try? Constant.jsonEncoder.encode(response) + task.mockResponse = HTTPURLResponse(statusCode: response.status) + return task + } + } +} diff --git a/Tests/PubNubTests/EventEngine/Presence/LeaveEffectTests.swift b/Tests/PubNubTests/EventEngine/Presence/LeaveEffectTests.swift new file mode 100644 index 00000000..00d6cefc --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Presence/LeaveEffectTests.swift @@ -0,0 +1,96 @@ +// +// LeaveEffectTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +class LeaveEffectTests: XCTestCase { + private var mockUrlSession: MockURLSession! + private var httpSession: HTTPSession! + private var delegate: HTTPSessionDelegate! + private var factory: PresenceEffectFactory! + + override func setUp() { + delegate = HTTPSessionDelegate() + mockUrlSession = MockURLSession(delegate: delegate) + httpSession = HTTPSession(session: mockUrlSession, delegate: delegate, sessionQueue: .main) + factory = PresenceEffectFactory(session: httpSession, presenceStateContainer: .shared) + + super.setUp() + } + + override func tearDown() { + mockUrlSession = nil + delegate = nil + httpSession = nil + super.tearDown() + } + + func test_LeaveEffect() { + let expectation = XCTestExpectation() + expectation.expectationDescription = "Effect Completion Expectation" + expectation.assertForOverFulfill = true + + mockResponse(GenericServicePayloadResponse(status: 200)) + + let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + heartbeatInterval: 2 + ) + let effect = factory.effect( + for: .leave(channels: ["c1", "c2"], groups: ["g1", "g2"]), + with: EventEngineDependencies(value: Presence.Dependencies(configuration: config)) + ) + effect.performTask { returnedEvents in + XCTAssertTrue(returnedEvents.isEmpty) + expectation.fulfill() + } + wait(for: [expectation], timeout: 0.5) + } + + func test_LeaveEffectForFailedRequest() { + let expectation = XCTestExpectation() + expectation.expectationDescription = "Effect Completion Expectation" + expectation.assertForOverFulfill = true + + mockResponse(GenericServicePayloadResponse(status: 500)) + + let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + heartbeatInterval: 2 + ) + let effect = factory.effect( + for: .leave(channels: ["c1", "c2"], groups: ["g1", "g2"]), + with: EventEngineDependencies(value: Presence.Dependencies(configuration: config)) + ) + effect.performTask { returnedEvents in + XCTAssertTrue(returnedEvents.isEmpty) + expectation.fulfill() + } + wait(for: [expectation], timeout: 0.5) + } +} + +fileprivate extension LeaveEffectTests { + func mockResponse(_ response: GenericServicePayloadResponse) { + mockUrlSession.responseForDataTask = { task, id in + task.mockError = nil + task.mockData = try? Constant.jsonEncoder.encode(response) + task.mockResponse = HTTPURLResponse(statusCode: response.status) + return task + } + } +} diff --git a/Tests/PubNubTests/EventEngine/Presence/PresenceTransitionTests.swift b/Tests/PubNubTests/EventEngine/Presence/PresenceTransitionTests.swift new file mode 100644 index 00000000..148023b3 --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Presence/PresenceTransitionTests.swift @@ -0,0 +1,669 @@ +// +// PresenceTransitionTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +extension Presence.Invocation: Equatable { + public static func ==(lhs: Presence.Invocation, rhs: Presence.Invocation) -> Bool { + switch (lhs, rhs) { + case let (.heartbeat(lC, lG), .heartbeat(rC, rG)): + return lC.sorted(by: <) == rC.sorted(by: <) && lG.sorted(by: <) == rG.sorted(by: <) + case let (.leave(lC, lG), .leave(rC, rG)): + return lC.sorted(by: <) == rC.sorted(by: <) && lG.sorted(by: <) == rG.sorted(by: <) + case let (.delayedHeartbeat(lC, lG, lAtt, lErr),.delayedHeartbeat(rC, rG, rAtt, rErr)): + return lC.sorted(by: <) == rC.sorted(by: <) && lG.sorted(by: <) == rG.sorted(by: <) && lAtt == rAtt && lErr == rErr + case (.wait, .wait): + return true + default: + return false + } + } +} + +extension Presence.Event: Equatable { + public static func == (lhs: Presence.Event, rhs: Presence.Event) -> Bool { + switch (lhs, rhs) { + case let (.joined(lC, lG), .joined(rC, rG)): + return lC.sorted(by: <) == rC.sorted(by: <) && lG.sorted(by: <) == rG.sorted(by: <) + case let (.left(lC, lG), .left(rC, rG)): + return lC.sorted(by: <) == rC.sorted(by: <) && lG.sorted(by: <) == rG.sorted(by: <) + case let (.heartbeatFailed(lError), .heartbeatFailed(rError)): + return lError == rError + case let (.heartbeatGiveUp(lError), .heartbeatGiveUp(rError)): + return lError == rError + case (.leftAll, .leftAll): + return true + case (.reconnect, .reconnect): + return true + case (.disconnect, .disconnect): + return true + case (.timesUp, .timesUp): + return true + case (.heartbeatSuccess, .heartbeatSuccess): + return true + default: + return false + } + } +} + +extension PresenceState { + func isEqual(to otherState: some PresenceState) -> Bool { + (otherState as? Self) == self + } +} + +class PresenceTransitionTests: XCTestCase { + private let transition = PresenceTransition( + configuration: PubNubConfiguration( + publishKey: "publishKey", + subscribeKey: "subscribeKey", + userId: "userId" + ) + ) + + // MARK: - Joined + + func testPresence_JoinedValidTransitions() { + let configWithEmptyInterval = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + heartbeatInterval: 0 + ) + let configWithInterval = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + heartbeatInterval: 30 + ) + + let state = Presence.HeartbeatInactive() + let event = Presence.Event.joined(channels: ["c1", "c2"], groups: ["g1", "g2"]) + + XCTAssertFalse(PresenceTransition(configuration: configWithEmptyInterval).canTransition(from: state, dueTo: event)) + XCTAssertTrue(PresenceTransition(configuration: configWithInterval).canTransition(from: state, dueTo: event)) + } + + func testPresence_JoinedEventForHeartbeatInactiveState() { + let results = transition.transition( + from: Presence.HeartbeatInactive(), + event: .joined(channels: ["c3"], groups: ["g3"]) + ) + let expectedInvocations: [EffectInvocation] = [ + .regular(.heartbeat(channels: ["c3"], groups: ["g3"])) + ] + let expectedState = Presence.Heartbeating( + input: PresenceInput(channels: ["c3"], groups: ["g3"]) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_JoinedEventForHeartbeatingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.Heartbeating(input: input), + event: .joined(channels: ["c3"], groups: ["g3"]) + ) + let expectedInvocations: [EffectInvocation] = [ + .regular(.heartbeat(channels: ["c1", "c2", "c3"], groups: ["g1", "g2", "g3"])) + ] + let expectedState = Presence.Heartbeating( + input: PresenceInput(channels: ["c1", "c2", "c3"], groups: ["g1", "g2", "g3"]) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_JoinedEventForStoppedState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatStopped(input: input), + event: .joined(channels: ["c3"], groups: ["g3"]) + ) + let expectedState = Presence.HeartbeatStopped( + input: PresenceInput(channels: ["c1", "c2", "c3"], groups: ["g1", "g2", "g3"]) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.isEmpty) + } + + func testPresence_JoinedEventForReconnectingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatReconnecting(input: input, retryAttempt: 1, error: PubNubError(.unknown)), + event: .joined(channels: ["c3"], groups: ["g3"]) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.delayedHeartbeat), + .regular(.heartbeat(channels: ["c1", "c2", "c3"], groups: ["g1", "g2", "g3"])) + ] + let expectedState = Presence.Heartbeating( + input: PresenceInput(channels: ["c1", "c2", "c3"], groups: ["g1", "g2", "g3"]) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_JoinedEventForCooldownState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatCooldown(input: input), + event: .joined(channels: ["c3"], groups: ["g3"]) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.wait), + .regular(.heartbeat(channels: ["c1", "c2", "c3"], groups: ["g1", "g2", "g3"])) + ] + let expectedState = Presence.Heartbeating( + input: PresenceInput(channels: ["c1", "c2", "c3"], groups: ["g1", "g2", "g3"]) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Left + + func testPresence_LeftEventForHeartbeatingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.Heartbeating(input: input), + event: .left(channels: ["c3"], groups: ["g3"]) + ) + let expectedInvocations: [EffectInvocation] = [ + .regular(.leave(channels: ["c3"], groups: ["g3"])), + .regular(.heartbeat(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.Heartbeating( + input: PresenceInput(channels: ["c1", "c2"], groups: ["g1", "g2"]) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_LeftEventForStoppedState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatStopped(input: input), + event: .left(channels: ["c3"], groups: ["g3"]) + ) + let expectedState = Presence.HeartbeatStopped( + input: PresenceInput(channels: ["c1", "c2"], groups: ["g1", "g2"]) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.isEmpty) + } + + func testPresence_LeftEventForReconnectingState() { + let input = PresenceInput( + channels: ["c1", "c2", "c3"], + groups: ["g1", "g2", "g3"] + ) + let results = transition.transition( + from: Presence.HeartbeatReconnecting(input: input, retryAttempt: 1, error: PubNubError(.unknown)), + event: .left(channels: ["c3"], groups: ["g3"]) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.delayedHeartbeat), + .regular(.leave(channels: ["c3"], groups: ["g3"])), + .regular(.heartbeat(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.Heartbeating( + input: PresenceInput(channels: ["c1", "c2"], groups: ["g1", "g2"]) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_LeftEventForCooldownState() { + let input = PresenceInput( + channels: ["c1", "c2", "c3"], + groups: ["g1", "g2", "g3"] + ) + let results = transition.transition( + from: Presence.HeartbeatCooldown(input: input), + event: .left(channels: ["c3"], groups: ["g3"]) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.wait), + .regular(.leave(channels: ["c3"], groups: ["g3"])), + .regular(.heartbeat(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.Heartbeating( + input: PresenceInput(channels: ["c1", "c2"], groups: ["g1", "g2"]) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_LeftEventWithAllChannelsForCooldownState() { + let input = PresenceInput( + channels: ["c1", "c2", "c3"], + groups: ["g1", "g2", "g3"] + ) + let results = transition.transition( + from: Presence.HeartbeatCooldown(input: input), + event: .left(channels: ["c1", "c2", "c3"], groups: ["g1", "g2", "g3"]) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.wait), + .regular(.leave(channels: ["c1", "c2", "c3"], groups: ["g1", "g2", "g3"])), + ] + let expectedState = Presence.HeartbeatInactive() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_LeftEventWithSuppressLeaveEventsSetInConfig() { + let input = PresenceInput( + channels: ["c1", "c2", "c3"], + groups: ["g1", "g2", "g3"] + ) + let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + supressLeaveEvents: true + ) + let results = PresenceTransition(configuration: config).transition( + from: Presence.HeartbeatCooldown(input: input), + event: .left(channels: ["c1", "c2"], groups: ["g1", "g2"]) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.wait), + .regular(.heartbeat(channels: ["c3"], groups: ["g3"])) + ] + let expectedState = Presence.Heartbeating(input: PresenceInput( + channels: ["c3"], + groups: ["g3"] + )) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Left All + + func testPresence_LeftAllForHeartbeatingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.Heartbeating(input: input), + event: .leftAll + ) + let expectedInvocations: [EffectInvocation] = [ + .regular(.leave(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.HeartbeatInactive() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_LeftAllForCooldownState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatCooldown(input: input), + event: .leftAll + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.wait), + .regular(.leave(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.HeartbeatInactive() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_LeftAllForReconnectingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatReconnecting( + input: input, + retryAttempt: 1, + error: PubNubError(.unknown) + ), + event: .leftAll + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.delayedHeartbeat), + .regular(.leave(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.HeartbeatInactive() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_LeftAllWithSuppressLeaveEventsSetInConfig() { + let input = PresenceInput( + channels: ["c1", "c2", "c3"], + groups: ["g1", "g2", "g3"] + ) + let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + supressLeaveEvents: true + ) + let results = PresenceTransition(configuration: config).transition( + from: Presence.HeartbeatCooldown(input: input), + event: .leftAll + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.wait) + ] + + XCTAssertTrue(results.state.isEqual(to: Presence.HeartbeatInactive())) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Reconnect + + func testPresence_ReconnectForStoppedState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatStopped(input: input), + event: .reconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .regular(.heartbeat(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.Heartbeating(input: input) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_ReconnectForFailedState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatFailed(input: input, error: PubNubError(.unknown)), + event: .reconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .regular(.heartbeat(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.Heartbeating(input: input) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_ReconnectForHeartbeatingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + + let results = transition.transition( + from: Presence.Heartbeating(input: input), + event: .heartbeatFailed(error: PubNubError(.unknown)) + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.delayedHeartbeat( + channels: ["c1", "c2"], groups: ["g1", "g2"], + retryAttempt: 0, error: PubNubError(.unknown) + )) + ] + let expectedState = Presence.HeartbeatReconnecting( + input: input, + retryAttempt: 0, error: PubNubError(.unknown) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Disconnect + + func testPresence_DisconnectForHeartbeatingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.Heartbeating(input: input), + event: .disconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .regular(.leave(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.HeartbeatStopped(input: input) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_DisconnectForCooldownState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatCooldown(input: input), + event: .disconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.wait), + .regular(.leave(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.HeartbeatStopped(input: input) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_DisconnectForHeartbeatReconnectingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatReconnecting(input: input, retryAttempt: 1, error: PubNubError(.unknown)), + event: .disconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.delayedHeartbeat), + .regular(.leave(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.HeartbeatStopped(input: input) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Heartbeat Success + + func testPresence_HeartbeatSuccessForHeartbeatingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.Heartbeating(input: input), + event: .heartbeatSuccess + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.wait) + ] + let expectedState = Presence.HeartbeatCooldown(input: input) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_HeartbeatSuccessForReconnectingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatReconnecting( + input: input, + retryAttempt: 1, + error: PubNubError(.unknown) + ), + event: .heartbeatSuccess + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.delayedHeartbeat), + .managed(.wait) + ] + let expectedState = Presence.HeartbeatCooldown(input: input) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Heartbeat Failed + + func testPresence_HeartbeatFailedForHeartbeatingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.Heartbeating(input: input), + event: .heartbeatFailed(error: PubNubError(.unknown)) + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.delayedHeartbeat( + channels: ["c1", "c2"], groups: ["g1", "g2"], + retryAttempt: 0, error: PubNubError(.unknown) + )) + ] + let expectedState = Presence.HeartbeatReconnecting( + input: input, + retryAttempt: 0, + error: PubNubError(.unknown) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testPresence_HeartbeatFailedForReconnectingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatReconnecting(input: input, retryAttempt: 1, error: PubNubError(.unknown)), + event: .heartbeatFailed(error: PubNubError(.badServerResponse)) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.delayedHeartbeat), + .managed(.delayedHeartbeat( + channels: ["c1", "c2"], groups: ["g1", "g2"], + retryAttempt: 2, error: PubNubError(.badServerResponse) + )) + ] + let expectedState = Presence.HeartbeatReconnecting( + input: input, + retryAttempt: 2, + error: PubNubError(.badServerResponse) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Heartbeat Give Up + + func testPresence_HeartbeatGiveUpForReconnectingState() { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatReconnecting(input: input, retryAttempt: 1, error: PubNubError(.unknown)), + event: .heartbeatGiveUp(error: PubNubError(.badServerResponse)) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.delayedHeartbeat), + ] + let expectedState = Presence.HeartbeatFailed( + input: input, + error: PubNubError(.badServerResponse) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Times Up + + func testPresence_TimesUpForCooldownState() throws { + let input = PresenceInput( + channels: ["c1", "c2"], + groups: ["g1", "g2"] + ) + let results = transition.transition( + from: Presence.HeartbeatCooldown(input: input), + event: .timesUp + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.wait), + .regular(.heartbeat(channels: ["c1", "c2"], groups: ["g1", "g2"])) + ] + let expectedState = Presence.Heartbeating(input: input) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } +} diff --git a/Tests/PubNubTests/EventEngine/Presence/WaitEffectTests.swift b/Tests/PubNubTests/EventEngine/Presence/WaitEffectTests.swift new file mode 100644 index 00000000..92a54d30 --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Presence/WaitEffectTests.swift @@ -0,0 +1,111 @@ +// +// WaitEffectTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +class WaitEffectTests: XCTestCase { + private var mockUrlSession: MockURLSession! + private var httpSession: HTTPSession! + private var delegate: HTTPSessionDelegate! + private var factory: PresenceEffectFactory! + + override func setUp() { + delegate = HTTPSessionDelegate() + mockUrlSession = MockURLSession(delegate: delegate) + httpSession = HTTPSession(session: mockUrlSession, delegate: delegate, sessionQueue: .main) + factory = PresenceEffectFactory(session: httpSession, presenceStateContainer: .shared) + + super.setUp() + } + + override func tearDown() { + mockUrlSession = nil + delegate = nil + httpSession = nil + super.tearDown() + } + + func test_WaitEffect() { + let expectation = XCTestExpectation() + expectation.expectationDescription = "Effect Completion Expectation" + expectation.assertForOverFulfill = true + + let heartbeatInterval = 2 + let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + heartbeatInterval: UInt(heartbeatInterval) + ) + + let effect = factory.effect( + for: .wait, + with: EventEngineDependencies(value: Presence.Dependencies(configuration: config)) + ) + let startDate = Date() + + effect.performTask { returnedEvents in + XCTAssertTrue(returnedEvents.elementsEqual([.timesUp])) + XCTAssertTrue(Int(Date().timeIntervalSince(startDate)) == heartbeatInterval) + expectation.fulfill() + } + wait(for: [expectation], timeout: 2.5) + } + + func test_WaitEffectCancellation() { + let expectation = XCTestExpectation() + expectation.expectationDescription = "Effect Completion Expectation" + expectation.assertForOverFulfill = true + expectation.isInverted = true + + let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + heartbeatInterval: UInt(2) + ) + let effect = factory.effect( + for: .wait, + with: EventEngineDependencies(value: Presence.Dependencies(configuration: config)) + ) + effect.performTask { returnedEvents in + expectation.fulfill() + } + effect.cancelTask() + + wait(for: [expectation], timeout: 0.5) + } + + func test_WaitEffectFinishesImmediatelyWithEmptyHeartbeatInterval() { + let expectation = XCTestExpectation() + expectation.expectationDescription = "Effect Completion Expectation" + expectation.assertForOverFulfill = true + + let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + heartbeatInterval: UInt(0) + ) + let effect = factory.effect( + for: .wait, + with: EventEngineDependencies(value: Presence.Dependencies(configuration: config)) + ) + effect.performTask { returnedEvents in + XCTAssertTrue(returnedEvents.isEmpty) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 0.5) + } +} diff --git a/Tests/PubNubTests/EventEngine/Subscribe/EmitMessagesTests.swift b/Tests/PubNubTests/EventEngine/Subscribe/EmitMessagesTests.swift new file mode 100644 index 00000000..96e0505a --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Subscribe/EmitMessagesTests.swift @@ -0,0 +1,281 @@ +// +// EmitMessagesTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +fileprivate class MockListener: BaseSubscriptionListener { + var onEmitMessagesCalled: ([SubscribeMessagePayload]) -> Void = { _ in } + var onEmitSubscribeEventCalled: ((PubNubSubscribeEvent) -> Void) = { _ in } + + override func emit(batch: [SubscribeMessagePayload]) { + onEmitMessagesCalled(batch) + } + override func emit(subscribe: PubNubSubscribeEvent) { + onEmitSubscribeEventCalled(subscribe) + } +} + +class EmitMessagesTests: XCTestCase { + private var listeners: [MockListener] = [] + + override func setUp() { + listeners = (0...2).map { _ in MockListener() } + super.setUp() + } + + override func tearDown() { + listeners = [] + super.tearDown() + } + + func testListener_WithMessage() { + let expectation = XCTestExpectation(description: "Emit Messages") + expectation.assertForOverFulfill = true + expectation.expectedFulfillmentCount = listeners.count + + let messages = [ + testMessage, + testSignal, + testObject, + testMessageAction, + testFile, + testPresenceChange + ] + let effect = EmitMessagesEffect( + messages: messages, + cursor: SubscribeCursor(timetoken: 12345, region: 11), + listeners: listeners, + messageCache: MessageCache() + ) + + listeners.forEach { + $0.onEmitMessagesCalled = { receivedMessages in + XCTAssertTrue(receivedMessages.elementsEqual(messages)) + expectation.fulfill() + } + } + + effect.performTask(completionBlock: { _ in + PubNub.log.debug("Did finish performing EmitMessages effect") + }) + + wait(for: [expectation], timeout: 0.15) + } + + func testListener_MessageCountExceededMaximum() { + let expectation = XCTestExpectation(description: "Emit Messages") + expectation.assertForOverFulfill = true + expectation.expectedFulfillmentCount = listeners.count + + let effect = EmitMessagesEffect( + messages: (1...100).map { + generateMessage( + with: .message, + payload: AnyJSON("Hello, it's message number \($0)") + ) + }, + cursor: SubscribeCursor(timetoken: 12345, region: 11), + listeners: listeners, + messageCache: MessageCache() + ) + + listeners.forEach() { + $0.onEmitSubscribeEventCalled = { event in + if case let .errorReceived(error) = event { + XCTAssertTrue(error.reason == .messageCountExceededMaximum) + expectation.fulfill() + } + } + } + + effect.performTask(completionBlock: { _ in + PubNub.log.debug("Did finish performing EmitMessages effect") + }) + + wait(for: [expectation], timeout: 0.1) + } + + func testEffect_SkipsDuplicatedMessages() { + let expectation = XCTestExpectation(description: "Emit Messages") + expectation.assertForOverFulfill = true + expectation.expectedFulfillmentCount = listeners.count + + let effect = EmitMessagesEffect( + messages: (1...50).map { _ in + generateMessage( + with: .message, + payload: AnyJSON("Hello, it's a message") + ) + }, + cursor: SubscribeCursor(timetoken: 12345, region: 11), + listeners: listeners, + messageCache: MessageCache() + ) + + listeners.forEach { + $0.onEmitMessagesCalled = { messages in + XCTAssertTrue(messages.count == 1) + XCTAssertTrue(messages[0].payload == "Hello, it's a message") + expectation.fulfill() + } + } + + effect.performTask(completionBlock: { _ in + PubNub.log.debug("Did finish performing EmitMessages effect") + }) + + wait(for: [expectation], timeout: 0.1) + } + + func testEffect_MessageCacheDropsTheOldestMessages() { + let initialMessages = (1...99).map { idx in + generateMessage( + with: .message, + payload: AnyJSON("Hello, it's a message \(idx)") + ) + } + let newMessages = (1...10).map { idx in + generateMessage( + with: .message, + payload: AnyJSON("Hello again, it's a message \(idx)") + ) + } + let cache = MessageCache( + messagesArray: initialMessages + ) + let effect = EmitMessagesEffect( + messages: newMessages, + cursor: SubscribeCursor(timetoken: 12345, region: 11), + listeners: listeners, + messageCache: cache + ) + + effect.performTask(completionBlock: { _ in + PubNub.log.debug("Did finish performing EmitMessages effect") + }) + + let allCachedMessages = cache.messagesArray.compactMap { $0 } + let expectedDroppedMssgs = Array(initialMessages[0...9]) + + for droppedMssg in expectedDroppedMssgs { + XCTAssertFalse(allCachedMessages.contains(droppedMssg)) + } + for newMessage in allCachedMessages { + XCTAssertTrue(allCachedMessages.contains(newMessage)) + } + } +} + +fileprivate extension EmitMessagesTests { + var testMessage: SubscribeMessagePayload { + generateMessage( + with: .message, + payload: "Hello, this is a message" + ) + } + + var testSignal: SubscribeMessagePayload { + generateMessage( + with: .signal, + payload: "Hello, this is a signal" + ) + } + + var testObject: SubscribeMessagePayload { + generateMessage( + with: .object, + payload: AnyJSON( + SubscribeObjectMetadataPayload( + source: "123", + version: "456", + event: .delete, + type: .uuid, + subscribeEvent: .uuidMetadataRemoved(metadataId: "12345") + ) + ) + ) + } + + var testMessageAction: SubscribeMessagePayload { + generateMessage( + with: .messageAction, + payload: AnyJSON( + [ + "event": "added", + "source": "actions", + "version": "1.0", + "data": [ + "messageTimetoken": "16844114408637596", + "type": "receipt", + "actionTimetoken": "16844114409339370", + "value": "read" + ] + ] as [String: Any] + ) + ) + } + + var testFile: SubscribeMessagePayload { + generateMessage( + with: .file, + payload: AnyJSON(FilePublishPayload( + channel: "", + fileId: "", + filename: "", + size: 54556, + contentType: "image/jpeg", + createdDate: nil, + additionalDetails: nil + )) + ) + } + + var testPresenceChange: SubscribeMessagePayload { + generateMessage( + with: .presence, + payload: AnyJSON( + SubscribePresencePayload( + actionEvent: .join, + occupancy: 15, + uuid: nil, + timestamp: 123123, + refreshHereNow: false, + state: nil, + join: ["dsadf", "fdsa"], + leave: [], + timeout: [] + ) + ) + ) + } + + func generateMessage( + with type: SubscribeMessagePayload.Action, + payload: AnyJSON + ) -> SubscribeMessagePayload { + SubscribeMessagePayload( + shard: "shard", + subscription: nil, + channel: "test-channel", + messageType: type, + payload: payload, + flags: 123, + publisher: "publisher", + subscribeKey: "FakeKey", + originTimetoken: nil, + publishTimetoken: SubscribeCursor(timetoken: 12312412412, region: 12), + meta: nil, + error: nil + ) + } +} diff --git a/Tests/PubNubTests/EventEngine/Subscribe/EmitStatusTests.swift b/Tests/PubNubTests/EventEngine/Subscribe/EmitStatusTests.swift new file mode 100644 index 00000000..44d6a3d7 --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Subscribe/EmitStatusTests.swift @@ -0,0 +1,104 @@ +// +// EmitStatusTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +fileprivate class MockListener: BaseSubscriptionListener { + var onEmitSubscribeEventCalled: ((PubNubSubscribeEvent) -> Void) = { _ in } + + override func emit(subscribe: PubNubSubscribeEvent) { + onEmitSubscribeEventCalled(subscribe) + } +} + +class EmitStatusTests: XCTestCase { + private var listeners: [MockListener] = [] + + override func setUp() { + listeners = (0...2).map { _ in MockListener() } + super.setUp() + } + + override func tearDown() { + listeners = [] + super.tearDown() + } + + func testEmitStatus_FromDisconnectedToConnected() { + let expectation = XCTestExpectation(description: "Emit Status Effect") + expectation.expectedFulfillmentCount = listeners.count + expectation.assertForOverFulfill = true + + let effect = EmitStatusEffect( + statusChange: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .connected, + error: nil + ), + listeners: listeners + ) + listeners.forEach { + $0.onEmitSubscribeEventCalled = { event in + if case let .connectionChanged(status) = event { + XCTAssertEqual(status, .connected) + expectation.fulfill() + } else { + XCTFail("Unexpected event") + } + } + } + + effect.performTask(completionBlock: { _ in + PubNub.log.debug("Did finish performing EmitStatus effect") + }) + + wait(for: [expectation], timeout: 0.1) + } + + func testEmitStatus_WithError() { + let expectation = XCTestExpectation(description: "Emit Status Effect") + expectation.expectedFulfillmentCount = listeners.count + expectation.assertForOverFulfill = true + + let errorExpectation = XCTestExpectation(description: "Emit Status Effect - Error Listener") + errorExpectation.expectedFulfillmentCount = listeners.count + errorExpectation.assertForOverFulfill = true + + let effect = EmitStatusEffect( + statusChange: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .connected, + error: SubscribeError(underlying: PubNubError(.unknown)) + ), + listeners: listeners + ) + listeners.forEach { + $0.onEmitSubscribeEventCalled = { event in + if case let .connectionChanged(status) = event { + XCTAssertEqual(status, .connected) + expectation.fulfill() + } + if case let .errorReceived(error) = event { + XCTAssertEqual(error, PubNubError(.unknown)) + errorExpectation.fulfill() + } + } + } + + effect.performTask(completionBlock: { _ in + PubNub.log.debug("Did finish performing EmitStatus effect") + }) + + wait(for: [expectation, errorExpectation], timeout: 0.1) + } +} diff --git a/Tests/PubNubTests/EventEngine/Subscribe/SubscribeEffectsTests.swift b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeEffectsTests.swift new file mode 100644 index 00000000..c11fc3e9 --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeEffectsTests.swift @@ -0,0 +1,445 @@ +// +// SubscribeEffectTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +class SubscribeEffectsTests: XCTestCase { + private var mockUrlSession: MockURLSession! + private var httpSession: HTTPSession! + private var delegate: HTTPSessionDelegate! + private var factory: SubscribeEffectFactory! + + private let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + automaticRetry: AutomaticRetry( + retryLimit: 3, + policy: .linear(delay: 2.0) + ) + ) + + private func configWithLinearPolicy(_ delay: Double = 2.0) -> PubNubConfiguration { + PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "userId", + automaticRetry: AutomaticRetry(retryLimit: 3, policy: .linear(delay: delay), excluded: []) + ) + } + + override func setUp() { + delegate = HTTPSessionDelegate() + mockUrlSession = MockURLSession(delegate: delegate) + httpSession = HTTPSession(session: mockUrlSession, delegate: delegate, sessionQueue: .main) + factory = SubscribeEffectFactory(session: httpSession, presenceStateContainer: .shared) + super.setUp() + } + + override func tearDown() { + mockUrlSession = nil + delegate = nil + httpSession = nil + super.tearDown() + } +} + +// MARK: - HandshakingEffect + +extension SubscribeEffectsTests { + func test_HandshakingEffectWithSuccessResponse() { + mockResponse(subscribeResponse: SubscribeResponse( + cursor: SubscribeCursor(timetoken: 12345, region: 1), + messages: [] + )) + runEffect( + configuration: config, + invocation: .handshakeRequest( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"] + ), + expectedOutput: [ + .handshakeSuccess( + cursor: SubscribeCursor( + timetoken: 12345, + region: 1 + ) + ) + ] + ) + } + + func test_HandshakingEffectWithFailedResponse() { + mockResponse( + errorIfAny: URLError(.cannotFindHost), + httpResponse: HTTPURLResponse(statusCode: 404)! + ) + runEffect( + configuration: config, + invocation: .handshakeRequest( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"] + ), expectedOutput: [ + .handshakeFailure(error: SubscribeError( + underlying: PubNubError( + .nameResolutionFailure, + underlying: URLError(.cannotFindHost) + ) + )) + ] + ) + } +} + +// MARK: ReceivingEffect + +extension SubscribeEffectsTests { + func test_ReceivingEffectWithSuccessResponse() { + mockResponse(subscribeResponse: SubscribeResponse( + cursor: SubscribeCursor(timetoken: 12345, region: 1), + messages: [firstMessage, secondMessage] + )) + runEffect( + configuration: config, + invocation: .receiveMessages( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + cursor: SubscribeCursor(timetoken: 111, region: 1) + ), expectedOutput: [ + .receiveSuccess( + cursor: SubscribeCursor(timetoken: 12345, region: 1), + messages: [firstMessage, secondMessage] + ) + ] + ) + } + + func test_ReceivingEffectWithFailedResponse() { + mockResponse( + errorIfAny: URLError(.cannotFindHost), + httpResponse: HTTPURLResponse(statusCode: 404)! + ) + runEffect( + configuration: config, + invocation: .receiveMessages( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + cursor: SubscribeCursor(timetoken: 111, region: 1) + ), expectedOutput: [ + .receiveFailure( + error: SubscribeError( + underlying: PubNubError( + .nameResolutionFailure, + underlying: URLError(.cannotFindHost) + ) + ) + ) + ]) + } +} + +// MARK: - HandshakeReconnecting + +extension SubscribeEffectsTests { + func test_HandshakeReconnectingSuccess() { + let delayRange = 2.0...3.0 + let urlError = URLError(.badServerResponse) + + mockResponse(subscribeResponse: SubscribeResponse( + cursor: SubscribeCursor(timetoken: 12345, region: 1), + messages: [] + )) + runEffect( + configuration: configWithLinearPolicy(delayRange.lowerBound), + invocation: .handshakeReconnect( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(urlError.pubnubReason!, underlying: urlError)) + ), + timeout: 2 * delayRange.upperBound, + expectedOutput: [ + .handshakeReconnectSuccess(cursor: SubscribeCursor( + timetoken: 12345, + region: 1 + )) + ] + ) + } + + func test_HandshakeReconnectingFailed() { + let delayRange = 2.0...3.0 + let urlError = URLError(.badServerResponse) + + mockResponse( + errorIfAny: URLError(.cannotFindHost), + httpResponse: HTTPURLResponse(statusCode: 404)! + ) + runEffect( + configuration: configWithLinearPolicy(delayRange.lowerBound), + invocation: .handshakeReconnect( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(urlError.pubnubReason!, underlying: urlError)) + ), + timeout: 2 * delayRange.upperBound, + expectedOutput: [ + .handshakeReconnectFailure( + error: SubscribeError( + underlying: PubNubError( + .nameResolutionFailure, + underlying: URLError(.cannotFindHost) + ) + ) + ) + ] + ) + } + + func test_HandshakeReconnectGiveUp() { + let delayRange = 2.0...3.0 + let urlError = URLError(.badServerResponse) + + runEffect( + configuration: configWithLinearPolicy(delayRange.lowerBound), + invocation: .handshakeReconnect( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + retryAttempt: 3, + reason: SubscribeError(underlying: PubNubError(urlError.pubnubReason!, underlying: urlError)) + ), + expectedOutput: [ + .handshakeReconnectGiveUp( + error: SubscribeError(underlying: PubNubError(.badServerResponse)) + ) + ] + ) + } + + func test_HandshakeReconnectIsDelayed() { + let delayRange = 2.0...3.0 + let urlError = URLError(.badServerResponse) + let startDate = Date() + + mockResponse(subscribeResponse: SubscribeResponse( + cursor: SubscribeCursor(timetoken: 12345, region: 1), + messages: [] + )) + runEffect( + configuration: configWithLinearPolicy(delayRange.lowerBound), + invocation: .handshakeReconnect( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(urlError.pubnubReason!, underlying: urlError)) + ), + timeout: 2 * delayRange.upperBound, + expectedOutput: [ + .handshakeReconnectSuccess( + cursor: SubscribeCursor(timetoken: 12345, region: 1) + ) + ], + additionalValidations: { + XCTAssertTrue( + Int(Date().timeIntervalSince(startDate)) <= Int(delayRange.upperBound) + ) + } + ) + } +} + +// MARK: - ReceiveReconnecting + +extension SubscribeEffectsTests { + func test_ReceiveReconnectingSuccess() { + let delayRange = 2.0...3.0 + let urlError = URLError(.badServerResponse) + + mockResponse(subscribeResponse: SubscribeResponse( + cursor: SubscribeCursor(timetoken: 12345, region: 1), + messages: [firstMessage, secondMessage] + )) + runEffect( + configuration: configWithLinearPolicy(delayRange.lowerBound), + invocation: .receiveReconnect( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + cursor: SubscribeCursor(timetoken: 1111, region: 1), + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(urlError.pubnubReason!, underlying: urlError)) + ), + timeout: 2 * delayRange.upperBound, + expectedOutput: [ + .receiveReconnectSuccess( + cursor: SubscribeCursor(timetoken: 12345, region: 1), + messages: [firstMessage, secondMessage] + ) + ] + ) + } + + func test_ReceiveReconnectingFailure() { + let delayRange = 2.0...3.0 + let urlError = URLError(.badServerResponse) + + mockResponse( + errorIfAny: URLError(.cannotFindHost), + httpResponse: HTTPURLResponse(statusCode: 404)! + ) + runEffect( + configuration: configWithLinearPolicy(delayRange.lowerBound), + invocation: .receiveReconnect( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + cursor: SubscribeCursor(timetoken: 1111, region: 1), + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(urlError.pubnubReason!, underlying: urlError)) + ), + timeout: 2 * delayRange.upperBound, + expectedOutput: [ + .receiveReconnectFailure( + error: SubscribeError(underlying: PubNubError(.nameResolutionFailure)) + ) + ] + ) + } + + func test_ReceiveReconnectGiveUp() { + let urlError = URLError(.badServerResponse) + let delayRange = 2.0...3.0 + + mockResponse( + errorIfAny: URLError(.cannotFindHost), + httpResponse: HTTPURLResponse(statusCode: 404)! + ) + runEffect( + configuration: configWithLinearPolicy(delayRange.lowerBound), + invocation: .receiveReconnect( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + cursor: SubscribeCursor(timetoken: 1111, region: 1), + retryAttempt: 3, + reason: SubscribeError(underlying: PubNubError(urlError.pubnubReason!, underlying: urlError)) + ), + expectedOutput: [ + .receiveReconnectGiveUp( + error: SubscribeError(underlying: PubNubError(.badServerResponse)) + ) + ] + ) + } + + func test_ReceiveReconnectingIsDelayed() { + let delayRange = 2.0...3.0 + let urlError = URLError(.badServerResponse) + let startDate = Date() + + mockResponse(subscribeResponse: SubscribeResponse( + cursor: SubscribeCursor(timetoken: 12345, region: 1), + messages: [firstMessage, secondMessage] + )) + runEffect( + configuration: configWithLinearPolicy(delayRange.lowerBound), + invocation: .receiveReconnect( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + cursor: SubscribeCursor(timetoken: 1111, region: 1), + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(urlError.pubnubReason!, underlying: urlError)) + ), + timeout: 2 * delayRange.upperBound, + expectedOutput: [ + .receiveReconnectSuccess( + cursor: SubscribeCursor(timetoken: 12345, region: 1), + messages: [firstMessage, secondMessage] + ) + ], + additionalValidations: { + XCTAssertTrue( + Int(Date().timeIntervalSince(startDate)) <= Int(delayRange.upperBound) + ) + } + ) + } +} + +// MARK: - Helpers + +fileprivate extension SubscribeEffectsTests { + func mockResponse( + subscribeResponse: SubscribeResponse? = nil, + errorIfAny: Error? = nil, + httpResponse: HTTPURLResponse = HTTPURLResponse(statusCode: 200)! + ) { + mockUrlSession.responseForDataTask = { task, id in + task.mockError = errorIfAny + task.mockData = try? Constant.jsonEncoder.encode(subscribeResponse) + task.mockResponse = httpResponse + return task + } + } + + private func runEffect( + configuration: PubNubConfiguration, + invocation: Subscribe.Invocation, + timeout: TimeInterval = 0.5, + expectedOutput results: [Subscribe.Event] = [], + additionalValidations validations: @escaping () -> Void = {} + ) { + let expectation = XCTestExpectation(description: "Effect Completion") + expectation.expectedFulfillmentCount = 1 + expectation.assertForOverFulfill = true + + let effect = factory.effect( + for: invocation, + with: EventEngineDependencies(value: Subscribe.Dependencies(configuration: configuration)) + ) + effect.performTask { + XCTAssertEqual(results, $0) + validations() + expectation.fulfill() + } + wait(for: [expectation], timeout: timeout) + } +} + +fileprivate let firstMessage = SubscribeMessagePayload( + shard: "", + subscription: nil, + channel: "test-channel", + messageType: .message, + payload: ["message": "hello!"], + flags: 123, + publisher: "publisher", + subscribeKey: "FakeKey", + originTimetoken: nil, + publishTimetoken: SubscribeCursor(timetoken: 12312412412, region: 12), + meta: nil, + error: nil +) + +fileprivate let secondMessage = SubscribeMessagePayload( + shard: "", + subscription: nil, + channel: "test-channel", + messageType: .messageAction, + payload: ["reaction": "👍"], + flags: 456, + publisher: "second-publisher", + subscribeKey: "FakeKey", + originTimetoken: nil, + publishTimetoken: SubscribeCursor(timetoken: 12312412555, region: 12), + meta: nil, + error: nil +) diff --git a/Tests/PubNubTests/EventEngine/Subscribe/SubscribeInputTests.swift b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeInputTests.swift new file mode 100644 index 00000000..f482cd5f --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeInputTests.swift @@ -0,0 +1,154 @@ +// +// SubscribeInputTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// +import Foundation +import XCTest + +@testable import PubNub + +class SubscribeInputTests: XCTestCase { + func test_ChannelsWithoutPresence() { + let input = SubscribeInput(channels: [ + PubNubChannel(id: "first-channel"), + PubNubChannel(id: "second-channel") + ]) + + let expectedAllSubscribedChannels = ["first-channel", "second-channel"] + let expectedSubscribedChannels = ["first-channel", "second-channel"] + + XCTAssertTrue(input.subscribedChannels.sorted(by: <).elementsEqual(expectedSubscribedChannels)) + XCTAssertTrue(input.allSubscribedChannels.sorted(by: <).elementsEqual(expectedAllSubscribedChannels)) + XCTAssertTrue(input.subscribedGroups.isEmpty) + XCTAssertTrue(input.allSubscribedGroups.isEmpty) + } + + func test_ChannelsWithPresence() { + let input = SubscribeInput(channels: [ + PubNubChannel(id: "first-channel", withPresence: true), + PubNubChannel(id: "second-channel") + ]) + + let expectedAllSubscribedChannels = ["first-channel", "first-channel-pnpres", "second-channel"] + let expectedSubscribedChannels = ["first-channel", "second-channel"] + + XCTAssertTrue(input.subscribedChannels.sorted(by: <).elementsEqual(expectedSubscribedChannels)) + XCTAssertTrue(input.allSubscribedChannels.sorted(by: <).elementsEqual(expectedAllSubscribedChannels)) + XCTAssertTrue(input.subscribedGroups.isEmpty) + XCTAssertTrue(input.allSubscribedGroups.isEmpty) + } + + func test_ChannelGroups() { + let input = SubscribeInput( + channels: [ + PubNubChannel(id: "first-channel"), + PubNubChannel(id: "second-channel") + ], + groups: [ + PubNubChannel(channel: "group-1"), + PubNubChannel(channel: "group-2") + ] + ) + + let expectedAllSubscribedChannels = ["first-channel", "second-channel"] + let expectedSubscribedChannels = ["first-channel", "second-channel"] + let expectedAllSubscribedGroups = ["group-1", "group-2"] + let expectedSubscribedGroups = ["group-1", "group-2"] + + XCTAssertTrue(input.subscribedChannels.sorted(by: <).elementsEqual(expectedSubscribedChannels)) + XCTAssertTrue(input.allSubscribedChannels.sorted(by: <).elementsEqual(expectedAllSubscribedChannels)) + XCTAssertTrue(input.subscribedGroups.sorted(by: <).elementsEqual(expectedSubscribedGroups)) + XCTAssertTrue(input.allSubscribedGroups.sorted(by: <).elementsEqual(expectedAllSubscribedGroups)) + } + + func test_addingInputContainsNoDuplicates() { + let input1 = SubscribeInput( + channels: [ + PubNubChannel(id: "c1"), + PubNubChannel(id: "c2", withPresence: true) + ], + groups: [ + PubNubChannel(id: "g1"), + PubNubChannel(id: "g2") + ] + ) + let result = input1 + SubscribeInput(channels: [ + PubNubChannel(id: "c1"), + PubNubChannel(id: "c3", withPresence: true) + ], groups: [ + PubNubChannel(id: "g1"), + PubNubChannel(id: "g3") + ]) + + let expectedAllSubscribedChannels = ["c1", "c2", "c2-pnpres", "c3", "c3-pnpres"] + let expectedSubscribedChannels = ["c1", "c2", "c3"] + let expectedAllSubscribedGroups = ["g1", "g2", "g3"] + let expectedSubscribedGroups = ["g1", "g2", "g3"] + + XCTAssertTrue(result.allSubscribedChannels.sorted(by: <).elementsEqual(expectedAllSubscribedChannels)) + XCTAssertTrue(result.subscribedChannels.sorted(by: <).elementsEqual(expectedSubscribedChannels)) + XCTAssertTrue(result.subscribedGroups.sorted(by: <).elementsEqual(expectedSubscribedGroups)) + XCTAssertTrue(result.allSubscribedGroups.sorted(by: <).elementsEqual(expectedAllSubscribedGroups)) + } + + func test_RemovingInput() { + let input1 = SubscribeInput( + channels: [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: true), + PubNubChannel(id: "c3", withPresence: true) + ], + groups: [ + PubNubChannel(id: "g1"), + PubNubChannel(id: "g2"), + PubNubChannel(id: "g3") + ] + ) + + let result = input1 - (channels: ["c1", "c3"], groups: ["g1", "g3"]) + let expectedAllSubscribedChannels = ["c2", "c2-pnpres"] + let expectedSubscribedChannels = ["c2"] + let expectedAllSubscribedGroups = ["g2"] + let expectedSubscribedGroups = ["g2"] + + XCTAssertTrue(result.allSubscribedChannels.sorted(by: <).elementsEqual(expectedAllSubscribedChannels)) + XCTAssertTrue(result.subscribedChannels.sorted(by: <).elementsEqual(expectedSubscribedChannels)) + XCTAssertTrue(result.subscribedGroups.sorted(by: <).elementsEqual(expectedSubscribedGroups)) + XCTAssertTrue(result.allSubscribedGroups.sorted(by: <).elementsEqual(expectedAllSubscribedGroups)) + } + + func test_RemovingInputWithPresenceOnly() { + let input1 = SubscribeInput( + channels: [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: true), + PubNubChannel(id: "c3", withPresence: true) + ], + groups: [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: true) + ] + ) + + let result = input1 - ( + channels: ["c1".presenceChannelName, "c2".presenceChannelName, "c3".presenceChannelName], + groups: ["g1".presenceChannelName, "g3".presenceChannelName] + ) + + let expectedAllSubscribedChannels = ["c1", "c2", "c3"] + let expectedSubscribedChannels = ["c1", "c2", "c3"] + let expectedAllSubscribedGroups = ["g1", "g2", "g2-pnpres", "g3"] + let expectedSubscribedGroups = ["g1", "g2", "g3"] + + XCTAssertTrue(result.allSubscribedChannels.sorted(by: <).elementsEqual(expectedAllSubscribedChannels)) + XCTAssertTrue(result.subscribedChannels.sorted(by: <).elementsEqual(expectedSubscribedChannels)) + XCTAssertTrue(result.subscribedGroups.sorted(by: <).elementsEqual(expectedSubscribedGroups)) + XCTAssertTrue(result.allSubscribedGroups.sorted(by: <).elementsEqual(expectedAllSubscribedGroups)) + } +} diff --git a/Tests/PubNubTests/EventEngine/Subscribe/SubscribeRequestTests.swift b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeRequestTests.swift new file mode 100644 index 00000000..b03bab6b --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeRequestTests.swift @@ -0,0 +1,64 @@ +// +// SubscribeRequestTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +class SubscribeRequestTests: XCTestCase { + func test_SubscribeRequestWithoutRetryPolicy() { + let config = PubNubConfiguration( + publishKey: "publishKey", + subscribeKey: "subscribeKey", + userId: "userId" + ) + let request = SubscribeRequest( + configuration: config, + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + channelStates: [:], + session: HTTPSession(configuration: .subscription), + sessionResponseQueue: .main + ) + + let urlResponse = HTTPURLResponse(statusCode: 500) + let error = SubscribeError(underlying: PubNubError(.connectionFailure), urlResponse: urlResponse) + + XCTAssertNil(request.reconnectionDelay(dueTo: error, with: 0)) + } + + func test_SubscribeRequestDoesNotRetryForNonSupportedCode() { + let automaticRetry = AutomaticRetry( + retryLimit: 2, + policy: .linear(delay: 3.0), + retryableURLErrorCodes: [.badURL] + ) + let config = PubNubConfiguration( + publishKey: "publishKey", + subscribeKey: "subscribeKey", + userId: "userId", + automaticRetry: automaticRetry + ) + let request = SubscribeRequest( + configuration: config, + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + channelStates: [:], + session: HTTPSession(configuration: .subscription), + sessionResponseQueue: .main + ) + + let urlError = URLError(.cannotFindHost) + let subscribeError = SubscribeError(underlying: PubNubError(urlError.pubnubReason!, underlying: urlError)) + + XCTAssertNil(request.reconnectionDelay(dueTo: subscribeError, with: 0)) + } +} diff --git a/Tests/PubNubTests/EventEngine/Subscribe/SubscribeTransitionTests.swift b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeTransitionTests.swift new file mode 100644 index 00000000..d32c57b4 --- /dev/null +++ b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeTransitionTests.swift @@ -0,0 +1,1432 @@ +// +// SubscribeTransitionTests.swift +// +// Copyright (c) PubNub Inc. +// All rights reserved. +// +// This source code is licensed under the license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation +import XCTest + +@testable import PubNub + +extension SubscribeState { + func isEqual(to otherState: some SubscribeState) -> Bool { + (otherState as? Self) == self + } +} + +extension Subscribe.Invocation : Equatable { + public static func ==(lhs: Subscribe.Invocation, rhs: Subscribe.Invocation) -> Bool { + switch (lhs, rhs) { + case let (.handshakeRequest(lC, lG), .handshakeRequest(rC, rG)): + return lC.sorted(by: <) == rC.sorted(by: <) && lG.sorted(by: <) == rG.sorted(by: <) + case let (.handshakeReconnect(lC, lG, lAtt, lErr),.handshakeReconnect(rC, rG, rAtt, rErr)): + return lC.sorted(by: <) == rC.sorted(by: <) && lG.sorted(by: <) == rG.sorted(by: <) && lAtt == rAtt && lErr == rErr + case let (.receiveMessages(lC, lG, lCrsr),.receiveMessages(rC, rG, rCrsr)): + return lC.sorted(by: <) == rC.sorted(by: <) && lG.sorted(by: <) == rG.sorted(by: <) && lCrsr == rCrsr + case let (.receiveReconnect(lC, lG, lCrsr, lAtt, lErr), .receiveReconnect(rC, rG, rCrsr, rAtt, rErr)): + return lC.sorted(by: <) == rC.sorted(by: <) && lG.sorted(by: <) == rG.sorted(by: <) && lCrsr == rCrsr && lAtt == rAtt && lErr == rErr + case let (.emitStatus(lhsChange), .emitStatus(rhsChange)): + return lhsChange == rhsChange + case let (.emitMessages(lhsMssgs, lhsCrsr), .emitMessages(rhsMssgs, rhsCrsr)): + return lhsMssgs == rhsMssgs && lhsCrsr == rhsCrsr + default: + return false + } + } +} + +extension Subscribe.Event: Equatable { + public static func == (lhs: Subscribe.Event, rhs: Subscribe.Event) -> Bool { + switch (lhs, rhs) { + case let (.subscriptionChanged(lC, lG), .subscriptionChanged(rC, rG)): + return lC.sorted(by: <) == rC.sorted(by: <) && lG.sorted(by: <) == rG.sorted(by: <) + case let (.subscriptionRestored(lC, lG, lCursor), .subscriptionRestored(rC, rG, rCursor)): + return lC.sorted(by: <) == rC.sorted(by: <) && lG.sorted(by: <) == rG.sorted(by: <) && lCursor == rCursor + case let (.handshakeSuccess(lCursor), .handshakeSuccess(rCursor)): + return lCursor == rCursor + case let (.handshakeReconnectSuccess(lCursor), .handshakeReconnectSuccess(rCursor)): + return lCursor == rCursor + case let (.handshakeFailure(lError), .handshakeFailure(rError)): + return lError == rError + case let (.handshakeReconnectFailure(lError), .handshakeReconnectFailure(rError)): + return lError == rError + case let (.handshakeReconnectGiveUp(lError), .handshakeReconnectGiveUp(rError)): + return lError == rError + case let (.receiveSuccess(lCursor, lMssgs), .receiveSuccess(rCursor, rMssgs)): + return lCursor == rCursor && lMssgs == rMssgs + case let (.receiveFailure(lError), .receiveFailure(rError)): + return lError == rError + case let (.receiveReconnectSuccess(lCursor, lMssgs), .receiveReconnectSuccess(rCursor, rMssgs)): + return lCursor == rCursor && lMssgs == rMssgs + case let (.receiveReconnectFailure(lError), .receiveReconnectFailure(rError)): + return lError == rError + case let (.receiveReconnectGiveUp(lError), .receiveReconnectGiveUp(rError)): + return lError == rError + case (.disconnect, .disconnect): + return true + case (.reconnect, .reconnect): + return true + case (.unsubscribeAll, .unsubscribeAll): + return true + default: + return false + } + } +} + +class SubscribeTransitionTests: XCTestCase { + private let transition = SubscribeTransition() + private let input = SubscribeInput(channels: [PubNubChannel(channel: "test-channel")]) + + // MARK: - Subscription Changed + + func test_SubscriptionChangedForUnsubscribedState() throws { + let results = transition.transition( + from: Subscribe.UnsubscribedState(), + event: .subscriptionChanged( + channels: ["c1", "c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2", "g2-pnpres", "g3"] + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.handshakeRequest( + channels: ["c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3"] + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: false) + ] + let expectedState = Subscribe.HandshakingState(input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), cursor: SubscribeCursor(timetoken: 0)!) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionChangedForHandshakeFailedState() throws { + let results = transition.transition( + from: Subscribe.HandshakeFailedState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + error: SubscribeError(underlying: PubNubError(.unknown)) + ), + event: .subscriptionChanged( + channels: ["c1", "c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2", "g2-pnpres", "g3"] + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.handshakeRequest( + channels: ["c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3"] + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: false) + ] + let expectedState = Subscribe.HandshakingState(input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), cursor: SubscribeCursor(timetoken: 0, region: 0)) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionChangedForHandshakeStoppedState() throws { + let results = transition.transition( + from: Subscribe.HandshakeStoppedState(input: input, cursor: SubscribeCursor(timetoken: 0, region: 0)), + event: .subscriptionChanged( + channels: ["c1", "c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2", "g2-pnpres", "g3"] + ) + ) + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: false) + ] + let expectedState = Subscribe.HandshakeStoppedState(input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), cursor: SubscribeCursor(timetoken: 0, region: 0)) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.isEmpty) + } + + func test_SubscriptionChangedForHandshakeReconnectingState() throws { + let reason = SubscribeError( + underlying: PubNubError(.unknown) + ) + let results = transition.transition( + from: Subscribe.HandshakeReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + retryAttempt: 1, reason: reason + ), + event: .subscriptionChanged( + channels: ["c1", "c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2", "g2-pnpres", "g3"] + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeReconnect), + .managed(.handshakeRequest( + channels: ["c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3"] + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: false) + ] + let expectedState = Subscribe.HandshakingState(input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), cursor: SubscribeCursor(timetoken: 0, region: 0)) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionChangedForHandshakingState() throws { + let results = transition.transition( + from: Subscribe.HandshakingState(input: input, cursor: SubscribeCursor(timetoken: 0, region: 0)), + event: .subscriptionChanged( + channels: ["c1", "c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2", "g2-pnpres", "g3"] + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeRequest), + .managed(.handshakeRequest( + channels: ["c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3"] + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: false) + ] + let expectedState = Subscribe.HandshakingState(input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), cursor: SubscribeCursor(timetoken: 0, region: 0)) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionChangedForReceivingState() throws { + let results = transition.transition( + from: Subscribe.ReceivingState(input: input, cursor: SubscribeCursor(timetoken: 5001000, region: 22)), + event: .subscriptionChanged( + channels: ["c1", "c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2", "g2-pnpres", "g3"] + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveMessages), + .managed(.receiveMessages( + channels: ["c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3"], + cursor: SubscribeCursor(timetoken: 5001000, region: 22) + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: false) + ] + let expectedState = Subscribe.ReceivingState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), cursor: SubscribeCursor( + timetoken: 5001000, + region: 22 + ) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionChangedForReceiveFailedState() throws { + let results = transition.transition( + from: Subscribe.ReceiveFailedState( + input: input, + cursor: SubscribeCursor(timetoken: 500100900, region: 11), + error: SubscribeError(underlying: PubNubError(.unknown)) + ), + event: .subscriptionChanged( + channels: ["c1", "c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2", "g2-pnpres", "g3"] + ) + ) + + let expectedInvocations: [EffectInvocation] = [ + .managed(.handshakeRequest( + channels: ["c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3"] + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: false) + ] + let expectedState = Subscribe.HandshakingState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), cursor: SubscribeCursor(timetoken: 500100900, region: 11) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionChangedForReceiveStoppedState() throws { + let results = transition.transition( + from: Subscribe.ReceiveStoppedState(input: input, cursor: SubscribeCursor(timetoken: 500100900, region: 11)), + event: .subscriptionChanged( + channels: ["c1", "c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2", "g2-pnpres", "g3"] + ) + ) + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: false) + ] + let expectedState = Subscribe.ReceiveStoppedState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), cursor: SubscribeCursor( + timetoken: 500100900, + region: 11 + ) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.isEmpty) + } + + func test_SubscriptionChangedForReceiveReconnectingState() throws { + let results = transition.transition( + from: Subscribe.ReceiveReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 500100900, region: 11), + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(.unknown)) + ), + event: .subscriptionChanged( + channels: ["c1", "c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2", "g2-pnpres", "g3"] + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveReconnect), + .managed(.receiveMessages( + channels: ["c1", "c1-pnpres", "c2"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3"], + cursor: SubscribeCursor(timetoken: 500100900, region: 11) + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: false) + ] + let expectedState = Subscribe.ReceivingState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), cursor: SubscribeCursor( + timetoken: 500100900, + region: 11 + ) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Subscription Restored + + func test_SubscriptionRestoredForReceivingState() throws { + let results = transition.transition( + from: Subscribe.ReceivingState(input: input, cursor: SubscribeCursor(timetoken: 1500100900, region: 41)), + event: .subscriptionRestored( + channels: ["c1", "c1-pnpres", "c2", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4", "g4"], + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveMessages), + .managed(.receiveMessages( + channels: ["c1", "c1-pnpres", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4"], + cursor: SubscribeCursor(timetoken: 100, region: 55) + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: true), + PubNubChannel(id: "c3", withPresence: true), + PubNubChannel(id: "c4", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: true), + PubNubChannel(id: "g4", withPresence: false) + ] + let expectedState = Subscribe.ReceivingState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionRestoredForReceiveReconnectingState() { + let results = transition.transition( + from: Subscribe.ReceiveReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 1500100900, region: 41), + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(.unknown)) + ), + event: .subscriptionRestored( + channels: ["c1", "c1-pnpres", "c2", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4", "g4"], + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveReconnect), + .managed(.receiveMessages( + channels: ["c1", "c1-pnpres", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4"], + cursor: SubscribeCursor(timetoken: 100, region: 55) + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: true), + PubNubChannel(id: "c3", withPresence: true), + PubNubChannel(id: "c4", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: true), + PubNubChannel(id: "g4", withPresence: false) + ] + let expectedState = Subscribe.ReceivingState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionRestoredForReceiveFailedState() { + let results = transition.transition( + from: Subscribe.ReceiveFailedState( + input: input, + cursor: SubscribeCursor(timetoken: 1500100900, region: 41), + error: SubscribeError(underlying: PubNubError(.unknown)) + ), + event: .subscriptionRestored( + channels: ["c1", "c1-pnpres", "c2", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4", "g4"], + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.handshakeRequest( + channels: ["c1", "c1-pnpres", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4"] + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: true), + PubNubChannel(id: "c3", withPresence: true), + PubNubChannel(id: "c4", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: true), + PubNubChannel(id: "g4", withPresence: false) + ] + let expectedState = Subscribe.HandshakingState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionRestoredForReceiveStoppedState() { + let results = transition.transition( + from: Subscribe.ReceiveStoppedState( + input: input, + cursor: SubscribeCursor(timetoken: 99, region: 9) + ), + event: .subscriptionRestored( + channels: ["c1", "c1-pnpres", "c2", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4", "g4"], + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + ) + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: true), + PubNubChannel(id: "c3", withPresence: true), + PubNubChannel(id: "c4", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: true), + PubNubChannel(id: "g4", withPresence: false) + ] + let expectedState = Subscribe.ReceiveStoppedState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.isEmpty) + } + + func test_SubscriptionRestoredForHandshakingState() { + let results = transition.transition( + from: Subscribe.HandshakingState(input: input, cursor: SubscribeCursor(timetoken: 0, region: 0)), + event: .subscriptionRestored( + channels: ["c1", "c1-pnpres", "c2", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4", "g4"], + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeRequest), + .managed(.handshakeRequest( + channels: ["c1", "c1-pnpres", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4"] + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: true), + PubNubChannel(id: "c3", withPresence: true), + PubNubChannel(id: "c4", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: true), + PubNubChannel(id: "g4", withPresence: false) + ] + let expectedState = Subscribe.HandshakingState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionRestoredForHandshakeReconnectingState() { + let reason = SubscribeError( + underlying: PubNubError(.unknown) + ) + let results = transition.transition( + from: Subscribe.HandshakeReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + retryAttempt: 1, reason: reason + ), + event: .subscriptionRestored( + channels: ["c1", "c1-pnpres", "c2", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4", "g4"], + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeReconnect), + .managed(.handshakeRequest( + channels: ["c1", "c1-pnpres", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4"] + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: true), + PubNubChannel(id: "c3", withPresence: true), + PubNubChannel(id: "c4", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: true), + PubNubChannel(id: "g4", withPresence: false) + ] + let expectedState = Subscribe.HandshakingState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionRestoredForHandshakeFailedState() { + let results = transition.transition( + from: Subscribe.HandshakeFailedState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + error: SubscribeError(underlying: PubNubError(.unknown)) + ), + event: .subscriptionRestored( + channels: ["c1", "c1-pnpres", "c2", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4", "g4"], + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.handshakeRequest( + channels: ["c1", "c1-pnpres", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4"] + )) + ] + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: true), + PubNubChannel(id: "c3", withPresence: true), + PubNubChannel(id: "c4", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: true), + PubNubChannel(id: "g4", withPresence: false) + ] + let expectedState = Subscribe.HandshakingState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_SubscriptionRestoredForHandshakeStoppedState() { + let results = transition.transition( + from: Subscribe.HandshakeStoppedState(input: input, cursor: SubscribeCursor(timetoken: 0, region: 0)), + event: .subscriptionRestored( + channels: ["c1", "c1-pnpres", "c2", "c2", "c2-pnpres", "c3", "c3-pnpres", "c4"], + groups: ["g1", "g1-pnpres", "g2", "g2-pnpres", "g3", "g3-pnpres", "g4", "g4"], + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + ) + let expectedChannels = [ + PubNubChannel(id: "c1", withPresence: true), + PubNubChannel(id: "c2", withPresence: true), + PubNubChannel(id: "c3", withPresence: true), + PubNubChannel(id: "c4", withPresence: false) + ] + let expectedGroups = [ + PubNubChannel(id: "g1", withPresence: true), + PubNubChannel(id: "g2", withPresence: true), + PubNubChannel(id: "g3", withPresence: true), + PubNubChannel(id: "g4", withPresence: false) + ] + let expectedState = Subscribe.HandshakeStoppedState( + input: SubscribeInput( + channels: expectedChannels, + groups: expectedGroups + ), + cursor: SubscribeCursor(timetoken: 100, region: 55) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.isEmpty) + } + + // MARK: - Handshake Success + + func test_HandshakeSuccessForHandshakingState() { + let cursor = SubscribeCursor( + timetoken: 1500100900, + region: 41 + ) + let results = transition.transition( + from: Subscribe.HandshakingState(input: input, cursor: SubscribeCursor(timetoken: 0, region: 0)), + event: .handshakeSuccess(cursor: cursor) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeRequest), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .connected, + error: nil + ))), + .managed(.receiveMessages(channels: input.allSubscribedChannels, + groups: input.allSubscribedGroups, + cursor: cursor + )) + ] + let expectedState = Subscribe.ReceivingState( + input: input, + cursor: SubscribeCursor(timetoken: 1500100900, region: 41) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Handshake Failure + + func test_HandshakeFailureForHandshakingState() { + let results = transition.transition( + from: Subscribe.HandshakingState(input: input, cursor: SubscribeCursor(timetoken: 0, region: 0)), + event: .handshakeFailure(error: SubscribeError(underlying: PubNubError(.unknown))) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeRequest), + .managed(.handshakeReconnect( + channels: input.allSubscribedChannels, + groups: input.allSubscribedGroups, + retryAttempt: 0, + reason: SubscribeError(underlying: PubNubError(.unknown)) + )) + ] + let expectedState = Subscribe.HandshakeReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + retryAttempt: 0, + reason: SubscribeError(underlying: PubNubError(.unknown)) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Handshake Reconnect Success + + func test_HandshakeReconnectSuccessForReconnectingState() { + let reason = SubscribeError( + underlying: PubNubError(.unknown) + ) + let cursor = SubscribeCursor( + timetoken: 200400600, + region: 45 + ) + let results = transition.transition( + from: Subscribe.HandshakeReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + retryAttempt: 1, reason: reason + ), + event: .handshakeReconnectSuccess(cursor: cursor) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeReconnect), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .connected, + error: nil + ))), + .managed(.receiveMessages( + channels: input.allSubscribedChannels, + groups: input.allSubscribedGroups, + cursor: SubscribeCursor(timetoken: 200400600, region: 45) + )) + ] + let expectedState = Subscribe.ReceivingState( + input: input, + cursor: SubscribeCursor(timetoken: 200400600, region: 45) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Handshake Reconnect Failure + + func test_HandshakeReconnectFailedForReconnectingState() { + let reason = SubscribeError( + underlying: PubNubError(.unknown) + ) + let results = transition.transition( + from: Subscribe.HandshakeReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + retryAttempt: 0, + reason: reason + ), + event: .handshakeReconnectFailure(error: SubscribeError(underlying: PubNubError(.unknown))) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeReconnect), + .managed(.handshakeReconnect( + channels: input.allSubscribedChannels, groups: input.allSubscribedGroups, + retryAttempt: 1, reason: SubscribeError(underlying: PubNubError(.unknown)) + )) + ] + let expectedState = Subscribe.HandshakeReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + retryAttempt: 1, + reason: reason + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Handshake Give Up + + func test_HandshakeGiveUpForReconnectingState() { + let reason = SubscribeError( + underlying: PubNubError(.unknown) + ) + let results = transition.transition( + from: Subscribe.HandshakeReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + retryAttempt: 3, + reason: reason + ), + event: .handshakeReconnectGiveUp(error: SubscribeError(underlying: PubNubError(.unknown))) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeReconnect), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .connectionError, + error: SubscribeError(underlying: PubNubError(.unknown)) + ))) + ] + let expectedState = Subscribe.HandshakeFailedState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + error: SubscribeError(underlying: PubNubError(.unknown)) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Receive Give Up + + func test_ReceiveGiveUpForReconnectingState() { + let reason = SubscribeError( + underlying: PubNubError(.unknown) + ) + let results = transition.transition( + from: Subscribe.ReceiveReconnectingState( + input: input, cursor: SubscribeCursor(timetoken: 18001000, region: 123), + retryAttempt: 3, reason: reason + ), + event: .receiveReconnectGiveUp(error: SubscribeError(underlying: PubNubError(.unknown))) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveReconnect), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .connected, + newStatus: .disconnectedUnexpectedly, + error: SubscribeError(underlying: PubNubError(.unknown)) + ))) + ] + let expectedState = Subscribe.ReceiveFailedState( + input: input, + cursor: SubscribeCursor(timetoken: 18001000, region: 123), + error: SubscribeError(underlying: PubNubError(.unknown)) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Receiving With Messages + + func test_ReceivingStateWithMessages() { + let results = transition.transition( + from: Subscribe.ReceivingState( + input: input, + cursor: SubscribeCursor(timetoken: 18001000, region: 123) + ), + event: .receiveSuccess( + cursor: SubscribeCursor(timetoken: 18002000, region: 123), + messages: [firstMessage, secondMessage] + ) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveMessages), + .managed(.emitMessages( + events: [firstMessage, secondMessage], + forCursor: SubscribeCursor(timetoken: 18002000, region: 123) + )), + .managed(.receiveMessages( + channels: input.allSubscribedChannels, + groups: input.allSubscribedGroups, + cursor: SubscribeCursor(timetoken: 18002000, region: 123) + )) + ] + let expectedState = Subscribe.ReceivingState( + input: input, + cursor: SubscribeCursor(timetoken: 18002000, region: 123) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Receive Failed + + func test_ReceiveFailedForReceivingState() { + let reason = SubscribeError( + underlying: PubNubError(.unknown) + ) + let results = transition.transition( + from: Subscribe.ReceivingState( + input: input, + cursor: SubscribeCursor(timetoken: 100500900, region: 11) + ), + event: .receiveFailure(error: SubscribeError(underlying: PubNubError(.unknown))) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveMessages), + .managed(.receiveReconnect( + channels: input.allSubscribedChannels, + groups: input.allSubscribedGroups, + cursor: SubscribeCursor(timetoken: 100500900, region: 11), + retryAttempt: 0, + reason: SubscribeError(underlying: PubNubError(.unknown)) + )) + ] + let expectedState = Subscribe.ReceiveReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 100500900, region: 11), + retryAttempt: 0, + reason: reason + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_ReceiveReconnectFailedForReconnectingState() { + let reason = SubscribeError( + underlying: PubNubError(.unknown) + ) + let results = transition.transition( + from: Subscribe.ReceiveReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 100500900, region: 11), + retryAttempt: 1, + reason: reason + ), + event: .receiveReconnectFailure(error: SubscribeError(underlying: PubNubError(.unknown))) + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveReconnect), + .managed(.receiveReconnect( + channels: input.allSubscribedChannels, + groups: input.allSubscribedGroups, + cursor: SubscribeCursor(timetoken: 100500900, region: 11), + retryAttempt: 2, + reason: SubscribeError(underlying: PubNubError(.unknown)) + )) + ] + let expectedState = Subscribe.ReceiveReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 100500900, region: 11), + retryAttempt: 2, + reason: reason + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Reconnect + + func test_ReconnectForHandshakeStoppedState() throws { + let results = transition.transition( + from: Subscribe.HandshakeStoppedState(input: input, cursor: SubscribeCursor(timetoken: 0, region: 0)), + event: .reconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.handshakeRequest( + channels: input.allSubscribedChannels, + groups: input.allSubscribedGroups) + ) + ] + let expectedState = Subscribe.HandshakingState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_ReconnectForHandshakeFailedState() throws { + let results = transition.transition( + from: Subscribe.HandshakeFailedState( + input: input, cursor: SubscribeCursor(timetoken: 0, region: 0), + error: SubscribeError(underlying: PubNubError(.unknown)) + ), + event: .reconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.handshakeRequest( + channels: input.allSubscribedChannels, + groups: input.allSubscribedGroups + )) + ] + let expectedState = Subscribe.HandshakingState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_ReconnectForReceiveStoppedState() throws { + let results = transition.transition( + from: Subscribe.ReceiveStoppedState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456) + ), + event: .reconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.handshakeRequest( + channels: input.allSubscribedChannels, + groups: input.allSubscribedGroups + )) + ] + let expectedState = Subscribe.HandshakingState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_ReconnectForReceiveFailedState() throws { + let results = transition.transition( + from: Subscribe.ReceiveFailedState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456), + error: SubscribeError(underlying: PubNubError(.unknown)) + ), + event: .reconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.handshakeRequest( + channels: input.allSubscribedChannels, + groups: input.allSubscribedGroups + )) + ] + let expectedState = Subscribe.HandshakingState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Disconnect + + func test_DisconnectForHandshakingState() { + let results = transition.transition( + from: Subscribe.HandshakingState(input: input, cursor: SubscribeCursor(timetoken: 0, region: 0)), + event: .disconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeRequest), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.HandshakeStoppedState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_DisconnectForHandshakeReconnectingState() { + let results = transition.transition( + from: Subscribe.HandshakeReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(.unknown)) + ), + event: .disconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeReconnect), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.HandshakeStoppedState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_DisconnectForReceivingState() { + let results = transition.transition( + from: Subscribe.ReceivingState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456) + ), + event: .disconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveMessages), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .connected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.ReceiveStoppedState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_DisconnectForReceiveReconnectingState() { + let results = transition.transition( + from: Subscribe.ReceiveReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456), + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(.unknown)) + ), + event: .disconnect + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveReconnect), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .connected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.ReceiveStoppedState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456) + ) + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + // MARK: - Unsubscribe All + + func testUnsubscribeAll_ForHandshakingState() throws { + let results = transition.transition( + from: Subscribe.HandshakingState(input: input, cursor: SubscribeCursor(timetoken: 0, region: 0)), + event: .unsubscribeAll + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeRequest), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.UnsubscribedState() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testUnsubscribeAll_ForHandshakeReconnectingState() throws { + let results = transition.transition( + from: Subscribe.HandshakeReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 0, region: 0), + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(.badRequest)) + ), + event: .unsubscribeAll + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.handshakeReconnect), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.UnsubscribedState() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testUnsubscribeAll_ForHandshakeFailedState() throws { + let results = transition.transition( + from: Subscribe.HandshakeFailedState( + input: input, cursor: SubscribeCursor(timetoken: 0, region: 0), + error: SubscribeError(underlying: PubNubError(.badRequest)) + ), + event: .unsubscribeAll + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.UnsubscribedState() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func testUnsubscribeAll_ForHandshakeStoppedState() throws { + let results = transition.transition( + from: Subscribe.HandshakeStoppedState(input: input, cursor: SubscribeCursor(timetoken: 0, region: 0)), + event: .unsubscribeAll + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.UnsubscribedState() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_UnsubscribeAllForReceivingState() throws { + let results = transition.transition( + from: Subscribe.ReceivingState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456) + ), + event: .unsubscribeAll + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveMessages), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .connected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.UnsubscribedState() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_UnsubscribeAllForReceiveReconnectingState() throws { + let results = transition.transition( + from: Subscribe.ReceiveReconnectingState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456), + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(.badRequest)) + ), + event: .unsubscribeAll + ) + let expectedInvocations: [EffectInvocation] = [ + .cancel(.receiveReconnect), + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .connected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.UnsubscribedState() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_UnsubscribeAllForReceiveFailedState() throws { + let results = transition.transition( + from: Subscribe.ReceiveFailedState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456), + error: SubscribeError(underlying: PubNubError(.badRequest)) + ), + event: .unsubscribeAll + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.UnsubscribedState() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } + + func test_UnsubscribeAllForReceiveStoppedState() throws { + let results = transition.transition( + from: Subscribe.ReceiveStoppedState( + input: input, + cursor: SubscribeCursor(timetoken: 123, region: 456) + ), + event: .unsubscribeAll + ) + let expectedInvocations: [EffectInvocation] = [ + .managed(.emitStatus(change: Subscribe.ConnectionStatusChange( + oldStatus: .disconnected, + newStatus: .disconnected, + error: nil + ))) + ] + let expectedState = Subscribe.UnsubscribedState() + + XCTAssertTrue(results.state.isEqual(to: expectedState)) + XCTAssertTrue(results.invocations.elementsEqual(expectedInvocations)) + } +} + +fileprivate let firstMessage = SubscribeMessagePayload( + shard: "", + subscription: nil, + channel: "test-channel", + messageType: .message, + payload: ["message": "hello!"], + flags: 123, + publisher: "publisher", + subscribeKey: "FakeKey", + originTimetoken: nil, + publishTimetoken: SubscribeCursor(timetoken: 12312412412, region: 12), + meta: nil, + error: nil +) + +fileprivate let secondMessage = SubscribeMessagePayload( + shard: "", + subscription: nil, + channel: "test-channel", + messageType: .messageAction, + payload: ["reaction": "👍"], + flags: 456, + publisher: "second-publisher", + subscribeKey: "FakeKey", + originTimetoken: nil, + publishTimetoken: SubscribeCursor(timetoken: 12312412555, region: 12), + meta: nil, + error: nil +) diff --git a/Tests/PubNubTests/Helpers/PAMTokenTests.swift b/Tests/PubNubTests/Helpers/PAMTokenTests.swift index 9182dd64..0a0cf4ef 100644 --- a/Tests/PubNubTests/Helpers/PAMTokenTests.swift +++ b/Tests/PubNubTests/Helpers/PAMTokenTests.swift @@ -14,7 +14,18 @@ import XCTest // swiftlint:disable line_length class PAMTokenTests: XCTestCase { - let config = PubNubConfiguration(publishKey: "", subscribeKey: "", userId: "tester") + let config = PubNubConfiguration( + publishKey: "", + subscribeKey: "", + userId: "tester" + ) + let eventEngineEnabledConfig = PubNubConfiguration( + publishKey: "", + subscribeKey: "", + userId: "tester", + enableEventEngine: true + ) + static let allPermissionsToken = "qEF2AkF0GmEI03xDdHRsGDxDcmVzpURjaGFuoWljaGFubmVsLTEY70NncnChb2NoYW5uZWxfZ3JvdXAtMQVDdXNyoENzcGOgRHV1aWShZnV1aWQtMRhoQ3BhdKVEY2hhbqFtXmNoYW5uZWwtXFMqJBjvQ2dycKF0XjpjaGFubmVsX2dyb3VwLVxTKiQFQ3VzcqBDc3BjoER1dWlkoWpedXVpZC1cUyokGGhEbWV0YaBEdXVpZHR0ZXN0LWF1dGhvcml6ZWQtdXVpZENzaWdYIPpU-vCe9rkpYs87YUrFNWkyNq8CVvmKwEjVinnDrJJc" } @@ -24,6 +35,7 @@ extension PAMTokenTests { func testParseToken() { let pubnub = PubNub(configuration: config) let token = pubnub.parse(token: PAMTokenTests.allPermissionsToken) + guard let resources = token?.resources else { return XCTAssert(false, "'resources' is missing") } @@ -48,14 +60,24 @@ extension PAMTokenTests { } func testSetToken() { + testSetToken(config: config) + testSetToken(config: eventEngineEnabledConfig) + } + + func testChangeToken() { + testChangeToken(config: config) + testChangeToken(config: eventEngineEnabledConfig) + } + + private func testSetToken(config: PubNubConfiguration) { let pubnub = PubNub(configuration: config) pubnub.set(token: "access-token") XCTAssertEqual(pubnub.configuration.authToken, "access-token") XCTAssertEqual(pubnub.subscription.configuration.authToken, "access-token") } - - func testChangeToken() { + + private func testChangeToken(config: PubNubConfiguration) { let pubnub = PubNub(configuration: config) pubnub.set(token: "access-token") pubnub.set(token: "access-token-updated") diff --git a/Tests/PubNubTests/Integration/SubscriptionIntegrationTests.swift b/Tests/PubNubTests/Integration/SubscriptionIntegrationTests.swift index f26e395f..d621e493 100644 --- a/Tests/PubNubTests/Integration/SubscriptionIntegrationTests.swift +++ b/Tests/PubNubTests/Integration/SubscriptionIntegrationTests.swift @@ -18,7 +18,6 @@ class SubscriptionIntegrationTests: XCTestCase { func testSubscribeError() { let subscribeExpect = expectation(description: "Subscribe Expectation") - let connectingExpect = expectation(description: "Connecting Expectation") let disconnectedExpect = expectation(description: "Disconnected Expectation") // Should return subscription key error @@ -29,14 +28,11 @@ class SubscriptionIntegrationTests: XCTestCase { listener.didReceiveSubscription = { event in switch event { case let .connectionStatusChanged(status): -// print("Status: \(status)") switch status { - case .connecting: - connectingExpect.fulfill() - case .disconnectedUnexpectedly: + case .disconnected: disconnectedExpect.fulfill() default: - XCTFail("Only should emit these two states") + XCTFail("Only should emit disconnected") } case .subscribeError: subscribeExpect.fulfill() // 8E988B17-C0AA-42F1-A6F9-1461BF51C82C @@ -48,7 +44,7 @@ class SubscriptionIntegrationTests: XCTestCase { pubnub.subscribe(to: [testChannel]) - wait(for: [subscribeExpect, connectingExpect, disconnectedExpect], timeout: 10.0) + wait(for: [subscribeExpect, disconnectedExpect], timeout: 10.0) } // swiftlint:disable:next function_body_length cyclomatic_complexity @@ -74,41 +70,25 @@ class SubscriptionIntegrationTests: XCTestCase { let listener = SubscriptionListener() listener.didReceiveSubscription = { [unowned self] event in switch event { - case let .subscriptionChanged(status): -// print("subscriptionChanged: \(status)") - switch status { - case let .subscribed(channels, _): - XCTAssertTrue(channels.contains(where: { $0.id == self.testChannel })) - XCTAssertTrue(pubnub.subscribedChannels.contains(self.testChannel)) - subscribeExpect.fulfill() - case let .responseHeader(channels, _, _, next): - XCTAssertTrue(channels.contains(where: { $0.id == self.testChannel })) -// print("channels: \(channels) previous: \(previous?.timetoken) next: \(next?.timetoken)") - XCTAssertEqual(pubnub.previousTimetoken, next?.timetoken) - case let .unsubscribed(channels, _): - XCTAssertTrue(channels.contains(where: { $0.id == self.testChannel })) - XCTAssertFalse(pubnub.subscribedChannels.contains(self.testChannel)) - unsubscribeExpect.fulfill() - } case .messageReceived: -// print("messageReceived: \(message)") pubnub.unsubscribe(from: [self.testChannel]) publishExpect.fulfill() case let .connectionStatusChanged(status): -// print("connectionStatusChanged: \(status)") switch status { case .connected: pubnub.publish(channel: self.testChannel, message: "Test") { _ in } connectedCount += 1 connectedExpect.fulfill() + case .connectionError: + XCTFail("An error was returned") case .disconnected: // Stop reconneced after N attempts if connectedCount < totalLoops { pubnub.subscribe(to: [self.testChannel]) } disconnectedExpect.fulfill() - default: - break + case .disconnectedUnexpectedly: + XCTFail("An error was returned") } case let .subscribeError(error): XCTFail("An error was returned: \(error)") diff --git a/Tests/PubNubTests/Mocking/Responses/Subscribe/subscription_handshake_success.json b/Tests/PubNubTests/Mocking/Responses/Subscribe/subscription_handshake_success.json new file mode 100644 index 00000000..5c6aa5c3 --- /dev/null +++ b/Tests/PubNubTests/Mocking/Responses/Subscribe/subscription_handshake_success.json @@ -0,0 +1,12 @@ +{ + "code": 200, + "body": { + "t":{ + "t":"16873352451141050", + "r":42 + }, + "m":[ + + ] + } +} diff --git a/Tests/PubNubTests/Networking/Operators/AutomaticRetryTests.swift b/Tests/PubNubTests/Networking/Operators/AutomaticRetryTests.swift index d7b474a4..b83da655 100644 --- a/Tests/PubNubTests/Networking/Operators/AutomaticRetryTests.swift +++ b/Tests/PubNubTests/Networking/Operators/AutomaticRetryTests.swift @@ -18,7 +18,7 @@ class AutomaticRetryTests: XCTestCase { func testReconnectionPolicy_DefaultLinearPolicy() { switch defaultLinearPolicy { case let .linear(delay): - XCTAssertEqual(delay, 3) + XCTAssertEqual(delay, 2) default: XCTFail("Default Linear Policy should only match to linear case") } @@ -26,10 +26,9 @@ class AutomaticRetryTests: XCTestCase { func testReconnectionPolicy_DefaultExponentialPolicy() { switch defaultExpoentialPolicy { - case let .exponential(base, scale, max): - XCTAssertEqual(base, 2) - XCTAssertEqual(scale, 2) - XCTAssertEqual(max, 300) + case let .exponential(minDelay, maxDelay): + XCTAssertEqual(minDelay, 2) + XCTAssertEqual(maxDelay, 150) default: XCTFail("Default Exponential Policy should only match to linear case") } @@ -39,77 +38,101 @@ class AutomaticRetryTests: XCTestCase { func testEquatable_Init_Valid_() { let testPolicy = AutomaticRetry.default - let policy = AutomaticRetry() + let automaticRetry = AutomaticRetry() - XCTAssertEqual(testPolicy, policy) + XCTAssertEqual(testPolicy, automaticRetry) } - func testEquatable_Init_Exponential_InvalidBase() { - let invalidBasePolicy = AutomaticRetry.ReconnectionPolicy.exponential(base: 0, scale: 3.0, maxDelay: 1) - let validBasePolicy = AutomaticRetry.ReconnectionPolicy.exponential(base: 2, scale: 3.0, maxDelay: 1) - let testPolicy = AutomaticRetry(retryLimit: 2, - policy: invalidBasePolicy, - retryableHTTPStatusCodes: [], - retryableURLErrorCodes: []) - - XCTAssertNotEqual(testPolicy.policy, invalidBasePolicy) - XCTAssertEqual(testPolicy.policy, validBasePolicy) + func testEquatable_Init_Exponential_InvalidMinDelay() { + let invalidBasePolicy = AutomaticRetry.ReconnectionPolicy.exponential(minDelay: 0, maxDelay: 30) + let validBasePolicy = AutomaticRetry.ReconnectionPolicy.exponential(minDelay: 2, maxDelay: 30) + let automaticRetry = AutomaticRetry( + retryLimit: 2, + policy: invalidBasePolicy, + retryableHTTPStatusCodes: [], + retryableURLErrorCodes: [] + ) + + XCTAssertNotEqual(automaticRetry.policy, invalidBasePolicy) + XCTAssertEqual(automaticRetry.policy, validBasePolicy) } - - func testEquatable_Init_Exponential_InvalidScale() { - let invalidBasePolicy = AutomaticRetry.ReconnectionPolicy.exponential(base: 2, scale: -1.0, maxDelay: 1) - let validBasePolicy = AutomaticRetry.ReconnectionPolicy.exponential(base: 2, scale: 0.0, maxDelay: 1) - let testPolicy = AutomaticRetry(retryLimit: 2, - policy: invalidBasePolicy, - retryableHTTPStatusCodes: [], - retryableURLErrorCodes: []) - - XCTAssertNotEqual(testPolicy.policy, invalidBasePolicy) - XCTAssertEqual(testPolicy.policy, validBasePolicy) + + func testEquatable_Init_Exponential_MinDelayGreaterThanMaxDelay() { + let invalidBasePolicy = AutomaticRetry.ReconnectionPolicy.exponential(minDelay: 10, maxDelay: 5) + let validBasePolicy = AutomaticRetry.ReconnectionPolicy.exponential(minDelay: 10, maxDelay: 10) + let automaticRetry = AutomaticRetry( + retryLimit: 2, + policy: invalidBasePolicy, + retryableHTTPStatusCodes: [], + retryableURLErrorCodes: [] + ) + + XCTAssertNotEqual(automaticRetry.policy, invalidBasePolicy) + XCTAssertEqual(automaticRetry.policy, validBasePolicy) } - - func testEquatable_Init_Exponential_InvalidBaseAndScale() { - let invalidBasePolicy = AutomaticRetry.ReconnectionPolicy.exponential(base: 0, scale: -1.0, maxDelay: 1) - let validBasePolicy = AutomaticRetry.ReconnectionPolicy.exponential(base: 2, scale: 0.0, maxDelay: 1) - let testPolicy = AutomaticRetry(retryLimit: 2, - policy: invalidBasePolicy, - retryableHTTPStatusCodes: [], - retryableURLErrorCodes: []) - - XCTAssertNotEqual(testPolicy.policy, invalidBasePolicy) - XCTAssertEqual(testPolicy.policy, validBasePolicy) + + func testEquatable_Init_Exponential_TooHighRetryLimit() { + let policy = AutomaticRetry.ReconnectionPolicy.exponential(minDelay: 5, maxDelay: 60) + let automaticRetry = AutomaticRetry( + retryLimit: 12, + policy: policy, + retryableHTTPStatusCodes: [], + retryableURLErrorCodes: [] + ) + + XCTAssertEqual(automaticRetry.policy, policy) + XCTAssertEqual(automaticRetry.retryLimit, 10) } func testEquatable_Init_Linear_InvalidDelay() { let invalidBasePolicy = AutomaticRetry.ReconnectionPolicy.linear(delay: -1.0) - let validBasePolicy = AutomaticRetry.ReconnectionPolicy.linear(delay: 0.0) - let testPolicy = AutomaticRetry(retryLimit: 2, - policy: invalidBasePolicy, - retryableHTTPStatusCodes: [], - retryableURLErrorCodes: []) - - XCTAssertNotEqual(testPolicy.policy, invalidBasePolicy) - XCTAssertEqual(testPolicy.policy, validBasePolicy) + let validBasePolicy = AutomaticRetry.ReconnectionPolicy.linear(delay: 2.0) + let automaticRetry = AutomaticRetry( + retryLimit: 2, + policy: invalidBasePolicy, + retryableHTTPStatusCodes: [], + retryableURLErrorCodes: [] + ) + + XCTAssertNotEqual(automaticRetry.policy, invalidBasePolicy) + XCTAssertEqual(automaticRetry.policy, validBasePolicy) + } + + func testEquatable_Init_Linear_TooHighRetryLimit() { + let policy = AutomaticRetry.ReconnectionPolicy.linear(delay: 3.0) + let automaticRetry = AutomaticRetry( + retryLimit: 12, + policy: policy, + retryableHTTPStatusCodes: [], + retryableURLErrorCodes: [] + ) + + XCTAssertEqual(automaticRetry.policy, policy) + XCTAssertEqual(automaticRetry.retryLimit, 10) } func testEquatable_Init_Linear_Valid() { - let validLinearPolicy = AutomaticRetry.ReconnectionPolicy.linear(delay: 1.0) - let testPolicy = AutomaticRetry(retryLimit: 2, - policy: validLinearPolicy, - retryableHTTPStatusCodes: [], - retryableURLErrorCodes: []) - - XCTAssertEqual(testPolicy.policy, validLinearPolicy) + let validLinearPolicy = AutomaticRetry.ReconnectionPolicy.linear(delay: 3.0) + let automaticRetry = AutomaticRetry( + retryLimit: 2, + policy: validLinearPolicy, + retryableHTTPStatusCodes: [], + retryableURLErrorCodes: [] + ) + + XCTAssertEqual(automaticRetry.policy, validLinearPolicy) } func testEquatable_Init_Other() { - let immediateasePolicy = AutomaticRetry.ReconnectionPolicy.immediately - let testPolicy = AutomaticRetry(retryLimit: 2, - policy: immediateasePolicy, - retryableHTTPStatusCodes: [], - retryableURLErrorCodes: []) - - XCTAssertEqual(testPolicy.policy, immediateasePolicy) + let linearPolicy = AutomaticRetry.ReconnectionPolicy.linear(delay: 3.0) + let automaticRetry = AutomaticRetry( + retryLimit: 2, + policy: linearPolicy, + retryableHTTPStatusCodes: [], + retryableURLErrorCodes: [] + ) + + XCTAssertEqual(automaticRetry.policy, linearPolicy) } // MARK: - retry(:session:for:dueTo:completion:) @@ -136,71 +159,75 @@ class AutomaticRetryTests: XCTestCase { } let testStatusCode = 500 - let testPolicy = AutomaticRetry(retryLimit: 2, - policy: .immediately, - retryableHTTPStatusCodes: [testStatusCode], - retryableURLErrorCodes: []) - let testResponse = HTTPURLResponse(url: url, - statusCode: testStatusCode, - httpVersion: nil, - headerFields: [:]) - - XCTAssertTrue(testPolicy.shouldRetry(response: testResponse, - error: PubNubError(.unknown))) + let testPolicy = AutomaticRetry( + retryLimit: 2, + policy: .linear(delay: 3.0), + retryableHTTPStatusCodes: [testStatusCode], + retryableURLErrorCodes: [] + ) + let testResponse = HTTPURLResponse( + url: url, + statusCode: testStatusCode, + httpVersion: nil, + headerFields: [:] + ) + + XCTAssertTrue(testPolicy.shouldRetry(response: testResponse, error: PubNubError(.unknown))) } func testShouldRetry_True_ErrorCodeMatch() { let testURLErrorCode = URLError.Code.timedOut let testError = URLError(testURLErrorCode) - let testPolicy = AutomaticRetry(retryLimit: 2, - policy: .immediately, - retryableHTTPStatusCodes: [], - retryableURLErrorCodes: [testURLErrorCode]) - - XCTAssertTrue(testPolicy.shouldRetry(response: nil, - error: testError)) + let testPolicy = AutomaticRetry( + retryLimit: 2, + policy: .linear(delay: 3.0), + retryableHTTPStatusCodes: [], + retryableURLErrorCodes: [testURLErrorCode] + ) + + XCTAssertTrue(testPolicy.shouldRetry(response: nil, error: testError)) } func testShouldRetry_False() { let testError = URLError(.timedOut) - let testPolicy = AutomaticRetry(retryLimit: 2, - policy: .immediately, - retryableHTTPStatusCodes: [], - retryableURLErrorCodes: []) - - XCTAssertFalse(testPolicy.shouldRetry(response: nil, - error: testError)) + let testPolicy = AutomaticRetry( + retryLimit: 2, + policy: .linear(delay: 3.0), + retryableHTTPStatusCodes: [], + retryableURLErrorCodes: [] + ) + + XCTAssertFalse(testPolicy.shouldRetry(response: nil, error: testError)) } // MARK: - exponentialBackoffDelay(for:scale:current:) func testExponentialBackoffDelay_DefaultScale() { let maxRetryCount = 5 - let scale = 2.0 - let base: UInt = 2 let maxDelay = UInt.max - - let delayForRetry = [4.0, 8.0, 16.0, 32.0, 64.0] - - for count in 1 ... maxRetryCount { - XCTAssertEqual(AutomaticRetry.ReconnectionPolicy - .exponential(base: base, scale: scale, maxDelay: maxDelay).delay(for: count), - delayForRetry[count - 1]) + // Usage of Range due to random delay (0...1) that's always added to the final value + let delayForRetry: [ClosedRange] = [2.0...3.0, 4.0...5.0, 8.0...9.0, 16.0...17.0, 32.0...33.0] + + for count in 0..] = [2.0...3.0, 3.0...4.0, 3.0...4.0, 3.0...4.0, 3.0...4.0] let maxRetryCount = 5 - let scale = 2.0 - let base: UInt = 2 - let maxDelay: UInt = 0 - - let delayForRetry = [0.0, 0.0, 0.0, 0.0, 0.0] - for count in 1 ... maxRetryCount { - XCTAssertEqual(AutomaticRetry.ReconnectionPolicy - .exponential(base: base, scale: scale, maxDelay: maxDelay).delay(for: count), - delayForRetry[count - 1]) + for count in 0..