From 10df5af5b0ccaa5ba91e528f59c8c405df3c9278 Mon Sep 17 00:00:00 2001 From: Serhii Mamontov Date: Tue, 21 Apr 2026 19:54:47 +0300 Subject: [PATCH] refactor(core): don't unsubscribe on `auth` change Don't unsubscribe old client as part of `-copyWithConfiguration:completion:` call when only `auth` has been changed. --- PubNub/Core/PubNub+Core.m | 8 +- .../PNCopyWithConfigurationSubscribeTest.m | 96 +++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/PubNub/Core/PubNub+Core.m b/PubNub/Core/PubNub+Core.m index eb7ce7ddf..f0a95f83e 100644 --- a/PubNub/Core/PubNub+Core.m +++ b/PubNub/Core/PubNub+Core.m @@ -302,12 +302,8 @@ - (void)copyWithConfiguration:(PNConfiguration *)configuration [self cancelSubscribeOperations]; BOOL uuidChanged = ![configuration.userID isEqualToString:self.configuration.userID]; - BOOL authKeyChanged = ((self.configuration.authKey && !configuration.authKey) || - (!self.configuration.authKey && configuration.authKey) || - (configuration.authKey && self.configuration.authKey && - ![configuration.authKey isEqualToString:self.configuration.authKey])); - - if (uuidChanged || authKeyChanged) { + + if (uuidChanged) { [self unsubscribeFromChannels:self.subscriberManager.channels groups:self.subscriberManager.channelGroups withPresence:YES diff --git a/Tests/Tests/Unit/Core/Subscribe/PNCopyWithConfigurationSubscribeTest.m b/Tests/Tests/Unit/Core/Subscribe/PNCopyWithConfigurationSubscribeTest.m index ac7b24795..bcefc7f7b 100644 --- a/Tests/Tests/Unit/Core/Subscribe/PNCopyWithConfigurationSubscribeTest.m +++ b/Tests/Tests/Unit/Core/Subscribe/PNCopyWithConfigurationSubscribeTest.m @@ -578,6 +578,102 @@ - (void)testContinuationUsesLongPollWhenStateIsConnected { } +#pragma mark - Tests :: copyWithConfiguration unsubscribe behavior + +/// Verify that `copyWithConfiguration:completion:` does **not** trigger an unsubscribe when only `authKey` changes. +/// +/// When a token expires and is refreshed, the client should seamlessly continue its subscription on the new instance +/// without sending a leave request with the old (possibly expired) credentials. The new client inherits subscriber state +/// and resubscribes with the fresh auth key automatically. +- (void)testCopyWithConfigurationDoesNotUnsubscribeWhenOnlyAuthKeyChanges { + [self.client.subscriberManager addChannels:@[@"test-channel"]]; + self.client.subscriberManager.currentState = PNTestConnectedSubscriberState; + + id clientMock = [self mockForObject:self.client]; + + OCMReject([clientMock unsubscribeFromChannels:[OCMArg any] + groups:[OCMArg any] + withPresence:YES + queryParameters:[OCMArg any] + completion:[OCMArg any]]); + + PNConfiguration *newConfig = [self.client.configuration copy]; + newConfig.authKey = @"new-auth-token"; + + XCTestExpectation *completionExpectation = [self expectationWithDescription:@"copyWithConfiguration: should complete"]; + + // Set up transport that will succeed for the new client's subscribe. + PNURLSessionTransport *transport = [PNURLSessionTransport new]; + PNTransportConfiguration *transportConfig = [PNTransportConfiguration new]; + transportConfig.maximumConnections = 1; + [transport setupWithConfiguration:transportConfig]; + + NSURLSessionConfiguration *sessionConfig = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + sessionConfig.protocolClasses = @[[PNSlowSubscribeProtocol class]]; + NSOperationQueue *opQueue = [NSOperationQueue new]; + opQueue.maxConcurrentOperationCount = 1; + NSURLSession *session = [NSURLSession sessionWithConfiguration:sessionConfig delegate:nil delegateQueue:opQueue]; + [transport setValue:session forKey:@"session"]; + + [self.client copyWithConfiguration:newConfig completion:^(PubNub *client) { + client.subscriptionNetwork = transport; + [completionExpectation fulfill]; + }]; + + // Complete the new client's subscribe request so the completion fires. + [self waitForCondition:^BOOL { + return [PNSlowSubscribeProtocol pendingCount] > 0; + } withTimeout:3.0 description:@"New client's subscribe request should be in-flight"]; + [PNSlowSubscribeProtocol completeAllPendingWithSuccess]; + + [self waitForExpectationsWithTimeout:1.0 handler:nil]; + + OCMVerifyAll(clientMock); +} + +/// Verify that `copyWithConfiguration:completion:` **does** trigger an unsubscribe when `userID` changes. +/// +/// A user identity change means the server-side presence state (join/leave) is tied to the old identity. +/// The old client must send a leave so presence accurately reflects that the old user departed, before the new client +/// subscribes under the new identity. +- (void)testCopyWithConfigurationUnsubscribesWhenUserIDChanges { + [self.client.subscriberManager addChannels:@[@"test-channel"]]; + self.client.subscriberManager.currentState = PNTestConnectedSubscriberState; + + __block BOOL unsubscribeCalled = NO; + id clientMock = [self mockForObject:self.client]; + + OCMStub([clientMock unsubscribeFromChannels:[OCMArg any] + groups:[OCMArg any] + withPresence:YES + queryParameters:[OCMArg any] + completion:([OCMArg invokeBlockWithArgs:[NSNull null], nil])]) + .andDo(^(__unused NSInvocation *invocation) { + unsubscribeCalled = YES; + }); + + PNConfiguration *newConfig = [self.client.configuration copy]; + newConfig.userID = @"different-user-id"; + + XCTestExpectation *completionExpectation = [self expectationWithDescription:@"copyWithConfiguration: should complete"]; + + [self.client copyWithConfiguration:newConfig completion:^(PubNub *client) { + [completionExpectation fulfill]; + }]; + + // Complete the new client's subscribe request. + [self waitForCondition:^BOOL { + return [PNSlowSubscribeProtocol pendingCount] > 0; + } withTimeout:3.0 description:@"New client's subscribe request should be in-flight"]; + [PNSlowSubscribeProtocol completeAllPendingWithSuccess]; + + [self waitForExpectationsWithTimeout:1.0 handler:nil]; + + XCTAssertTrue(unsubscribeCalled, + @"copyWithConfiguration: should unsubscribe from old identity when userID changes"); +} + + #pragma mark - Helpers