From 54d2ad04a18b3f2a56e683f8f5268ca17a94dec1 Mon Sep 17 00:00:00 2001 From: jguz-pubnub Date: Mon, 4 Dec 2023 21:27:21 +0100 Subject: [PATCH] Presence & Subscribe EE * Added default static factory methods for Subscribe/PresenceEffectFactory * Presence EE contract tests (finalizing) * Fixes for DelayedHeartbeatEffect --- .../Effects/DelayedHeartbeatEffect.swift | 25 ++++--- .../Presence/Effects/HeartbeatEffect.swift | 3 - .../Presence/Effects/LeaveEffect.swift | 3 - .../Presence/PresenceTransition.swift | 26 ++++--- ...entEngineSubscriptionSessionStrategy.swift | 4 +- ...ubNubPresenceEngineContractTestSteps.swift | 35 +++++++-- ...NubSubscribeEngineContractTestsSteps.swift | 2 +- .../DelayedHeartbeatEffectTests.swift | 41 ++++++----- .../Presence/PresenceTransitionTests.swift | 72 +++++++++++++++++++ 9 files changed, 161 insertions(+), 50 deletions(-) diff --git a/Sources/PubNub/EventEngine/Presence/Effects/DelayedHeartbeatEffect.swift b/Sources/PubNub/EventEngine/Presence/Effects/DelayedHeartbeatEffect.swift index 924649bb..87e6d334 100644 --- a/Sources/PubNub/EventEngine/Presence/Effects/DelayedHeartbeatEffect.swift +++ b/Sources/PubNub/EventEngine/Presence/Effects/DelayedHeartbeatEffect.swift @@ -50,16 +50,25 @@ class DelayedHeartbeatEffect: DelayedEffectHandler { } func delayInterval() -> TimeInterval? { - switch retryAttempt { - case 0: - return 0 - case 1: - return 0.5 * Double(configuration.durationUntilTimeout) - case 2: - return 0.5 * Double(configuration.durationUntilTimeout) - 1.0 - default: + guard let automaticRetry = configuration.automaticRetry 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) { diff --git a/Sources/PubNub/EventEngine/Presence/Effects/HeartbeatEffect.swift b/Sources/PubNub/EventEngine/Presence/Effects/HeartbeatEffect.swift index be06a4a4..ebee82eb 100644 --- a/Sources/PubNub/EventEngine/Presence/Effects/HeartbeatEffect.swift +++ b/Sources/PubNub/EventEngine/Presence/Effects/HeartbeatEffect.swift @@ -35,9 +35,6 @@ class HeartbeatEffect: EffectHandler { } func performTask(completionBlock: @escaping ([Presence.Event]) -> Void) { - guard request.configuration.heartbeatInterval > 0 else { - completionBlock([]); return - } request.execute() { result in switch result { case .success(_): diff --git a/Sources/PubNub/EventEngine/Presence/Effects/LeaveEffect.swift b/Sources/PubNub/EventEngine/Presence/Effects/LeaveEffect.swift index 5b842714..6c5c336c 100644 --- a/Sources/PubNub/EventEngine/Presence/Effects/LeaveEffect.swift +++ b/Sources/PubNub/EventEngine/Presence/Effects/LeaveEffect.swift @@ -35,9 +35,6 @@ class LeaveEffect: EffectHandler { } func performTask(completionBlock: @escaping ([Presence.Event]) -> Void) { - guard !request.configuration.supressLeaveEvents else { - completionBlock([]); return - } request.execute() { result in switch result { case .success(_): diff --git a/Sources/PubNub/EventEngine/Presence/PresenceTransition.swift b/Sources/PubNub/EventEngine/Presence/PresenceTransition.swift index b5f54328..8666ade5 100644 --- a/Sources/PubNub/EventEngine/Presence/PresenceTransition.swift +++ b/Sources/PubNub/EventEngine/Presence/PresenceTransition.swift @@ -41,7 +41,7 @@ class PresenceTransition: TransitionProtocol { func canTransition(from state: State, dueTo event: Event) -> Bool { switch event { case .joined(_,_): - return true + return configuration.heartbeatInterval > 0 case .left(_,_): return !(state is Presence.HeartbeatInactive) case .heartbeatSuccess: @@ -64,7 +64,10 @@ class PresenceTransition: TransitionProtocol { private func onEntry(to state: State) -> [EffectInvocation] { switch state { case is Presence.Heartbeating: - return [.regular(.heartbeat(channels: state.channels, groups: state.input.groups))] + 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, @@ -150,15 +153,14 @@ fileprivate extension PresenceTransition { state: Presence.HeartbeatStopped(input: newInput), invocations: [] ) - } else if newInput.isEmpty { - return TransitionResult( - state: Presence.HeartbeatInactive(), - invocations: [.regular(.leave(channels: leaving.channels, groups: leaving.groups))] - ) } else { + let leaveInvocation = EffectInvocation.regular(Presence.Invocation.leave( + channels: leaving.channels, + groups: leaving.groups + )) return TransitionResult( - state: Presence.Heartbeating(input: newInput), - invocations: [.regular(.leave(channels: leaving.channels, groups: leaving.groups))] + state: newInput.isEmpty ? Presence.HeartbeatInactive() : Presence.Heartbeating(input: newInput), + invocations: configuration.supressLeaveEvents ? [] : [leaveInvocation] ) } } @@ -216,9 +218,13 @@ fileprivate extension PresenceTransition { 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: [.regular(.leave(channels: state.input.channels, groups: state.input.groups))] + invocations: configuration.supressLeaveEvents ? []: [leaveInvocation] ) } } diff --git a/Sources/PubNub/Subscription/Strategy/EventEngineSubscriptionSessionStrategy.swift b/Sources/PubNub/Subscription/Strategy/EventEngineSubscriptionSessionStrategy.swift index 8400c530..8744bb21 100644 --- a/Sources/PubNub/Subscription/Strategy/EventEngineSubscriptionSessionStrategy.swift +++ b/Sources/PubNub/Subscription/Strategy/EventEngineSubscriptionSessionStrategy.swift @@ -129,8 +129,8 @@ class EventEngineSubscriptionSessionStrategy: SubscriptionSessionStrategy { )) } sendPresenceEvent(event: .joined( - channels: newInput.presenceSubscribedChannels, - groups: newInput.presenceSubscribedGroups + channels: newInput.subscribedChannels, + groups: newInput.subscribedGroups )) } diff --git a/Tests/PubNubContractTest/Steps/EventEngine/PubNubPresenceEngineContractTestSteps.swift b/Tests/PubNubContractTest/Steps/EventEngine/PubNubPresenceEngineContractTestSteps.swift index 2d6cde0c..502326f8 100644 --- a/Tests/PubNubContractTest/Steps/EventEngine/PubNubPresenceEngineContractTestSteps.swift +++ b/Tests/PubNubContractTest/Steps/EventEngine/PubNubPresenceEngineContractTestSteps.swift @@ -74,7 +74,7 @@ extension Presence.Event: ContractTestIdentifiable { case .heartbeatSuccess: return "HEARTBEAT_SUCCESS" case .heartbeatFailed(_): - return "HEARTBEAT_FAILED" + return "HEARTBEAT_FAILURE" case .heartbeatGiveUp(_): return "HEARTBEAT_GIVE_UP" } @@ -116,8 +116,8 @@ class PubNubPresenceEngineContractTestsSteps: PubNubEventEngineContractTestsStep ) ) return PubNub( - configuration: self.configuration, - session: HTTPSession(session: URLSession.shared, delegate: HTTPSessionDelegate(), sessionQueue: .global(qos: .default)), + configuration: configuration, + session: HTTPSession(configuration: configuration.urlSessionConfiguration), fileSession: URLSession(configuration: .pubnubBackground), subscriptionSession: subscriptionSession ) @@ -137,6 +137,20 @@ class PubNubPresenceEngineContractTestsSteps: PubNubEventEngineContractTestsStep )) } + 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, @@ -156,6 +170,14 @@ class PubNubPresenceEngineContractTestsSteps: PubNubEventEngineContractTestsStep 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) } @@ -171,7 +193,7 @@ class PubNubPresenceEngineContractTestsSteps: PubNubEventEngineContractTestsStep XCTAssertNotNil(self.waitForPresenceChanges(self.client, count: 2)) } - Then("^I leave '(.*)' and '(.*)' channels$") { args, _ in + Then("^I leave '(.*)' and '(.*)' channels with presence$") { args, _ in let firstChannel = args?[0] ?? "" let secondChannel = args?[1] ?? "" @@ -189,5 +211,10 @@ class PubNubPresenceEngineContractTestsSteps: PubNubEventEngineContractTestsStep 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 index a623076c..e31b9a8d 100644 --- a/Tests/PubNubContractTest/Steps/EventEngine/PubNubSubscribeEngineContractTestsSteps.swift +++ b/Tests/PubNubContractTest/Steps/EventEngine/PubNubSubscribeEngineContractTestsSteps.swift @@ -149,7 +149,7 @@ class PubNubSubscribeEngineContractTestsSteps: PubNubEventEngineContractTestsSte ) return PubNub( configuration: self.configuration, - session: HTTPSession(session: URLSession.shared, delegate: HTTPSessionDelegate(), sessionQueue: .global(qos: .default)), + session: HTTPSession(configuration: configuration.urlSessionConfiguration), fileSession: URLSession(configuration: .pubnubBackground), subscriptionSession: subscriptionSession ) diff --git a/Tests/PubNubTests/EventEngine/Presence/DelayedHeartbeatEffectTests.swift b/Tests/PubNubTests/EventEngine/Presence/DelayedHeartbeatEffectTests.swift index ba556fc4..2f57503b 100644 --- a/Tests/PubNubTests/EventEngine/Presence/DelayedHeartbeatEffectTests.swift +++ b/Tests/PubNubTests/EventEngine/Presence/DelayedHeartbeatEffectTests.swift @@ -59,16 +59,16 @@ class DelayedHeartbeatEffectTests: XCTestCase { mockResponse(GenericServicePayloadResponse(status: 200)) - let timeout: UInt = 4 - let effect = configureEffect(attempt: 0, durationUntilTimeout: timeout, error: PubNubError(.unknown)) + let automaticRetry = AutomaticRetry(retryLimit: 3, policy: .linear(delay: 1.0)) + let effect = configureEffect(attempt: 0, automaticRetry: automaticRetry, error: PubNubError(.unknown)) let startDate = Date() effect.performTask { returnedEvents in XCTAssertTrue(returnedEvents.elementsEqual([.heartbeatSuccess])) - XCTAssertEqual(Int(Date().timeIntervalSince(startDate)), 0) + XCTAssertEqual(Int(Date().timeIntervalSince(startDate)), 1) expectation.fulfill() } - wait(for: [expectation], timeout: 0.5) + wait(for: [expectation], timeout: 2.5) } func test_DelayedHeartbeatEffectIsShiftedForSecondAttempt() { @@ -78,13 +78,13 @@ class DelayedHeartbeatEffectTests: XCTestCase { mockResponse(GenericServicePayloadResponse(status: 200)) - let timeout: UInt = 4 - let effect = configureEffect(attempt: 1, durationUntilTimeout: timeout, error: PubNubError(.unknown)) + let automaticRetry = AutomaticRetry(retryLimit: 3, policy: .linear(delay: 1.0)) + let effect = configureEffect(attempt: 1, automaticRetry: automaticRetry, error: PubNubError(.unknown)) let startDate = Date() effect.performTask { returnedEvents in XCTAssertTrue(returnedEvents.elementsEqual([.heartbeatSuccess])) - XCTAssertEqual(Int(Date().timeIntervalSince(startDate)), Int(timeout) / 2) + XCTAssertEqual(Int(Date().timeIntervalSince(startDate)), 1) expectation.fulfill() } wait(for: [expectation], timeout: 2.5) @@ -97,16 +97,16 @@ class DelayedHeartbeatEffectTests: XCTestCase { mockResponse(GenericServicePayloadResponse(status: 200)) - let timeout: UInt = 4 - let effect = configureEffect(attempt: 2, durationUntilTimeout: timeout, error: PubNubError(.unknown)) + let automaticRetry = AutomaticRetry(retryLimit: 3, policy: .linear(delay: 1.0)) + let effect = configureEffect(attempt: 2, automaticRetry: automaticRetry, error: PubNubError(.unknown)) let startDate = Date() effect.performTask { returnedEvents in XCTAssertTrue(returnedEvents.elementsEqual([.heartbeatSuccess])) - XCTAssertEqual(Int(Date().timeIntervalSince(startDate)), Int(0.5 * Double(timeout)) - 1) + XCTAssertEqual(Int(Date().timeIntervalSince(startDate)), 1) expectation.fulfill() } - wait(for: [expectation], timeout: 3.5) + wait(for: [expectation], timeout: 2.5) } func test_DelayedHeartbeatEffectFailure() { @@ -116,15 +116,15 @@ class DelayedHeartbeatEffectTests: XCTestCase { mockResponse(GenericServicePayloadResponse(status: 500)) - let timeout: UInt = 4 + let automaticRetry = AutomaticRetry(retryLimit: 3, policy: .linear(delay: 1.0)) let error = PubNubError(.unknown) - let effect = configureEffect(attempt: 0, durationUntilTimeout: timeout, error: error) + let effect = configureEffect(attempt: 0, automaticRetry: automaticRetry, error: error) effect.performTask { returnedEvents in XCTAssertTrue(returnedEvents.elementsEqual([.heartbeatFailed(error: PubNubError(.internalServiceError))])) expectation.fulfill() } - wait(for: [expectation], timeout: 0.5) + wait(for: [expectation], timeout: 2.5) } func test_DelayedHeartbeatEffectGiveUp() { @@ -132,9 +132,9 @@ class DelayedHeartbeatEffectTests: XCTestCase { expectation.expectationDescription = "Effect Completion Expectation" expectation.assertForOverFulfill = true - let timeout: UInt = 4 + let automaticRetry = AutomaticRetry(retryLimit: 3, policy: .linear(delay: 1.0)) let error = PubNubError(.unknown) - let effect = configureEffect(attempt: 3, durationUntilTimeout: timeout, error: error) + let effect = configureEffect(attempt: 3, automaticRetry: automaticRetry, error: error) mockResponse(GenericServicePayloadResponse(status: 200)) @@ -142,7 +142,7 @@ class DelayedHeartbeatEffectTests: XCTestCase { XCTAssertTrue(returnedEvents.elementsEqual([.heartbeatGiveUp(error: PubNubError(.unknown))])) expectation.fulfill() } - wait(for: [expectation], timeout: 3.5) + wait(for: [expectation], timeout: 2.5) } } @@ -156,7 +156,10 @@ fileprivate extension DelayedHeartbeatEffectTests { } } - func configureEffect(attempt: Int, durationUntilTimeout: UInt, error: PubNubError) -> any EffectHandler { + func configureEffect( + attempt: Int, automaticRetry: AutomaticRetry?, + error: PubNubError + ) -> any EffectHandler { factory.effect( for: .delayedHeartbeat( channels: ["channel-1", "channel-2"], groups: ["group-1", "group-2"], @@ -167,7 +170,7 @@ fileprivate extension DelayedHeartbeatEffectTests { publishKey: "pubKey", subscribeKey: "subKey", userId: "userId", - durationUntilTimeout: durationUntilTimeout + automaticRetry: automaticRetry )) ) ) diff --git a/Tests/PubNubTests/EventEngine/Presence/PresenceTransitionTests.swift b/Tests/PubNubTests/EventEngine/Presence/PresenceTransitionTests.swift index a75c4355..8c7c2c6b 100644 --- a/Tests/PubNubTests/EventEngine/Presence/PresenceTransitionTests.swift +++ b/Tests/PubNubTests/EventEngine/Presence/PresenceTransitionTests.swift @@ -91,6 +91,27 @@ class PresenceTransitionTests: XCTestCase { // 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(), @@ -289,6 +310,34 @@ class PresenceTransitionTests: XCTestCase { 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() { @@ -351,6 +400,29 @@ class PresenceTransitionTests: XCTestCase { 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() {