diff --git a/PubNub.xcodeproj/project.pbxproj b/PubNub.xcodeproj/project.pbxproj index 5c562ac2..3cd6b4eb 100644 --- a/PubNub.xcodeproj/project.pbxproj +++ b/PubNub.xcodeproj/project.pbxproj @@ -413,6 +413,7 @@ 3DB56B652A715F7E00FC35A0 /* HeartbeatEffectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB56B632A715F1700FC35A0 /* HeartbeatEffectTests.swift */; }; 3DCC4DE02A42E93200F4A67A /* subscription_handshake_success.json in Resources */ = {isa = PBXBuildFile; fileRef = 3DCC4DDF2A42E93200F4A67A /* subscription_handshake_success.json */; }; 3DD048812A8CDC4F00CE0408 /* WaitEffectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD048802A8CDC4F00CE0408 /* WaitEffectTests.swift */; }; + 3DE73D1F2B221493001B5C1E /* EventEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE73D1E2B221493001B5C1E /* EventEngineTests.swift */; }; 3DE7487C2A1FA426009B0809 /* TransitionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE7486D2A1FA426009B0809 /* TransitionProtocol.swift */; }; 3DE7487D2A1FA426009B0809 /* Dispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE7486E2A1FA426009B0809 /* Dispatcher.swift */; }; 3DE7487E2A1FA426009B0809 /* EffectHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE7486F2A1FA426009B0809 /* EffectHandler.swift */; }; @@ -982,6 +983,7 @@ 3DB56B632A715F1700FC35A0 /* HeartbeatEffectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeartbeatEffectTests.swift; sourceTree = ""; }; 3DCC4DDF2A42E93200F4A67A /* subscription_handshake_success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscription_handshake_success.json; sourceTree = ""; }; 3DD048802A8CDC4F00CE0408 /* WaitEffectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitEffectTests.swift; sourceTree = ""; }; + 3DE73D1E2B221493001B5C1E /* EventEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventEngineTests.swift; sourceTree = ""; }; 3DE7486D2A1FA426009B0809 /* TransitionProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransitionProtocol.swift; sourceTree = ""; }; 3DE7486E2A1FA426009B0809 /* Dispatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Dispatcher.swift; sourceTree = ""; }; 3DE7486F2A1FA426009B0809 /* EffectHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EffectHandler.swift; sourceTree = ""; }; @@ -2147,6 +2149,7 @@ 3DE748882A1FA449009B0809 /* EventEngine */ = { isa = PBXGroup; children = ( + 3DE73D1E2B221493001B5C1E /* EventEngineTests.swift */, 3DE748892A1FA449009B0809 /* DispatcherTests.swift */, 3D9B29EE2A65605900C988C9 /* Presence */, 3D9B29EF2A65605900C988C9 /* Subscribe */, @@ -3441,6 +3444,7 @@ 35CDFEAD22E7655700F3B9F2 /* URL+PubNubTests.swift in Sources */, 35CDFEBA22E77E2B00F3B9F2 /* URLSessionConfiguration+PubNubTests.swift in Sources */, 35FE941F22F0929A0051C455 /* RequestRetrierTests.swift in Sources */, + 3DE73D1F2B221493001B5C1E /* EventEngineTests.swift in Sources */, 35580686230F47EA005CDD92 /* RequestIdOperatorTests.swift in Sources */, 3DFA33952A8CEFD7003B595F /* DelayedHeartbeatEffectTests.swift in Sources */, 35CDFEA722E75BE800F3B9F2 /* OperationQueue+PubNubTests.swift in Sources */, diff --git a/Sources/PubNub/EventEngine/Core/EventEngineFactory.swift b/Sources/PubNub/EventEngine/Core/EventEngineFactory.swift index 6806e0c4..d5f7d636 100644 --- a/Sources/PubNub/EventEngine/Core/EventEngineFactory.swift +++ b/Sources/PubNub/EventEngine/Core/EventEngineFactory.swift @@ -38,8 +38,8 @@ typealias PresenceDispatcher = Dispatcher SubscribeEngine { EventEngine( state: Subscribe.UnsubscribedState(), @@ -51,7 +51,7 @@ class EventEngineFactory { func presenceEngine( with configuration: PubNubConfiguration, - dispatcher: some PresenceDispatcher = EffectDispatcher(factory: PresenceEffectFactory.defaultFactory()), + dispatcher: some PresenceDispatcher, transition: some PresenceTransitions ) -> PresenceEngine { EventEngine( diff --git a/Sources/PubNub/EventEngine/Presence/Effects/PresenceEffectFactory.swift b/Sources/PubNub/EventEngine/Presence/Effects/PresenceEffectFactory.swift index ab59a9f3..884187b6 100644 --- a/Sources/PubNub/EventEngine/Presence/Effects/PresenceEffectFactory.swift +++ b/Sources/PubNub/EventEngine/Presence/Effects/PresenceEffectFactory.swift @@ -30,20 +30,16 @@ 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)) { + init( + session: SessionReplaceable, + sessionResponseQueue: DispatchQueue = .global(qos: .default), + presenceStateContainer: PresenceStateContainer + ) { self.session = session self.sessionResponseQueue = sessionResponseQueue - } - - static func defaultFactory() -> PresenceEffectFactory { - PresenceEffectFactory( - session: HTTPSession( - configuration: .pubnub, - sessionQueue: DispatchQueue(label: "Presence Response Queue"), - sessionStream: SessionListener() - ) - ) + self.presenceStateContainer = presenceStateContainer } func effect( @@ -56,6 +52,7 @@ class PresenceEffectFactory: EffectHandlerFactory { request: PresenceHeartbeatRequest( channels: channels, groups: groups, + channelStates: presenceStateContainer.getStates(forChannels: channels), configuration: dependencies.value.configuration, session: session, sessionResponseQueue: sessionResponseQueue @@ -66,6 +63,7 @@ class PresenceEffectFactory: EffectHandlerFactory { request: PresenceHeartbeatRequest( channels: channels, groups: groups, + channelStates: presenceStateContainer.getStates(forChannels: channels), configuration: dependencies.value.configuration, session: session, sessionResponseQueue: sessionResponseQueue diff --git a/Sources/PubNub/EventEngine/Presence/Helpers/PresenceHeartbeatRequest.swift b/Sources/PubNub/EventEngine/Presence/Helpers/PresenceHeartbeatRequest.swift index ea8d86f2..4c7c540f 100644 --- a/Sources/PubNub/EventEngine/Presence/Helpers/PresenceHeartbeatRequest.swift +++ b/Sources/PubNub/EventEngine/Presence/Helpers/PresenceHeartbeatRequest.swift @@ -27,6 +27,66 @@ 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] @@ -34,26 +94,35 @@ class PresenceHeartbeatRequest { 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) { - request = session.request(with: PresenceRouter( - .heartbeat(channels: channels, groups: groups, presenceTimeout: configuration.durationUntilTimeout), - configuration: configuration, eventEngineEnabled: true), requestOperator: nil + 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 { diff --git a/Sources/PubNub/EventEngine/Presence/Helpers/PresenceLeaveRequest.swift b/Sources/PubNub/EventEngine/Presence/Helpers/PresenceLeaveRequest.swift index 356d3bc1..c2a69a73 100644 --- a/Sources/PubNub/EventEngine/Presence/Helpers/PresenceLeaveRequest.swift +++ b/Sources/PubNub/EventEngine/Presence/Helpers/PresenceLeaveRequest.swift @@ -51,9 +51,13 @@ class PresenceLeaveRequest { } func execute(completionBlock: @escaping (Result) -> Void) { - request = session.request(with: PresenceRouter( - .leave(channels: channels, groups: groups), - configuration: configuration, eventEngineEnabled: true), requestOperator: nil + 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 { diff --git a/Sources/PubNub/EventEngine/Subscribe/Effects/SubscribeEffectFactory.swift b/Sources/PubNub/EventEngine/Subscribe/Effects/SubscribeEffectFactory.swift index 6626ff16..181c499f 100644 --- a/Sources/PubNub/EventEngine/Subscribe/Effects/SubscribeEffectFactory.swift +++ b/Sources/PubNub/EventEngine/Subscribe/Effects/SubscribeEffectFactory.swift @@ -31,23 +31,18 @@ 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() + messageCache: MessageCache = MessageCache(), + presenceStateContainer: PresenceStateContainer ) { self.session = session self.sessionResponseQueue = sessionResponseQueue self.messageCache = messageCache - } - - static func defaultFactory() -> SubscribeEffectFactory { - SubscribeEffectFactory(session: HTTPSession( - configuration: URLSessionConfiguration.subscription, - sessionQueue: DispatchQueue(label: "Subscribe Response Queue"), - sessionStream: SessionListener() - )) + self.presenceStateContainer = presenceStateContainer } func effect( @@ -61,6 +56,7 @@ class SubscribeEffectFactory: EffectHandlerFactory { configuration: dependencies.value.configuration, channels: channels, groups: groups, + channelStates: presenceStateContainer.getStates(forChannels: channels), timetoken: 0, session: session, sessionResponseQueue: sessionResponseQueue @@ -72,6 +68,7 @@ class SubscribeEffectFactory: EffectHandlerFactory { configuration: dependencies.value.configuration, channels: channels, groups: groups, + channelStates: presenceStateContainer.getStates(forChannels: channels), timetoken: 0, session: session, sessionResponseQueue: sessionResponseQueue @@ -85,6 +82,7 @@ class SubscribeEffectFactory: EffectHandlerFactory { configuration: dependencies.value.configuration, channels: channels, groups: groups, + channelStates: [:], timetoken: cursor.timetoken, region: cursor.region, session: session, @@ -97,6 +95,7 @@ class SubscribeEffectFactory: EffectHandlerFactory { configuration: dependencies.value.configuration, channels: channels, groups: groups, + channelStates: [:], timetoken: cursor.timetoken, region: cursor.region, session: session, diff --git a/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeRequest.swift b/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeRequest.swift index aabb024f..5f8ed5fb 100644 --- a/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeRequest.swift +++ b/Sources/PubNub/EventEngine/Subscribe/Helpers/SubscribeRequest.swift @@ -33,9 +33,11 @@ class SubscribeRequest { let timetoken: Timetoken? let region: Int? - private let configuration: SubscriptionConfiguration + 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 { @@ -43,9 +45,10 @@ class SubscribeRequest { } init( - configuration: SubscriptionConfiguration, + configuration: PubNubConfiguration, channels: [String], groups: [String], + channelStates: [String: [String: JSONCodableScalar]], timetoken: Timetoken? = nil, region: Int? = nil, session: SessionReplaceable, @@ -54,6 +57,7 @@ class SubscribeRequest { self.configuration = configuration self.channels = channels self.groups = groups + self.channelStates = channelStates self.timetoken = timetoken self.region = region self.session = session @@ -78,18 +82,21 @@ class SubscribeRequest { } 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: SubscribeRouter( - .subscribe( - channels: channels, - groups: groups, - timetoken: timetoken, - region: region?.description ?? nil, - heartbeat: configuration.durationUntilTimeout, - filter: configuration.filterExpression, - eventEngineEnabled: true - ), configuration: configuration - ), requestOperator: nil + with: router, + requestOperator: nil ) request?.validate().response( on: sessionResponseQueue, diff --git a/Sources/PubNub/Extensions/URLQueryItem+PubNub.swift b/Sources/PubNub/Extensions/URLQueryItem+PubNub.swift index 95998ec5..408f7e34 100644 --- a/Sources/PubNub/Extensions/URLQueryItem+PubNub.swift +++ b/Sources/PubNub/Extensions/URLQueryItem+PubNub.swift @@ -70,7 +70,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/Networking/HTTPRouter.swift b/Sources/PubNub/Networking/HTTPRouter.swift index 3bfd2bff..d12c756c 100644 --- a/Sources/PubNub/Networking/HTTPRouter.swift +++ b/Sources/PubNub/Networking/HTTPRouter.swift @@ -49,6 +49,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 sendsStateAutomatically: Bool { get } } public extension RouterConfiguration { diff --git a/Sources/PubNub/Networking/Routers/PresenceRouter.swift b/Sources/PubNub/Networking/Routers/PresenceRouter.swift index c542d128..799d12cc 100644 --- a/Sources/PubNub/Networking/Routers/PresenceRouter.swift +++ b/Sources/PubNub/Networking/Routers/PresenceRouter.swift @@ -32,7 +32,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) @@ -61,7 +61,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 @@ -78,7 +78,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 @@ -95,16 +95,14 @@ struct PresenceRouter: HTTPRouter { } // Init - init(_ endpoint: Endpoint, configuration: RouterConfiguration, eventEngineEnabled: Bool = false) { + init(_ endpoint: Endpoint, configuration: RouterConfiguration) { self.endpoint = endpoint self.configuration = configuration - self.eventEngineEnabled = eventEngineEnabled } var endpoint: Endpoint var configuration: RouterConfiguration - var eventEngineEnabled: Bool - + // Protocol Properties var service: PubNubService { return .presence @@ -118,7 +116,7 @@ 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" @@ -142,17 +140,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) - if eventEngineEnabled { - query.append(URLQueryItem(key: .eventEngine, value: nil)) - } + 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.sendsStateAutomatically && !channelStates.isEmpty + ) case let .leave(_, groups): query.appendIfNotEmpty(key: .channelGroup, value: groups) - if eventEngineEnabled { - query.append(URLQueryItem(key: .eventEngine, value: nil)) - } + 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)) @@ -182,7 +191,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): @@ -222,7 +231,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 b07146ac..3619aafa 100644 --- a/Sources/PubNub/Networking/Routers/SubscribeRouter.swift +++ b/Sources/PubNub/Networking/Routers/SubscribeRouter.swift @@ -33,9 +33,9 @@ struct SubscribeRouter: HTTPRouter { // Nested Endpoint enum Endpoint: CaseAccessible, CustomStringConvertible { case subscribe( - channels: [String], groups: [String], + channels: [String], groups: [String], channelStates: [String: [String: JSONCodableScalar]], timetoken: Timetoken?, region: String?, - heartbeat: UInt?, filter: String?, eventEngineEnabled: Bool + heartbeat: UInt?, filter: String? ) var description: String { @@ -54,7 +54,7 @@ struct SubscribeRouter: HTTPRouter { var endpoint: Endpoint var configuration: RouterConfiguration - + // Protocol Properties var service: PubNubService { return .subscribe @@ -79,16 +79,37 @@ struct SubscribeRouter: HTTPRouter { var query = defaultQueryItems switch endpoint { - case let .subscribe(_, groups, timetoken, region, heartbeat, filter, eventEngineEnabled): - 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) - - if eventEngineEnabled { - query.append(URLQueryItem(key: .eventEngine, value: nil)) - } + 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.sendsStateAutomatically && !channelStates.isEmpty + ) } return .success(query) diff --git a/Sources/PubNub/PubNub.swift b/Sources/PubNub/PubNub.swift index 5eaf5c74..da80c6be 100644 --- a/Sources/PubNub/PubNub.swift +++ b/Sources/PubNub/PubNub.swift @@ -50,7 +50,9 @@ 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: @@ -466,10 +468,16 @@ public extension PubNub { .setState(channels: channels, groups: groups, state: state), configuration: requestConfig.customConfiguration ?? configuration ) + if configuration.enableEventEngine && configuration.sendsStateAutomatically { + presenceStateContainer.registerState(state, forChannels: channels) + presenceStateContainer.registerState(state, forChannelGroups: groups) + } - route(router, - responseDecoder: PresenceResponseDecoder>(), - custom: requestConfig) { result in + route( + router, + responseDecoder: PresenceResponseDecoder>(), + custom: requestConfig + ) { result in completion?(result.map { $0.payload.payload }) } } diff --git a/Sources/PubNub/PubNubConfiguration.swift b/Sources/PubNub/PubNubConfiguration.swift index fa190202..50faeedb 100644 --- a/Sources/PubNub/PubNubConfiguration.swift +++ b/Sources/PubNub/PubNubConfiguration.swift @@ -87,6 +87,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 + /// - sendsStateAutomatically: Whether to automatically resend the last Presence channel state, + /// applies if `heartbeatInterval` is greater than zero and `eventEngineEnabled` is true public init( publishKey: String?, subscribeKey: String, @@ -105,7 +108,8 @@ public struct PubNubConfiguration: Hashable { supressLeaveEvents: Bool = false, requestMessageCountThreshold: UInt = 100, filterExpression: String? = nil, - enableEventEngine: Bool = false + enableEventEngine: Bool = false, + sendsStateAutomatically: Bool = false ) { guard userId.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else { preconditionFailure("UserId should not be empty.") @@ -129,6 +133,7 @@ public struct PubNubConfiguration: Hashable { self.requestMessageCountThreshold = requestMessageCountThreshold self.filterExpression = filterExpression self.enableEventEngine = enableEventEngine + self.sendsStateAutomatically = sendsStateAutomatically } // swiftlint:disable:next line_length @@ -222,8 +227,14 @@ public struct PubNubConfiguration: Hashable { public var useInstanceId: Bool /// Whether a request identifier should be included on outgoing requests public var useRequestId: Bool - /// A flag describing whether to enable the new strategy for handling subscription loop + /// 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 sendsStateAutomatically: 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/Strategy/EventEngineSubscriptionSessionStrategy.swift b/Sources/PubNub/Subscription/Strategy/EventEngineSubscriptionSessionStrategy.swift index 8744bb21..6474b436 100644 --- a/Sources/PubNub/Subscription/Strategy/EventEngineSubscriptionSessionStrategy.swift +++ b/Sources/PubNub/Subscription/Strategy/EventEngineSubscriptionSessionStrategy.swift @@ -31,6 +31,7 @@ class EventEngineSubscriptionSessionStrategy: SubscriptionSessionStrategy { let uuid = UUID() let subscribeEngine: SubscribeEngine let presenceEngine: PresenceEngine + let presenceStateContainer: PresenceStateContainer var privateListeners: WeakSet = WeakSet([]) var configuration: PubNubConfiguration @@ -39,11 +40,13 @@ class EventEngineSubscriptionSessionStrategy: SubscriptionSessionStrategy { internal init( configuration: PubNubConfiguration, subscribeEngine: SubscribeEngine, - presenceEngine: PresenceEngine + presenceEngine: PresenceEngine, + presenceStateContainer: PresenceStateContainer ) { self.subscribeEngine = subscribeEngine self.configuration = configuration self.presenceEngine = presenceEngine + self.presenceStateContainer = presenceStateContainer self.listenForStateUpdates() } @@ -140,7 +143,11 @@ class EventEngineSubscriptionSessionStrategy: SubscriptionSessionStrategy { let groups = input.allSubscribedGroups if let cursor = cursor { - sendSubscribeEvent(event: .subscriptionRestored(channels: channels, groups: groups, cursor: cursor)) + sendSubscribeEvent(event: .subscriptionRestored( + channels: channels, + groups: groups, + cursor: cursor + )) } else { sendSubscribeEvent(event: .reconnect) } @@ -158,6 +165,10 @@ class EventEngineSubscriptionSessionStrategy: SubscriptionSessionStrategy { 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 diff --git a/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy+Presence.swift b/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy+Presence.swift index 710ab802..37ef04dd 100644 --- a/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy+Presence.swift +++ b/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy+Presence.swift @@ -68,7 +68,7 @@ extension LegacySubscriptionSessionStrategy { // Perform Heartbeat let router = PresenceRouter( - .heartbeat(channels: channels, groups: groups, presenceTimeout: configuration.durationUntilTimeout), + .heartbeat(channels: channels, groups: groups, channelStates: [:], presenceTimeout: configuration.durationUntilTimeout), configuration: configuration ) diff --git a/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy.swift b/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy.swift index 32bee48c..905eddee 100644 --- a/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy.swift +++ b/Sources/PubNub/Subscription/Strategy/LegacySubscriptionSessionStrategy.swift @@ -185,9 +185,9 @@ class LegacySubscriptionSessionStrategy: SubscriptionSessionStrategy { // Create Endpoing let router = SubscribeRouter( .subscribe( - channels: channels, groups: groups, timetoken: cursor?.timetoken, + channels: channels, groups: groups, channelStates: [:], timetoken: cursor?.timetoken, region: cursor?.region.description, heartbeat: configuration.durationUntilTimeout, - filter: filterExpression, eventEngineEnabled: false + filter: filterExpression ), configuration: configuration ) diff --git a/Sources/PubNub/Subscription/SubscribeSessionFactory.swift b/Sources/PubNub/Subscription/SubscribeSessionFactory.swift index 0b4546b3..f41cc031 100644 --- a/Sources/PubNub/Subscription/SubscribeSessionFactory.swift +++ b/Sources/PubNub/Subscription/SubscribeSessionFactory.swift @@ -97,38 +97,47 @@ public class SubscribeSessionFactory { presenceSession: SessionReplaceable? ) -> any SubscriptionSessionStrategy { // Creates default network session objects if they're not provided - let finalSubscribeSession = subscribeSession ?? HTTPSession( + let subscribeSession = subscribeSession ?? HTTPSession( configuration: URLSessionConfiguration.subscription, sessionQueue: subscribeQueue, - sessionStream: SessionListener() + sessionStream: SessionListener(queue: subscribeQueue) ) - let finalPresenceSession = presenceSession ?? HTTPSession( + let presenceSession = presenceSession ?? HTTPSession( configuration: URLSessionConfiguration.pubnub, sessionQueue: subscribeQueue, - sessionStream: SessionListener() + sessionStream: SessionListener(queue: subscribeQueue) ) if configuration.enableEventEngine { + let subscribeEffectFactory = SubscribeEffectFactory( + session: subscribeSession, + presenceStateContainer: .shared + ) let subscribeEngine = EventEngineFactory().subscribeEngine( with: configuration, - dispatcher: EffectDispatcher(factory: SubscribeEffectFactory(session: finalSubscribeSession)), + dispatcher: EffectDispatcher(factory: subscribeEffectFactory), transition: SubscribeTransition() ) + let presenceEffectFactory = PresenceEffectFactory( + session: presenceSession, + presenceStateContainer: .shared + ) let presenceEngine = EventEngineFactory().presenceEngine( with: configuration, - dispatcher: EffectDispatcher(factory: PresenceEffectFactory(session: finalPresenceSession)), + dispatcher: EffectDispatcher(factory: presenceEffectFactory), transition: PresenceTransition(configuration: configuration) ) return EventEngineSubscriptionSessionStrategy( configuration: configuration, subscribeEngine: subscribeEngine, - presenceEngine: presenceEngine + presenceEngine: presenceEngine, + presenceStateContainer: .shared ) } return LegacySubscriptionSessionStrategy( configuration: configuration, - network: finalSubscribeSession, - presenceSession: finalPresenceSession + network: subscribeSession, + presenceSession: presenceSession ) } } diff --git a/Tests/PubNubContractTest/Steps/EventEngine/PubNubPresenceEngineContractTestSteps.swift b/Tests/PubNubContractTest/Steps/EventEngine/PubNubPresenceEngineContractTestSteps.swift index 99bacd15..53d1b42a 100644 --- a/Tests/PubNubContractTest/Steps/EventEngine/PubNubPresenceEngineContractTestSteps.swift +++ b/Tests/PubNubContractTest/Steps/EventEngine/PubNubPresenceEngineContractTestSteps.swift @@ -94,25 +94,49 @@ class PubNubPresenceEngineContractTestsSteps: PubNubEventEngineContractTestsStep } override func createPubNubClient() -> PubNub { - dispatcherDecorator = DispatcherDecorator(wrappedInstance: EffectDispatcher( - factory: PresenceEffectFactory.defaultFactory() - )) + 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 configuration = self.configuration - let factory = EventEngineFactory() - + + 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: factory.subscribeEngine(with: configuration), - presenceEngine: factory.presenceEngine( - with: configuration, - dispatcher: self.dispatcherDecorator, - transition: self.transitionDecorator - ) + subscribeEngine: subscribeEngine, + presenceEngine: presenceEngine, + presenceStateContainer: .shared ) ) return PubNub( diff --git a/Tests/PubNubContractTest/Steps/EventEngine/PubNubSubscribeEngineContractTestsSteps.swift b/Tests/PubNubContractTest/Steps/EventEngine/PubNubSubscribeEngineContractTestsSteps.swift index e31b9a8d..57f3a42b 100644 --- a/Tests/PubNubContractTest/Steps/EventEngine/PubNubSubscribeEngineContractTestsSteps.swift +++ b/Tests/PubNubContractTest/Steps/EventEngine/PubNubSubscribeEngineContractTestsSteps.swift @@ -123,9 +123,19 @@ class PubNubSubscribeEngineContractTestsSteps: PubNubEventEngineContractTestsSte } override func createPubNubClient() -> PubNub { - dispatcherDecorator = DispatcherDecorator(wrappedInstance: EffectDispatcher( - factory: SubscribeEffectFactory.defaultFactory() - )) + /// 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() ) @@ -133,22 +143,33 @@ class PubNubSubscribeEngineContractTestsSteps: PubNubEventEngineContractTestsSte 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: factory.subscribeEngine( - with: configuration, - dispatcher: self.dispatcherDecorator, - transition: self.transitionDecorator - ), - presenceEngine: factory.presenceEngine( - with: configuration, - transition: PresenceTransition(configuration: configuration) - ) + subscribeEngine: subscribeEngine, + presenceEngine: presenceEngine, + presenceStateContainer: .shared ) ) return PubNub( - configuration: self.configuration, + configuration: configuration, session: HTTPSession(configuration: configuration.urlSessionConfiguration), fileSession: URLSession(configuration: .pubnubBackground), subscriptionSession: subscriptionSession diff --git a/Tests/PubNubTests/EventEngine/EventEngineTests.swift b/Tests/PubNubTests/EventEngine/EventEngineTests.swift new file mode 100644 index 00000000..81326262 --- /dev/null +++ b/Tests/PubNubTests/EventEngine/EventEngineTests.swift @@ -0,0 +1,171 @@ +// +// EventEngineTests.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 +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/Presence/DelayedHeartbeatEffectTests.swift b/Tests/PubNubTests/EventEngine/Presence/DelayedHeartbeatEffectTests.swift index 2f57503b..5c6334c1 100644 --- a/Tests/PubNubTests/EventEngine/Presence/DelayedHeartbeatEffectTests.swift +++ b/Tests/PubNubTests/EventEngine/Presence/DelayedHeartbeatEffectTests.swift @@ -40,7 +40,7 @@ class DelayedHeartbeatEffectTests: XCTestCase { delegate = HTTPSessionDelegate() mockUrlSession = MockURLSession(delegate: delegate) httpSession = HTTPSession(session: mockUrlSession, delegate: delegate, sessionQueue: .main) - factory = PresenceEffectFactory(session: httpSession) + factory = PresenceEffectFactory(session: httpSession, presenceStateContainer: .shared) super.setUp() } diff --git a/Tests/PubNubTests/EventEngine/Presence/HeartbeatEffectTests.swift b/Tests/PubNubTests/EventEngine/Presence/HeartbeatEffectTests.swift index 323998cf..d754064c 100644 --- a/Tests/PubNubTests/EventEngine/Presence/HeartbeatEffectTests.swift +++ b/Tests/PubNubTests/EventEngine/Presence/HeartbeatEffectTests.swift @@ -47,7 +47,7 @@ class HeartbeatEffectTests: XCTestCase { delegate = HTTPSessionDelegate() mockUrlSession = MockURLSession(delegate: delegate) httpSession = HTTPSession(session: mockUrlSession, delegate: delegate, sessionQueue: .main) - factory = PresenceEffectFactory(session: httpSession) + factory = PresenceEffectFactory(session: httpSession, presenceStateContainer: .shared) super.setUp() } diff --git a/Tests/PubNubTests/EventEngine/Presence/LeaveEffectTests.swift b/Tests/PubNubTests/EventEngine/Presence/LeaveEffectTests.swift index a9a5b7c0..4d534654 100644 --- a/Tests/PubNubTests/EventEngine/Presence/LeaveEffectTests.swift +++ b/Tests/PubNubTests/EventEngine/Presence/LeaveEffectTests.swift @@ -40,7 +40,7 @@ class LeaveEffectTests: XCTestCase { delegate = HTTPSessionDelegate() mockUrlSession = MockURLSession(delegate: delegate) httpSession = HTTPSession(session: mockUrlSession, delegate: delegate, sessionQueue: .main) - factory = PresenceEffectFactory(session: httpSession) + factory = PresenceEffectFactory(session: httpSession, presenceStateContainer: .shared) super.setUp() } diff --git a/Tests/PubNubTests/EventEngine/Presence/WaitEffectTests.swift b/Tests/PubNubTests/EventEngine/Presence/WaitEffectTests.swift index d9907e33..a932da72 100644 --- a/Tests/PubNubTests/EventEngine/Presence/WaitEffectTests.swift +++ b/Tests/PubNubTests/EventEngine/Presence/WaitEffectTests.swift @@ -40,7 +40,7 @@ class WaitEffectTests: XCTestCase { delegate = HTTPSessionDelegate() mockUrlSession = MockURLSession(delegate: delegate) httpSession = HTTPSession(session: mockUrlSession, delegate: delegate, sessionQueue: .main) - factory = PresenceEffectFactory(session: httpSession) + factory = PresenceEffectFactory(session: httpSession, presenceStateContainer: .shared) super.setUp() } diff --git a/Tests/PubNubTests/EventEngine/Subscribe/SubscribeEffectsTests.swift b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeEffectsTests.swift index 0f18170c..929683cb 100644 --- a/Tests/PubNubTests/EventEngine/Subscribe/SubscribeEffectsTests.swift +++ b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeEffectsTests.swift @@ -59,8 +59,7 @@ class SubscribeEffectsTests: XCTestCase { delegate = HTTPSessionDelegate() mockUrlSession = MockURLSession(delegate: delegate) httpSession = HTTPSession(session: mockUrlSession, delegate: delegate, sessionQueue: .main) - factory = SubscribeEffectFactory(session: httpSession) - + factory = SubscribeEffectFactory(session: httpSession, presenceStateContainer: .shared) super.setUp() } @@ -70,7 +69,11 @@ class SubscribeEffectsTests: XCTestCase { httpSession = nil super.tearDown() } - +} + +// MARK: - HandshakingEffect + +extension SubscribeEffectsTests { func test_HandshakingEffectWithSuccessResponse() { let expectation = XCTestExpectation() expectation.expectationDescription = "Effect Completion Expectation" @@ -124,7 +127,11 @@ class SubscribeEffectsTests: XCTestCase { } wait(for: [expectation], timeout: 0.5) } - +} + +// MARK: ReceivingEffect + +extension SubscribeEffectsTests { func test_ReceivingEffectWithSuccessResponse() { let expectation = XCTestExpectation() expectation.expectationDescription = "Effect Completion Expectation" @@ -180,7 +187,11 @@ class SubscribeEffectsTests: XCTestCase { } wait(for: [expectation], timeout: 0.5) } - +} + +// MARK: - HandshakeReconnecting + +extension SubscribeEffectsTests { func test_HandshakeReconnectingSuccess() { let expectation = XCTestExpectation() expectation.expectationDescription = "Effect Completion Expectation" @@ -294,6 +305,40 @@ class SubscribeEffectsTests: XCTestCase { wait(for: [expectation], timeout: 1.5) } + func test_CancelledHandshakeReconnect() { + let expectation = XCTestExpectation() + expectation.expectationDescription = "Effect Completion Expectation" + expectation.assertForOverFulfill = true + expectation.isInverted = true + + let dependencies = EventEngineDependencies(value: Subscribe.Dependencies(configuration: configWithLinearPolicy(1.0))) + let urlError = URLError(.badServerResponse) + + mockResponse(subscribeResponse: SubscribeResponse( + cursor: SubscribeCursor(timetoken: 12345, region: 1), + messages: [] + )) + + let effect = factory.effect( + for: .handshakeReconnect( + channels: ["channel1", "channel1-pnpres", "channel2"], + groups: ["g1", "g2", "g2-pnpres"], + retryAttempt: 1, + reason: SubscribeError(underlying: PubNubError(urlError.pubnubReason!, underlying: urlError)) + ), with: dependencies + ) + effect.performTask { returnedEvents in + expectation.fulfill() + } + effect.cancelTask() + + wait(for: [expectation], timeout: 1.0) + } +} + +// MARK: - ReceiveReconnecting + +extension SubscribeEffectsTests { func test_ReceiveReconnectingSuccess() { let expectation = XCTestExpectation() expectation.expectationDescription = "Effect Completion Expectation" @@ -418,36 +463,6 @@ class SubscribeEffectsTests: XCTestCase { wait(for: [expectation], timeout: 1.5) } - func test_CancelledHandshakeReconnect() { - let expectation = XCTestExpectation() - expectation.expectationDescription = "Effect Completion Expectation" - expectation.assertForOverFulfill = true - expectation.isInverted = true - - let dependencies = EventEngineDependencies(value: Subscribe.Dependencies(configuration: configWithLinearPolicy(1.0))) - let urlError = URLError(.badServerResponse) - - mockResponse(subscribeResponse: SubscribeResponse( - cursor: SubscribeCursor(timetoken: 12345, region: 1), - messages: [] - )) - - let effect = factory.effect( - for: .handshakeReconnect( - channels: ["channel1", "channel1-pnpres", "channel2"], - groups: ["g1", "g2", "g2-pnpres"], - retryAttempt: 1, - reason: SubscribeError(underlying: PubNubError(urlError.pubnubReason!, underlying: urlError)) - ), with: dependencies - ) - effect.performTask { returnedEvents in - expectation.fulfill() - } - effect.cancelTask() - - wait(for: [expectation], timeout: 1.0) - } - func test_CancelledReceiveReconnect() { let expectation = XCTestExpectation() expectation.expectationDescription = "Effect Completion Expectation" @@ -480,6 +495,8 @@ class SubscribeEffectsTests: XCTestCase { } } +// MARK: - Helpers + fileprivate extension SubscribeEffectsTests { func mockResponse( subscribeResponse: SubscribeResponse? = nil, diff --git a/Tests/PubNubTests/EventEngine/Subscribe/SubscribeRequestTests.swift b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeRequestTests.swift index 166e4de2..e8e66bd7 100644 --- a/Tests/PubNubTests/EventEngine/Subscribe/SubscribeRequestTests.swift +++ b/Tests/PubNubTests/EventEngine/Subscribe/SubscribeRequestTests.swift @@ -41,6 +41,7 @@ class SubscribeRequestTests: XCTestCase { configuration: config, channels: ["channel1", "channel1-pnpres", "channel2"], groups: ["g1", "g2", "g2-pnpres"], + channelStates: [:], session: HTTPSession(configuration: .subscription), sessionResponseQueue: .main ) @@ -62,6 +63,7 @@ class SubscribeRequestTests: XCTestCase { configuration: config, channels: ["channel1", "channel1-pnpres", "channel2"], groups: ["g1", "g2", "g2-pnpres"], + channelStates: [:], session: HTTPSession(configuration: .subscription), sessionResponseQueue: .main ) @@ -90,6 +92,7 @@ class SubscribeRequestTests: XCTestCase { configuration: config, channels: ["channel1", "channel1-pnpres", "channel2"], groups: ["g1", "g2", "g2-pnpres"], + channelStates: [:], session: HTTPSession(configuration: .subscription), sessionResponseQueue: .main ) diff --git a/Tests/PubNubTests/Networking/Routers/PresenceRouterTests.swift b/Tests/PubNubTests/Networking/Routers/PresenceRouterTests.swift index 002da477..a119e650 100644 --- a/Tests/PubNubTests/Networking/Routers/PresenceRouterTests.swift +++ b/Tests/PubNubTests/Networking/Routers/PresenceRouterTests.swift @@ -41,9 +41,10 @@ final class PresenceRouterTests: XCTestCase { extension PresenceRouterTests { func testHereNow_Router() { let router = PresenceRouter( - .hereNow(channels: [channelName], groups: [], includeUUIDs: true, includeState: true), configuration: config + .hereNow(channels: [channelName], groups: [], includeUUIDs: true, includeState: true), + configuration: config ) - + XCTAssertEqual(router.endpoint.description, "Here Now") XCTAssertEqual(router.category, "Here Now") XCTAssertEqual(router.service, .presence) @@ -51,26 +52,31 @@ extension PresenceRouterTests { func testHereNow_Router_ValidationError() { let router = PresenceRouter( - .hereNow(channels: [], groups: [], includeUUIDs: true, includeState: true), configuration: config + .hereNow(channels: [], groups: [], includeUUIDs: true, includeState: true), + configuration: config + ) + + XCTAssertEqual( + router.validationError?.pubNubError?.details.first, + ErrorDescription.missingChannelsAnyGroups ) - - XCTAssertEqual(router.validationError?.pubNubError?.details.first, - ErrorDescription.missingChannelsAnyGroups) } func testHereNow_Router_Channels() { let router = PresenceRouter( - .hereNow(channels: [channelName], groups: [], includeUUIDs: true, includeState: true), configuration: config + .hereNow(channels: [channelName], groups: [], includeUUIDs: true, includeState: true), + configuration: config ) - + XCTAssertEqual(router.endpoint.channels, [channelName]) } func testHereNow_Router_Groups() { let router = PresenceRouter( - .hereNow(channels: [], groups: [channelName], includeUUIDs: true, includeState: true), configuration: config + .hereNow(channels: [], groups: [channelName], includeUUIDs: true, includeState: true), + configuration: config ) - + XCTAssertEqual(router.endpoint.groups, [channelName]) } @@ -270,19 +276,16 @@ extension PresenceRouterTests { func testHereNowGlobal_Router_ValidationError() { let router = PresenceRouter(.hereNowGlobal(includeUUIDs: true, includeState: true), configuration: config) - XCTAssertNil(router.validationError) } func testHereNowGlobal_Router_Channels() { let router = PresenceRouter(.hereNowGlobal(includeUUIDs: true, includeState: true), configuration: config) - XCTAssertEqual(router.endpoint.channels, []) } func testHereNowGlobal_Router_Groups() { let router = PresenceRouter(.hereNowGlobal(includeUUIDs: true, includeState: true), configuration: config) - XCTAssertEqual(router.endpoint.groups, []) } @@ -327,19 +330,19 @@ extension PresenceRouterTests { func testWhereNow_Router_ValidationError() { let router = PresenceRouter(.whereNow(uuid: ""), configuration: config) - XCTAssertEqual(router.validationError?.pubNubError?.details.first, - ErrorDescription.emptyUUIDString) + XCTAssertEqual( + router.validationError?.pubNubError?.details.first, + ErrorDescription.emptyUUIDString + ) } func testWhereNow_Router_Channels() { let router = PresenceRouter(.whereNow(uuid: "Something"), configuration: config) - XCTAssertEqual(router.endpoint.channels, []) } func testWhereNow_Router_Groups() { let router = PresenceRouter(.whereNow(uuid: "Something"), configuration: config) - XCTAssertEqual(router.endpoint.groups, []) } @@ -390,8 +393,12 @@ extension PresenceRouterTests { extension PresenceRouterTests { func testHeartbeat_Router() { - let router = PresenceRouter(.heartbeat(channels: [channelName], groups: [], presenceTimeout: nil), - configuration: config) + let router = PresenceRouter( + .heartbeat( + channels: [channelName], groups: [], + channelStates: [:], presenceTimeout: nil + ), configuration: config + ) XCTAssertEqual(router.endpoint.description, "Heartbeat") XCTAssertEqual(router.category, "Heartbeat") @@ -399,25 +406,177 @@ extension PresenceRouterTests { } func testHeartbeat_Router_ValidationError() { - let router = PresenceRouter(.heartbeat(channels: [], groups: [], presenceTimeout: nil), configuration: config) + let router = PresenceRouter( + .heartbeat( + channels: [], groups: [], + channelStates: [:], presenceTimeout: nil + ), configuration: config + ) - XCTAssertEqual(router.validationError?.pubNubError?.details.first, - ErrorDescription.missingChannelsAnyGroups) + XCTAssertEqual( + router.validationError?.pubNubError?.details.first, + ErrorDescription.missingChannelsAnyGroups + ) } func testHeartbeat_Router_Channels() { - let router = PresenceRouter(.heartbeat(channels: [channelName], groups: [], presenceTimeout: nil), - configuration: config) + let router = PresenceRouter( + .heartbeat( + channels: [channelName], + groups: [], + channelStates: [:], + presenceTimeout: nil + ), configuration: config + ) XCTAssertEqual(router.endpoint.channels, [channelName]) } func testHeartbeat_Router_Groups() { - let router = PresenceRouter(.heartbeat(channels: [], groups: [channelName], presenceTimeout: nil), - configuration: config) + let router = PresenceRouter( + .heartbeat( + channels: [], + groups: [channelName], + channelStates: [:], + presenceTimeout: nil + ), configuration: config + ) XCTAssertEqual(router.endpoint.groups, [channelName]) } + + func testHeartbeat_QueryParamsWithEventEngineEnabled() { + let stateContainer = PresenceStateContainer.shared + stateContainer.registerState(["x": 1], forChannels: ["c1"]) + stateContainer.registerState(["a": "someText"], forChannels: ["c2"]) + + let endpoint = PresenceRouter.Endpoint.heartbeat( + channels: ["c1", "c2"], + groups: ["group-1", "group-2"], + channelStates: stateContainer.getStates(forChannels: ["c1", "c2"]), + presenceTimeout: 30 + ) + let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "someId", + enableEventEngine: true, + sendsStateAutomatically: true + ) + let router = PresenceRouter( + endpoint, + configuration: config + ) + + let queryItems = (try? router.queryItems.get()) ?? [] + + // There's no guaranteed order of returned states. + // Therefore, these are two possible and valid combinations: + let expStateValues = [ + "{\"c1\":{\"x\":1},\"c2\":{\"a\":\"someText\"}}", + "{\"c2\":{\"a\":\"someText\"},\"c1\":{\"x\":1}}" + ] + + XCTAssertTrue(queryItems.count == 6) + XCTAssertTrue(queryItems.contains { $0.name == "pnsdk" }) + XCTAssertTrue(queryItems.contains { $0.name == "uuid" && $0.value == "someId" }) + XCTAssertTrue(queryItems.contains { $0.name == "channel-group" && $0.value!.contains("group-1,group-2") }) + XCTAssertTrue(queryItems.contains { $0.name == "heartbeat" && $0.value! == "30" }) + XCTAssertTrue(queryItems.contains { $0.name == "ee" && $0.value == nil }) + XCTAssertTrue(queryItems.contains { $0.name == "state" && expStateValues.contains($0.value!) }) + } + + func testHeartbeat_QueryParamsWithEventEngineDisabled() { + let stateContainer = PresenceStateContainer.shared + stateContainer.registerState(["x": 1], forChannels: ["c1"]) + stateContainer.registerState(["a": "someText"], forChannels: ["c2"]) + + let endpoint = PresenceRouter.Endpoint.heartbeat( + channels: ["c1", "c2"], + groups: ["group-1", "group-2"], + channelStates: stateContainer.getStates(forChannels: ["c1", "c2"]), + presenceTimeout: 30 + ) + let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "someId", + enableEventEngine: false, + sendsStateAutomatically: true + ) + + let router = PresenceRouter( + endpoint, + configuration: config + ) + let queryItems = (try? router.queryItems.get()) ?? [] + + XCTAssertTrue(queryItems.count == 4) + XCTAssertTrue(queryItems.contains { $0.name == "pnsdk" }) + XCTAssertTrue(queryItems.contains { $0.name == "uuid" && $0.value == "someId" }) + XCTAssertTrue(queryItems.contains { $0.name == "channel-group" && $0.value!.contains("group-1,group-2") }) + XCTAssertTrue(queryItems.contains { $0.name == "heartbeat" && $0.value! == "30" }) + } + + func testHeartbeat_QueryParamsWithSendStateDisabled() { + let stateContainer = PresenceStateContainer.shared + stateContainer.registerState(["x": 1], forChannels: ["c1"]) + stateContainer.registerState(["a": "someText"], forChannels: ["c2"]) + + let endpoint = PresenceRouter.Endpoint.heartbeat( + channels: ["c1", "c2"], + groups: ["group-1", "group-2"], + channelStates: stateContainer.getStates(forChannels: ["c1", "c2"]), + presenceTimeout: 30 + ) + let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "someId", + enableEventEngine: true, + sendsStateAutomatically: false + ) + let router = PresenceRouter( + endpoint, + configuration: config + ) + let queryItems = (try? router.queryItems.get()) ?? [] + + XCTAssertTrue(queryItems.count == 5) + XCTAssertTrue(queryItems.contains { $0.name == "pnsdk" }) + XCTAssertTrue(queryItems.contains { $0.name == "uuid" && $0.value == "someId" }) + XCTAssertTrue(queryItems.contains { $0.name == "channel-group" && $0.value!.contains("group-1,group-2") }) + XCTAssertTrue(queryItems.contains { $0.name == "heartbeat" && $0.value! == "30" }) + XCTAssertTrue(queryItems.contains { $0.name == "ee" && $0.value == nil }) + } + + func testHeartbeat_QueryParamsWithEmptyStates() { + let endpoint = PresenceRouter.Endpoint.heartbeat( + channels: ["c1", "c2"], + groups: ["group-1", "group-2"], + channelStates: [:], + presenceTimeout: 30 + ) + let config = PubNubConfiguration( + publishKey: "pubKey", + subscribeKey: "subKey", + userId: "someId", + enableEventEngine: true, + sendsStateAutomatically: true + ) + let router = PresenceRouter( + endpoint, + configuration: config + ) + let queryItems = (try? router.queryItems.get()) ?? [] + + XCTAssertTrue(queryItems.count == 5) + XCTAssertTrue(queryItems.contains { $0.name == "pnsdk" }) + XCTAssertTrue(queryItems.contains { $0.name == "uuid" && $0.value == "someId" }) + XCTAssertTrue(queryItems.contains { $0.name == "channel-group" && $0.value!.contains("group-1,group-2") }) + XCTAssertTrue(queryItems.contains { $0.name == "heartbeat" && $0.value! == "30" }) + XCTAssertTrue(queryItems.contains { $0.name == "ee" && $0.value == nil }) + } } // MARK: - Leave Tests @@ -434,19 +593,19 @@ extension PresenceRouterTests { func testLeave_Router_ValidationError() { let router = PresenceRouter(.leave(channels: [], groups: []), configuration: config) - XCTAssertEqual(router.validationError?.pubNubError?.details.first, - ErrorDescription.missingChannelsAnyGroups) + XCTAssertEqual( + router.validationError?.pubNubError?.details.first, + ErrorDescription.missingChannelsAnyGroups + ) } func testLeave_Router_Channels() { let router = PresenceRouter(.leave(channels: [channelName], groups: []), configuration: config) - XCTAssertEqual(router.endpoint.channels, [channelName]) } func testLeave_Router_Groups() { let router = PresenceRouter(.leave(channels: [], groups: [channelName]), configuration: config) - XCTAssertEqual(router.endpoint.groups, [channelName]) } } @@ -464,24 +623,28 @@ extension PresenceRouterTests { func testGetState_Router_ValidationError() { let router = PresenceRouter(.getState(uuid: "", channels: [channelName], groups: []), configuration: config) - XCTAssertEqual(router.validationError?.pubNubError?.details.first, - ErrorDescription.emptyUUIDString) + XCTAssertEqual( + router.validationError?.pubNubError?.details.first, + ErrorDescription.emptyUUIDString + ) - let missingChannelsGroups = PresenceRouter(.getState(uuid: "TestUUID", channels: [], groups: []), - configuration: config) - XCTAssertEqual(missingChannelsGroups.validationError?.pubNubError?.details.first, - ErrorDescription.missingChannelsAnyGroups) + let missingChannelsGroups = PresenceRouter( + .getState(uuid: "TestUUID", channels: [], groups: []), + configuration: config + ) + XCTAssertEqual( + missingChannelsGroups.validationError?.pubNubError?.details.first, + ErrorDescription.missingChannelsAnyGroups + ) } func testGetState_Router_Channels() { let router = PresenceRouter(.getState(uuid: "TestUUID", channels: [channelName], groups: []), configuration: config) - XCTAssertEqual(router.endpoint.channels, [channelName]) } func testGetState_Router_Groups() { let router = PresenceRouter(.getState(uuid: "TestUUID", channels: [], groups: [channelName]), configuration: config) - XCTAssertEqual(router.endpoint.groups, [channelName]) } } @@ -500,19 +663,19 @@ extension PresenceRouterTests { func testSetState_Router_ValidationError() { let router = PresenceRouter(.setState(channels: [], groups: [], state: [:]), configuration: config) - XCTAssertEqual(router.validationError?.pubNubError?.details.first, - ErrorDescription.missingChannelsAnyGroups) + XCTAssertEqual( + router.validationError?.pubNubError?.details.first, + ErrorDescription.missingChannelsAnyGroups + ) } func testSetState_Router_Channels() { let router = PresenceRouter(.setState(channels: [channelName], groups: [], state: [:]), configuration: config) - XCTAssertEqual(router.endpoint.channels, [channelName]) } func testSetState_Router_Groups() { let router = PresenceRouter(.setState(channels: [], groups: [channelName], state: [:]), configuration: config) - XCTAssertEqual(router.endpoint.groups, [channelName]) } diff --git a/Tests/PubNubTests/Networking/Routers/SubscribeRouterTests.swift b/Tests/PubNubTests/Networking/Routers/SubscribeRouterTests.swift index f26734ec..e9ef8295 100644 --- a/Tests/PubNubTests/Networking/Routers/SubscribeRouterTests.swift +++ b/Tests/PubNubTests/Networking/Routers/SubscribeRouterTests.swift @@ -173,6 +173,145 @@ extension SubscribeRouterTests { } } +// MARK: - Subscribe Query Params + +extension SubscribeRouterTests { + func testSubscribeRouter_QueryParamsWithEventEngineEnabled() { + let config = PubNubConfiguration( + publishKey: "FakeTestString", + subscribeKey: "FakeTestString", + userId: "someId", + enableEventEngine: true, + sendsStateAutomatically: true + ) + let channelStates: [String: [String: JSONCodableScalar]] = [ + "c1": ["x": 1], + "c2": ["a": "someText"] + ] + let endpoint = SubscribeRouter.Endpoint.subscribe( + channels: ["c1"], groups: ["group-1", "group-2"], channelStates: channelStates, + timetoken: 123456, region: "42", heartbeat: 30, filter: nil + ) + let router = SubscribeRouter( + endpoint, + configuration: config + ) + + // There's no guaranteed order of returned states. + // Therefore, these are two possible and valid combinations: + let expStateValues = [ + "{\"c1\":{\"x\":1},\"c2\":{\"a\":\"someText\"}}", + "{\"c2\":{\"a\":\"someText\"},\"c1\":{\"x\":1}}" + ] + + let queryItems = (try? router.queryItems.get()) ?? [] + + XCTAssertTrue(queryItems.count == 8) + XCTAssertTrue(queryItems.contains { $0.name == "pnsdk" }) + XCTAssertTrue(queryItems.contains { $0.name == "uuid" && $0.value == "someId" }) + XCTAssertTrue(queryItems.contains { $0.name == "heartbeat" && $0.value == "30" }) + XCTAssertTrue(queryItems.contains { $0.name == "channel-group" && $0.value == "group-1,group-2" }) + XCTAssertTrue(queryItems.contains { $0.name == "tt" && $0.value == "123456" }) + XCTAssertTrue(queryItems.contains { $0.name == "tr" && $0.value == "42" }) + XCTAssertTrue(queryItems.contains { $0.name == "ee" && $0.value == nil }) + XCTAssertTrue(queryItems.contains { $0.name == "state" && expStateValues.contains($0.value!) }) + } + + func testSubscribeRouter_QueryParamsWithEventEngineDisabled() { + let config = PubNubConfiguration( + publishKey: "FakeTestString", + subscribeKey: "FakeTestString", + userId: "someId", + enableEventEngine: false, + sendsStateAutomatically: true + ) + let channelStates: [String: [String: JSONCodableScalar]] = [ + "c1": ["x": 1], + "c2": ["a": "someText"] + ] + let endpoint = SubscribeRouter.Endpoint.subscribe( + channels: ["c1"], groups: ["group-1", "group-2"], channelStates: channelStates, + timetoken: 123456, region: "42", heartbeat: 30, filter: nil + ) + let router = SubscribeRouter( + endpoint, + configuration: config + ) + + let queryItems = (try? router.queryItems.get()) ?? [] + + XCTAssertTrue(queryItems.count == 6) + XCTAssertTrue(queryItems.contains { $0.name == "pnsdk" }) + XCTAssertTrue(queryItems.contains { $0.name == "uuid" && $0.value == "someId" }) + XCTAssertTrue(queryItems.contains { $0.name == "heartbeat" && $0.value == "30" }) + XCTAssertTrue(queryItems.contains { $0.name == "channel-group" && $0.value == "group-1,group-2" }) + XCTAssertTrue(queryItems.contains { $0.name == "tt" && $0.value == "123456" }) + XCTAssertTrue(queryItems.contains { $0.name == "tr" && $0.value == "42" }) + } + + func testSubscribeRouter_QueryParamsWithSendStateDisabled() { + let config = PubNubConfiguration( + publishKey: "FakeTestString", + subscribeKey: "FakeTestString", + userId: "someId", + enableEventEngine: true, + sendsStateAutomatically: false + ) + let channelStates: [String: [String: JSONCodableScalar]] = [ + "c1": ["x": 1], + "c2": ["a": "someText"] + ] + let endpoint = SubscribeRouter.Endpoint.subscribe( + channels: ["c1"], groups: ["group-1", "group-2"], channelStates: channelStates, + timetoken: 123456, region: "42", heartbeat: 30, filter: nil + ) + let router = SubscribeRouter( + endpoint, + configuration: config + ) + + let queryItems = (try? router.queryItems.get()) ?? [] + + XCTAssertTrue(queryItems.count == 7) + XCTAssertTrue(queryItems.contains { $0.name == "pnsdk" }) + XCTAssertTrue(queryItems.contains { $0.name == "uuid" && $0.value == "someId" }) + XCTAssertTrue(queryItems.contains { $0.name == "heartbeat" && $0.value == "30" }) + XCTAssertTrue(queryItems.contains { $0.name == "channel-group" && $0.value == "group-1,group-2" }) + XCTAssertTrue(queryItems.contains { $0.name == "tt" && $0.value == "123456" }) + XCTAssertTrue(queryItems.contains { $0.name == "tr" && $0.value == "42" }) + XCTAssertTrue(queryItems.contains { $0.name == "ee" && $0.value == nil }) + } + + func testSubscribeRouter_QueryParamsWithEmptyStates() { + let config = PubNubConfiguration( + publishKey: "FakeTestString", + subscribeKey: "FakeTestString", + userId: "someId", + enableEventEngine: true, + sendsStateAutomatically: true + ) + let endpoint = SubscribeRouter.Endpoint.subscribe( + channels: ["c1"], groups: ["group-1", "group-2"], channelStates: [:], + timetoken: 123456, region: "42", heartbeat: 30, filter: nil + ) + let router = SubscribeRouter( + endpoint, + configuration: config + ) + + let queryItems = (try? router.queryItems.get()) ?? [] + + XCTAssertTrue(queryItems.count == 7) + XCTAssertTrue(queryItems.contains { $0.name == "pnsdk" }) + XCTAssertTrue(queryItems.contains { $0.name == "uuid" && $0.value == "someId" }) + XCTAssertTrue(queryItems.contains { $0.name == "heartbeat" && $0.value == "30" }) + XCTAssertTrue(queryItems.contains { $0.name == "channel-group" && $0.value == "group-1,group-2" }) + XCTAssertTrue(queryItems.contains { $0.name == "tt" && $0.value == "123456" }) + XCTAssertTrue(queryItems.contains { $0.name == "tr" && $0.value == "42" }) + XCTAssertTrue(queryItems.contains { $0.name == "ee" && $0.value == nil }) + } +} + // MARK: - Unsubscribe extension SubscribeRouterTests { @@ -192,8 +331,8 @@ extension SubscribeRouterTests { extension SubscribeRouterTests { func testSubscribe_Router(config: PubNubConfiguration) { let router = SubscribeRouter(.subscribe( - channels: ["TestChannel"], groups: [], timetoken: 0, - region: nil, heartbeat: nil, filter: nil, eventEngineEnabled: config.enableEventEngine + channels: ["TestChannel"], groups: [], channelStates: [:], timetoken: 0, + region: nil, heartbeat: nil, filter: nil ), configuration: config) XCTAssertEqual(router.endpoint.description, "Subscribe") @@ -203,8 +342,8 @@ extension SubscribeRouterTests { func testSubscribe_Router_ValidationError(config: PubNubConfiguration) { let router = SubscribeRouter(.subscribe( - channels: [], groups: [], timetoken: 0, - region: nil, heartbeat: nil, filter: nil, eventEngineEnabled: config.enableEventEngine + channels: [], groups: [], channelStates: [:], timetoken: 0, + region: nil, heartbeat: nil, filter: nil ), configuration: config) XCTAssertNotEqual(