diff --git a/QonversionTests/Managers/QNIdentityManagerTests.m b/QonversionTests/Managers/QNIdentityManagerTests.m index cad0f0e1..f7a20539 100644 --- a/QonversionTests/Managers/QNIdentityManagerTests.m +++ b/QonversionTests/Managers/QNIdentityManagerTests.m @@ -64,8 +64,6 @@ - (void)testSuccessIdentity { OCMStub([self.mockUserInfoService obtainUserID]).andReturn(anonUserID); OCMStub([self.mockIdentityService identify:userID anonUserID:anonUserID completion:OCMOCK_ANY]).andDo(testBlock); - OCMExpect([self.mockUserInfoService storeIdentity:identityID]); - // when [self.manager identify:userID completion:^(NSString * _Nullable result, NSError * _Nullable error) { resultString = result; @@ -77,8 +75,6 @@ - (void)testSuccessIdentity { XCTAssertEqual(randomError, resultError); OCMVerify([self.mockUserInfoService obtainUserID]); - OCMVerify([self.mockUserInfoService storeIdentity:identityID]); - OCMVerify([self.mockIdentityService identify:userID anonUserID:anonUserID completion:OCMOCK_ANY]); } diff --git a/QonversionTests/Managers/QNProductCenterManagerIdentifyRemoteConfigTests.m b/QonversionTests/Managers/QNProductCenterManagerIdentifyRemoteConfigTests.m index 9984aa8e..93640202 100644 --- a/QonversionTests/Managers/QNProductCenterManagerIdentifyRemoteConfigTests.m +++ b/QonversionTests/Managers/QNProductCenterManagerIdentifyRemoteConfigTests.m @@ -35,8 +35,12 @@ @interface QNProductCenterManager (IdentifyRemoteConfigTestPrivate) @property (nonatomic, assign) BOOL launchingFinished; +@property (nonatomic, assign) BOOL identityInProgress; +@property (nonatomic, strong) QONUser *user; +@property (nonatomic, strong) NSRecursiveLock *identityMutationLock; - (void)processIdentity:(NSString *)identityId; +- (void)deliverIdentityRequest:(id)request error:(nullable NSError *)error; @end @@ -50,6 +54,57 @@ @interface QNProductCenterManagerIdentifyRemoteConfigTests : XCTestCase @end +@interface QNTrackingRecursiveLock : NSObject + +@property (nonatomic, strong) NSRecursiveLock *backingLock; +@property (nonatomic, strong) NSObject *metadataLock; +@property (nonatomic, strong, nullable) NSThread *ownerThread; +@property (nonatomic, assign) NSUInteger recursionDepth; + +- (BOOL)isHeldByCurrentThread; + +@end + + +@implementation QNTrackingRecursiveLock + +- (instancetype)init { + self = [super init]; + if (self) { + _backingLock = [NSRecursiveLock new]; + _metadataLock = [NSObject new]; + } + return self; +} + +- (void)lock { + [self.backingLock lock]; + @synchronized (self.metadataLock) { + self.ownerThread = [NSThread currentThread]; + self.recursionDepth += 1; + } +} + +- (void)unlock { + @synchronized (self.metadataLock) { + NSAssert(self.ownerThread == [NSThread currentThread] && self.recursionDepth > 0, + @"only the owning thread may unlock the identity mutation probe"); + self.recursionDepth -= 1; + if (self.recursionDepth == 0) { + self.ownerThread = nil; + } + } + [self.backingLock unlock]; +} + +- (BOOL)isHeldByCurrentThread { + @synchronized (self.metadataLock) { + return self.ownerThread == [NSThread currentThread] && self.recursionDepth > 0; + } +} + +@end + @implementation QNProductCenterManagerIdentifyRemoteConfigTests - (void)setUp { @@ -96,6 +151,7 @@ - (void)testProcessIdentity_SameUid_InvalidatesRemoteConfigsCache { // The destructive user-switch path must NOT fire on same-uid OCMReject([_mockRemoteConfigManager userHasBeenChanged]); + OCMReject([_mockRemoteConfigManager userHasBeenChangedToUserID:[OCMArg any]]); // When - launchingFinished stays NO, so handlePendingRequests: returns // early and fireIdentitySuccess no-ops on the empty pending blocks @@ -135,6 +191,28 @@ - (void)testProcessIdentity_SameUid_InvalidatesBeforePendingRequestReplay { XCTAssertEqualObjects(order, (@[@"invalidate", @"replay"])); } +- (void)testProcessIdentity_SameUid_RemainsUnstableUntilRemoteConfigsAreInvalidated { + NSString *identityId = @"login@example.com"; + NSString *sameUid = @"uid_initial"; + OCMStub([_mockUserInfoService obtainUserID]).andReturn(sameUid); + OCMStub(([_mockIdentityManager identify:identityId + completion:[OCMArg invokeBlockWithArgs:sameUid, [NSNull null], nil]])); + + _manager.launchingFinished = YES; + _manager.identityInProgress = YES; + + __block BOOL stableDuringInvalidation = YES; + OCMStub([_mockRemoteConfigManager invalidateRemoteConfigsCache]).andDo(^(NSInvocation *invocation) { + stableDuringInvalidation = [self.manager isUserStable]; + }); + + [_manager processIdentity:identityId]; + + XCTAssertFalse(stableDuringInvalidation, + @"a concurrent Remote Config request must not observe the old warm cache during identity completion"); + XCTAssertTrue([_manager isUserStable]); +} + - (void)testProcessIdentity_IdentityError_DoesNotInvalidateRemoteConfigsCache { // Given - identify fails NSString *identityId = @"login@example.com"; @@ -145,6 +223,7 @@ - (void)testProcessIdentity_IdentityError_DoesNotInvalidateRemoteConfigsCache { OCMReject([_mockRemoteConfigManager invalidateRemoteConfigsCache]); OCMReject([_mockRemoteConfigManager userHasBeenChanged]); + OCMReject([_mockRemoteConfigManager userHasBeenChangedToUserID:[OCMArg any]]); // When [_manager processIdentity:identityId]; @@ -154,4 +233,413 @@ - (void)testProcessIdentity_IdentityError_DoesNotInvalidateRemoteConfigsCache { OCMVerifyAll(_mockRemoteConfigManager); } +- (void)testIdentifyRetryStartsFreshRemoteConfigIdentityWindow { + NSString *identityId = @"login@example.com"; + NSError *identityError = [NSError errorWithDomain:@"test" code:2 userInfo:nil]; + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid_initial"); + __block NSUInteger remoteConfigAttemptStarts = 0; + OCMStub([_mockRemoteConfigManager userChangingRequestStarted]).andDo(^(NSInvocation *invocation) { + remoteConfigAttemptStarts += 1; + }); + + __block NSUInteger identityCalls = 0; + OCMStub([_mockIdentityManager identify:identityId completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + identityCalls += 1; + if (identityCalls == 1) { + __unsafe_unretained QNIdentityCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + completion(nil, identityError); + } + }); + + [_manager identify:identityId completion:nil]; + [_manager identify:identityId completion:nil]; + + XCTAssertEqual(identityCalls, 2); + XCTAssertEqual(remoteConfigAttemptStarts, 2); + OCMVerify([_mockRemoteConfigManager userChangingRequestFailedWithError:identityError]); +} + +- (void)testPublicIdentifyPersistsMergedIdentityAfterAttemptOwnershipCheck { + NSString *identityID = @"login@example.com"; + NSString *userID = @"uid_initial"; + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(userID); + OCMExpect([_mockUserInfoService storeIdentity:userID]); + OCMExpect([_mockUserInfoService storeCustomIdentityUserID:identityID]); + OCMStub(([_mockIdentityManager identify:identityID + completion:[OCMArg invokeBlockWithArgs:userID, [NSNull null], nil]])); + + [_manager identify:identityID completion:nil]; + + OCMVerify([_mockUserInfoService storeIdentity:userID]); + OCMVerify([_mockUserInfoService storeCustomIdentityUserID:identityID]); +} + +- (void)testOverlappingIdentifyCallsAreSerializedBeforeRemoteConfigAttemptStart { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid_initial"); + __block NSUInteger remoteConfigAttemptStarts = 0; + OCMStub([_mockRemoteConfigManager userChangingRequestStarted]).andDo(^(NSInvocation *invocation) { + remoteConfigAttemptStarts += 1; + }); + __block NSMutableArray *identityCompletions = [NSMutableArray new]; + OCMStub([_mockIdentityManager identify:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNIdentityCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + [identityCompletions addObject:[completion copy]]; + }); + + [_manager identify:@"first@example.com" completion:nil]; + [_manager identify:@"second@example.com" completion:nil]; + XCTAssertEqual(identityCompletions.count, 1, @"the second identity request must not overlap the active attempt"); + XCTAssertEqual(remoteConfigAttemptStarts, 1); + + NSError *firstError = [NSError errorWithDomain:@"identity" code:3 userInfo:nil]; + identityCompletions[0](nil, firstError); + + XCTAssertEqual(identityCompletions.count, 2, @"the queued identity must start after the first terminal result"); + XCTAssertEqual(remoteConfigAttemptStarts, 2); + OCMVerify([_mockRemoteConfigManager userChangingRequestFailedWithError:firstError]); +} + +- (void)testDuplicateActiveIdentitySharesFailureWithoutHiddenRetry { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid_initial"); + __block NSMutableArray *identityCompletions = [NSMutableArray new]; + OCMStub([_mockIdentityManager identify:@"a@example.com" completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNIdentityCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + [identityCompletions addObject:[completion copy]]; + }); + + __block NSUInteger callbackCount = 0; + [_manager identify:@"a@example.com" completion:^(QONUser * _Nullable user, NSError * _Nullable error) { + callbackCount += 1; + XCTAssertEqual(error.code, 77); + }]; + [_manager identify:@"a@example.com" completion:^(QONUser * _Nullable user, NSError * _Nullable error) { + callbackCount += 1; + XCTAssertEqual(error.code, 77); + }]; + + XCTAssertEqual(identityCompletions.count, 1); + NSError *failure = [NSError errorWithDomain:@"identity" code:77 userInfo:nil]; + identityCompletions.firstObject(nil, failure); + + XCTAssertEqual(identityCompletions.count, 1, @"an active duplicate must not be retried without a live caller"); + XCTAssertEqual(callbackCount, 2); +} + +- (void)testSeparatedDuplicateIdentityPreservesFIFOOrder { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid_initial"); + __block NSMutableArray *startedIdentityIDs = [NSMutableArray new]; + __block NSMutableArray *identityCompletions = [NSMutableArray new]; + OCMStub([_mockIdentityManager identify:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained NSString *identityID = nil; + __unsafe_unretained QNIdentityCompletionHandler completion = nil; + [invocation getArgument:&identityID atIndex:2]; + [invocation getArgument:&completion atIndex:3]; + [startedIdentityIDs addObject:[identityID copy]]; + [identityCompletions addObject:[completion copy]]; + }); + + [_manager identify:@"a@example.com" completion:nil]; + [_manager identify:@"b@example.com" completion:nil]; + [_manager identify:@"a@example.com" completion:nil]; + XCTAssertEqualObjects(startedIdentityIDs, (@[@"a@example.com"])); + + identityCompletions[0](@"uid_initial", nil); + XCTAssertEqualObjects(startedIdentityIDs, (@[@"a@example.com", @"b@example.com"])); + identityCompletions[1](@"uid_initial", nil); + XCTAssertEqualObjects(startedIdentityIDs, (@[@"a@example.com", @"b@example.com", @"a@example.com"])); + identityCompletions[2](@"uid_initial", nil); + XCTAssertFalse(_manager.identityInProgress); +} + +- (void)testQueuedSeparatedDuplicateIsNotGloballyDeduplicated { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid_initial"); + __block NSMutableArray *startedIdentityIDs = [NSMutableArray new]; + __block NSMutableArray *identityCompletions = [NSMutableArray new]; + OCMStub([_mockIdentityManager identify:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained NSString *identityID = nil; + __unsafe_unretained QNIdentityCompletionHandler completion = nil; + [invocation getArgument:&identityID atIndex:2]; + [invocation getArgument:&completion atIndex:3]; + [startedIdentityIDs addObject:[identityID copy]]; + [identityCompletions addObject:[completion copy]]; + }); + + [_manager identify:@"a@example.com" completion:nil]; + [_manager identify:@"b@example.com" completion:nil]; + [_manager identify:@"c@example.com" completion:nil]; + [_manager identify:@"b@example.com" completion:nil]; + + identityCompletions[0](@"uid_initial", nil); + identityCompletions[1](@"uid_initial", nil); + identityCompletions[2](@"uid_initial", nil); + identityCompletions[3](@"uid_initial", nil); + XCTAssertEqualObjects(startedIdentityIDs, + (@[@"a@example.com", @"b@example.com", @"c@example.com", @"b@example.com"])); +} + +- (void)testLogoutCancelsActiveAndQueuedIdentityCompletionsAndIgnoresLateResponse { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid_initial"); + OCMStub([_mockIdentityManager logoutIfNeeded]).andReturn(NO); + __block QNIdentityCompletionHandler activeCompletion = nil; + OCMStub([_mockIdentityManager identify:@"a@example.com" completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNIdentityCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + activeCompletion = [completion copy]; + }); + OCMReject([_mockUserInfoService storeCustomIdentityUserID:@"a@example.com"]); + OCMReject([_mockUserInfoService storeIdentity:[OCMArg any]]); + + __block NSUInteger cancelledCallbacks = 0; + QONUserInfoCompletionHandler callback = ^(QONUser * _Nullable user, NSError * _Nullable error) { + XCTAssertEqualObjects(error.domain, NSURLErrorDomain); + XCTAssertEqual(error.code, NSURLErrorCancelled); + cancelledCallbacks += 1; + }; + [_manager identify:@"a@example.com" completion:callback]; + [_manager identify:@"b@example.com" completion:callback]; + + [_manager logout]; + XCTAssertEqual(cancelledCallbacks, 2); + activeCompletion(@"uid_initial", nil); + XCTAssertEqual(cancelledCallbacks, 2); + XCTAssertTrue([_manager isUserStable]); +} + +- (void)testLogoutCancelsPendingOnlyIdentityAndRemoteConfigWaiters { + _manager.launchingFinished = NO; + OCMStub([_mockIdentityManager logoutIfNeeded]).andReturn(NO); + __block NSError *callbackError = nil; + OCMExpect([_mockRemoteConfigManager userChangingRequestFailedWithError:[OCMArg checkWithBlock:^BOOL(NSError *error) { + return error.code == NSURLErrorCancelled; + }]]); + + [_manager identify:@"queued@example.com" completion:^(QONUser * _Nullable user, NSError * _Nullable error) { + callbackError = error; + }]; + [_manager logout]; + + XCTAssertEqual(callbackError.code, NSURLErrorCancelled); + OCMVerifyAll(_mockRemoteConfigManager); +} + +- (void)testLogoutDrainsCancelledRemoteConfigWindowBeforePublishingOriginalUserScope { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid_initial"); + OCMStub([_mockIdentityManager logoutIfNeeded]).andReturn(YES); + OCMStub([_mockIdentityManager identify:[OCMArg any] completion:[OCMArg any]]); + + NSMutableArray *order = [NSMutableArray new]; + OCMStub([_mockRemoteConfigManager userChangingRequestFailedWithError:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + [order addObject:@"cancel-old-window"]; + }); + OCMStub([_mockRemoteConfigManager userHasBeenChangedToUserID:@"uid_initial"]).andDo(^(NSInvocation *invocation) { + [order addObject:@"publish-original-scope"]; + }); + + [_manager identify:@"active@example.com" completion:nil]; + [_manager logout]; + + XCTAssertEqualObjects(order, (@[@"cancel-old-window", @"publish-original-scope"]), + @"the successful logout scope must clear the cancellation latch last"); +} + +- (void)testLogoutKeepsRemoteConfigCancellationInsideIdentityMutationBoundary { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid_initial"); + OCMStub([_mockIdentityManager logoutIfNeeded]).andReturn(NO); + + dispatch_semaphore_t firstIdentityStarted = dispatch_semaphore_create(0); + dispatch_semaphore_t secondIdentityStarted = dispatch_semaphore_create(0); + dispatch_semaphore_t cancellationEntered = dispatch_semaphore_create(0); + dispatch_semaphore_t releaseCancellation = dispatch_semaphore_create(0); + dispatch_semaphore_t logoutFinished = dispatch_semaphore_create(0); + __block NSUInteger identityCallCount = 0; + __block QNIdentityCompletionHandler secondIdentityCompletion = nil; + OCMStub([_mockIdentityManager identify:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNIdentityCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + @synchronized (self) { + identityCallCount += 1; + if (identityCallCount == 1) { + dispatch_semaphore_signal(firstIdentityStarted); + } else { + secondIdentityCompletion = [completion copy]; + dispatch_semaphore_signal(secondIdentityStarted); + } + } + }); + + __block BOOL blockFirstCancellation = YES; + OCMStub([_mockRemoteConfigManager userChangingRequestFailedWithError:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + if (blockFirstCancellation) { + dispatch_semaphore_signal(cancellationEntered); + dispatch_semaphore_wait(releaseCancellation, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)); + } + }); + + [_manager identify:@"active@example.com" completion:nil]; + XCTAssertEqual(dispatch_semaphore_wait(firstIdentityStarted, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [self.manager logout]; + dispatch_semaphore_signal(logoutFinished); + }); + XCTAssertEqual(dispatch_semaphore_wait(cancellationEntered, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [self.manager identify:@"new@example.com" completion:nil]; + }); + long prematureStart = dispatch_semaphore_wait(secondIdentityStarted, + dispatch_time(DISPATCH_TIME_NOW, 100 * NSEC_PER_MSEC)); + XCTAssertNotEqual(prematureStart, 0, + @"a new identify must not start while logout is still draining the old RC window"); + + blockFirstCancellation = NO; + dispatch_semaphore_signal(releaseCancellation); + XCTAssertEqual(dispatch_semaphore_wait(logoutFinished, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + if (prematureStart != 0) { + XCTAssertEqual(dispatch_semaphore_wait(secondIdentityStarted, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + } + + NSError *cleanupError = [NSError errorWithDomain:@"test" code:901 userInfo:nil]; + secondIdentityCompletion(nil, cleanupError); +} + +- (void)testReentrantLogoutCannotReleaseOuterLogoutMutationBoundary { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid_initial"); + OCMStub([_mockIdentityManager logoutIfNeeded]).andReturn(YES); + + __block NSUInteger identityCallCount = 0; + __block QNIdentityCompletionHandler secondIdentityCompletion = nil; + OCMStub([_mockIdentityManager identify:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNIdentityCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + identityCallCount += 1; + if (identityCallCount == 2) { + secondIdentityCompletion = [completion copy]; + } + }); + + __block BOOL secondIdentityStartedInsideDrain = NO; + OCMStub([_mockRemoteConfigManager userChangingRequestFailedWithError:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + // Remote Config can deliver multiple deferred callbacks synchronously. + // The first callback re-enters logout; a later callback starts identify. + // Neither may release the outer logout's ownership boundary. + [self.manager logout]; + [self.manager identify:@"new@example.com" completion:nil]; + secondIdentityStartedInsideDrain = identityCallCount > 1; + }); + + [_manager identify:@"active@example.com" completion:nil]; + XCTAssertEqual(identityCallCount, 1); + + [_manager logout]; + + XCTAssertFalse(secondIdentityStartedInsideDrain, + @"a nested logout must not let identify start before the outer scope is published"); + XCTAssertEqual(identityCallCount, 2, + @"the queued identify should start after the outer logout finishes"); + + NSError *cleanupError = [NSError errorWithDomain:@"test" code:902 userInfo:nil]; + secondIdentityCompletion(nil, cleanupError); +} + +- (void)testSameUIDTerminalCommitRemainsInsideIdentityMutationBoundary { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid_initial"); + __block QNIdentityCompletionHandler identityCompletion = nil; + OCMStub([_mockIdentityManager identify:@"same@example.com" completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNIdentityCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + identityCompletion = [completion copy]; + }); + + QNTrackingRecursiveLock *mutationLock = [QNTrackingRecursiveLock new]; + _manager.identityMutationLock = (NSRecursiveLock *)mutationLock; + __block BOOL lockHeldDuringTerminalCommit = NO; + id partialManager = OCMPartialMock(_manager); + OCMStub([partialManager deliverIdentityRequest:[OCMArg any] error:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + lockHeldDuringTerminalCommit = [mutationLock isHeldByCurrentThread]; + }); + + [_manager identify:@"same@example.com" completion:nil]; + identityCompletion(@"uid_initial", nil); + + XCTAssertTrue(lockHeldDuringTerminalCommit, + @"logout must not overtake a same-UID identify terminal commit"); + [partialManager stopMocking]; +} + +- (void)testUserInfoSnapshotsIdentityBeforeDispatchingCompletionToMain { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid-a"); + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(@"a@example.com"); + XCTestExpectation *completionExpectation = [self expectationWithDescription:@"user info completion"]; + dispatch_semaphore_t scheduled = dispatch_semaphore_create(0); + __block QONUser *expectedUser = nil; + __block QONUser *deliveredUser = nil; + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [self.manager userInfo:^(QONUser * _Nullable user, NSError * _Nullable error) { + deliveredUser = user; + [completionExpectation fulfill]; + }]; + expectedUser = self.manager.user; + dispatch_semaphore_signal(scheduled); + }); + XCTAssertEqual(dispatch_semaphore_wait(scheduled, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + _manager.user = [QONUser new]; + + [self waitForExpectationsWithTimeout:1 handler:nil]; + XCTAssertEqual(deliveredUser, expectedUser); +} + +- (void)testConcurrentIdentifyCallsStartOnlyOneNetworkAttempt { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"uid_initial"); + OCMStub([_mockIdentityManager logoutIfNeeded]).andReturn(NO); + __block NSUInteger identityCalls = 0; + OCMStub([_mockIdentityManager identify:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + @synchronized (self) { + identityCalls += 1; + } + }); + + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + for (NSUInteger index = 0; index < 24; index++) { + dispatch_group_async(group, queue, ^{ + [self.manager identify:[NSString stringWithFormat:@"user-%lu@example.com", (unsigned long)index] completion:nil]; + }); + } + XCTAssertEqual(dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)), 0); + @synchronized (self) { + XCTAssertEqual(identityCalls, 1, @"the identity lock must make simultaneous callers share one active slot"); + } + [_manager logout]; +} + @end diff --git a/QonversionTests/Managers/QONRemoteConfigManagerInvalidationTests.m b/QonversionTests/Managers/QONRemoteConfigManagerInvalidationTests.m index 773c5a13..b221c4b7 100644 --- a/QonversionTests/Managers/QONRemoteConfigManagerInvalidationTests.m +++ b/QonversionTests/Managers/QONRemoteConfigManagerInvalidationTests.m @@ -10,15 +10,98 @@ #import "QONRemoteConfigManager.h" #import "QONRemoteConfigService.h" #import "QONRemoteConfigLoadingState.h" +#import "QONRemoteConfigListRequestData.h" #import "QNProductCenterManager.h" #import "QNUserPropertiesManager.h" #import "QONRemoteConfig.h" +#import "QONRemoteConfig+Protected.h" #import "QONRemoteConfigList+Protected.h" +#import "QONRemoteConfigMapper.h" #import "QONRemoteConfigurationSource.h" +#import "QONRemoteConfigurationSource+Protected.h" +#import "QONExperiment+Protected.h" +#import "QONExperimentGroup+Protected.h" #import "QONFallbackService.h" #import "QONFallbackObject.h" #import "QONErrors.h" #import "Qonversion.h" +#import "QNAPIClient.h" +#import "QNInMemoryStorage.h" +#import "QNLocalStorage.h" +#import "NSError+Sugare.h" + +static NSString *const kTestRemoteConfigLKGStorageKey = @"com.qonversion.keys.remote-config-lkg"; + +static QONRemoteConfig *QONTestRemoteConfig(NSString *identifier, NSString *contextKey, NSString *value) { + QONRemoteConfigurationSource *source = [[QONRemoteConfigurationSource alloc] + initWithIdentifier:identifier + name:identifier + type:QONRemoteConfigurationSourceTypeRemoteConfiguration + assignmentType:QONRemoteConfigurationAssignmentTypeAuto + contextKey:contextKey]; + return [[QONRemoteConfig alloc] initWithPayload:@{@"value": value} + experiment:nil + source:source]; +} + +static QONRemoteConfig *QONTestFrozenExperimentRemoteConfig(NSString *identifier, NSString *contextKey) { + QONExperimentGroup *group = [[QONExperimentGroup alloc] + initWithIdentifier:@"group" + type:QONExperimentGroupTypeControl + name:@"group"]; + QONExperiment *experiment = [[QONExperiment alloc] + initWithIdentifier:@"experiment" + name:@"experiment" + group:group]; + QONRemoteConfigurationSource *source = [[QONRemoteConfigurationSource alloc] + initWithIdentifier:identifier + name:identifier + type:QONRemoteConfigurationSourceTypeExperimentControlGroup + assignmentType:QONRemoteConfigurationAssignmentTypeFrozen + contextKey:contextKey]; + return [[QONRemoteConfig alloc] initWithPayload:@{ @"value": @"frozen" } + experiment:experiment + source:source]; +} + +static NSDictionary *QONTestRemoteConfigResponse(NSString *identifier, NSString *contextKey) { + NSMutableDictionary *source = [@{ + @"uid": identifier, + @"name": identifier, + @"type": @"remote_configuration", + @"assignment_type": @"auto", + } mutableCopy]; + if (contextKey) { + source[@"context_key"] = contextKey; + } + return @{ @"payload": @{ @"value": identifier }, @"source": source }; +} + +@interface QONThrowingLocalStorage : NSObject + +@property (nonatomic, assign) BOOL removed; + +@end + + +@implementation QONThrowingLocalStorage + +- (void)storeObject:(id)object forKey:(NSString *)key {} + +- (id)loadObjectForKey:(NSString *)key { + [NSException raise:NSInvalidUnarchiveOperationException format:@"corrupt archive"]; + return nil; +} + +- (void)loadObjectForKey:(NSString *)key withCompletion:(void (^)(id))completion { + completion([self loadObjectForKey:key]); +} + +- (void)removeObjectForKey:(NSString *)key { + self.removed = YES; +} + +@end /* * Contract tests for the cache invalidation seams (DEV-1231 + DEV-1236 B4). @@ -36,7 +119,50 @@ @interface QONRemoteConfigManager (InvalidationContractPrivate) @property (nonatomic, strong) NSMutableDictionary *loadingStates; +@property (nonatomic, strong) NSMutableArray *listRequests; +@property (nonatomic, strong) NSMutableArray *activeListRequests; @property (atomic, assign) NSUInteger cacheGeneration; +- (QONRemoteConfigLoadingState *)loadingStateForContextKey:(NSString *)contextKey; +- (BOOL)isOnStateQueue; +- (void)userHasBeenChangedToUserID:(NSString *)userID; +- (void)storePersistentLKGEntries:(NSArray *)entries; +- (NSData *)serializedJSONDataForObject:(id)object; + +@end + +@interface QONRemoteConfigManagerRaceHarness : QONRemoteConfigManager + +@property (atomic, copy) dispatch_block_t loadingStateReadHook; + +@end + + +@implementation QONRemoteConfigManagerRaceHarness + +- (QONRemoteConfigLoadingState *)loadingStateForContextKey:(NSString *)contextKey { + QONRemoteConfigLoadingState *state = [super loadingStateForContextKey:contextKey]; + dispatch_block_t hook = self.loadingStateReadHook; + if (hook) { + hook(); + } + return state; +} + +@end + +@interface QONRemoteConfigManagerSerializationHarness : QONRemoteConfigManager + +@property (nonatomic, assign) NSUInteger serializationCount; + +@end + + +@implementation QONRemoteConfigManagerSerializationHarness + +- (NSData *)serializedJSONDataForObject:(id)object { + self.serializationCount += 1; + return [NSJSONSerialization dataWithJSONObject:object options:0 error:nil]; +} @end @@ -89,6 +215,27 @@ - (void)stubUserStableAndImmediatePropertiesFlush { }); } +- (void)usePersistentManagerWithStorage:(id)storage + apiClient:(QNAPIClient *)apiClient + immediatePropertiesFlush:(BOOL)immediatePropertiesFlush { + self.manager = [[QONRemoteConfigManager alloc] initWithLocalStorage:storage]; + self.manager.remoteConfigService = self.mockService; + self.manager.productCenterManager = self.mockProductCenterManager; + self.manager.userPropertiesManager = self.mockUserPropertiesManager; + self.manager.fallbackService = self.mockFallbackService; + OCMStub([self.mockService apiClient]).andReturn(apiClient); + // An unstubbed class mock already returns nil. Do not install a default + // fallback stub here: OCMock resolves the first matching stub, so a later + // scenario-specific bundled fallback would otherwise be shadowed. + if (immediatePropertiesFlush) { + [self stubUserStableAndImmediatePropertiesFlush]; + } +} + +- (void)usePersistentManagerWithStorage:(id)storage apiClient:(QNAPIClient *)apiClient { + [self usePersistentManagerWithStorage:storage apiClient:apiClient immediatePropertiesFlush:YES]; +} + - (void)seedCachedConfigs { QONRemoteConfigLoadingState *emptyKeyState = [QONRemoteConfigLoadingState new]; emptyKeyState.loadedConfig = OCMClassMock([QONRemoteConfig class]); @@ -302,7 +449,7 @@ - (void)testReissueOntoWarmCacheServesQueuedWaiters { XCTAssertEqual(self.manager.loadingStates[@"ctx"].completions.count, 0); } -- (void)testFailedReissueDeliversSupersededEvaluation { +- (void)testFailedReissuePropagatesNonTransientClientError { // given - a load is in flight [self stubUserStableAndImmediatePropertiesFlush]; __block NSUInteger singleCalls = 0; @@ -325,22 +472,48 @@ - (void)testFailedReissueDeliversSupersededEvaluation { }]; // when - invalidation mid-flight, the superseded (valid) response triggers - // a re-issue, and the retry fails without a fallback (no bundled data) + // a re-issue, and the retry returns an authoritative client error [self.manager invalidateRemoteConfigsCache]; QONRemoteConfig *supersededConfig = OCMClassMock([QONRemoteConfig class]); QONRemoteConfigCompletionHandler firstServiceCompletion = serviceCompletion; firstServiceCompletion(supersededConfig, nil); XCTAssertEqual(singleCalls, 2); - serviceCompletion(nil, [NSError errorWithDomain:@"test" code:400 userInfo:nil]); + NSError *clientError = [NSError errorWithDomain:QonversionErrorDomain code:400 userInfo:nil]; + serviceCompletion(nil, clientError); - // then - never worse than before: the superseded evaluation is delivered - // as a success instead of surfacing the retry error, and nothing is cached + // then - a stale baseline must not hide a non-transient response XCTAssertEqual(deliveryCount, 1); - XCTAssertEqual(deliveredConfig, supersededConfig); - XCTAssertNil(deliveredError); + XCTAssertNil(deliveredConfig); + XCTAssertEqual(deliveredError, clientError); XCTAssertNil(self.manager.loadingStates[@"ctx"].loadedConfig); } +- (void)testFailedReissuePropagatesAuthorizationError { + [self stubUserStableAndImmediatePropertiesFlush]; + __block NSMutableArray *serviceCompletions = [NSMutableArray new]; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + [serviceCompletions addObject:[completion copy]]; + }); + + __block QONRemoteConfig *deliveredConfig = nil; + __block NSError *deliveredError = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + deliveredConfig = config; + deliveredError = error; + }]; + [self.manager invalidateRemoteConfigsCache]; + serviceCompletions[0](QONTestRemoteConfig(@"superseded", @"ctx", @"old"), nil); + XCTAssertEqual(serviceCompletions.count, 2); + + NSError *authorizationError = [NSError errorWithDomain:QonversionErrorDomain code:401 userInfo:nil]; + serviceCompletions[1](nil, authorizationError); + + XCTAssertNil(deliveredConfig); + XCTAssertEqual(deliveredError, authorizationError); +} + - (void)testFailedReissuePrefersBaselineOverBundledFallback { // given - a bundled fallback EXISTS for the key, and a load is in flight [self stubUserStableAndImmediatePropertiesFlush]; @@ -407,7 +580,7 @@ - (void)testLateJoinerDuringRetryReceivesBaseline { // when - invalidation mid-flight, the superseded response triggers a // re-issue, a SECOND caller joins while the retry is flying, and the - // retry fails without a fallback (no bundled data) + // retry fails transiently [self.manager invalidateRemoteConfigsCache]; QONRemoteConfig *supersededConfig = OCMClassMock([QONRemoteConfig class]); QONRemoteConfigCompletionHandler firstServiceCompletion = serviceCompletion; @@ -419,20 +592,20 @@ - (void)testLateJoinerDuringRetryReceivesBaseline { deliveredB = remoteConfig; errorB = error; }]; - serviceCompletion(nil, [NSError errorWithDomain:@"test" code:400 userInfo:nil]); + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorTimedOut userInfo:nil]); // then - the never-worse guarantee is uniform: the late joiner gets the - // baseline too, not the retry error + // baseline too, not the transient retry error XCTAssertEqual(deliveredA, supersededConfig); XCTAssertEqual(deliveredB, supersededConfig); XCTAssertNil(errorA); XCTAssertNil(errorB); } -- (void)testStashLeftByUserChangeFailureCannotResurfaceLater { +- (void)testUnstableResponseIgnoredByFailedUserChangeCannotResurfaceLater { // given - the superseded response arrives while the user is unstable, so - // the re-issue can only queue, and the identity change then fails - the - // one drain path that bypasses fireRemoteConfig + // it is ignored and the identity change then fails through the one drain + // path that bypasses fireRemoteConfig __block BOOL userStable = YES; OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { [invocation setReturnValue:&userStable]; @@ -452,9 +625,11 @@ - (void)testStashLeftByUserChangeFailureCannotResurfaceLater { }); __block QONRemoteConfig *deliveredA = nil; + __block NSError *deliveredErrorA = nil; [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable remoteConfig, NSError * _Nullable error) { deliveredA = remoteConfig; + deliveredErrorA = error; }]; [self.manager invalidateRemoteConfigsCache]; userStable = NO; @@ -462,8 +637,10 @@ - (void)testStashLeftByUserChangeFailureCannotResurfaceLater { serviceCompletion(supersededConfig, nil); [self.manager userChangingRequestFailedWithError:[NSError errorWithDomain:@"test" code:1 userInfo:nil]]; - // the snapshot belt still serves the original caller with the baseline - XCTAssertEqual(deliveredA, supersededConfig); + // The response belongs to the identity-stability window and must not cross + // it. If identify fails, the pending caller receives that failure instead. + XCTAssertNil(deliveredA); + XCTAssertNotNil(deliveredErrorA); // when - the user stabilises and a later load for the same key fails userStable = YES; @@ -482,192 +659,2065 @@ - (void)testStashLeftByUserChangeFailureCannotResurfaceLater { XCTAssertNotNil(lateError); } -- (void)testUserSwitchMidFlightDoesNotReissue { - // given - a load is in flight - [self stubUserStableAndImmediatePropertiesFlush]; - __block NSUInteger singleCalls = 0; - __block QONRemoteConfigCompletionHandler serviceCompletion = nil; - OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { - singleCalls += 1; - __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; - [invocation getArgument:&completion atIndex:3]; - serviceCompletion = [completion copy]; +- (void)testFailedUserChangeDrainsQueuedListsExactlyOnceOutsideStateQueue { + OCMStub([self.mockProductCenterManager isUserStable]).andReturn(NO); + NSError *identityError = [NSError errorWithDomain:@"identity" code:17 userInfo:nil]; + __block NSUInteger keyedDeliveries = 0; + __block NSUInteger unfilteredDeliveries = 0; + __block NSUInteger reentrantDeliveries = 0; + __block BOOL callbackRanOnStateQueue = YES; + __block NSError *keyedError = nil; + __block NSError *unfilteredError = nil; + __block NSError *reentrantError = nil; + + [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] + includeEmptyContextKey:NO + completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + keyedDeliveries += 1; + keyedError = error; + callbackRanOnStateQueue = [self.manager isOnStateQueue]; + [self.manager obtainRemoteConfigList:^(QONRemoteConfigList * _Nullable reentrantList, NSError * _Nullable reentrantRequestError) { + reentrantDeliveries += 1; + reentrantError = reentrantRequestError; + }]; + }]; + [self.manager obtainRemoteConfigList:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + unfilteredDeliveries += 1; + unfilteredError = error; + }]; + XCTAssertEqual(self.manager.listRequests.count, 2); + + [self.manager userChangingRequestFailedWithError:identityError]; + + XCTAssertEqual(keyedDeliveries, 1); + XCTAssertEqual(unfilteredDeliveries, 1); + XCTAssertEqual(reentrantDeliveries, 1, @"a post-failure list request must terminate instead of joining an orphaned queue"); + XCTAssertEqual(keyedError, identityError); + XCTAssertEqual(unfilteredError, identityError); + XCTAssertEqual(reentrantError, identityError); + XCTAssertFalse(callbackRanOnStateQueue, @"list callbacks must run after the state transaction is committed"); + XCTAssertEqual(self.manager.listRequests.count, 0); + + [self.manager userChangingRequestFailedWithError:identityError]; + XCTAssertEqual(keyedDeliveries, 1); + XCTAssertEqual(unfilteredDeliveries, 1); + XCTAssertEqual(reentrantDeliveries, 1); +} + +- (void)testListPreflightCrossingFailedUserChangeTerminatesInsteadOfLateEnqueue { + __block BOOL userStable = YES; + OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { + [invocation setReturnValue:&userStable]; }); - [self.manager obtainRemoteConfigWithContextKey:@"ctx" - completion:^(QONRemoteConfig * _Nullable remoteConfig, NSError * _Nullable error) {}]; - XCTAssertEqual(singleCalls, 1); + __block QONUserPropertiesEmptyCompletionHandler propertyFlush = nil; + OCMStub([self.mockUserPropertiesManager forceSendProperties:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONUserPropertiesEmptyCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:2]; + propertyFlush = [completion copy]; + }); + NSError *identityError = [NSError errorWithDomain:@"identity" code:18 userInfo:nil]; + __block NSUInteger deliveries = 0; + __block NSError *deliveredError = nil; - // when - the user switches (states map replaced), then the response lands - // on the now-orphaned state - [self.manager userHasBeenChanged]; - serviceCompletion(OCMClassMock([QONRemoteConfig class]), nil); + [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] includeEmptyContextKey:NO completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveries += 1; + deliveredError = error; + }]; + XCTAssertNotNil(propertyFlush); + userStable = NO; + [self.manager userChangingRequestFailedWithError:identityError]; + XCTAssertEqual(deliveries, 1, @"identity failure must terminate an async preflight without waiting for its callback"); + XCTAssertEqual(deliveredError, identityError); + + // Even if a subsequent identity attempt succeeds before this old preflight + // returns, the operation that crossed the first terminal failure must not be + // replayed silently under a different outcome. + [self.manager userChangingRequestStarted]; + userStable = YES; + [self.manager handlePendingRequests]; + propertyFlush(); - // then - the orphaned state must not fire a request nobody awaits - XCTAssertEqual(singleCalls, 1); + XCTAssertEqual(deliveries, 1); + XCTAssertEqual(deliveredError, identityError); + XCTAssertEqual(self.manager.listRequests.count, 0); } -- (void)testRateLimitedLoadDeliversBundledFallback { - // given - a bundled fallback exists and a load is in flight - [self stubUserStableAndImmediatePropertiesFlush]; - QONRemoteConfig *fallbackConfig = OCMClassMock([QONRemoteConfig class]); - QONRemoteConfigurationSource *fallbackSource = OCMClassMock([QONRemoteConfigurationSource class]); - OCMStub([fallbackConfig source]).andReturn(fallbackSource); - OCMStub([fallbackSource contextKey]).andReturn(@"ctx"); - QONFallbackObject *fallbackObject = [QONFallbackObject new]; - fallbackObject.remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[fallbackConfig]]; - OCMStub([self.mockFallbackService obtainFallbackData]).andReturn(fallbackObject); +- (void)testNewUserChangeAttemptClearsLatchedListFailure { + __block BOOL userStable = NO; + OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { + [invocation setReturnValue:&userStable]; + }); + NSError *identityError = [NSError errorWithDomain:@"identity" code:20 userInfo:nil]; + [self.manager userChangingRequestFailedWithError:identityError]; - __block QONRemoteConfigCompletionHandler serviceCompletion = nil; - OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { - __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; - [invocation getArgument:&completion atIndex:3]; + __block NSUInteger failedDeliveries = 0; + [self.manager obtainRemoteConfigList:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + failedDeliveries += 1; + XCTAssertEqual(error, identityError); + }]; + XCTAssertEqual(failedDeliveries, 1); + + [self.manager userChangingRequestStarted]; + __block NSUInteger nextAttemptDeliveries = 0; + [self.manager obtainRemoteConfigList:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + nextAttemptDeliveries += 1; + }]; + + XCTAssertEqual(nextAttemptDeliveries, 0, @"a new identity window must not inherit the previous attempt's terminal error"); + XCTAssertEqual(self.manager.listRequests.count, 1); +} + +- (void)testListResponseCrossingFailedUserChangeTerminatesInsteadOfLateEnqueue { + __block BOOL userStable = YES; + OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { + [invocation setReturnValue:&userStable]; + }); + OCMStub([self.mockUserPropertiesManager forceSendProperties:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONUserPropertiesEmptyCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:2]; + if (completion) completion(); + }); + __block QONRemoteConfigListCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfigList:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigListCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:2]; serviceCompletion = [completion copy]; }); - - __block QONRemoteConfig *deliveredConfig = nil; + NSError *identityError = [NSError errorWithDomain:@"identity" code:19 userInfo:nil]; + __block NSUInteger deliveries = 0; __block NSError *deliveredError = nil; - [self.manager obtainRemoteConfigWithContextKey:@"ctx" - completion:^(QONRemoteConfig * _Nullable remoteConfig, NSError * _Nullable error) { - deliveredConfig = remoteConfig; + + [self.manager obtainRemoteConfigList:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveries += 1; deliveredError = error; }]; - XCTAssertNotNil(serviceCompletion, @"the load must reach the service"); + XCTAssertNotNil(serviceCompletion); + userStable = NO; + [self.manager userChangingRequestFailedWithError:identityError]; + XCTAssertEqual(deliveries, 1, @"identity failure must terminate an in-flight list without waiting for the network callback"); + XCTAssertEqual(deliveredError, identityError); - // when - the request is short-circuited by the local rate limiter (since - // fallbacks are no longer cached, offline repeat calls hit the limiter - // instead of the old cached-fallback fast path) - // The rate-limit arm of shouldFireFallback is domain-pinned (code 35 - // collides with unrelated domains, e.g. POSIX EAGAIN) - serviceCompletion(nil, [NSError errorWithDomain:QonversionErrorDomain - code:QONErrorCodeApiRateLimitExceeded - userInfo:nil]); + [self.manager userChangingRequestStarted]; + userStable = YES; + [self.manager handlePendingRequests]; + serviceCompletion([[QONRemoteConfigList alloc] initWithRemoteConfigs:@[]], nil); - // then - the bundled payload is served instead of a hard error, uncached - XCTAssertEqual(deliveredConfig, fallbackConfig); - XCTAssertNil(deliveredError); - XCTAssertNil(self.manager.loadingStates[@"ctx"].loadedConfig); + XCTAssertEqual(deliveries, 1); + XCTAssertEqual(deliveredError, identityError); + XCTAssertEqual(self.manager.listRequests.count, 0); } -- (void)testFallbackConfigIsDeliveredWithoutBeingCached { - // given - a bundled fallback exists and a single-key load is in flight - [self stubUserStableAndImmediatePropertiesFlush]; +- (void)testSingleRequestAfterFailedUserChangeTerminatesAndNextAttemptUsesItsOwnError { + __block BOOL userStable = NO; + OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { + [invocation setReturnValue:&userStable]; + }); + NSError *firstError = [NSError errorWithDomain:@"identity" code:21 userInfo:nil]; + NSError *secondError = [NSError errorWithDomain:@"identity" code:22 userInfo:nil]; + [self.manager userChangingRequestFailedWithError:firstError]; + + __block NSUInteger firstDeliveries = 0; + __block NSError *firstDeliveredError = nil; + __block BOOL callbackRanOnStateQueue = YES; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + firstDeliveries += 1; + firstDeliveredError = error; + callbackRanOnStateQueue = [self.manager isOnStateQueue]; + }]; + XCTAssertEqual(firstDeliveries, 1); + XCTAssertEqual(firstDeliveredError, firstError); + XCTAssertFalse(callbackRanOnStateQueue); + + [self.manager userChangingRequestStarted]; + __block NSUInteger secondDeliveries = 0; + __block NSError *secondDeliveredError = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + secondDeliveries += 1; + secondDeliveredError = error; + }]; + XCTAssertEqual(secondDeliveries, 0, @"a request during the next active attempt must wait for that attempt"); - QONRemoteConfig *fallbackConfig = OCMClassMock([QONRemoteConfig class]); - QONRemoteConfigurationSource *fallbackSource = OCMClassMock([QONRemoteConfigurationSource class]); - OCMStub([fallbackConfig source]).andReturn(fallbackSource); - OCMStub([fallbackSource contextKey]).andReturn(@"ctx"); - QONFallbackObject *fallbackObject = [QONFallbackObject new]; - fallbackObject.remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[fallbackConfig]]; - OCMStub([self.mockFallbackService obtainFallbackData]).andReturn(fallbackObject); + [self.manager userChangingRequestFailedWithError:secondError]; + XCTAssertEqual(secondDeliveries, 1); + XCTAssertEqual(secondDeliveredError, secondError); +} - __block QONRemoteConfigCompletionHandler serviceCompletion = nil; +- (void)testUnstableUserDuringSinglePreflightDefersAllWaitersUntilOneFreshReplay { + __block BOOL userStable = YES; + OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { + [invocation setReturnValue:&userStable]; + }); + __block NSMutableArray *propertyFlushes = [NSMutableArray new]; + OCMStub([self.mockUserPropertiesManager forceSendProperties:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONUserPropertiesEmptyCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:2]; + if (completion) [propertyFlushes addObject:[completion copy]]; + }); + __block NSMutableArray *serviceCompletions = [NSMutableArray new]; OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; [invocation getArgument:&completion atIndex:3]; - serviceCompletion = [completion copy]; + [serviceCompletions addObject:[completion copy]]; }); - __block QONRemoteConfig *deliveredConfig = nil; - [self.manager obtainRemoteConfigWithContextKey:@"ctx" - completion:^(QONRemoteConfig * _Nullable remoteConfig, NSError * _Nullable error) { - deliveredConfig = remoteConfig; + __block NSUInteger firstDeliveries = 0; + __block NSUInteger secondDeliveries = 0; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + firstDeliveries += 1; }]; - XCTAssertNotNil(serviceCompletion, @"the load must reach the service"); - - // when - the network fails in a fallback-eligible way - NSError *networkError = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]; - serviceCompletion(nil, networkError); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + secondDeliveries += 1; + }]; + XCTAssertEqual(propertyFlushes.count, 1); - // then - the fallback is delivered but NOT pinned into the cache, so the - // next call retries the network instead of serving the fallback until the - // next invalidation - XCTAssertEqual(deliveredConfig, fallbackConfig); - XCTAssertNil(self.manager.loadingStates[@"ctx"].loadedConfig); + userStable = NO; + propertyFlushes[0](); + XCTAssertEqual(serviceCompletions.count, 0, @"an identity-window preflight must not send under the old uid"); + XCTAssertEqual(self.manager.loadingStates[@"ctx"].completions.count, 2); XCTAssertFalse(self.manager.loadingStates[@"ctx"].isInProgress); -} - -- (void)testFallbackListIsBuiltFromBundledDataAndNotCached { - // given - a bundled fallback exists and a keyed list load is in flight - [self stubUserStableAndImmediatePropertiesFlush]; - QONRemoteConfig *fallbackConfig = OCMClassMock([QONRemoteConfig class]); - QONRemoteConfigurationSource *fallbackSource = OCMClassMock([QONRemoteConfigurationSource class]); - OCMStub([fallbackConfig source]).andReturn(fallbackSource); - OCMStub([fallbackSource contextKey]).andReturn(@"ctx"); - QONFallbackObject *fallbackObject = [QONFallbackObject new]; - fallbackObject.remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[fallbackConfig]]; - OCMStub([self.mockFallbackService obtainFallbackData]).andReturn(fallbackObject); + userStable = YES; + [self.manager handlePendingRequests]; + XCTAssertEqual(propertyFlushes.count, 2); + propertyFlushes[1](); + XCTAssertEqual(serviceCompletions.count, 1); + serviceCompletions[0](QONTestRemoteConfig(@"fresh", @"ctx", @"fresh"), nil); + XCTAssertEqual(firstDeliveries, 1); + XCTAssertEqual(secondDeliveries, 1); +} - __block QONRemoteConfigListCompletionHandler serviceCompletion = nil; - OCMStub([self.mockService loadRemoteConfigList:[OCMArg any] includeEmptyContextKey:NO completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { - __unsafe_unretained QONRemoteConfigListCompletionHandler completion = nil; - [invocation getArgument:&completion atIndex:4]; - serviceCompletion = [completion copy]; +- (void)testUnstableUserAtSingleResponseDoesNotDeliverOldResponseAndReplaysOnce { + __block BOOL userStable = YES; + __block BOOL destabilizeAfterNextCheck = NO; + OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { + [invocation setReturnValue:&userStable]; + if (destabilizeAfterNextCheck) { + destabilizeAfterNextCheck = NO; + userStable = NO; + } + }); + OCMStub([self.mockUserPropertiesManager forceSendProperties:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONUserPropertiesEmptyCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:2]; + if (completion) completion(); + }); + __block NSMutableArray *serviceCompletions = [NSMutableArray new]; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + [serviceCompletions addObject:[completion copy]]; }); - __block QONRemoteConfigList *deliveredList = nil; - [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] - includeEmptyContextKey:NO - completion:^(QONRemoteConfigList * _Nullable remoteConfigList, NSError * _Nullable error) { - deliveredList = remoteConfigList; + __block NSUInteger firstDeliveries = 0; + __block NSUInteger secondDeliveries = 0; + __block QONRemoteConfig *deliveredConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + firstDeliveries += 1; + deliveredConfig = config; }]; - XCTAssertNotNil(serviceCompletion, @"the list load must reach the service"); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + secondDeliveries += 1; + }]; + XCTAssertEqual(serviceCompletions.count, 1); - // when - the network fails in a fallback-eligible way - NSError *networkError = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]; - serviceCompletion(nil, networkError); + destabilizeAfterNextCheck = YES; + serviceCompletions[0](QONTestRemoteConfig(@"old", @"ctx", @"old"), nil); + XCTAssertEqual(firstDeliveries, 0); + XCTAssertEqual(secondDeliveries, 0); + XCTAssertEqual(self.manager.loadingStates[@"ctx"].completions.count, 2); + XCTAssertFalse(self.manager.loadingStates[@"ctx"].isInProgress); - // then - the keyed fallback is filtered from the BUNDLED list (previously - // it was filtered from the nil network list and always came back empty), - // delivered, and nothing is cached - XCTAssertEqual(deliveredList.remoteConfigs.count, 1); - XCTAssertEqual(deliveredList.remoteConfigs.firstObject, fallbackConfig); - XCTAssertNil(self.manager.loadingStates[@"ctx"]); + userStable = YES; + [self.manager handlePendingRequests]; + XCTAssertEqual(serviceCompletions.count, 2, @"all live waiters must share one replay"); + QONRemoteConfig *freshConfig = QONTestRemoteConfig(@"fresh", @"ctx", @"fresh"); + serviceCompletions[1](freshConfig, nil); + XCTAssertEqual(firstDeliveries, 1); + XCTAssertEqual(secondDeliveries, 1); + XCTAssertEqual(deliveredConfig, freshConfig); } -- (void)testAttachInvalidationPreventsInFlightListLoadFromReCachingStaleConfigs { - // given - the user is stable and a list load is in flight (attach does NOT - // replace the states map, so the generation guard is the only barrier here) - OCMStub([self.mockProductCenterManager isUserStable]).andReturn(YES); +- (void)testUnstableUserDuringKeyedListPreflightMovesCompletionOnceUntilPendingReplay { + __block BOOL userStable = YES; + OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { + [invocation setReturnValue:&userStable]; + }); + __block NSMutableArray *propertyFlushes = [NSMutableArray new]; OCMStub([self.mockUserPropertiesManager forceSendProperties:[OCMArg any]]).andDo(^(NSInvocation *invocation) { - __unsafe_unretained QONUserPropertiesEmptyCompletionHandler flushCompletion = nil; - [invocation getArgument:&flushCompletion atIndex:2]; - if (flushCompletion) { - flushCompletion(); - } + __unsafe_unretained QONUserPropertiesEmptyCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:2]; + if (completion) [propertyFlushes addObject:[completion copy]]; }); - - __block QONRemoteConfigListCompletionHandler serviceCompletion = nil; + __block NSMutableArray *serviceCompletions = [NSMutableArray new]; OCMStub([self.mockService loadRemoteConfigList:[OCMArg any] includeEmptyContextKey:NO completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { __unsafe_unretained QONRemoteConfigListCompletionHandler completion = nil; [invocation getArgument:&completion atIndex:4]; - serviceCompletion = [completion copy]; + [serviceCompletions addObject:[completion copy]]; }); - __block QONRemoteConfigList *deliveredList = nil; + __block NSUInteger deliveries = 0; + [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] includeEmptyContextKey:NO completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveries += 1; + }]; + XCTAssertEqual(propertyFlushes.count, 1); + XCTAssertEqual(self.manager.activeListRequests.count, 1); + userStable = NO; + propertyFlushes[0](); + XCTAssertEqual(serviceCompletions.count, 0); + XCTAssertEqual(self.manager.listRequests.count, 1); + XCTAssertEqual(self.manager.activeListRequests.count, 1, @"a replay must retain the one external request record"); + + [self.manager handlePendingRequests]; + XCTAssertEqual(self.manager.listRequests.count, 1, @"unstable replay must not duplicate the queued completion"); + XCTAssertEqual(self.manager.activeListRequests.count, 1); + userStable = YES; + [self.manager handlePendingRequests]; + XCTAssertEqual(propertyFlushes.count, 2); + XCTAssertEqual(self.manager.activeListRequests.count, 1); + propertyFlushes[1](); + XCTAssertEqual(serviceCompletions.count, 1); + serviceCompletions[0]([[QONRemoteConfigList alloc] initWithRemoteConfigs:@[QONTestRemoteConfig(@"fresh", @"ctx", @"fresh")]], nil); + XCTAssertEqual(deliveries, 1); + XCTAssertEqual(self.manager.activeListRequests.count, 0); +} + +- (void)testListSuccessIsCommittedBeforeLaterIdentityFailure { + [self stubUserStableAndImmediatePropertiesFlush]; + __block QONRemoteConfigListCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfigList:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigListCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:2]; + serviceCompletion = [completion copy]; + }); + + __block NSUInteger deliveries = 0; + __block NSError *deliveredError = nil; + __block NSUInteger activeCountSeenByCallback = NSNotFound; + [self.manager obtainRemoteConfigList:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveries += 1; + deliveredError = error; + activeCountSeenByCallback = self.manager.activeListRequests.count; + }]; + XCTAssertEqual(self.manager.activeListRequests.count, 1); + + serviceCompletion([[QONRemoteConfigList alloc] initWithRemoteConfigs:@[]], nil); + XCTAssertEqual(deliveries, 1); + XCTAssertNil(deliveredError); + XCTAssertEqual(activeCountSeenByCallback, 0, @"terminal state must be committed before the user callback runs"); + XCTAssertEqual(self.manager.activeListRequests.count, 0); + + [self.manager userChangingRequestFailedWithError:[NSError errorWithDomain:@"identity" code:99 userInfo:nil]]; + XCTAssertEqual(deliveries, 1, @"a later identity failure must not replace an already committed success"); +} + +- (void)testUnstableUserAtUnfilteredListResponseMovesCompletionOnceUntilPendingReplay { + __block BOOL userStable = YES; + __block BOOL destabilizeAfterNextCheck = NO; + OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { + [invocation setReturnValue:&userStable]; + if (destabilizeAfterNextCheck) { + destabilizeAfterNextCheck = NO; + userStable = NO; + } + }); + OCMStub([self.mockUserPropertiesManager forceSendProperties:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONUserPropertiesEmptyCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:2]; + if (completion) completion(); + }); + __block NSMutableArray *serviceCompletions = [NSMutableArray new]; + OCMStub([self.mockService loadRemoteConfigList:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigListCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:2]; + [serviceCompletions addObject:[completion copy]]; + }); + + __block NSUInteger deliveries = 0; + __block QONRemoteConfigList *deliveredList = nil; + [self.manager obtainRemoteConfigList:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveries += 1; + deliveredList = list; + }]; + XCTAssertEqual(serviceCompletions.count, 1); + destabilizeAfterNextCheck = YES; + serviceCompletions[0]([[QONRemoteConfigList alloc] initWithRemoteConfigs:@[QONTestRemoteConfig(@"old", @"ctx", @"old")]], nil); + XCTAssertEqual(deliveries, 0); + XCTAssertEqual(self.manager.listRequests.count, 1); + + [self.manager handlePendingRequests]; + XCTAssertEqual(self.manager.listRequests.count, 1); + userStable = YES; + [self.manager handlePendingRequests]; + XCTAssertEqual(serviceCompletions.count, 2); + QONRemoteConfigList *freshList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[QONTestRemoteConfig(@"fresh", @"ctx", @"fresh")]]; + serviceCompletions[1](freshList, nil); + XCTAssertEqual(deliveries, 1); + XCTAssertEqual(deliveredList, freshList); +} + +- (void)testUnstableUserDoesNotReceiveWarmKeyedListBeforePendingReplay { + __block BOOL userStable = NO; + OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { + [invocation setReturnValue:&userStable]; + }); + QONRemoteConfigLoadingState *warmState = [QONRemoteConfigLoadingState new]; + warmState.loadedConfig = QONTestRemoteConfig(@"old", @"ctx", @"old"); + self.manager.loadingStates[@"ctx"] = warmState; + + __block NSUInteger deliveries = 0; + [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] includeEmptyContextKey:NO completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveries += 1; + }]; + XCTAssertEqual(deliveries, 0); + XCTAssertEqual(self.manager.listRequests.count, 1); + + userStable = YES; + [self.manager handlePendingRequests]; + XCTAssertEqual(deliveries, 1); + XCTAssertEqual(self.manager.listRequests.count, 0); +} + +- (void)testSingleWarmCacheBecomingUnstableDuringPropertyFlushStaysLiveUntilReplay { + __block BOOL userStable = YES; + __block BOOL destabilizeOnFlush = YES; + OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { + [invocation setReturnValue:&userStable]; + }); + OCMStub([self.mockUserPropertiesManager forceSendProperties:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + if (destabilizeOnFlush) userStable = NO; + }); + QONRemoteConfigLoadingState *warmState = [QONRemoteConfigLoadingState new]; + warmState.loadedConfig = QONTestRemoteConfig(@"old", @"ctx", @"old"); + self.manager.loadingStates[@"ctx"] = warmState; + + __block NSUInteger deliveries = 0; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + deliveries += 1; + }]; + XCTAssertEqual(deliveries, 0); + XCTAssertEqual(warmState.completions.count, 1); + + destabilizeOnFlush = NO; + userStable = YES; + [self.manager handlePendingRequests]; + XCTAssertEqual(deliveries, 1); + XCTAssertEqual(warmState.completions.count, 0); +} + +- (void)testListWarmCacheBecomingUnstableDuringPropertyFlushMovesCompletionUntilReplay { + __block BOOL userStable = YES; + __block BOOL destabilizeOnFlush = YES; + OCMStub([self.mockProductCenterManager isUserStable]).andDo(^(NSInvocation *invocation) { + [invocation setReturnValue:&userStable]; + }); + OCMStub([self.mockUserPropertiesManager forceSendProperties:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + if (destabilizeOnFlush) userStable = NO; + }); + QONRemoteConfigLoadingState *warmState = [QONRemoteConfigLoadingState new]; + warmState.loadedConfig = QONTestRemoteConfig(@"old", @"ctx", @"old"); + self.manager.loadingStates[@"ctx"] = warmState; + + __block NSUInteger deliveries = 0; + [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] includeEmptyContextKey:NO completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveries += 1; + }]; + XCTAssertEqual(deliveries, 0); + XCTAssertEqual(self.manager.listRequests.count, 1); + + destabilizeOnFlush = NO; + userStable = YES; + [self.manager handlePendingRequests]; + XCTAssertEqual(deliveries, 1); + XCTAssertEqual(self.manager.listRequests.count, 0); +} + +- (void)testUserSwitchMidFlightReissuesWithoutDeliveringOldIdentityConfig { + // given - a load is in flight for the old identity + [self stubUserStableAndImmediatePropertiesFlush]; + __block NSUInteger singleCalls = 0; + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + singleCalls += 1; + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + __block QONRemoteConfig *deliveredConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable remoteConfig, NSError * _Nullable error) { + deliveredConfig = remoteConfig; + }]; + XCTAssertEqual(singleCalls, 1); + + // when - the user switches (states map replaced), then the old response + // lands on the now-orphaned state + [self.manager userHasBeenChanged]; + QONRemoteConfig *oldIdentityConfig = OCMClassMock([QONRemoteConfig class]); + serviceCompletion(oldIdentityConfig, nil); + + // then - it must never be delivered across the identity boundary; the live + // direct caller is re-issued exactly once for the new identity instead + XCTAssertNil(deliveredConfig); + XCTAssertEqual(singleCalls, 2); + + QONRemoteConfig *newIdentityConfig = OCMClassMock([QONRemoteConfig class]); + serviceCompletion(newIdentityConfig, nil); + XCTAssertEqual(deliveredConfig, newIdentityConfig); + XCTAssertEqual(singleCalls, 2, @"identity recovery must remain bounded"); +} + +- (void)testOldIdentityErrorDoesNotDrainNewIdentityWaiters { + [self stubUserStableAndImmediatePropertiesFlush]; + __block NSMutableArray *serviceCompletions = [NSMutableArray new]; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + [serviceCompletions addObject:[completion copy]]; + }); + + __block QONRemoteConfig *oldCallerConfig = nil; + __block QONRemoteConfig *newCallerConfig = nil; + __block NSUInteger oldCallerDeliveryCount = 0; + __block NSUInteger newCallerDeliveryCount = 0; + __block NSUInteger newWaiterDeliveryCount = 0; + __block QONRemoteConfig *newWaiterConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + oldCallerDeliveryCount += 1; + oldCallerConfig = config; + }]; + XCTAssertEqual(serviceCompletions.count, 1); + + [self.manager userHasBeenChanged]; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + newCallerDeliveryCount += 1; + newCallerConfig = config; + }]; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + newWaiterDeliveryCount += 1; + newWaiterConfig = config; + }]; + XCTAssertEqual(serviceCompletions.count, 2); + + serviceCompletions[0](nil, [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorNotConnectedToInternet + userInfo:nil]); + XCTAssertNil(oldCallerConfig); + XCTAssertNil(newCallerConfig, @"old error must not drain the new loading state"); + XCTAssertEqual(oldCallerDeliveryCount, 0); + XCTAssertEqual(newCallerDeliveryCount, 0); + XCTAssertEqual(newWaiterDeliveryCount, 0, @"old error must not drain a waiter queued on the new loading state"); + XCTAssertEqual(serviceCompletions.count, 2, @"old caller joins the one new-identity request"); + + QONRemoteConfig *newIdentityConfig = OCMClassMock([QONRemoteConfig class]); + serviceCompletions[1](newIdentityConfig, nil); + XCTAssertEqual(oldCallerConfig, newIdentityConfig); + XCTAssertEqual(newCallerConfig, newIdentityConfig); + XCTAssertEqual(oldCallerDeliveryCount, 1); + XCTAssertEqual(newCallerDeliveryCount, 1); + XCTAssertEqual(newWaiterDeliveryCount, 1); + XCTAssertEqual(newWaiterConfig, newIdentityConfig); +} + +- (void)testIdentityMutationCannotInterleaveBetweenStateCheckAndOldResponseDelivery { + QONRemoteConfigManagerRaceHarness *raceManager = [QONRemoteConfigManagerRaceHarness new]; + self.manager = raceManager; + self.manager.remoteConfigService = self.mockService; + self.manager.productCenterManager = self.mockProductCenterManager; + self.manager.userPropertiesManager = self.mockUserPropertiesManager; + self.manager.fallbackService = self.mockFallbackService; + [self stubUserStableAndImmediatePropertiesFlush]; + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + + dispatch_semaphore_t stateCheckReached = dispatch_semaphore_create(0); + dispatch_semaphore_t releaseOldResponse = dispatch_semaphore_create(0); + dispatch_semaphore_t oldResponseFinished = dispatch_semaphore_create(0); + dispatch_semaphore_t identityMutationStarted = dispatch_semaphore_create(0); + dispatch_semaphore_t identityMutationFinished = dispatch_semaphore_create(0); + __block BOOL shouldBlock = YES; + raceManager.loadingStateReadHook = ^{ + if (shouldBlock) { + shouldBlock = NO; + dispatch_semaphore_signal(stateCheckReached); + dispatch_semaphore_wait(releaseOldResponse, DISPATCH_TIME_FOREVER); + } + }; + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorNotConnectedToInternet + userInfo:nil]); + dispatch_semaphore_signal(oldResponseFinished); + }); + XCTAssertEqual(dispatch_semaphore_wait(stateCheckReached, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + dispatch_semaphore_signal(identityMutationStarted); + [self.manager userHasBeenChanged]; + dispatch_semaphore_signal(identityMutationFinished); + }); + XCTAssertEqual(dispatch_semaphore_wait(identityMutationStarted, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + XCTAssertTrue(dispatch_semaphore_wait(identityMutationFinished, dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC)) != 0, + @"identity mutation must wait for the old response's atomic state transition"); + + dispatch_semaphore_signal(releaseOldResponse); + XCTAssertEqual(dispatch_semaphore_wait(identityMutationFinished, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + XCTAssertEqual(dispatch_semaphore_wait(oldResponseFinished, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); +} + +- (void)testAtomicUserTransitionDoesNotExposeNewAPIUserBeforeManagerStateCanTransition { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"old-user"; + QONRemoteConfigManagerRaceHarness *raceManager = [[QONRemoteConfigManagerRaceHarness alloc] initWithLocalStorage:storage]; + self.manager = raceManager; + self.manager.remoteConfigService = self.mockService; + self.manager.productCenterManager = self.mockProductCenterManager; + self.manager.userPropertiesManager = self.mockUserPropertiesManager; + self.manager.fallbackService = self.mockFallbackService; + OCMStub([self.mockService apiClient]).andReturn(apiClient); + [self stubUserStableAndImmediatePropertiesFlush]; + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + + dispatch_semaphore_t stateCheckReached = dispatch_semaphore_create(0); + dispatch_semaphore_t releaseOldResponse = dispatch_semaphore_create(0); + dispatch_semaphore_t oldResponseFinished = dispatch_semaphore_create(0); + dispatch_semaphore_t transitionStarted = dispatch_semaphore_create(0); + dispatch_semaphore_t transitionFinished = dispatch_semaphore_create(0); + __block BOOL shouldBlock = YES; + raceManager.loadingStateReadHook = ^{ + if (shouldBlock) { + shouldBlock = NO; + dispatch_semaphore_signal(stateCheckReached); + dispatch_semaphore_wait(releaseOldResponse, DISPATCH_TIME_FOREVER); + } + }; + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCancelled userInfo:nil]); + dispatch_semaphore_signal(oldResponseFinished); + }); + XCTAssertEqual(dispatch_semaphore_wait(stateCheckReached, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + dispatch_semaphore_signal(transitionStarted); + [self.manager userHasBeenChangedToUserID:@"new-user"]; + dispatch_semaphore_signal(transitionFinished); + }); + XCTAssertEqual(dispatch_semaphore_wait(transitionStarted, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + XCTAssertTrue(dispatch_semaphore_wait(transitionFinished, dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC)) != 0); + XCTAssertEqualObjects(apiClient.userID, @"old-user", @"API identity and manager state must change in one serial transition"); + + dispatch_semaphore_signal(releaseOldResponse); + XCTAssertEqual(dispatch_semaphore_wait(transitionFinished, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + XCTAssertEqual(dispatch_semaphore_wait(oldResponseFinished, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + XCTAssertEqualObjects(apiClient.userID, @"new-user"); +} + +- (void)testUserTransitionMovesPreflightInitiatorAndWaitersExactlyOnce { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"old-user"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient immediatePropertiesFlush:NO]; + + OCMStub([self.mockProductCenterManager isUserStable]).andReturn(YES); + __block NSMutableArray *propertyFlushes = [NSMutableArray new]; + OCMStub([self.mockUserPropertiesManager forceSendProperties:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONUserPropertiesEmptyCompletionHandler flushCompletion = nil; + [invocation getArgument:&flushCompletion atIndex:2]; + if (flushCompletion) { + [propertyFlushes addObject:[flushCompletion copy]]; + } + }); + + __block NSMutableArray *serviceCompletions = [NSMutableArray new]; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + [serviceCompletions addObject:[completion copy]]; + }); + + __block NSUInteger initiatorDeliveries = 0; + __block NSUInteger oldWaiterDeliveries = 0; + __block NSUInteger newWaiterDeliveries = 0; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + initiatorDeliveries += 1; + }]; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + oldWaiterDeliveries += 1; + }]; + XCTAssertEqual(propertyFlushes.count, 1); + XCTAssertEqual(serviceCompletions.count, 0); + + [self.manager userHasBeenChangedToUserID:@"new-user"]; + [self.manager handlePendingRequests]; + XCTAssertEqual(propertyFlushes.count, 2, @"transferred waiters must start one new-identity preflight"); + + propertyFlushes[0](); + XCTAssertEqual(serviceCompletions.count, 0, @"orphaned preflight must never start a request under the new API identity"); + propertyFlushes[1](); + XCTAssertEqual(serviceCompletions.count, 1); + + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + newWaiterDeliveries += 1; + }]; + QONRemoteConfig *newConfig = QONTestRemoteConfig(@"new", @"ctx", @"new-value"); + serviceCompletions[0](newConfig, nil); + XCTAssertEqual(initiatorDeliveries, 1); + XCTAssertEqual(oldWaiterDeliveries, 1); + XCTAssertEqual(newWaiterDeliveries, 1); +} + +- (void)testNetworkAndMemoryCompletionsRunOutsideStateQueueOnCallingCallbackQueue { + [self stubUserStableAndImmediatePropertiesFlush]; + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + + static char callbackQueueKey; + dispatch_queue_t callbackQueue = dispatch_queue_create("io.qonversion.remote-config-test-callback", DISPATCH_QUEUE_SERIAL); + dispatch_queue_set_specific(callbackQueue, &callbackQueueKey, &callbackQueueKey, NULL); + __block BOOL networkCompletionUsedCallbackQueue = NO; + __block BOOL networkCompletionUsedStateQueue = YES; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + networkCompletionUsedCallbackQueue = dispatch_get_specific(&callbackQueueKey) == &callbackQueueKey; + networkCompletionUsedStateQueue = [self.manager isOnStateQueue]; + }]; + dispatch_sync(callbackQueue, ^{ + serviceCompletion(QONTestRemoteConfig(@"server", @"ctx", @"value"), nil); + }); + XCTAssertTrue(networkCompletionUsedCallbackQueue); + XCTAssertFalse(networkCompletionUsedStateQueue); + + NSThread *callingThread = [NSThread currentThread]; + __block NSThread *memoryCompletionThread = nil; + __block BOOL memoryCompletionUsedStateQueue = YES; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + memoryCompletionThread = [NSThread currentThread]; + memoryCompletionUsedStateQueue = [self.manager isOnStateQueue]; + }]; + XCTAssertEqual(memoryCompletionThread, callingThread); + XCTAssertFalse(memoryCompletionUsedStateQueue); +} + +- (void)testRateLimitedLoadDeliversBundledFallback { + // given - a bundled fallback exists and a load is in flight + [self stubUserStableAndImmediatePropertiesFlush]; + QONRemoteConfig *fallbackConfig = OCMClassMock([QONRemoteConfig class]); + QONRemoteConfigurationSource *fallbackSource = OCMClassMock([QONRemoteConfigurationSource class]); + OCMStub([fallbackConfig source]).andReturn(fallbackSource); + OCMStub([fallbackSource contextKey]).andReturn(@"ctx"); + QONFallbackObject *fallbackObject = [QONFallbackObject new]; + fallbackObject.remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[fallbackConfig]]; + OCMStub([self.mockFallbackService obtainFallbackData]).andReturn(fallbackObject); + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + + __block QONRemoteConfig *deliveredConfig = nil; + __block NSError *deliveredError = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable remoteConfig, NSError * _Nullable error) { + deliveredConfig = remoteConfig; + deliveredError = error; + }]; + XCTAssertNotNil(serviceCompletion, @"the load must reach the service"); + + // when - the request is short-circuited by the local rate limiter (since + // fallbacks are no longer cached, offline repeat calls hit the limiter + // instead of the old cached-fallback fast path) + // The rate-limit arm of shouldFireFallback is domain-pinned (code 35 + // collides with unrelated domains, e.g. POSIX EAGAIN) + serviceCompletion(nil, [NSError errorWithDomain:QonversionErrorDomain + code:QONErrorCodeApiRateLimitExceeded + userInfo:nil]); + + // then - the bundled payload is served instead of a hard error, uncached + XCTAssertEqual(deliveredConfig, fallbackConfig); + XCTAssertNil(deliveredError); + XCTAssertNil(self.manager.loadingStates[@"ctx"].loadedConfig); +} + +- (void)testFallbackConfigIsDeliveredWithoutBeingCached { + // given - a bundled fallback exists and a single-key load is in flight + [self stubUserStableAndImmediatePropertiesFlush]; + + QONRemoteConfig *fallbackConfig = OCMClassMock([QONRemoteConfig class]); + QONRemoteConfigurationSource *fallbackSource = OCMClassMock([QONRemoteConfigurationSource class]); + OCMStub([fallbackConfig source]).andReturn(fallbackSource); + OCMStub([fallbackSource contextKey]).andReturn(@"ctx"); + QONFallbackObject *fallbackObject = [QONFallbackObject new]; + fallbackObject.remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[fallbackConfig]]; + OCMStub([self.mockFallbackService obtainFallbackData]).andReturn(fallbackObject); + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + + __block QONRemoteConfig *deliveredConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable remoteConfig, NSError * _Nullable error) { + deliveredConfig = remoteConfig; + }]; + XCTAssertNotNil(serviceCompletion, @"the load must reach the service"); + + // when - the network fails in a fallback-eligible way + NSError *networkError = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]; + serviceCompletion(nil, networkError); + + // then - the fallback is delivered but NOT pinned into the cache, so the + // next call retries the network instead of serving the fallback until the + // next invalidation + XCTAssertEqual(deliveredConfig, fallbackConfig); + XCTAssertNil(self.manager.loadingStates[@"ctx"].loadedConfig); + XCTAssertFalse(self.manager.loadingStates[@"ctx"].isInProgress); +} + +- (void)testFallbackListIsBuiltFromBundledDataAndNotCached { + // given - a bundled fallback exists and a keyed list load is in flight + [self stubUserStableAndImmediatePropertiesFlush]; + + QONRemoteConfig *fallbackConfig = OCMClassMock([QONRemoteConfig class]); + QONRemoteConfigurationSource *fallbackSource = OCMClassMock([QONRemoteConfigurationSource class]); + OCMStub([fallbackConfig source]).andReturn(fallbackSource); + OCMStub([fallbackSource contextKey]).andReturn(@"ctx"); + QONFallbackObject *fallbackObject = [QONFallbackObject new]; + fallbackObject.remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[fallbackConfig]]; + OCMStub([self.mockFallbackService obtainFallbackData]).andReturn(fallbackObject); + + __block QONRemoteConfigListCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfigList:[OCMArg any] includeEmptyContextKey:NO completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigListCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:4]; + serviceCompletion = [completion copy]; + }); + + __block QONRemoteConfigList *deliveredList = nil; + [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] + includeEmptyContextKey:NO + completion:^(QONRemoteConfigList * _Nullable remoteConfigList, NSError * _Nullable error) { + deliveredList = remoteConfigList; + }]; + XCTAssertNotNil(serviceCompletion, @"the list load must reach the service"); + + // when - the network fails in a fallback-eligible way + NSError *networkError = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]; + serviceCompletion(nil, networkError); + + // then - the keyed fallback is filtered from the BUNDLED list (previously + // it was filtered from the nil network list and always came back empty), + // delivered, and nothing is cached + XCTAssertEqual(deliveredList.remoteConfigs.count, 1); + XCTAssertEqual(deliveredList.remoteConfigs.firstObject, fallbackConfig); + XCTAssertNil(self.manager.loadingStates[@"ctx"]); +} + +- (void)testAttachInvalidationPreventsInFlightListLoadFromReCachingStaleConfigs { + // given - the user is stable and a list load is in flight (attach does NOT + // replace the states map, so the generation guard is the only barrier here) + OCMStub([self.mockProductCenterManager isUserStable]).andReturn(YES); + OCMStub([self.mockUserPropertiesManager forceSendProperties:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONUserPropertiesEmptyCompletionHandler flushCompletion = nil; + [invocation getArgument:&flushCompletion atIndex:2]; + if (flushCompletion) { + flushCompletion(); + } + }); + + __block QONRemoteConfigListCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfigList:[OCMArg any] includeEmptyContextKey:NO completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigListCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:4]; + serviceCompletion = [completion copy]; + }); + + __block QONRemoteConfigList *deliveredList = nil; + [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] + includeEmptyContextKey:NO + completion:^(QONRemoteConfigList * _Nullable remoteConfigList, NSError * _Nullable error) { + deliveredList = remoteConfigList; + }]; + XCTAssertNotNil(serviceCompletion, @"the list load must reach the service"); + + // when - the attach invalidates mid-flight, then the pre-attach list lands + [self.manager attachUserToRemoteConfiguration:@"config_id" + completion:^(BOOL success, NSError * _Nullable error) {}]; + + // Typed receivers: with plain id the compiler cannot disambiguate the many + // -source selectors in scope and fails the build. + QONRemoteConfig *staleConfig = OCMClassMock([QONRemoteConfig class]); + QONRemoteConfigurationSource *staleSource = OCMClassMock([QONRemoteConfigurationSource class]); + OCMStub([staleConfig source]).andReturn(staleSource); + OCMStub([staleSource contextKey]).andReturn(@"ctx"); + QONRemoteConfigList *staleList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[staleConfig]]; + serviceCompletion(staleList, nil); + + // then - the list is delivered but nothing from it is cached + XCTAssertEqual(deliveredList, staleList); + XCTAssertNil(self.manager.loadingStates[@"ctx"].loadedConfig); +} + +- (void)testProcessRestartOfflineServesDiskLKGBeforeBundleAndRetriesNetworkOnNextCall { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block NSUInteger serviceCalls = 0; + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + serviceCalls += 1; + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + + QONRemoteConfig *serverConfig = QONTestRemoteConfig(@"server", @"ctx", @"server-value"); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletion(serverConfig, nil); + XCTAssertNotNil([storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]); + + // Simulate a process restart: a new manager has no in-memory loading state, + // but receives the same durable storage and identity scope. + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + QONRemoteConfig *bundledConfig = QONTestRemoteConfig(@"bundle", @"ctx", @"bundle-value"); + QONFallbackObject *fallbackObject = [QONFallbackObject new]; + fallbackObject.remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[bundledConfig]]; + OCMStub([self.mockFallbackService obtainFallbackData]).andReturn(fallbackObject); + + __block QONRemoteConfig *deliveredConfig = nil; + __block NSError *deliveredError = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + deliveredConfig = config; + deliveredError = error; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorTimedOut userInfo:nil]); + + XCTAssertEqualObjects(deliveredConfig.payload, serverConfig.payload); + XCTAssertEqualObjects(deliveredConfig.source.identifier, @"server"); + XCTAssertNil(deliveredError); + XCTAssertEqual(self.manager.lastDeliveryOrigin, QONRemoteConfigDeliveryOriginDiskLastKnownGood); + XCTAssertNil(self.manager.loadingStates[@"ctx"].loadedConfig, + @"disk LKG must not become a warm cache that suppresses recovery"); + + // A second caller performs one more bounded network attempt and degrades to + // the same disk value again if the outage persists. + deliveredConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + deliveredConfig = config; + }]; + XCTAssertEqual(serviceCalls, 3); + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNetworkConnectionLost userInfo:nil]); + XCTAssertEqualObjects(deliveredConfig.source.identifier, @"server"); + XCTAssertEqual(serviceCalls, 3); +} + +- (void)testSameIdentityInvalidationRetainsDiskLKGButScopeChangesNeverReadIt { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletion(QONTestRemoteConfig(@"user-a-config", @"ctx", @"a"), nil); + + [self.manager invalidateRemoteConfigsCache]; + __block QONRemoteConfig *sameIdentityConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + sameIdentityConfig = config; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]); + XCTAssertEqualObjects(sameIdentityConfig.source.identifier, @"user-a-config"); + + apiClient.userID = @"user-b"; + [self.manager userHasBeenChanged]; + __block QONRemoteConfig *otherUserConfig = nil; + __block NSError *otherUserError = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + otherUserConfig = config; + otherUserError = error; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]); + XCTAssertNil(otherUserConfig); + XCTAssertNotNil(otherUserError); + + apiClient.userID = @"user-a"; + apiClient.apiKey = @"project-b"; + [self.manager userHasBeenChanged]; + __block QONRemoteConfig *otherProjectConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + otherProjectConfig = config; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]); + XCTAssertNil(otherProjectConfig); + + apiClient.apiKey = @"project-a"; + [self.manager userHasBeenChanged]; + __block QONRemoteConfig *otherContextConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"other-context" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + otherContextConfig = config; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]); + XCTAssertNil(otherContextConfig); +} + +- (void)testInvalidServerValueIsNeverPersistedAsLKG { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + + QONRemoteConfig *invalidConfig = [[QONRemoteConfig alloc] initWithPayload:@{@"value": @"invalid"} + experiment:nil + source:nil]; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletion(invalidConfig, nil); + XCTAssertNil([storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]); +} + +- (void)testAuthoritativeNoConfigResponseRemovesStaleDiskLKG { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletion(QONTestRemoteConfig(@"stale", @"ctx", @"stale-value"), nil); + [self.manager invalidateRemoteConfigsCache]; + + __block NSError *notAvailableError = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + notAvailableError = error; + }]; + serviceCompletion(nil, [QONErrors errorWithCode:QONErrorCodeRemoteConfigurationNotAvailable + message:@"not available"]); + XCTAssertEqual(notAvailableError.code, QONErrorCodeRemoteConfigurationNotAvailable); + + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + XCTAssertNil(config); + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorNotConnectedToInternet + userInfo:nil]); +} + +- (void)testAuthoritativeNoConfigRetryDoesNotServeBaselineAndRemovesStaleDiskLKG { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block NSMutableArray *serviceCompletions = [NSMutableArray new]; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + [serviceCompletions addObject:[completion copy]]; + }); + + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletions[0](QONTestRemoteConfig(@"persisted", @"ctx", @"persisted"), nil); + XCTAssertNotNil([storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]); + + [self.manager invalidateRemoteConfigsCache]; + __block QONRemoteConfig *deliveredConfig = nil; + __block NSError *deliveredError = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + deliveredConfig = config; + deliveredError = error; + }]; + [self.manager invalidateRemoteConfigsCache]; + serviceCompletions[1](QONTestRemoteConfig(@"superseded", @"ctx", @"superseded"), nil); + XCTAssertEqual(serviceCompletions.count, 3); + + NSError *notAvailableError = [QONErrors errorWithCode:QONErrorCodeRemoteConfigurationNotAvailable + message:@"not available"]; + serviceCompletions[2](nil, notAvailableError); + + XCTAssertNil(deliveredConfig); + XCTAssertEqual(deliveredError, notAvailableError); + XCTAssertNil([storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]); +} + +- (void)testUnknownSchemaAndCorruptArchiveAreIgnoredAndCleared { + QNInMemoryStorage *unknownSchemaStorage = [QNInMemoryStorage new]; + [unknownSchemaStorage storeObject:@{@"schema_version": @99, @"scopes": @{}} + forKey:kTestRemoteConfigLKGStorageKey]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:unknownSchemaStorage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + + __block NSError *deliveredError = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + deliveredError = error; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]); + XCTAssertNotNil(deliveredError); + XCTAssertNil([unknownSchemaStorage loadObjectForKey:kTestRemoteConfigLKGStorageKey]); + + QONThrowingLocalStorage *corruptStorage = [QONThrowingLocalStorage new]; + [self usePersistentManagerWithStorage:corruptStorage apiClient:apiClient]; + deliveredError = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + deliveredError = error; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]); + XCTAssertNotNil(deliveredError); + XCTAssertTrue(corruptStorage.removed); +} + +- (void)testListPathUsesDiskLKGBeforeBundleAndDoesNotWarmMemoryCache { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler singleCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + singleCompletion = [completion copy]; + }); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + singleCompletion(QONTestRemoteConfig(@"disk", @"ctx", @"disk-value"), nil); + + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + QONRemoteConfig *bundledConfig = QONTestRemoteConfig(@"bundle", @"ctx", @"bundle-value"); + QONFallbackObject *fallbackObject = [QONFallbackObject new]; + fallbackObject.remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[bundledConfig]]; + OCMStub([self.mockFallbackService obtainFallbackData]).andReturn(fallbackObject); + + __block NSUInteger listCalls = 0; + __block QONRemoteConfigListCompletionHandler listCompletion = nil; + OCMStub([self.mockService loadRemoteConfigList:[OCMArg any] includeEmptyContextKey:NO completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + listCalls += 1; + __unsafe_unretained QONRemoteConfigListCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:4]; + listCompletion = [completion copy]; + }); + + __block QONRemoteConfigList *deliveredList = nil; [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] includeEmptyContextKey:NO - completion:^(QONRemoteConfigList * _Nullable remoteConfigList, NSError * _Nullable error) { - deliveredList = remoteConfigList; + completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveredList = list; }]; - XCTAssertNotNil(serviceCompletion, @"the list load must reach the service"); + listCompletion(nil, [NSError errorWithDomain:QonversionErrorDomain code:503 userInfo:nil]); - // when - the attach invalidates mid-flight, then the pre-attach list lands - [self.manager attachUserToRemoteConfiguration:@"config_id" - completion:^(BOOL success, NSError * _Nullable error) {}]; + XCTAssertEqual(deliveredList.remoteConfigs.count, 1); + XCTAssertEqualObjects(deliveredList.remoteConfigs.firstObject.source.identifier, @"disk"); + XCTAssertEqual(self.manager.lastDeliveryOrigin, QONRemoteConfigDeliveryOriginDiskLastKnownGood); + XCTAssertNil(self.manager.loadingStates[@"ctx"]); - // Typed receivers: with plain id the compiler cannot disambiguate the many - // -source selectors in scope and fails the build. - QONRemoteConfig *staleConfig = OCMClassMock([QONRemoteConfig class]); - QONRemoteConfigurationSource *staleSource = OCMClassMock([QONRemoteConfigurationSource class]); - OCMStub([staleConfig source]).andReturn(staleSource); - OCMStub([staleSource contextKey]).andReturn(@"ctx"); - QONRemoteConfigList *staleList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[staleConfig]]; - serviceCompletion(staleList, nil); + deliveredList = nil; + [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] + includeEmptyContextKey:NO + completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveredList = list; + }]; + XCTAssertEqual(listCalls, 2); + listCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorTimedOut userInfo:nil]); + XCTAssertEqualObjects(deliveredList.remoteConfigs.firstObject.source.identifier, @"disk"); +} - // then - the list is delivered but nothing from it is cached - XCTAssertEqual(deliveredList, staleList); - XCTAssertNil(self.manager.loadingStates[@"ctx"].loadedConfig); +- (void)testListFallbackMergesEachRequestedKeyWithDiskBeforeBundle { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler singleCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + singleCompletion = [completion copy]; + }); + [self.manager obtainRemoteConfigWithContextKey:@"disk-key" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + singleCompletion(QONTestRemoteConfig(@"disk-winner", @"disk-key", @"disk"), nil); + + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + QONFallbackObject *fallbackObject = [QONFallbackObject new]; + fallbackObject.remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[ + QONTestRemoteConfig(@"bundle-loser", @"disk-key", @"bundle-old"), + QONTestRemoteConfig(@"bundle-missing-key", @"bundle-key", @"bundle"), + ]]; + OCMStub([self.mockFallbackService obtainFallbackData]).andReturn(fallbackObject); + + __block QONRemoteConfigListCompletionHandler listCompletion = nil; + OCMStub([self.mockService loadRemoteConfigList:[OCMArg any] includeEmptyContextKey:NO completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigListCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:4]; + listCompletion = [completion copy]; + }); + + __block QONRemoteConfigList *deliveredList = nil; + [self.manager obtainRemoteConfigListWithContextKeys:@[@"disk-key", @"bundle-key"] + includeEmptyContextKey:NO + completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveredList = list; + }]; + listCompletion(nil, [NSError errorWithDomain:QonversionErrorDomain code:503 userInfo:nil]); + + XCTAssertEqual(deliveredList.remoteConfigs.count, 2); + XCTAssertEqualObjects([deliveredList remoteConfigForContextKey:@"disk-key"].source.identifier, @"disk-winner"); + XCTAssertEqualObjects([deliveredList remoteConfigForContextKey:@"bundle-key"].source.identifier, @"bundle-missing-key"); + XCTAssertEqual(self.manager.lastDeliveryOrigin, QONRemoteConfigDeliveryOriginDiskLastKnownGood); +} + +- (void)testPersistentCacheIsGloballyBoundedAndEvictsLeastRecentlyUsedEntry { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + + for (NSUInteger index = 0; index < 64; index++) { + NSString *contextKey = [NSString stringWithFormat:@"ctx-%lu", (unsigned long)index]; + [self.manager obtainRemoteConfigWithContextKey:contextKey + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletion(QONTestRemoteConfig(contextKey, contextKey, contextKey), nil); + } + + // Restart and read ctx-0 from disk to make it most-recently-used. + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + __block QONRemoteConfig *touchedConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx-0" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + touchedConfig = config; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]); + XCTAssertEqualObjects(touchedConfig.source.identifier, @"ctx-0"); + + [self.manager obtainRemoteConfigWithContextKey:@"ctx-64" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletion(QONTestRemoteConfig(@"ctx-64", @"ctx-64", @"ctx-64"), nil); + + NSDictionary *root = [storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]; + XCTAssertEqual([root[@"entries"] count], 64); + + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + __block QONRemoteConfig *evictedConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx-1" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + evictedConfig = config; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]); + XCTAssertNil(evictedConfig); + + __block QONRemoteConfig *retainedConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx-0" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + retainedConfig = config; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]); + XCTAssertEqualObjects(retainedConfig.source.identifier, @"ctx-0"); +} + +- (void)testPersistentCacheLargeBatchUsesOnePassByteBudgetAndKeepsNewestSuffix { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QONRemoteConfigManagerSerializationHarness *manager = + [[QONRemoteConfigManagerSerializationHarness alloc] initWithLocalStorage:storage]; + NSString *nearLimitValue = [@"x" stringByPaddingToLength:480 * 1024 + withString:@"x" + startingAtIndex:0]; + NSMutableArray *entries = [NSMutableArray new]; + for (NSUInteger index = 0; index < 64; index++) { + [entries addObject:@{ + @"project_key": @"project-a", + @"effective_api_key": @"project-a", + @"environment": @"production", + @"user_id": @"user-a", + @"context_key": [NSString stringWithFormat:@"ctx-%lu", (unsigned long)index], + @"config": @{ + @"payload": @{ @"value": nearLimitValue }, + @"source": @{ + @"identifier": @"source", + @"name": @"source", + @"type": @(QONRemoteConfigurationSourceTypeRemoteConfiguration), + @"assignment_type": @(QONRemoteConfigurationAssignmentTypeAuto), + @"context_key": [NSString stringWithFormat:@"ctx-%lu", (unsigned long)index], + }, + @"experiment": [NSNull null], + }, + }]; + } + + [manager storePersistentLKGEntries:entries]; + + NSDictionary *root = [storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]; + NSArray *storedEntries = root[@"entries"]; + XCTAssertEqual(storedEntries.count, 1); + XCTAssertEqualObjects(storedEntries.firstObject[@"context_key"], @"ctx-63", + @"byte eviction must keep the deterministic newest suffix"); + XCTAssertGreaterThan(manager.serializationCount, 0); + XCTAssertLessThanOrEqual(manager.serializationCount, 66, + @"64 entries require at most one serialization each, plus empty and final roots"); +} + +- (void)testPersistentCacheRejectsSingleEntryAboveByteQuotaWithoutEvictingValidLKG { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + [self.manager obtainRemoteConfigWithContextKey:@"valid" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletion(QONTestRemoteConfig(@"valid", @"valid", @"small"), nil); + + NSString *oversizedValue = [@"x" stringByPaddingToLength:600 * 1024 withString:@"x" startingAtIndex:0]; + [self.manager obtainRemoteConfigWithContextKey:@"oversized" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletion(QONTestRemoteConfig(@"oversized", @"oversized", oversizedValue), nil); + NSDictionary *root = [storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]; + XCTAssertEqual([root[@"entries"] count], 1); + XCTAssertEqualObjects(root[@"entries"][0][@"context_key"], @"valid"); +} + +- (void)testPersistentScopeSeparatesProductionAndSandboxEffectiveAPIKeys { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + apiClient.debug = NO; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletion(QONTestRemoteConfig(@"production", @"ctx", @"production"), nil); + + NSDictionary *root = [storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]; + NSDictionary *productionEntry = root[@"entries"][0]; + XCTAssertEqualObjects(productionEntry[@"environment"], @"production"); + XCTAssertEqualObjects(productionEntry[@"effective_api_key"], @"project-a"); + + apiClient.debug = YES; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + __block QONRemoteConfig *sandboxConfig = nil; + __block NSError *sandboxError = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + sandboxConfig = config; + sandboxError = error; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]); + XCTAssertNil(sandboxConfig, @"sandbox must never receive production LKG for the same raw project key"); + XCTAssertNotNil(sandboxError); +} + +- (void)testPersistentLKGAcceptsKnownFrozenAndExperimentEnumValues { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletion(QONTestFrozenExperimentRemoteConfig(@"frozen", @"ctx"), nil); + + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + __block QONRemoteConfig *diskConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + diskConfig = config; + }]; + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCannotConnectToHost userInfo:nil]); + XCTAssertEqual(diskConfig.source.assignmentType, QONRemoteConfigurationAssignmentTypeFrozen); + XCTAssertEqual(diskConfig.source.type, QONRemoteConfigurationSourceTypeExperimentControlGroup); + XCTAssertEqual(diskConfig.experiment.group.type, QONExperimentGroupTypeControl); +} + +- (void)testPersistentLKGRejectsUnknownSemanticEnumValuesAndClearsArchive { + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + + // Keep one matching OCMock stub for the whole table-driven test. Adding the + // same stub inside the loop makes OCMock keep invoking the first iteration's + // block, leaving the current serviceCompletion nil and crashing the test host. + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + + NSArray *corruptionKinds = @[@"source", @"assignment", @"group"]; + for (NSString *corruptionKind in corruptionKinds) { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + serviceCompletion = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + XCTAssertNotNil(serviceCompletion); + if (!serviceCompletion) { + continue; + } + serviceCompletion(QONTestFrozenExperimentRemoteConfig(@"frozen", @"ctx"), nil); + + NSMutableDictionary *root = [[storage loadObjectForKey:kTestRemoteConfigLKGStorageKey] mutableCopy]; + NSMutableArray *entries = [root[@"entries"] mutableCopy]; + NSMutableDictionary *entry = [entries[0] mutableCopy]; + NSMutableDictionary *storedConfig = [entry[@"config"] mutableCopy]; + if ([corruptionKind isEqualToString:@"group"]) { + NSMutableDictionary *experiment = [storedConfig[@"experiment"] mutableCopy]; + NSMutableDictionary *group = [experiment[@"group"] mutableCopy]; + group[@"type"] = @999; + experiment[@"group"] = group; + storedConfig[@"experiment"] = experiment; + } else { + NSMutableDictionary *source = [storedConfig[@"source"] mutableCopy]; + source[[corruptionKind isEqualToString:@"source"] ? @"type" : @"assignment_type"] = @999; + storedConfig[@"source"] = source; + } + entry[@"config"] = storedConfig; + entries[0] = entry; + root[@"entries"] = entries; + [storage storeObject:root forKey:kTestRemoteConfigLKGStorageKey]; + + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + __block QONRemoteConfig *deliveredConfig = nil; + __block NSError *deliveredError = nil; + serviceCompletion = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + deliveredConfig = config; + deliveredError = error; + }]; + XCTAssertNotNil(serviceCompletion); + if (!serviceCompletion) { + continue; + } + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCannotConnectToHost userInfo:nil]); + XCTAssertNil(deliveredConfig, @"unknown %@ enum must never be served from disk", corruptionKind); + XCTAssertNotNil(deliveredError); + XCTAssertNil([storage loadObjectForKey:kTestRemoteConfigLKGStorageKey], @"semantic corruption must clear the archive"); + } +} + +- (void)testPersistentLKGRejectsFractionalAndBooleanEnumValuesAndClearsArchive { + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + + // The callback stub must be shared across iterations for the same reason as + // the semantic-enum table above: duplicate matching stubs retain stale block + // storage and turn a normal assertion failure into a test-host crash. + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + + NSArray *corruptions = @[ + @{ @"kind": @"source", @"value": @0.5 }, + @{ @"kind": @"assignment", @"value": @YES }, + @{ @"kind": @"group", @"value": @0.5 }, + ]; + for (NSDictionary *corruption in corruptions) { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + serviceCompletion = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + XCTAssertNotNil(serviceCompletion); + if (!serviceCompletion) { + continue; + } + serviceCompletion(QONTestFrozenExperimentRemoteConfig(@"frozen", @"ctx"), nil); + + NSMutableDictionary *root = [[storage loadObjectForKey:kTestRemoteConfigLKGStorageKey] mutableCopy]; + NSMutableArray *entries = [root[@"entries"] mutableCopy]; + NSMutableDictionary *entry = [entries[0] mutableCopy]; + NSMutableDictionary *storedConfig = [entry[@"config"] mutableCopy]; + NSString *kind = corruption[@"kind"]; + if ([kind isEqualToString:@"group"]) { + NSMutableDictionary *experiment = [storedConfig[@"experiment"] mutableCopy]; + NSMutableDictionary *group = [experiment[@"group"] mutableCopy]; + group[@"type"] = corruption[@"value"]; + experiment[@"group"] = group; + storedConfig[@"experiment"] = experiment; + } else { + NSMutableDictionary *source = [storedConfig[@"source"] mutableCopy]; + source[[kind isEqualToString:@"source"] ? @"type" : @"assignment_type"] = corruption[@"value"]; + storedConfig[@"source"] = source; + } + entry[@"config"] = storedConfig; + entries[0] = entry; + root[@"entries"] = entries; + [storage storeObject:root forKey:kTestRemoteConfigLKGStorageKey]; + + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + __block QONRemoteConfig *deliveredConfig = nil; + __block NSError *deliveredError = nil; + serviceCompletion = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + deliveredConfig = config; + deliveredError = error; + }]; + XCTAssertNotNil(serviceCompletion); + if (!serviceCompletion) { + continue; + } + serviceCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCannotConnectToHost userInfo:nil]); + + XCTAssertNil(deliveredConfig, @"non-integral %@ enum must never be served from disk", kind); + XCTAssertNotNil(deliveredError); + XCTAssertNil([storage loadObjectForKey:kTestRemoteConfigLKGStorageKey], + @"non-integral semantic corruption must clear the archive"); + } +} + +- (void)testSupersededSingleResponseDoesNotReachDiskBeforeFreshGeneration { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block NSMutableArray *serviceCompletions = [NSMutableArray new]; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + [serviceCompletions addObject:[completion copy]]; + }); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + [self.manager invalidateRemoteConfigsCache]; + serviceCompletions[0](QONTestRemoteConfig(@"stale", @"ctx", @"stale"), nil); + XCTAssertNil([storage loadObjectForKey:kTestRemoteConfigLKGStorageKey], @"superseded generation must not become restart LKG"); + XCTAssertEqual(serviceCompletions.count, 2); + + serviceCompletions[1](QONTestRemoteConfig(@"fresh", @"ctx", @"fresh"), nil); + XCTAssertNotNil([storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]); +} + +- (void)testSupersededListResponseDoesNotReachDisk { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigListCompletionHandler listCompletion = nil; + OCMStub([self.mockService loadRemoteConfigList:[OCMArg any] includeEmptyContextKey:NO completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigListCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:4]; + listCompletion = [completion copy]; + }); + [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] includeEmptyContextKey:NO completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) {}]; + [self.manager invalidateRemoteConfigsCache]; + QONRemoteConfigList *staleList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:@[QONTestRemoteConfig(@"stale", @"ctx", @"stale")]]; + listCompletion(staleList, nil); + XCTAssertNil([storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]); +} + +- (void)testOversizedServerListEntryKeepsOlderSameKeyLKG { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler singleCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + singleCompletion = [completion copy]; + }); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + singleCompletion(QONTestRemoteConfig(@"old-valid", @"ctx", @"old"), nil); + + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + __block QONRemoteConfigListCompletionHandler listCompletion = nil; + OCMStub([self.mockService loadRemoteConfigList:[OCMArg any] includeEmptyContextKey:NO completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigListCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:4]; + listCompletion = [completion copy]; + }); + NSString *oversizedValue = [@"x" stringByPaddingToLength:600 * 1024 withString:@"x" startingAtIndex:0]; + [self.manager obtainRemoteConfigListWithContextKeys:@[@"ctx"] includeEmptyContextKey:NO completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) {}]; + listCompletion([[QONRemoteConfigList alloc] initWithRemoteConfigs:@[QONTestRemoteConfig(@"too-large", @"ctx", oversizedValue)]], nil); + + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + __block QONRemoteConfig *fallbackConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + fallbackConfig = config; + }]; + singleCompletion(nil, [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNotConnectedToInternet userInfo:nil]); + XCTAssertEqualObjects(fallbackConfig.source.identifier, @"old-valid"); +} + +- (void)testInternalResponseFailureUsesDiskLKG { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler serviceCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + serviceCompletion = [completion copy]; + }); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + serviceCompletion(QONTestRemoteConfig(@"disk", @"ctx", @"disk"), nil); + + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + __block QONRemoteConfig *fallbackConfig = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + fallbackConfig = config; + }]; + serviceCompletion(nil, [QONErrors internalErrorWithCode:QONErrorCodeResponseParsingFailed]); + XCTAssertEqualObjects(fallbackConfig.source.identifier, @"disk"); + XCTAssertEqual(self.manager.lastDeliveryOrigin, QONRemoteConfigDeliveryOriginDiskLastKnownGood); +} + +- (void)testRemoteConfigServiceMapsNoSourceToNotAvailableAndMalformedSuccessToInternalError { + QONRemoteConfigService *service = [QONRemoteConfigService new]; + id apiClientMock = OCMClassMock([QNAPIClient class]); + service.apiClient = apiClientMock; + __block QNAPIClientDictCompletionHandler apiCompletion = nil; + OCMStub([apiClientMock loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNAPIClientDictCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + apiCompletion = [completion copy]; + }); + + __block NSError *emptyError = nil; + [service loadRemoteConfig:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + emptyError = error; + }]; + apiCompletion(@{}, nil); + XCTAssertEqual(emptyError.code, QONErrorCodeRemoteConfigurationNotAvailable); + + __block NSError *noSourceError = nil; + [service loadRemoteConfig:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + noSourceError = error; + }]; + apiCompletion(@{ @"payload": @{} }, nil); + XCTAssertEqual(noSourceError.code, QONErrorCodeRemoteConfigurationNotAvailable); + + __block NSError *malformedSourceError = nil; + [service loadRemoteConfig:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + malformedSourceError = error; + }]; + apiCompletion(@{ @"payload": @{}, @"source": @{} }, nil); + XCTAssertEqual(malformedSourceError.code, QONErrorCodeInternalError); + + __block NSError *malformedError = nil; + [service loadRemoteConfig:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + malformedError = error; + }]; + apiCompletion(@{ + @"payload": @"not-a-dictionary", + @"source": @{ + @"uid": @"source", + @"name": @"source", + @"type": @"remote_configuration", + @"assignment_type": @"auto", + }, + }, nil); + XCTAssertEqual(malformedError.code, QONErrorCodeInternalError); + + __block NSError *invalidContextTypeError = nil; + [service loadRemoteConfig:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + invalidContextTypeError = error; + }]; + apiCompletion(@{ + @"payload": @{}, + @"source": @{ + @"uid": @"source", + @"name": @"source", + @"type": @"remote_configuration", + @"assignment_type": @"auto", + @"context_key": @42, + }, + }, nil); + XCTAssertEqual(invalidContextTypeError.code, QONErrorCodeInternalError); + + __block NSError *malformedExperimentError = nil; + [service loadRemoteConfig:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + malformedExperimentError = error; + }]; + apiCompletion(@{ + @"payload": @{}, + @"source": @{ + @"uid": @"source", + @"name": @"source", + @"type": @"remote_configuration", + @"assignment_type": @"auto", + }, + @"experiment": @{ + @"uid": @42, + @"name": @"experiment", + @"group": @{ + @"uid": @"group", + @"name": @"group", + @"type": @"control", + }, + }, + }, nil); + XCTAssertEqual(malformedExperimentError.code, QONErrorCodeInternalError); + [apiClientMock stopMocking]; +} + +- (void)testServiceNoConfigResponseRemovesManagerDiskLKGAndSurfacesNotAvailable { + QNInMemoryStorage *storage = [QNInMemoryStorage new]; + QNAPIClient *apiClient = [QNAPIClient new]; + apiClient.apiKey = @"project-a"; + apiClient.userID = @"user-a"; + [self usePersistentManagerWithStorage:storage apiClient:apiClient]; + + __block QONRemoteConfigCompletionHandler seedCompletion = nil; + OCMStub([self.mockService loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONRemoteConfigCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + seedCompletion = [completion copy]; + }); + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) {}]; + seedCompletion(QONTestRemoteConfig(@"stale", @"ctx", @"stale"), nil); + XCTAssertNotNil([storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]); + + id apiClientMock = OCMPartialMock(apiClient); + QONRemoteConfigService *service = [QONRemoteConfigService new]; + service.apiClient = apiClientMock; + __block QNAPIClientDictCompletionHandler apiCompletion = nil; + OCMStub([apiClientMock loadRemoteConfig:@"ctx" completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNAPIClientDictCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + apiCompletion = [completion copy]; + }); + + self.manager = [[QONRemoteConfigManager alloc] initWithLocalStorage:storage]; + self.manager.remoteConfigService = service; + self.manager.productCenterManager = self.mockProductCenterManager; + self.manager.userPropertiesManager = self.mockUserPropertiesManager; + self.manager.fallbackService = self.mockFallbackService; + + __block QONRemoteConfig *deliveredConfig = nil; + __block NSError *deliveredError = nil; + [self.manager obtainRemoteConfigWithContextKey:@"ctx" + completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + deliveredConfig = config; + deliveredError = error; + }]; + apiCompletion(@{}, nil); + + XCTAssertNil(deliveredConfig); + XCTAssertEqual(deliveredError.code, QONErrorCodeRemoteConfigurationNotAvailable); + XCTAssertNil([storage loadObjectForKey:kTestRemoteConfigLKGStorageKey]); + [apiClientMock stopMocking]; +} + +- (void)testRemoteConfigServiceRejectsSingleContextMismatch { + QONRemoteConfigService *service = [QONRemoteConfigService new]; + id apiClientMock = OCMClassMock([QNAPIClient class]); + service.apiClient = apiClientMock; + __block QNAPIClientDictCompletionHandler apiCompletion = nil; + OCMStub([apiClientMock loadRemoteConfig:@"requested" completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNAPIClientDictCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + apiCompletion = [completion copy]; + }); + + __block QONRemoteConfig *deliveredConfig = nil; + __block NSError *deliveredError = nil; + [service loadRemoteConfig:@"requested" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + deliveredConfig = config; + deliveredError = error; + }]; + apiCompletion(QONTestRemoteConfigResponse(@"unexpected", @"other"), nil); + + XCTAssertNil(deliveredConfig); + XCTAssertEqual(deliveredError.code, QONErrorCodeInternalError); + [apiClientMock stopMocking]; +} + +- (void)testRemoteConfigServiceRejectsUnexpectedFilteredListContext { + QONRemoteConfigService *service = [QONRemoteConfigService new]; + id apiClientMock = OCMClassMock([QNAPIClient class]); + service.apiClient = apiClientMock; + __block QNAPIClientArrayCompletionHandler apiCompletion = nil; + OCMStub([apiClientMock loadRemoteConfigListForContextKeys:@[@"requested"] includeEmptyContextKey:NO completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNAPIClientArrayCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:4]; + apiCompletion = [completion copy]; + }); + + __block QONRemoteConfigList *deliveredList = nil; + __block NSError *deliveredError = nil; + [service loadRemoteConfigList:@[@"requested"] includeEmptyContextKey:NO completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveredList = list; + deliveredError = error; + }]; + apiCompletion(@[QONTestRemoteConfigResponse(@"unexpected", @"other")], nil); + + XCTAssertNil(deliveredList); + XCTAssertEqual(deliveredError.code, QONErrorCodeInternalError); + [apiClientMock stopMocking]; +} + +- (void)testRemoteConfigServiceRejectsDuplicateFilteredListContexts { + QONRemoteConfigService *service = [QONRemoteConfigService new]; + id apiClientMock = OCMClassMock([QNAPIClient class]); + service.apiClient = apiClientMock; + __block QNAPIClientArrayCompletionHandler apiCompletion = nil; + OCMStub([apiClientMock loadRemoteConfigListForContextKeys:@[@"requested"] includeEmptyContextKey:NO completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNAPIClientArrayCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:4]; + apiCompletion = [completion copy]; + }); + + __block NSError *deliveredError = nil; + [service loadRemoteConfigList:@[@"requested"] includeEmptyContextKey:NO completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveredError = error; + }]; + apiCompletion(@[ + QONTestRemoteConfigResponse(@"first", @"requested"), + QONTestRemoteConfigResponse(@"second", @"requested"), + ], nil); + + XCTAssertEqual(deliveredError.code, QONErrorCodeInternalError); + [apiClientMock stopMocking]; +} + +- (void)testRemoteConfigServiceRejectsDuplicateFullListContexts { + QONRemoteConfigService *service = [QONRemoteConfigService new]; + id apiClientMock = OCMClassMock([QNAPIClient class]); + service.apiClient = apiClientMock; + __block QNAPIClientArrayCompletionHandler apiCompletion = nil; + OCMStub([apiClientMock loadRemoteConfigList:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNAPIClientArrayCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:2]; + apiCompletion = [completion copy]; + }); + + __block NSError *deliveredError = nil; + [service loadRemoteConfigList:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveredError = error; + }]; + apiCompletion(@[ + QONTestRemoteConfigResponse(@"first", @"duplicate"), + QONTestRemoteConfigResponse(@"second", @"duplicate"), + ], nil); + + XCTAssertEqual(deliveredError.code, QONErrorCodeInternalError); + [apiClientMock stopMocking]; +} + +- (void)testRemoteConfigServiceAcceptsUniqueFilteredSubsetAndOptionalEmptyContext { + QONRemoteConfigService *service = [QONRemoteConfigService new]; + id apiClientMock = OCMClassMock([QNAPIClient class]); + service.apiClient = apiClientMock; + __block QNAPIClientArrayCompletionHandler apiCompletion = nil; + OCMStub(([apiClientMock loadRemoteConfigListForContextKeys:@[@"first", @"omitted"] includeEmptyContextKey:YES completion:[OCMArg any]])).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNAPIClientArrayCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:4]; + apiCompletion = [completion copy]; + }); + + __block QONRemoteConfigList *deliveredList = nil; + __block NSError *deliveredError = nil; + [service loadRemoteConfigList:@[@"first", @"omitted"] includeEmptyContextKey:YES completion:^(QONRemoteConfigList * _Nullable list, NSError * _Nullable error) { + deliveredList = list; + deliveredError = error; + }]; + apiCompletion(@[ + QONTestRemoteConfigResponse(@"first", @"first"), + QONTestRemoteConfigResponse(@"empty", @""), + ], nil); + + XCTAssertEqual(deliveredList.remoteConfigs.count, 2); + XCTAssertNil(deliveredError); + [apiClientMock stopMocking]; +} + +- (void)testFrozenAssignmentTypeIsPubliclyMappedDescribedAndUnknownRemainsForwardCompatible { + QONRemoteConfigurationAssignmentType frozenType = QONRemoteConfigurationAssignmentTypeFrozen; + XCTAssertEqual(frozenType, 2); + XCTAssertEqual(QONRemoteConfigurationAssignmentTypeManual, 1); + + QONRemoteConfigMapper *mapper = [QONRemoteConfigMapper new]; + NSDictionary *baseSource = @{ + @"uid": @"source", + @"name": @"source", + @"type": @"remote_configuration", + }; + NSMutableDictionary *frozenSource = [baseSource mutableCopy]; + frozenSource[@"assignment_type"] = @"frozen"; + QONRemoteConfig *frozenConfig = [mapper mapRemoteConfig:@{@"payload": @{}, @"source": frozenSource}]; + XCTAssertEqual(frozenConfig.source.assignmentType, QONRemoteConfigurationAssignmentTypeFrozen); + XCTAssertTrue([frozenConfig.source.description containsString:@"assignmentType=frozen"]); + + NSMutableDictionary *futureSource = [baseSource mutableCopy]; + futureSource[@"assignment_type"] = @"future_server_value"; + QONRemoteConfig *futureConfig = [mapper mapRemoteConfig:@{@"payload": @{}, @"source": futureSource}]; + XCTAssertEqual(futureConfig.source.assignmentType, QONRemoteConfigurationAssignmentTypeUnknown); +} + +- (void)testRemoteConfigServiceAcceptsFrozenAndRejectsUnknownFutureAssignment { + QONRemoteConfigService *service = [QONRemoteConfigService new]; + id apiClientMock = OCMClassMock([QNAPIClient class]); + service.apiClient = apiClientMock; + __block QNAPIClientDictCompletionHandler apiCompletion = nil; + OCMStub([apiClientMock loadRemoteConfig:[OCMArg any] completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNAPIClientDictCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + apiCompletion = [completion copy]; + }); + NSDictionary *source = @{ + @"uid": @"source", + @"name": @"source", + @"type": @"remote_configuration", + @"context_key": @"ctx", + }; + + __block QONRemoteConfig *frozenConfig = nil; + __block NSError *frozenError = nil; + [service loadRemoteConfig:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + frozenConfig = config; + frozenError = error; + }]; + NSMutableDictionary *frozenSource = [source mutableCopy]; + frozenSource[@"assignment_type"] = @"frozen"; + apiCompletion(@{ @"payload": @{}, @"source": frozenSource }, nil); + XCTAssertEqual(frozenConfig.source.assignmentType, QONRemoteConfigurationAssignmentTypeFrozen); + XCTAssertNil(frozenError); + + __block QONRemoteConfig *futureConfig = nil; + __block NSError *futureError = nil; + [service loadRemoteConfig:@"ctx" completion:^(QONRemoteConfig * _Nullable config, NSError * _Nullable error) { + futureConfig = config; + futureError = error; + }]; + NSMutableDictionary *futureSource = [source mutableCopy]; + futureSource[@"assignment_type"] = @"future_server_value"; + apiCompletion(@{ @"payload": @{}, @"source": futureSource }, nil); + XCTAssertNil(futureConfig); + XCTAssertEqual(futureError.code, QONErrorCodeInternalError); + [apiClientMock stopMocking]; +} + +- (void)testTransientRemoteConfigErrorsIncludeTimeoutConnectionLossAndServerFailures { + XCTAssertTrue([[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorTimedOut userInfo:nil] shouldFireFallback]); + XCTAssertTrue([[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNetworkConnectionLost userInfo:nil] shouldFireFallback]); + XCTAssertTrue([[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCannotConnectToHost userInfo:nil] shouldFireFallback]); + XCTAssertTrue([[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCannotFindHost userInfo:nil] shouldFireFallback]); + XCTAssertTrue([[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorDNSLookupFailed userInfo:nil] shouldFireFallback]); + XCTAssertTrue([[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCallIsActive userInfo:nil] shouldFireFallback]); + XCTAssertTrue([[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorDataNotAllowed userInfo:nil] shouldFireFallback]); + XCTAssertTrue([[NSError errorWithDomain:QonversionErrorDomain code:503 userInfo:nil] shouldFireFallback]); + XCTAssertTrue([[NSError errorWithDomain:QonversionErrorDomain code:QONErrorCodeInternalError userInfo:nil] shouldFireFallback]); + XCTAssertFalse([[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCancelled userInfo:nil] shouldFireFallback]); + XCTAssertFalse([[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorBadURL userInfo:nil] shouldFireFallback]); + XCTAssertFalse([[NSError errorWithDomain:QonversionErrorDomain code:401 userInfo:nil] shouldFireFallback]); + XCTAssertFalse([[NSError errorWithDomain:QonversionErrorDomain code:404 userInfo:nil] shouldFireFallback]); + XCTAssertFalse([[NSError errorWithDomain:NSPOSIXErrorDomain code:503 userInfo:nil] shouldFireFallback]); } @end diff --git a/QonversionTests/ProductCenterManagerIdentifyContractTests.m b/QonversionTests/ProductCenterManagerIdentifyContractTests.m index 3a94c2a8..62a084f4 100644 --- a/QonversionTests/ProductCenterManagerIdentifyContractTests.m +++ b/QonversionTests/ProductCenterManagerIdentifyContractTests.m @@ -128,11 +128,40 @@ - (void)testProcessIdentity_DifferentUid_ResetsCacheAndLaunchesWithIdentifyTrigg [_partialManagerMock processIdentity:identityId]; // Then - OCMVerify([_mockClient setUserID:mergedUid]); - OCMVerify([_mockRemoteConfigManager userHasBeenChanged]); + OCMVerify([_mockRemoteConfigManager userHasBeenChangedToUserID:mergedUid]); + OCMReject([_mockClient setUserID:[OCMArg any]]); OCMVerifyAll(_partialManagerMock); // resetActualPermissionsCache + launchWithTrigger } +- (void)testProcessIdentity_DifferentUid_RemainsUnstableUntilRemoteConfigUserTransition { + NSString *identityId = @"user@example.com"; + NSString *currentUid = @"uid_initial"; + NSString *mergedUid = @"uid_merged_999"; + + OCMStub([_mockUserInfoService obtainUserID]).andReturn(currentUid); + OCMStub([_mockIdentityManager identify:identityId completion:[OCMArg invokeBlockWithArgs:mergedUid, [NSNull null], nil]]); + OCMStub([_partialManagerMock resetActualPermissionsCache]); + OCMStub([_partialManagerMock launchWithTrigger:QONRequestTriggerIdentify completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QONLaunchCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + completion([QONLaunchResult new], nil); + }); + + _manager.launchingFinished = YES; + _manager.identityInProgress = YES; + + __block BOOL stableDuringUserTransition = YES; + OCMStub([_mockRemoteConfigManager userHasBeenChangedToUserID:mergedUid]).andDo(^(NSInvocation *invocation) { + stableDuringUserTransition = [self.manager isUserStable]; + }); + + [_partialManagerMock processIdentity:identityId]; + + XCTAssertFalse(stableDuringUserTransition, + @"the new RC scope must replace the old scope before callers can observe a stable identity"); + XCTAssertTrue([_manager isUserStable]); +} + /* * Same-uid case: the user "identifies" with what is effectively the same * Qonversion uid (e.g. host app calls identify again with no real switch). @@ -173,6 +202,7 @@ - (void)testProcessIdentity_IdentityError_DoesNotResetCacheOrRelaunch { OCMReject([_partialManagerMock resetActualPermissionsCache]); OCMReject([_partialManagerMock launchWithTrigger:QONRequestTriggerIdentify completion:[OCMArg any]]); OCMReject([_mockRemoteConfigManager userHasBeenChanged]); + OCMReject([_mockRemoteConfigManager userHasBeenChangedToUserID:[OCMArg any]]); OCMReject([_mockClient setUserID:[OCMArg any]]); // When diff --git a/QonversionTests/ProductCenterManagerRestoreUserSwitchTests.m b/QonversionTests/ProductCenterManagerRestoreUserSwitchTests.m index 8993ae75..677d3f40 100644 --- a/QonversionTests/ProductCenterManagerRestoreUserSwitchTests.m +++ b/QonversionTests/ProductCenterManagerRestoreUserSwitchTests.m @@ -13,6 +13,7 @@ #import "QONFallbackService.h" #import "QONRemoteConfigManager.h" #import "QONRequestTrigger.h" +#import "Helpers/XCTestCase+TestJSON.h" @interface QNProductCenterManager (RestoreTestPrivate) @@ -21,12 +22,20 @@ @interface QNProductCenterManager (RestoreTestPrivate) @property (nonatomic) QNAPIClient *apiClient; @property (nonatomic) QONLaunchResult *launchResult; @property (nonatomic) NSError *launchError; +@property (nonatomic) QONUser *user; @property (nonatomic, assign) BOOL launchingFinished; @property (nonatomic, assign) BOOL receiptRestoreInProgress; @property (nonatomic, assign) BOOL restoreInProgress; @property (nonatomic, assign) BOOL awaitingRestoreResult; +@property (nonatomic, assign) BOOL unhandledLogoutAvailable; +@property (nonatomic, strong) NSRecursiveLock *identityMutationLock; - (void)handleUserSwitchIfNeededWithResult:(QONLaunchResult *)result; +- (void)restoreReceipt:(QNRestoreCompletionHandler)completion; +- (void)restoreTransactions:(QNRestoreCompletionHandler)completion; +- (void)handleRestoreCompletedTransactionsFinished; +- (void)handleRestoreCompletedTransactionsFailed:(NSError *)error; +- (void)actualizeEntitlements:(QONEntitlementsCompletionHandler)completion; @end @@ -34,11 +43,61 @@ @interface ProductCenterManagerRestoreUserSwitchTests : XCTestCase @property (nonatomic, strong) id mockClient; @property (nonatomic, strong) id mockUserInfoService; +@property (nonatomic, strong) id mockIdentityManager; @property (nonatomic, strong) id mockRemoteConfigManager; +@property (nonatomic, strong) id mockStoreKitService; @property (nonatomic, strong) QNProductCenterManager *manager; @end +@interface QNRestoreTrackingRecursiveLock : NSObject + +@property (nonatomic, strong) NSRecursiveLock *backingLock; +@property (nonatomic, strong) NSObject *metadataLock; +@property (nonatomic, strong, nullable) NSThread *ownerThread; +@property (nonatomic, assign) NSUInteger recursionDepth; + +- (BOOL)isHeldByCurrentThread; + +@end + +@implementation QNRestoreTrackingRecursiveLock + +- (instancetype)init { + self = [super init]; + if (self) { + _backingLock = [NSRecursiveLock new]; + _metadataLock = [NSObject new]; + } + return self; +} + +- (void)lock { + [self.backingLock lock]; + @synchronized (self.metadataLock) { + self.ownerThread = [NSThread currentThread]; + self.recursionDepth += 1; + } +} + +- (void)unlock { + @synchronized (self.metadataLock) { + self.recursionDepth -= 1; + if (self.recursionDepth == 0) { + self.ownerThread = nil; + } + } + [self.backingLock unlock]; +} + +- (BOOL)isHeldByCurrentThread { + @synchronized (self.metadataLock) { + return self.ownerThread == [NSThread currentThread] && self.recursionDepth > 0; + } +} + +@end + @implementation ProductCenterManagerRestoreUserSwitchTests - (void)setUp { @@ -46,15 +105,18 @@ - (void)setUp { OCMStub([_mockClient shared]).andReturn(_mockClient); _mockUserInfoService = OCMProtocolMock(@protocol(QNUserInfoServiceInterface)); - id mockIdentityManager = OCMClassMock([QNIdentityManager class]); + _mockIdentityManager = OCMClassMock([QNIdentityManager class]); id mockLocalStorage = OCMProtocolMock(@protocol(QNLocalStorage)); id mockFallbackService = OCMClassMock([QONFallbackService class]); _manager = [[QNProductCenterManager alloc] initWithUserInfoService:_mockUserInfoService - identityManager:mockIdentityManager + identityManager:_mockIdentityManager localStorage:mockLocalStorage fallbackService:mockFallbackService]; [_manager setApiClient:_mockClient]; + + _mockStoreKitService = OCMClassMock([QNStoreKitService class]); + _manager.storeKitService = _mockStoreKitService; _mockRemoteConfigManager = OCMClassMock([QONRemoteConfigManager class]); _manager.remoteConfigManager = _mockRemoteConfigManager; @@ -62,6 +124,9 @@ - (void)setUp { - (void)tearDown { [_mockClient stopMocking]; + [_mockIdentityManager stopMocking]; + [_mockStoreKitService stopMocking]; + [_mockRemoteConfigManager stopMocking]; _manager = nil; } @@ -78,6 +143,7 @@ - (void)testHandleUserSwitch_SameUid_NoSwitch { // Set up reject expectations before the action OCMReject([_mockUserInfoService storeIdentity:[OCMArg any]]); OCMReject([_mockRemoteConfigManager userHasBeenChanged]); + OCMReject([_mockRemoteConfigManager userHasBeenChangedToUserID:[OCMArg any]]); // When [_manager handleUserSwitchIfNeededWithResult:launchResult]; @@ -101,14 +167,346 @@ - (void)testHandleUserSwitch_DifferentUid_SwitchOccurs { // Then OCMVerify([_mockUserInfoService storeIdentity:originalUserId]); - OCMVerify([_mockClient setUserID:originalUserId]); - OCMVerify([_mockRemoteConfigManager userHasBeenChanged]); + OCMVerify([_mockRemoteConfigManager userHasBeenChangedToUserID:originalUserId]); + OCMReject([_mockClient setUserID:[OCMArg any]]); +} + +- (void)testHandleUserSwitch_CommitsStorageAndRemoteConfigScopeInsideMutationBoundary { + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"user_old"); + QONLaunchResult *launchResult = [[QONLaunchResult alloc] init]; + launchResult.uid = @"user_new"; + + QNRestoreTrackingRecursiveLock *mutationLock = [QNRestoreTrackingRecursiveLock new]; + _manager.identityMutationLock = (NSRecursiveLock *)mutationLock; + __block BOOL scopePublishedInsideMutationBoundary = NO; + OCMStub([_mockRemoteConfigManager userHasBeenChangedToUserID:@"user_new"]).andDo(^(NSInvocation *invocation) { + scopePublishedInsideMutationBoundary = [mutationLock isHeldByCurrentThread]; + }); + + [_manager handleUserSwitchIfNeededWithResult:launchResult]; + + XCTAssertTrue(scopePublishedInsideMutationBoundary, + @"storage and Remote Config scope must commit under one identity boundary"); +} + +- (void)testRestoreReceiptStartedBeforeLogoutCannotApplyLateUserScope { + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"user_initial"); + OCMStub([_mockIdentityManager logoutIfNeeded]).andReturn(NO); + + __block void (^launchCompletion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + OCMStub([_mockStoreKitService receipt:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained void (^receiptCompletion)(NSString *) = nil; + [invocation getArgument:&receiptCompletion atIndex:2]; + receiptCompletion(@"receipt"); + }); + OCMStub([_mockClient launchRequest:QONRequestTriggerRestore completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained void (^completion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + [invocation getArgument:&completion atIndex:3]; + launchCompletion = [completion copy]; + }); + + __block BOOL storedLateUser = NO; + __block BOOL publishedLateScope = NO; + OCMStub([_mockUserInfoService storeIdentity:@"qonversion_user_id"]).andDo(^(NSInvocation *invocation) { + storedLateUser = YES; + }); + OCMStub([_mockRemoteConfigManager userHasBeenChangedToUserID:@"qonversion_user_id"]).andDo(^(NSInvocation *invocation) { + publishedLateScope = YES; + }); + + XCTestExpectation *completionExpectation = [self expectationWithDescription:@"stale restore completes"]; + __block NSError *restoreError = nil; + [_manager restoreReceipt:^(NSDictionary *entitlements, NSError *error) { + restoreError = error; + [completionExpectation fulfill]; + }]; + XCTAssertNotNil(launchCompletion); + + [_manager logout]; + NSDictionary *response = [self JSONObjectFromContentsOfFile:keyQNInitFullSuccessJSON]; + launchCompletion(response, nil); + + [self waitForExpectationsWithTimeout:keyQNTestTimeout handler:nil]; + XCTAssertFalse(storedLateUser, @"a restore older than logout must not rewrite identity storage"); + XCTAssertFalse(publishedLateScope, @"a restore older than logout must not publish its Remote Config scope"); + XCTAssertNotEqualObjects(_manager.launchResult.uid, @"qonversion_user_id"); + XCTAssertEqual(restoreError.code, NSURLErrorCancelled); +} + +- (void)testRestoreTransactionsStartedBeforeLogoutCannotApplyLateUserScope { + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"user_initial"); + OCMStub([_mockIdentityManager logoutIfNeeded]).andReturn(NO); + + __block void (^launchCompletion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + OCMStub([_mockClient launchRequest:QONRequestTriggerSyncHistoricalData completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained void (^completion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + [invocation getArgument:&completion atIndex:3]; + launchCompletion = [completion copy]; + }); + + __block BOOL storedLateUser = NO; + __block BOOL publishedLateScope = NO; + OCMStub([_mockUserInfoService storeIdentity:@"qonversion_user_id"]).andDo(^(NSInvocation *invocation) { + storedLateUser = YES; + }); + OCMStub([_mockRemoteConfigManager userHasBeenChangedToUserID:@"qonversion_user_id"]).andDo(^(NSInvocation *invocation) { + publishedLateScope = YES; + }); + + XCTestExpectation *completionExpectation = [self expectationWithDescription:@"stale transaction restore completes"]; + __block NSError *restoreError = nil; + [_manager restoreTransactions:^(NSDictionary *entitlements, NSError *error) { + restoreError = error; + [completionExpectation fulfill]; + }]; + [_manager handleRestoreCompletedTransactionsFinished]; + XCTAssertNotNil(launchCompletion); + + [_manager logout]; + NSDictionary *response = [self JSONObjectFromContentsOfFile:keyQNInitFullSuccessJSON]; + launchCompletion(response, nil); + + [self waitForExpectationsWithTimeout:keyQNTestTimeout handler:nil]; + XCTAssertFalse(storedLateUser, @"a transaction restore older than logout must not rewrite identity storage"); + XCTAssertFalse(publishedLateScope, @"a transaction restore older than logout must not publish its Remote Config scope"); + XCTAssertNotEqualObjects(_manager.launchResult.uid, @"qonversion_user_id"); + XCTAssertEqual(restoreError.code, NSURLErrorCancelled); +} + +- (void)testOrdinaryLaunchResponseCannotOverwriteNewerRestoreScopeState { + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"user_initial"); + __block void (^launchCompletion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + OCMStub([_mockClient launchRequest:QONRequestTriggerActualizePermissions completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained void (^completion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + [invocation getArgument:&completion atIndex:3]; + launchCompletion = [completion copy]; + }); + + QONUser *sentinelUser = (QONUser *)[NSObject new]; + _manager.user = sentinelUser; + __block NSError *launchError = nil; + [_manager launch:QONRequestTriggerActualizePermissions completion:^(QONLaunchResult *result, NSError *error) { + launchError = error; + }]; + XCTAssertNotNil(launchCompletion); + + QONLaunchResult *restoreResult = [[QONLaunchResult alloc] init]; + restoreResult.uid = @"restored_user"; + [_manager handleUserSwitchIfNeededWithResult:restoreResult]; + + NSDictionary *oldScopeResponse = [self JSONObjectFromContentsOfFile:keyQNInitFullSuccessJSON]; + launchCompletion(oldScopeResponse, nil); + + XCTAssertEqual(_manager.user, sentinelUser, + @"an old ordinary launch must be rejected before mapper state is written"); + XCTAssertEqual(launchError.code, NSURLErrorCancelled); +} + +- (void)testStaleActualizeResponseCannotDisarmPendingLogoutRefresh { + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"user_initial"); + OCMStub([_mockIdentityManager logoutIfNeeded]).andReturn(YES); + NSDictionary *response = [self JSONObjectFromContentsOfFile:keyQNInitFullSuccessJSON]; + + __block void (^actualizeResponse)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + OCMStub([_mockClient launchRequest:QONRequestTriggerActualizePermissions completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained void (^completion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + [invocation getArgument:&completion atIndex:3]; + actualizeResponse = [completion copy]; + }); + __block BOOL logoutRefreshStarted = NO; + OCMStub([_mockClient launchRequest:QONRequestTriggerLogout completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + logoutRefreshStarted = YES; + __unsafe_unretained void (^completion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + [invocation getArgument:&completion atIndex:3]; + completion(response, nil); + }); + + XCTestExpectation *actualizeExpectation = [self expectationWithDescription:@"stale actualize completes"]; + __block NSError *actualizeError = nil; + [_manager actualizeEntitlements:^(NSDictionary *entitlements, NSError *error) { + actualizeError = error; + [actualizeExpectation fulfill]; + }]; + XCTAssertNotNil(actualizeResponse); + + [_manager logout]; + XCTAssertTrue(_manager.unhandledLogoutAvailable); + actualizeResponse(response, nil); + + [self waitForExpectationsWithTimeout:keyQNTestTimeout handler:nil]; + XCTAssertEqual(actualizeError.code, NSURLErrorCancelled); + XCTAssertTrue(logoutRefreshStarted, + @"the stale actualize callback must not clear the pending logout refresh"); + XCTAssertFalse(_manager.unhandledLogoutAvailable); +} + +- (void)testSupersededLaunchDrainsEveryLaunchDependentCallbackQueue { + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"user_initial"); + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockIdentityManager logoutIfNeeded]).andReturn(NO); + + __block void (^launchResponse)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + OCMStub([_mockClient launchRequest:QONRequestTriggerInit completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained void (^completion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + [invocation getArgument:&completion atIndex:3]; + launchResponse = [completion copy]; + }); + + __block NSUInteger userCallbacks = 0; + __block NSUInteger productCallbacks = 0; + __block NSUInteger offeringCallbacks = 0; + __block NSError *userError = nil; + __block NSError *productError = nil; + __block NSError *offeringError = nil; + [_manager launchWithTrigger:QONRequestTriggerInit completion:nil]; + [_manager userInfo:^(QONUser *user, NSError *error) { + userCallbacks += 1; + userError = error; + }]; + [_manager products:^(NSDictionary *products, NSError *error) { + productCallbacks += 1; + productError = error; + }]; + [_manager offerings:^(QONOfferings *offerings, NSError *error) { + offeringCallbacks += 1; + offeringError = error; + }]; + + [_manager logout]; + NSDictionary *response = [self JSONObjectFromContentsOfFile:keyQNInitFullSuccessJSON]; + launchResponse(response, nil); + + XCTAssertEqual(userCallbacks, 1); + XCTAssertEqual(productCallbacks, 1); + XCTAssertEqual(offeringCallbacks, 1); + XCTAssertEqual(userError.code, NSURLErrorCancelled); + XCTAssertEqual(productError.code, NSURLErrorCancelled); + XCTAssertEqual(offeringError.code, NSURLErrorCancelled); + XCTAssertTrue([_manager isUserStable]); +} + +- (void)testInactiveIdentityLaunchDrainsEveryWaiterExactlyOnceAsCancelled { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainCustomIdentityUserID]).andReturn(nil); + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"user_initial"); + OCMStub([_mockIdentityManager logoutIfNeeded]).andReturn(NO); + + __block QNIdentityCompletionHandler identityResponse = nil; + OCMStub([_mockIdentityManager identify:@"login@example.com" completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained QNIdentityCompletionHandler completion = nil; + [invocation getArgument:&completion atIndex:3]; + identityResponse = [completion copy]; + }); + + __block void (^launchResponse)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + OCMStub([_mockClient launchRequest:QONRequestTriggerIdentify completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained void (^completion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + [invocation getArgument:&completion atIndex:3]; + launchResponse = [completion copy]; + }); + + [_manager identify:@"login@example.com" completion:nil]; + XCTAssertNotNil(identityResponse); + identityResponse(@"user_after_identify", nil); + XCTAssertNotNil(launchResponse); + XCTAssertFalse(_manager.launchingFinished); + + XCTestExpectation *userExpectation = [self expectationWithDescription:@"user waiter cancelled"]; + XCTestExpectation *productsExpectation = [self expectationWithDescription:@"products waiter cancelled"]; + XCTestExpectation *offeringsExpectation = [self expectationWithDescription:@"offerings waiter cancelled"]; + __block NSUInteger userCallbacks = 0; + __block NSUInteger productCallbacks = 0; + __block NSUInteger offeringCallbacks = 0; + __block NSError *userError = nil; + __block NSError *productError = nil; + __block NSError *offeringError = nil; + [_manager userInfo:^(QONUser *user, NSError *error) { + userCallbacks += 1; + userError = error; + [userExpectation fulfill]; + }]; + [_manager products:^(NSDictionary *products, NSError *error) { + productCallbacks += 1; + productError = error; + [productsExpectation fulfill]; + }]; + [_manager offerings:^(QONOfferings *offerings, NSError *error) { + offeringCallbacks += 1; + offeringError = error; + [offeringsExpectation fulfill]; + }]; + + // This logout does not start a replacement launch, but it makes the + // in-flight identify request inactive. Its eventual response must therefore + // terminate every queue with cancellation instead of reporting false + // success or retaining callers forever. + [_manager logout]; + NSDictionary *response = [self JSONObjectFromContentsOfFile:keyQNInitFullSuccessJSON]; + launchResponse(response, nil); + + [self waitForExpectationsWithTimeout:keyQNTestTimeout handler:nil]; + XCTAssertEqual(userCallbacks, 1); + XCTAssertEqual(productCallbacks, 1); + XCTAssertEqual(offeringCallbacks, 1); + XCTAssertEqualObjects(userError.domain, NSURLErrorDomain); + XCTAssertEqualObjects(productError.domain, NSURLErrorDomain); + XCTAssertEqualObjects(offeringError.domain, NSURLErrorDomain); + XCTAssertEqual(userError.code, NSURLErrorCancelled); + XCTAssertEqual(productError.code, NSURLErrorCancelled); + XCTAssertEqual(offeringError.code, NSURLErrorCancelled); + XCTAssertTrue(_manager.launchingFinished); + XCTAssertTrue([_manager isUserStable]); + + // A duplicate terminal action from a broken transport must be harmless. + launchResponse(response, nil); + XCTAssertEqual(userCallbacks, 1); + XCTAssertEqual(productCallbacks, 1); + XCTAssertEqual(offeringCallbacks, 1); +} + +- (void)testReceiptRestoreMakesUserUnstableUntilItsTerminalDrain { + _manager.launchingFinished = YES; + OCMStub([_mockUserInfoService obtainUserID]).andReturn(@"qonversion_user_id"); + + __block void (^receiptResponse)(NSString *) = nil; + __block void (^launchResponse)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + OCMStub([_mockStoreKitService receipt:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained void (^completion)(NSString *) = nil; + [invocation getArgument:&completion atIndex:2]; + receiptResponse = [completion copy]; + }); + OCMStub([_mockClient launchRequest:QONRequestTriggerRestore completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained void (^completion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + [invocation getArgument:&completion atIndex:3]; + launchResponse = [completion copy]; + }); + + [_manager restoreReceipt:nil]; + XCTAssertFalse([_manager isUserStable]); + receiptResponse(@"receipt"); + XCTAssertFalse([_manager isUserStable]); + + NSDictionary *response = [self JSONObjectFromContentsOfFile:keyQNInitFullSuccessJSON]; + launchResponse(response, nil); + XCTAssertTrue([_manager isUserStable]); +} + +- (void)testTransactionRestoreMakesUserUnstableUntilItsTerminalDrain { + _manager.launchingFinished = YES; + + [_manager restoreTransactions:nil]; + XCTAssertFalse([_manager isUserStable]); + + NSError *restoreError = [NSError errorWithDomain:@"test" code:903 userInfo:nil]; + [_manager handleRestoreCompletedTransactionsFailed:restoreError]; + XCTAssertTrue([_manager isUserStable]); } - (void)testHandleUserSwitch_NilResult_NoSwitch { // Given - set up reject expectations before the action OCMReject([_mockUserInfoService storeIdentity:[OCMArg any]]); OCMReject([_mockRemoteConfigManager userHasBeenChanged]); + OCMReject([_mockRemoteConfigManager userHasBeenChangedToUserID:[OCMArg any]]); // When [_manager handleUserSwitchIfNeededWithResult:nil]; @@ -126,6 +524,7 @@ - (void)testHandleUserSwitch_EmptyUid_NoSwitch { // Set up reject expectations before the action OCMReject([_mockUserInfoService storeIdentity:[OCMArg any]]); OCMReject([_mockRemoteConfigManager userHasBeenChanged]); + OCMReject([_mockRemoteConfigManager userHasBeenChangedToUserID:[OCMArg any]]); // When [_manager handleUserSwitchIfNeededWithResult:launchResult]; diff --git a/QonversionTests/ProductCenterManagerTests.m b/QonversionTests/ProductCenterManagerTests.m index 87577e0a..14d5ebc7 100644 --- a/QonversionTests/ProductCenterManagerTests.m +++ b/QonversionTests/ProductCenterManagerTests.m @@ -22,6 +22,7 @@ @interface QNProductCenterManager (Private) @property (nonatomic, copy) NSMutableArray *entitlementsBlocks; @property (nonatomic, copy) NSMutableArray *productsBlocks; +@property (nonatomic, copy) NSMutableArray *userInfoBlocks; @property (nonatomic) QNAPIClient *apiClient; @property (nonatomic) QONLaunchResult *launchResult; @@ -29,11 +30,52 @@ @interface QNProductCenterManager (Private) @property (nonatomic, assign) BOOL launchingFinished; @property (nonatomic, assign) BOOL productsLoaded; - -@property (nonatomic, copy) NSString *pendingIdentityUserID; +@property (nonatomic, strong) NSRecursiveLock *identityMutationLock; - (void)checkEntitlements:(QONEntitlementsCompletionHandler)result; - (void)actualizeEntitlements:(QONEntitlementsCompletionHandler)completion; +- (void)executeUserBlocks; + +@end + +@interface QNLockOrderArray : NSMutableArray + +@property (nonatomic, strong) NSMutableArray *storage; +@property (nonatomic) dispatch_semaphore_t objectAdded; + +@end + +@implementation QNLockOrderArray + +- (instancetype)initWithObjectAddedSemaphore:(dispatch_semaphore_t)objectAdded { + self = [super init]; + if (self) { + _storage = [NSMutableArray new]; + _objectAdded = objectAdded; + } + return self; +} + +- (NSUInteger)count { + return self.storage.count; +} + +- (id)objectAtIndex:(NSUInteger)index { + return self.storage[index]; +} + +- (void)insertObject:(id)anObject atIndex:(NSUInteger)index { + [self.storage insertObject:anObject atIndex:index]; + dispatch_semaphore_signal(self.objectAdded); +} + +- (void)removeObjectAtIndex:(NSUInteger)index { + [self.storage removeObjectAtIndex:index]; +} + +- (id)copyWithZone:(NSZone *)zone { + return self; +} @end @@ -78,6 +120,50 @@ - (void)testThatProductCenterGetLaunchModel { [self waitForExpectationsWithTimeout:keyQNTestTimeout handler:nil]; } +- (void)testLaunchingFinishedWaitsForEveryConcurrentLaunchTicket { + __block void (^firstResponse)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + __block void (^secondResponse)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + OCMStub([_mockClient launchRequest:QONRequestTriggerInit completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained void (^completion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + [invocation getArgument:&completion atIndex:3]; + firstResponse = [completion copy]; + }); + OCMStub([_mockClient launchRequest:QONRequestTriggerProducts completion:[OCMArg any]]).andDo(^(NSInvocation *invocation) { + __unsafe_unretained void (^completion)(NSDictionary * _Nullable, NSError * _Nullable) = nil; + [invocation getArgument:&completion atIndex:3]; + secondResponse = [completion copy]; + }); + + [_manager launch:QONRequestTriggerInit completion:^(QONLaunchResult *result, NSError *error) {}]; + [_manager launch:QONRequestTriggerProducts completion:^(QONLaunchResult *result, NSError *error) {}]; + XCTAssertFalse(_manager.launchingFinished); + + NSDictionary *response = [self JSONObjectFromContentsOfFile:keyQNInitFullSuccessJSON]; + firstResponse(response, nil); + XCTAssertFalse(_manager.launchingFinished, + @"one response must not make the manager stable while another launch is in flight"); + + secondResponse(response, nil); + XCTAssertTrue(_manager.launchingFinished); +} + +- (void)testFinalLaunchTicketDrainsUserInfoQueuedAfterWrapperSnapshot { + NSDictionary *response = [self JSONObjectFromContentsOfFile:keyQNInitFullSuccessJSON]; + OCMStub([_mockClient launchRequest:QONRequestTriggerInit completion:([OCMArg invokeBlockWithArgs:response, [NSNull null], nil])]); + __block NSUInteger userCallbacks = 0; + + [_manager launchWithTrigger:QONRequestTriggerInit completion:^(QONLaunchResult *result, NSError *error) { + // The wrapper already performed its first userInfo snapshot, but the + // low-level launch ticket has not yet made launchingFinished true. + [self.manager userInfo:^(QONUser *user, NSError *userError) { + userCallbacks += 1; + }]; + }]; + + XCTAssertEqual(userCallbacks, 1); + XCTAssertTrue(_manager.launchingFinished); +} + - (void)testThatCheckPermissionStoreBlocksWhenLaunchingIsActive { // Given @@ -108,6 +194,67 @@ - (void)testThatCheckPermissionCallBlockWhenLaunchingFinished { [self waitForExpectationsWithTimeout:keyQNTestTimeout handler:nil]; } +- (void)testCheckEntitlementsDoesNotInvertIdentityAndCallbackLocks { + dispatch_semaphore_t mutationLockHeld = dispatch_semaphore_create(0); + dispatch_semaphore_t objectAdded = dispatch_semaphore_create(0); + dispatch_semaphore_t checkFinished = dispatch_semaphore_create(0); + dispatch_semaphore_t unlockFinished = dispatch_semaphore_create(0); + _manager.entitlementsBlocks = (NSMutableArray *)[[QNLockOrderArray alloc] + initWithObjectAddedSemaphore:objectAdded]; + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [self.manager.identityMutationLock lock]; + dispatch_semaphore_signal(mutationLockHeld); + dispatch_semaphore_wait(objectAdded, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)); + @synchronized (self.manager) { + [self.manager.identityMutationLock unlock]; + } + dispatch_semaphore_signal(unlockFinished); + }); + XCTAssertEqual(dispatch_semaphore_wait(mutationLockHeld, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [self.manager checkEntitlements:^(NSDictionary *result, NSError *error) {}]; + dispatch_semaphore_signal(checkFinished); + }); + + XCTAssertEqual(dispatch_semaphore_wait(unlockFinished, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0, + @"callback storage must release @synchronized(self) before waiting for identityMutationLock"); + XCTAssertEqual(dispatch_semaphore_wait(checkFinished, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); +} + +- (void)testExecuteUserBlocksDoesNotHoldManagerMonitorDuringExternalCallbacks { + dispatch_semaphore_t callbackEntered = dispatch_semaphore_create(0); + dispatch_semaphore_t allowMonitorAttempt = dispatch_semaphore_create(0); + dispatch_semaphore_t monitorAcquired = dispatch_semaphore_create(0); + dispatch_semaphore_t backgroundFinished = dispatch_semaphore_create(0); + __block BOOL monitorWasAvailableDuringCallback = NO; + + self.manager.userInfoBlocks = [@[^(QONUser *user, NSError *error) { + dispatch_semaphore_signal(callbackEntered); + dispatch_semaphore_signal(allowMonitorAttempt); + monitorWasAvailableDuringCallback = dispatch_semaphore_wait( + monitorAcquired, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)) == 0; + }] mutableCopy]; + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [self.manager.identityMutationLock lock]; + dispatch_semaphore_wait(callbackEntered, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)); + dispatch_semaphore_wait(allowMonitorAttempt, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)); + @synchronized (self.manager) { + [self.manager.identityMutationLock unlock]; + } + dispatch_semaphore_signal(monitorAcquired); + dispatch_semaphore_signal(backgroundFinished); + }); + + [self.manager executeUserBlocks]; + + XCTAssertTrue(monitorWasAvailableDuringCallback, + @"external callbacks must run after releasing the manager monitor"); + XCTAssertEqual(dispatch_semaphore_wait(backgroundFinished, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)), 0); +} + // MARK: - SUP3-30: actualizeEntitlements must preserve backend entitlements on error - (void)testActualizeEntitlements_backendReturnsEntitlementsWithError_preservesEntitlements { diff --git a/Sources/Qonversion/Public/QONRemoteConfigurationSource.h b/Sources/Qonversion/Public/QONRemoteConfigurationSource.h index 070129a6..4209403d 100644 --- a/Sources/Qonversion/Public/QONRemoteConfigurationSource.h +++ b/Sources/Qonversion/Public/QONRemoteConfigurationSource.h @@ -13,7 +13,8 @@ NS_ASSUME_NONNULL_BEGIN typedef NS_ENUM(NSInteger, QONRemoteConfigurationAssignmentType) { QONRemoteConfigurationAssignmentTypeUnknown = -1, QONRemoteConfigurationAssignmentTypeAuto = 0, - QONRemoteConfigurationAssignmentTypeManual = 1 + QONRemoteConfigurationAssignmentTypeManual = 1, + QONRemoteConfigurationAssignmentTypeFrozen = 2 } NS_SWIFT_NAME(Qonversion.RemoteConfigurationAssignmentType); typedef NS_ENUM(NSInteger, QONRemoteConfigurationSourceType) { diff --git a/Sources/Qonversion/Public/QONRemoteConfigurationSource.m b/Sources/Qonversion/Public/QONRemoteConfigurationSource.m index 9eafe03a..8fbd21db 100644 --- a/Sources/Qonversion/Public/QONRemoteConfigurationSource.m +++ b/Sources/Qonversion/Public/QONRemoteConfigurationSource.m @@ -49,6 +49,9 @@ - (NSString *)prettyAssignmentType { case QONRemoteConfigurationAssignmentTypeManual: result = @"manual"; break; + + case QONRemoteConfigurationAssignmentTypeFrozen: + result = @"frozen"; break; default: result = @"unknown"; break; diff --git a/Sources/Qonversion/Public/Qonversion.m b/Sources/Qonversion/Public/Qonversion.m index 7a8baf00..b4c22167 100644 --- a/Sources/Qonversion/Public/Qonversion.m +++ b/Sources/Qonversion/Public/Qonversion.m @@ -320,7 +320,7 @@ - (instancetype)initWithCustomUserDefaults:(NSUserDefaults *)userDefaults { _fallbackService = fallbackService; _propertiesManager = [QNUserPropertiesManager new]; _attributionManager = [QNAttributionManager new]; - _remoteConfigManager = [QONRemoteConfigManager new]; + _remoteConfigManager = [[QONRemoteConfigManager alloc] initWithLocalStorage:_localStorage]; _exceptionManager = [QONExceptionManager shared]; _redemptionManager = [QONRedemptionManager new]; _redemptionManager.productCenterManager = _productCenterManager; diff --git a/Sources/Qonversion/Qonversion/Main/QNIdentityManager/QNIdentityManager.m b/Sources/Qonversion/Qonversion/Main/QNIdentityManager/QNIdentityManager.m index 20f8f5b7..8b0e357e 100644 --- a/Sources/Qonversion/Qonversion/Main/QNIdentityManager/QNIdentityManager.m +++ b/Sources/Qonversion/Qonversion/Main/QNIdentityManager/QNIdentityManager.m @@ -13,13 +13,11 @@ @implementation QNIdentityManager - (void)identify:(NSString *)userID completion:(QNIdentityCompletionHandler)completion { - __block __weak QNIdentityManager *weakSelf = self; - NSString *anonUserID = [self.userInfoService obtainUserID]; [self.identityService identify:userID anonUserID:anonUserID completion:^(NSString * _Nullable result, NSError * _Nullable error) { - if (result.length > 0) { - [weakSelf.userInfoService storeIdentity:result]; - } + // The Product Center owns identity-attempt serialization and cancellation. + // Persisting here would let a response that arrives after logout silently + // restore the canceled user before the owner can reject the callback. completion(result, error); }]; } diff --git a/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.m b/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.m index 402d2800..f7a0435c 100644 --- a/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.m +++ b/Sources/Qonversion/Qonversion/Main/QNProductCenterManager/QNProductCenterManager.m @@ -34,6 +34,39 @@ static NSString * const kLaunchResult = @"qonversion.launch.result"; static NSString * const kLaunchResultTimeStamp = @"qonversion.launch.result.timestamp"; static NSString * const kUserDefaultsSuiteName = @"qonversion.product-center.suite"; +static NSString * const kIdentityMutationSupersededKey = @"qonversion.identity-mutation-superseded"; + +@interface QNIdentityRequestData : NSObject + +@property (nonatomic, copy) NSString *identityID; +@property (nonatomic, strong) NSMutableArray *completions; + +- (instancetype)initWithIdentityID:(NSString *)identityID + completion:(nullable QONUserInfoCompletionHandler)completion; +- (void)addCompletion:(nullable QONUserInfoCompletionHandler)completion; + +@end + +@implementation QNIdentityRequestData + +- (instancetype)initWithIdentityID:(NSString *)identityID + completion:(nullable QONUserInfoCompletionHandler)completion { + self = [super init]; + if (self) { + _identityID = [identityID copy]; + _completions = [NSMutableArray new]; + [self addCompletion:completion]; + } + return self; +} + +- (void)addCompletion:(nullable QONUserInfoCompletionHandler)completion { + if (completion) { + [self.completions addObject:[completion copy]]; + } +} + +@end @interface QNProductCenterManager() @@ -67,15 +100,40 @@ @interface QNProductCenterManager() @property (nonatomic, copy) NSDictionary *processingPurchaseOptions; -@property (nonatomic, assign) BOOL launchingFinished; +@property (atomic, assign) BOOL launchingFinished; @property (nonatomic, assign) BOOL productsLoading; -@property (nonatomic, assign) BOOL restoreInProgress; -@property (nonatomic, assign) BOOL receiptRestoreInProgress; +@property (atomic, assign) BOOL restoreInProgress; +@property (atomic, assign) BOOL receiptRestoreInProgress; @property (nonatomic, assign) BOOL awaitingRestoreResult; -@property (nonatomic, assign) BOOL identityInProgress; -@property (nonatomic, assign) BOOL unhandledLogoutAvailable; -@property (nonatomic, copy) NSString *pendingIdentityUserID; -@property (nonatomic, strong) NSMutableDictionary *> *pendingIdentityBlocks; +@property (atomic, assign) BOOL identityInProgress; +@property (atomic, assign) BOOL identityLogoutInProgress; +@property (atomic, assign) BOOL unhandledLogoutAvailable; +@property (nonatomic, strong) NSLock *identityStateLock; +@property (nonatomic, strong) NSRecursiveLock *identityMutationLock; +@property (nonatomic, strong) NSLock *entitlementsBlocksLock; +@property (nonatomic, strong) NSLock *userInfoBlocksLock; +@property (nonatomic, strong) NSLock *restoreBlocksLock; +@property (nonatomic, strong) NSLock *launchStateLock; +@property (nonatomic, assign) NSUInteger launchesInFlight; +@property (nonatomic, strong, nullable) NSError *pendingLaunchTerminalError; +@property (nonatomic, assign) NSUInteger identityMutationGeneration; +@property (nonatomic, assign) NSUInteger receiptRestoreIdentityMutationGeneration; +@property (nonatomic, assign) NSUInteger transactionsRestoreIdentityMutationGeneration; +@property (nonatomic, strong, nullable) QNIdentityRequestData *activeIdentityRequest; +@property (nonatomic, strong) NSMutableArray *pendingIdentityRequests; + +- (NSError *)identityMutationSupersededError; +- (BOOL)isIdentityMutationSupersededError:(nullable NSError *)error; +- (void)finishReceiptRestoreWithResult:(nullable QONLaunchResult *)result error:(nullable NSError *)error; +- (void)launch:(QONRequestTrigger)requestTrigger + identityRequest:(nullable QNIdentityRequestData *)identityRequest +expectedIdentityMutationGeneration:(nullable NSNumber *)expectedGeneration + completion:(void (^)(QONLaunchResult * _Nullable result, NSError * _Nullable error))completion; +- (void)launchWithTrigger:(QONRequestTrigger)requestTrigger + identityRequest:(nullable QNIdentityRequestData *)identityRequest +expectedIdentityMutationGeneration:(nullable NSNumber *)expectedGeneration + scopeCommit:(nullable void (^)(QONLaunchResult *result))scopeCommit + completion:(nullable QONLaunchCompletionHandler)completion; @end @@ -111,7 +169,13 @@ - (instancetype)initWithUserInfoService:(id)userInfo _productsBlocks = [NSMutableArray new]; _offeringsBlocks = [NSMutableArray new]; _userInfoBlocks = [NSMutableArray new]; - _pendingIdentityBlocks = [NSMutableDictionary new]; + _identityStateLock = [NSLock new]; + _identityMutationLock = [NSRecursiveLock new]; + _entitlementsBlocksLock = [NSLock new]; + _userInfoBlocksLock = [NSLock new]; + _restoreBlocksLock = [NSLock new]; + _launchStateLock = [NSLock new]; + _pendingIdentityRequests = [NSMutableArray new]; } return self; @@ -223,13 +287,76 @@ - (QONOfferings * _Nullable)getActualOfferings { } - (BOOL)isUserStable { - return self.launchingFinished && !self.identityInProgress && self.pendingIdentityUserID.length == 0 && !self.unhandledLogoutAvailable; + [self.identityStateLock lock]; + BOOL hasIdentityWork = self.activeIdentityRequest != nil || self.pendingIdentityRequests.count > 0; + [self.identityStateLock unlock]; + return self.launchingFinished + && !self.identityInProgress + && !self.identityLogoutInProgress + && !self.restoreInProgress + && !self.receiptRestoreInProgress + && !hasIdentityWork + && !self.unhandledLogoutAvailable; +} + +- (NSError *)identityMutationSupersededError { + return [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorCancelled + userInfo:@{ + NSLocalizedDescriptionKey: @"The restore result was superseded by a newer identity operation.", + kIdentityMutationSupersededKey: @YES, + }]; +} + +- (BOOL)isIdentityMutationSupersededError:(nullable NSError *)error { + return [error.userInfo[kIdentityMutationSupersededKey] boolValue]; } - (void)launchWithTrigger:(QONRequestTrigger)requestTrigger completion:(nullable QONLaunchCompletionHandler)completion { + [self launchWithTrigger:requestTrigger identityRequest:nil completion:completion]; +} + +- (void)launchWithTrigger:(QONRequestTrigger)requestTrigger + identityRequest:(nullable QNIdentityRequestData *)identityRequest + completion:(nullable QONLaunchCompletionHandler)completion { + [self launchWithTrigger:requestTrigger + identityRequest:identityRequest +expectedIdentityMutationGeneration:nil + scopeCommit:nil + completion:completion]; +} + +- (void)launchWithTrigger:(QONRequestTrigger)requestTrigger + identityRequest:(nullable QNIdentityRequestData *)identityRequest +expectedIdentityMutationGeneration:(nullable NSNumber *)expectedGeneration + scopeCommit:(nullable void (^)(QONLaunchResult *result))scopeCommit + completion:(nullable QONLaunchCompletionHandler)completion { __block __weak QNProductCenterManager *weakSelf = self; - [self launch:requestTrigger completion:^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { + [self launch:requestTrigger +identityRequest:identityRequest +expectedIdentityMutationGeneration:expectedGeneration + completion:^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { + if ([weakSelf isIdentityMutationSupersededError:error]) { + [weakSelf handlePendingRequests:nil]; + if (completion) { + run_block_on_main(completion, result, error) + } + return; + } + + BOOL mutationLockHeld = NO; + if (identityRequest) { + [weakSelf.identityMutationLock lock]; + mutationLockHeld = YES; + if (![weakSelf isActiveIdentityRequest:identityRequest]) { + [weakSelf.identityMutationLock unlock]; + return; + } + } + if (scopeCommit && !error) { + scopeCommit(result); + } [weakSelf storeLaunchResultIfNeeded:result]; weakSelf.launchResult = result; @@ -251,60 +378,74 @@ - (void)launchWithTrigger:(QONRequestTrigger)requestTrigger completion:(nullable if (error) { QONVERSION_LOG(@"❗️ Request failed %@", error.description); } + if (mutationLockHeld) { + [weakSelf.identityMutationLock unlock]; + } }]; } - (void)identify:(NSString *)identityId completion:(nullable QONUserInfoCompletionHandler)completion { + [self.identityMutationLock lock]; + // A newly accepted identity intent supersedes every restore that was + // accepted against the previous user boundary, even when it coalesces. + self.identityMutationGeneration += 1; self.unhandledLogoutAvailable = NO; - - NSString *currentIdentityId = [self.userInfoService obtainCustomIdentityUserID]; - if ([currentIdentityId isEqualToString:identityId]) { - if (completion) { - [self userInfo:completion]; - } - return; + QNIdentityRequestData *requestToStart = nil; + [self.identityStateLock lock]; + // Coalesce only adjacent equal requests. A,A shares one network attempt; + // A,B,A remains three ordered state transitions and ends on A. + QNIdentityRequestData *coalescingRequest = self.pendingIdentityRequests.lastObject; + if (!coalescingRequest && [self.activeIdentityRequest.identityID isEqualToString:identityId]) { + coalescingRequest = self.activeIdentityRequest; } - - [self addIdentityCompletion:identityId completion:completion]; - self.pendingIdentityUserID = identityId; - if (!self.launchingFinished || self.restoreInProgress) { - return; - } - - self.identityInProgress = YES; - if (self.launchError) { - __block __weak QNProductCenterManager *weakSelf = self; - - [weakSelf launch:QONRequestTriggerIdentify completion:^(QONLaunchResult * _Nullable result, NSError * _Nullable error) { - if (error) { - weakSelf.identityInProgress = NO; - [weakSelf executeEntitlementsBlocksWithError:error]; - [weakSelf.remoteConfigManager userChangingRequestFailedWithError:error]; - } else { - [weakSelf processIdentity:identityId]; - } - }]; + if ([coalescingRequest.identityID isEqualToString:identityId]) { + [coalescingRequest addCompletion:completion]; } else { - [self processIdentity:identityId]; + QNIdentityRequestData *request = [[QNIdentityRequestData alloc] initWithIdentityID:identityId + completion:completion]; + [self.pendingIdentityRequests addObject:request]; } + requestToStart = [self takeNextIdentityRequestIfReadyLocked]; + [self.identityStateLock unlock]; + + [self startIdentityRequest:requestToStart]; + [self.identityMutationLock unlock]; } - (void)processIdentity:(NSString *)identityId { + [self processIdentity:identityId request:nil]; +} + +- (void)processIdentity:(NSString *)identityId request:(nullable QNIdentityRequestData *)request { NSString *currentUserID = [self.userInfoService obtainUserID]; __block __weak QNProductCenterManager *weakSelf = self; [self.identityManager identify:identityId completion:^(NSString *result, NSError * _Nullable error) { - weakSelf.identityInProgress = NO; - + if (request && ![weakSelf isActiveIdentityRequest:request]) { + // logout (or another cancellation boundary) won the race. Never apply a + // late identity response after callers were told the attempt was canceled. + return; + } if (error) { - [weakSelf executeEntitlementsBlocksWithError:error]; - [weakSelf.remoteConfigManager userChangingRequestFailedWithError:error]; - [weakSelf fireIdentityError:error identityId:identityId]; + [weakSelf failIdentityRequest:request error:error]; return; } - - weakSelf.pendingIdentityUserID = nil; - + + [weakSelf.identityMutationLock lock]; + BOOL mutationLockHeld = YES; + if (request && ![weakSelf isActiveIdentityRequest:request]) { + [weakSelf.identityMutationLock unlock]; + return; + } + // Persistence, the custom identity, and the Remote Config scope commit as + // one logout-serialized boundary. Whichever owns identityMutationLock + // first wins; logout can no longer slip between validation and storage. + // Claim the successful scope/custom-identity commit. This invalidates a + // restore accepted while the network identity request was still active. + weakSelf.identityMutationGeneration += 1; + if (result.length > 0) { + [weakSelf.userInfoService storeIdentity:result]; + } [weakSelf.userInfoService storeCustomIdentityUserID:identityId]; if ([currentUserID isEqualToString:result]) { @@ -316,37 +457,104 @@ - (void)processIdentity:(NSString *)identityId { // must miss the cache, or queued completions would be served the // pre-identify evaluation and orphaned by the cache-hit path. [weakSelf.remoteConfigManager invalidateRemoteConfigsCache]; - [weakSelf handlePendingRequests:nil]; - [weakSelf fireIdentitySuccess:identityId]; + // Keep isUserStable false through the RC boundary transition. Clearing + // these earlier opens a window where a concurrent caller can consume a + // pre-identify warm config before invalidation reaches the RC manager. + [weakSelf finishIdentityRequest:request error:nil]; + if (mutationLockHeld) { + [weakSelf.identityMutationLock unlock]; + } } else { - [[QNAPIClient shared] setUserID:result]; - [weakSelf.remoteConfigManager userHasBeenChanged]; + [weakSelf.remoteConfigManager userHasBeenChangedToUserID:result]; + if (mutationLockHeld) { + [weakSelf.identityMutationLock unlock]; + } [weakSelf resetActualPermissionsCache]; - [weakSelf launchWithTrigger:QONRequestTriggerIdentify completion:^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { - if (error) { - [weakSelf fireIdentityError:error identityId:identityId]; - } else { - [weakSelf fireIdentitySuccess:identityId]; + QONLaunchCompletionHandler launchCompletion = ^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { + if (request && ![weakSelf isActiveIdentityRequest:request]) { + return; } - }]; + [weakSelf finishIdentityRequest:request error:error]; + }; + if (request) { + [weakSelf launchWithTrigger:QONRequestTriggerIdentify identityRequest:request completion:launchCompletion]; + } else { + [weakSelf launchWithTrigger:QONRequestTriggerIdentify completion:launchCompletion]; + } } }]; } - (void)logout { - self.pendingIdentityUserID = nil; + NSError *cancellationError = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorCancelled + userInfo:@{NSLocalizedDescriptionKey: @"The identify request was canceled by logout."}]; + [self.identityMutationLock lock]; + [self.identityStateLock lock]; + if (self.identityLogoutInProgress) { + // A synchronous Remote Config callback may re-enter logout while the + // outer call still owns this recursive lock. The outer call has already + // unlinked the identity and is the sole owner of cancellation + scope + // publication; a nested call must not clear its logical boundary. + [self.identityStateLock unlock]; + [self.identityMutationLock unlock]; + return; + } + // The public logout intent is newer than every previously accepted restore, + // even when there is no persisted identity left to unlink. + self.identityMutationGeneration += 1; + self.identityLogoutInProgress = YES; + QNIdentityRequestData *activeRequest = self.activeIdentityRequest; + NSMutableArray *cancelledRequests = [self.pendingIdentityRequests mutableCopy]; + if (activeRequest) { + [cancelledRequests insertObject:activeRequest atIndex:0]; + } + self.activeIdentityRequest = nil; + [self.pendingIdentityRequests removeAllObjects]; + self.identityInProgress = NO; + [self.identityStateLock unlock]; + BOOL isLogoutNeeded = [self.identityManager logoutIfNeeded]; + NSString *logoutUserID = nil; if (isLogoutNeeded) { [self.userInfoService storeCustomIdentityUserID:nil]; + [self actualizeUserInfo]; self.unhandledLogoutAvailable = YES; - [self.remoteConfigManager userHasBeenChanged]; - NSString *userID = [self.userInfoService obtainUserID]; - [[QNAPIClient shared] setUserID:userID]; - + logoutUserID = [self.userInfoService obtainUserID]; [self resetActualPermissionsCache]; } + + if (cancelledRequests.count > 0) { + // Drain the cancelled identity's Remote Config window while logout still + // owns the mutation boundary. A concurrent identify can only queue here, + // so this cancellation can never land in the new attempt's window. + [self.remoteConfigManager userChangingRequestFailedWithError:cancellationError]; + } + if (isLogoutNeeded) { + // Publish the successful logout scope last: this clears the cancellation + // latch and makes the original user the only observable stable scope. + [self.remoteConfigManager userHasBeenChangedToUserID:logoutUserID]; + self.identityMutationGeneration += 1; + } + + [self.identityStateLock lock]; + self.identityLogoutInProgress = NO; + BOOL hasNewIdentityRequest = self.pendingIdentityRequests.count > 0; + [self.identityStateLock unlock]; + + [self.identityMutationLock unlock]; + + for (QNIdentityRequestData *request in cancelledRequests) { + [self deliverIdentityRequest:request error:cancellationError]; + } + if (hasNewIdentityRequest) { + // A post-logout identify supersedes the deferred logout launch and will + // fetch the final user's state itself. + self.unhandledLogoutAvailable = NO; + [self handlePendingRequests:nil]; + } } - (void)setPromoPurchasesDelegate:(id)delegate { @@ -367,13 +575,18 @@ - (void)setDeferredPurchasesListener:(id)listener } - (void)userInfo:(QONUserInfoCompletionHandler)completion { + [self.userInfoBlocksLock lock]; if (!self.launchingFinished) { [self.userInfoBlocks addObject:completion]; + [self.userInfoBlocksLock unlock]; return; } + [self.userInfoBlocksLock unlock]; [self actualizeUserInfo]; - run_block_on_main(completion, self.user, self.launchError); + QONUser *user = self.user; + NSError *error = self.launchError; + run_block_on_main(completion, user, error); } - (void)presentCodeRedemptionSheet { @@ -385,10 +598,10 @@ - (void)checkEntitlements:(QONEntitlementsCompletionHandler)completion { return; } - @synchronized (self) { - [self.entitlementsBlocks addObject:completion]; - [self handlePendingRequests:nil]; - } + [self.entitlementsBlocksLock lock]; + [self.entitlementsBlocks addObject:completion]; + [self.entitlementsBlocksLock unlock]; + [self handlePendingRequests:nil]; } - (void)handleLogout { @@ -454,6 +667,10 @@ - (void)handleLaunchErrorForProduct:(QONProduct *)product completion:(nonnull QONPurchaseResultCompletionHandler)completion { __block __weak QNProductCenterManager *weakSelf = self; [self launchWithTrigger:QONRequestTriggerPurchase completion:^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { + if ([weakSelf isIdentityMutationSupersededError:error]) { + [weakSelf handlePurchaseError:error completion:completion]; + return; + } NSDictionary *products = [weakSelf getActualProducts]; if (error && products.count == 0) { [weakSelf handlePurchaseError:error completion:completion]; @@ -527,47 +744,78 @@ - (void)handlePurchaseError:(NSError *)error - (void)restoreReceipt:(QNRestoreCompletionHandler)completion { + [self.identityMutationLock lock]; + [self.restoreBlocksLock lock]; if (completion) { [self.receiptRestoreBlocks addObject:completion]; } - if (self.receiptRestoreInProgress) { + [self.restoreBlocksLock unlock]; + [self.identityMutationLock unlock]; return; } - self.receiptRestoreInProgress = YES; + self.receiptRestoreIdentityMutationGeneration = self.identityMutationGeneration; + NSUInteger ownerGeneration = self.receiptRestoreIdentityMutationGeneration; + [self.restoreBlocksLock unlock]; + [self.identityMutationLock unlock]; __block __weak QNProductCenterManager *weakSelf = self; [self.storeKitService receipt:^(NSString * _Nonnull receipt) { - [weakSelf launchWithTrigger:QONRequestTriggerRestore completion:^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { - if (!error) { - [weakSelf handleUserSwitchIfNeededWithResult:result]; - } - - @synchronized (weakSelf) { - weakSelf.receiptRestoreInProgress = NO; - NSArray *completions = [self.receiptRestoreBlocks copy]; - [weakSelf.receiptRestoreBlocks removeAllObjects]; - - for (QONEntitlementsCompletionHandler block in completions) { - run_block_on_main(block, result.entitlements, error); - } - } + [weakSelf.identityMutationLock lock]; + if (weakSelf.identityMutationGeneration != ownerGeneration) { + NSError *supersededError = [weakSelf identityMutationSupersededError]; + [weakSelf.identityMutationLock unlock]; + [weakSelf finishReceiptRestoreWithResult:nil error:supersededError]; + [weakSelf handlePendingRequests:nil]; + return; + } + [weakSelf launchWithTrigger:QONRequestTriggerRestore + identityRequest:nil +expectedIdentityMutationGeneration:@(ownerGeneration) + scopeCommit:^(QONLaunchResult *result) { + [weakSelf handleUserSwitchIfNeededWithResult:result]; + } + completion:^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { + [weakSelf finishReceiptRestoreWithResult:result error:error]; }]; + [weakSelf.identityMutationLock unlock]; }]; } +- (void)finishReceiptRestoreWithResult:(nullable QONLaunchResult *)result error:(nullable NSError *)error { + [self.restoreBlocksLock lock]; + self.receiptRestoreInProgress = NO; + NSArray *completions = [self.receiptRestoreBlocks copy]; + [self.receiptRestoreBlocks removeAllObjects]; + [self.restoreBlocksLock unlock]; + + NSDictionary *entitlements = result.entitlements ?: @{}; + for (QNRestoreCompletionHandler block in completions) { + dispatch_async(dispatch_get_main_queue(), ^{ + block(entitlements, error); + }); + } +} + - (void)restoreTransactions:(QNRestoreCompletionHandler)completion { + [self.identityMutationLock lock]; + [self.restoreBlocksLock lock]; if (completion != nil) { [self.restorePurchasesBlocks addObject:completion]; } if (self.restoreInProgress) { + [self.restoreBlocksLock unlock]; + [self.identityMutationLock unlock]; return; } self.awaitingRestoreResult = YES; self.restoreInProgress = YES; + self.transactionsRestoreIdentityMutationGeneration = self.identityMutationGeneration; + [self.restoreBlocksLock unlock]; + [self.identityMutationLock unlock]; [self.storeKitService restore]; } @@ -576,10 +824,14 @@ - (void)actualizeEntitlements:(QONEntitlementsCompletionHandler)completion { __block __weak QNProductCenterManager *weakSelf = self; [self launchWithTrigger:QONRequestTriggerActualizePermissions completion:^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { + if ([weakSelf isIdentityMutationSupersededError:error]) { + run_block_on_main(completion, @{}, error); + return; + } weakSelf.unhandledLogoutAvailable = NO; NSDictionary *entitlements = result.entitlements; NSError *resultError = error; - if (error && !weakSelf.pendingIdentityUserID) { + if (error && ![weakSelf hasPendingIdentityRequests]) { // Preserve backend entitlements when available (e.g. Stripe subscriptions). // Only fall back to cache when backend returned no entitlements. if (!entitlements || entitlements.count == 0) { @@ -625,43 +877,46 @@ - (void)fireEntitlementsBlocks:(NSArray *)bloc } - (void)executeEntitlementsBlocksWithError:(NSError *)error { - @synchronized (self) { - if (self.entitlementsBlocks.count == 0) { - return; - } - - NSMutableArray *_blocks = [self.entitlementsBlocks copy]; - [self.entitlementsBlocks removeAllObjects]; - - if (error) { - if (self.pendingIdentityUserID.length > 0) { - [self fireEntitlementsBlocks:[_blocks copy] result:@{} error:error]; - } else { - NSDictionary *cachedEntitlements = [self getActualEntitlementsForDefaultState:NO]; - cachedEntitlements = cachedEntitlements ?: @{}; - [self fireEntitlementsBlocks:[_blocks copy] result:cachedEntitlements error:error]; - } + [self.entitlementsBlocksLock lock]; + NSArray *blocks = [self.entitlementsBlocks copy]; + [self.entitlementsBlocks removeAllObjects]; + [self.entitlementsBlocksLock unlock]; + if (blocks.count == 0) { + return; + } + + if (error) { + if ([self hasPendingIdentityRequests]) { + [self fireEntitlementsBlocks:blocks result:@{} error:error]; } else { - [self prepareEntitlementsResultWithCompletion:^(NSDictionary * _Nonnull result, NSError * _Nullable error) { - [self fireEntitlementsBlocks:[_blocks copy] result:result ?: @{} error:error]; - }]; + NSDictionary *cachedEntitlements = [self getActualEntitlementsForDefaultState:NO]; + cachedEntitlements = cachedEntitlements ?: @{}; + [self fireEntitlementsBlocks:blocks result:cachedEntitlements error:error]; } + } else { + [self prepareEntitlementsResultWithCompletion:^(NSDictionary * _Nonnull result, NSError * _Nullable resultError) { + [self fireEntitlementsBlocks:blocks result:result ?: @{} error:resultError]; + }]; } } - (void)executeUserBlocks { - @synchronized (self) { - NSArray *blocks = [self.userInfoBlocks copy]; - if (blocks.count == 0) { - return; - } - - [self.userInfoBlocks removeAllObjects]; - - [self actualizeUserInfo]; - for (QONUserInfoCompletionHandler block in blocks) { - run_block_on_main(block, self.user, self.launchError); - } + [self executeUserBlocksWithError:self.launchError]; +} + +- (void)executeUserBlocksWithError:(nullable NSError *)resultError { + [self.userInfoBlocksLock lock]; + NSArray *blocks = [self.userInfoBlocks copy]; + [self.userInfoBlocks removeAllObjects]; + [self.userInfoBlocksLock unlock]; + if (blocks.count == 0) { + return; + } + + [self actualizeUserInfo]; + QONUser *user = self.user; + for (QONUserInfoCompletionHandler block in blocks) { + run_block_on_main(block, user, resultError); } } @@ -904,15 +1159,138 @@ - (QONProduct * _Nullable)QNProduct:(NSString *)productID { - (void)launch:(QONRequestTrigger)requestTrigger completion:(void (^)(QONLaunchResult * _Nullable result, NSError * _Nullable error))completion { - _launchingFinished = NO; + [self launch:requestTrigger identityRequest:nil completion:completion]; +} + +- (void)launch:(QONRequestTrigger)requestTrigger + identityRequest:(nullable QNIdentityRequestData *)identityRequest + completion:(void (^)(QONLaunchResult * _Nullable result, NSError * _Nullable error))completion { + [self launch:requestTrigger +identityRequest:identityRequest +expectedIdentityMutationGeneration:nil + completion:completion]; +} + +- (void)launch:(QONRequestTrigger)requestTrigger + identityRequest:(nullable QNIdentityRequestData *)identityRequest +expectedIdentityMutationGeneration:(nullable NSNumber *)expectedGeneration + completion:(void (^)(QONLaunchResult * _Nullable result, NSError * _Nullable error))completion { + [self.identityMutationLock lock]; + NSNumber *ownerGeneration = expectedGeneration; + if (!identityRequest && !ownerGeneration) { + // Every response that can write user/launch state belongs to the user + // generation at request start. This prevents an old products/actualize/ + // init response from overwriting state after identify/logout/restore. + ownerGeneration = @(self.identityMutationGeneration); + } + + [self.userInfoBlocksLock lock]; + [self.launchStateLock lock]; + self.launchesInFlight += 1; + self.launchingFinished = NO; + [self.launchStateLock unlock]; + [self.userInfoBlocksLock unlock]; + __block __weak QNProductCenterManager *weakSelf = self; - [self.apiClient launchRequest:requestTrigger completion:^(NSDictionary * _Nullable dict, NSError * _Nullable error) { - @synchronized (weakSelf) { - weakSelf.launchingFinished = YES; - NSNotification *notification = [NSNotification notificationWithName:kLaunchIsFinishedNotification object:self]; + __block BOOL launchTicketReleased = NO; + void (^releaseLaunchTicket)(NSError * _Nullable) = ^(NSError * _Nullable terminalError) { + BOOL allLaunchesFinished = NO; + NSError *errorToDeliver = nil; + NSArray *terminalUserBlocks = nil; + QONUser *terminalUser = nil; + [weakSelf.identityMutationLock lock]; + [weakSelf.userInfoBlocksLock lock]; + [weakSelf.launchStateLock lock]; + if (!launchTicketReleased) { + launchTicketReleased = YES; + // The last finishing launch determines the terminal state seen by work + // that was waiting for the whole concurrent launch set to become idle. + weakSelf.pendingLaunchTerminalError = terminalError; + if (weakSelf.launchesInFlight > 0) { + weakSelf.launchesInFlight -= 1; + } + allLaunchesFinished = weakSelf.launchesInFlight == 0; + weakSelf.launchingFinished = allLaunchesFinished; + if (allLaunchesFinished) { + errorToDeliver = weakSelf.pendingLaunchTerminalError; + weakSelf.pendingLaunchTerminalError = nil; + } + } + [weakSelf.launchStateLock unlock]; + + if (allLaunchesFinished) { + terminalUserBlocks = [weakSelf.userInfoBlocks copy]; + [weakSelf.userInfoBlocks removeAllObjects]; + if (terminalUserBlocks.count > 0) { + // Preserve the historical no-waiter behavior: a superseded launch + // must not rewrite the newer scope's in-memory user merely because + // its bookkeeping ticket became terminal. Snapshot user state only + // when there are actual userInfo callers to drain. + [weakSelf actualizeUserInfo]; + terminalUser = weakSelf.user; + } + } + [weakSelf.userInfoBlocksLock unlock]; + [weakSelf.identityMutationLock unlock]; + + if (allLaunchesFinished) { + // launchingFinished and the terminal userInfo snapshot are published + // atomically with launch start. A new launch cannot enqueue its callback + // into the ticket that just finished. + for (QONUserInfoCompletionHandler block in terminalUserBlocks) { + run_block_on_main(block, terminalUser, errorToDeliver); + } + if ([weakSelf isIdentityMutationSupersededError:errorToDeliver]) { + // A superseded response intentionally skips the normal high-level + // commit path. Terminate every queue that depended on that launch; + // otherwise products/offerings can remain retained forever + // when no replacement launch is required (for example, no-op logout). + [weakSelf executeProductsBlocksWithError:errorToDeliver]; + [weakSelf executeOfferingsBlocksWithError:errorToDeliver]; + } + NSNotification *notification = [NSNotification notificationWithName:kLaunchIsFinishedNotification object:weakSelf]; [[NSNotificationCenter defaultCenter] postNotification:notification]; + [weakSelf handlePendingRequests:errorToDeliver]; + } + }; + + [self.apiClient launchRequest:requestTrigger completion:^(NSDictionary * _Nullable dict, NSError * _Nullable error) { + __block BOOL mutationLockHeld = NO; + if (identityRequest || ownerGeneration) { + [weakSelf.identityMutationLock lock]; + mutationLockHeld = YES; + if (identityRequest && ![weakSelf isActiveIdentityRequest:identityRequest]) { + [weakSelf.identityMutationLock unlock]; + mutationLockHeld = NO; + releaseLaunchTicket([weakSelf identityMutationSupersededError]); + return; + } + if (ownerGeneration && weakSelf.identityMutationGeneration != ownerGeneration.unsignedIntegerValue) { + NSError *supersededError = [weakSelf identityMutationSupersededError]; + if (completion) { + completion([[QONLaunchResult alloc] init], supersededError); + } + [weakSelf.identityMutationLock unlock]; + releaseLaunchTicket(supersededError); + return; + } } + + void (^finishLaunch)(QONLaunchResult *, NSError *) = ^(QONLaunchResult *result, NSError *finishError) { + if (completion) { + completion(result, finishError); + } + if (mutationLockHeld) { + [weakSelf.identityMutationLock unlock]; + mutationLockHeld = NO; + } + releaseLaunchTicket(finishError); + }; if (!completion) { + if (mutationLockHeld) { + [weakSelf.identityMutationLock unlock]; + } + releaseLaunchTicket(error); return; } @@ -927,13 +1305,13 @@ - (void)launch:(QONRequestTrigger)requestTrigger launchResult = [QNMapper fillLaunchResult:mappedResult.data]; } } - completion(launchResult, error); + finishLaunch(launchResult, error); return; } QNMapperObject *result = [QNMapper mapperObjectFrom:dict]; if (result.error) { - completion([[QONLaunchResult alloc] init], result.error); + finishLaunch([[QONLaunchResult alloc] init], result.error); return; } @@ -944,13 +1322,14 @@ - (void)launch:(QONRequestTrigger)requestTrigger [weakSelf.persistentStorage storeObject:weakSelf.productsEntitlementsRelation forKey:kKeyQUserDefaultsProductsPermissionsRelation]; QONLaunchResult *launchResult = [QNMapper fillLaunchResult:result.data]; - completion(launchResult, nil); + finishLaunch(launchResult, nil); static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ [weakSelf.apiClient processStoredRequests]; }); }]; + [self.identityMutationLock unlock]; } - (void)handleFailedTransaction:(SKPaymentTransaction *)transaction forProduct:(SKProduct *)product error:(NSError *)error { @@ -1129,9 +1508,22 @@ - (void)handleRestoreCompletedTransactionsFinished { NSArray *restoredTransactionsCopy = [self.restoredTransactions copy]; self.restoredTransactions = nil; __block __weak QNProductCenterManager *weakSelf = self; - [self launch:QONRequestTriggerSyncHistoricalData completion:^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { + [self.identityMutationLock lock]; + NSUInteger ownerGeneration = self.transactionsRestoreIdentityMutationGeneration; + if (self.identityMutationGeneration != ownerGeneration) { + NSError *supersededError = [self identityMutationSupersededError]; + [self.identityMutationLock unlock]; + [self executeRestoreBlocksWithResult:@{} error:supersededError]; + return; + } + [self launch:QONRequestTriggerSyncHistoricalData +identityRequest:nil +expectedIdentityMutationGeneration:@(ownerGeneration) + completion:^(QONLaunchResult * _Nonnull result, NSError * _Nullable error) { if (error) { - if ([weakSelf shouldCalculateEntitlementsForError:error]) { + if ([weakSelf isIdentityMutationSupersededError:error]) { + [weakSelf executeRestoreBlocksWithResult:@{} error:error]; + } else if ([weakSelf shouldCalculateEntitlementsForError:error]) { NSArray *storeProducts = [weakSelf.storeKitService getLoadedProducts]; NSDictionary *calculatedEntitlements = [weakSelf calculateEntitlementsForRestoredTransactions:restoredTransactionsCopy products:storeProducts]; @@ -1146,6 +1538,7 @@ - (void)handleRestoreCompletedTransactionsFinished { [weakSelf executeRestoreBlocksWithResult:result.entitlements error:error]; } }]; + [self.identityMutationLock unlock]; } - (void)handleRestoreCompletedTransactionsFailed:(NSError *)error { @@ -1154,13 +1547,17 @@ - (void)handleRestoreCompletedTransactionsFailed:(NSError *)error { } - (void)executeRestoreBlocksWithResult:(NSDictionary *)entitlements error:(NSError *)error { + [self.restoreBlocksLock lock]; self.restoreInProgress = NO; - - NSMutableArray *_blocks = [self.restorePurchasesBlocks copy]; + NSArray *blocks = [self.restorePurchasesBlocks copy]; [self.restorePurchasesBlocks removeAllObjects]; + [self.restoreBlocksLock unlock]; - for (QONEntitlementsCompletionHandler block in _blocks) { - run_block_on_main(block, entitlements, error); + NSDictionary *resultEntitlements = entitlements ?: @{}; + for (QNRestoreCompletionHandler block in blocks) { + dispatch_async(dispatch_get_main_queue(), ^{ + block(resultEntitlements, error); + }); } [self handlePendingRequests:error]; @@ -1290,21 +1687,32 @@ - (void)resetActualPermissionsCache { } - (void)handleUserSwitchIfNeededWithResult:(QONLaunchResult *)result { - if (!result || result.uid.length == 0) { + if (!result) { return; } + [self.identityMutationLock lock]; + // A valid restore response claims this generation even when the receipt + // belongs to the current uid. Only the first response accepted against a + // generation may update global launch/user state; sibling restores become + // stale before they can publish a different scope. + self.identityMutationGeneration += 1; + if (result.uid.length == 0) { + [self.identityMutationLock unlock]; + return; + } NSString *currentUserID = [self.userInfoService obtainUserID]; if ([currentUserID isEqualToString:result.uid]) { + [self.identityMutationLock unlock]; return; } QONVERSION_LOG(@"🔄 Restore: user switch detected from %@ to %@", currentUserID, result.uid); [self.userInfoService storeIdentity:result.uid]; - [[QNAPIClient shared] setUserID:result.uid]; - [self.remoteConfigManager userHasBeenChanged]; + [self.remoteConfigManager userHasBeenChangedToUserID:result.uid]; [self resetActualPermissionsCache]; + [self.identityMutationLock unlock]; } // MARK: - Move to separate file @@ -1454,56 +1862,155 @@ - (void)actualizeUserInfo { } - (void)handlePendingRequests:(NSError *)lastError { - if (!self.launchingFinished || self.restoreInProgress || self.identityInProgress) { + [self.identityMutationLock lock]; + if (!self.launchingFinished || self.restoreInProgress) { + [self.identityMutationLock unlock]; return; } - if (self.pendingIdentityUserID) { - [self identify:self.pendingIdentityUserID completion:nil]; + QNIdentityRequestData *requestToStart = nil; + [self.identityStateLock lock]; + if (self.activeIdentityRequest || self.identityLogoutInProgress) { + [self.identityStateLock unlock]; + [self.identityMutationLock unlock]; + return; + } + requestToStart = [self takeNextIdentityRequestIfReadyLocked]; + BOOL identityWorkBecameActive = self.activeIdentityRequest != nil || self.pendingIdentityRequests.count > 0; + [self.identityStateLock unlock]; + if (requestToStart) { + [self startIdentityRequest:requestToStart]; + } else if (identityWorkBecameActive) { + [self.identityMutationLock unlock]; + return; } else if (self.unhandledLogoutAvailable) { [self handleLogout]; } else { [self.remoteConfigManager handlePendingRequests]; [self executeEntitlementsBlocksWithError:lastError]; } + [self.identityMutationLock unlock]; } -- (void)addIdentityCompletion:(NSString *)identityId completion:(nullable QONUserInfoCompletionHandler)completion { - if (!completion) { - return; - } +- (BOOL)hasPendingIdentityRequests { + [self.identityStateLock lock]; + BOOL hasRequests = self.activeIdentityRequest != nil || self.pendingIdentityRequests.count > 0; + [self.identityStateLock unlock]; + return hasRequests; +} - NSMutableArray *completions = self.pendingIdentityBlocks[identityId]; - if (!completions) { - completions = [NSMutableArray new]; - self.pendingIdentityBlocks[identityId] = completions; - } - [completions addObject:completion]; +- (nullable QNIdentityRequestData *)takeNextIdentityRequestIfReadyLocked { + if (self.activeIdentityRequest || self.identityLogoutInProgress || !self.launchingFinished || self.restoreInProgress || self.pendingIdentityRequests.count == 0) { + return nil; + } + QNIdentityRequestData *request = self.pendingIdentityRequests.firstObject; + [self.pendingIdentityRequests removeObjectAtIndex:0]; + self.activeIdentityRequest = request; + self.identityInProgress = YES; + return request; +} + +- (BOOL)isActiveIdentityRequest:(QNIdentityRequestData *)request { + [self.identityStateLock lock]; + BOOL isActive = self.activeIdentityRequest == request; + [self.identityStateLock unlock]; + return isActive; } -- (void)fireIdentitySuccess:(NSString *)identityId { - NSMutableArray *completions = self.pendingIdentityBlocks[identityId]; - if (!completions) { +- (void)startIdentityRequest:(nullable QNIdentityRequestData *)request { + if (!request || ![self isActiveIdentityRequest:request]) { + return; + } + + NSString *identityID = request.identityID; + NSString *currentIdentityID = [self.userInfoService obtainCustomIdentityUserID]; + if ([currentIdentityID isEqualToString:identityID]) { + [self finishIdentityRequest:request error:nil]; + return; + } + + // Clear the previous terminal-error latch before any network work for this + // identity begins. identityInProgress is already true, so Remote Config + // cannot observe a stable old scope between serialized attempts. + [self.remoteConfigManager userChangingRequestStarted]; + if (![self isActiveIdentityRequest:request]) { + return; + } + + __block __weak QNProductCenterManager *weakSelf = self; + if (self.launchError) { + [self launch:QONRequestTriggerIdentify identityRequest:request completion:^(QONLaunchResult * _Nullable result, NSError * _Nullable error) { + if (![weakSelf isActiveIdentityRequest:request]) { return; + } + if (error) { + [weakSelf failIdentityRequest:request error:error]; + } else { + [weakSelf processIdentity:identityID request:request]; + } + }]; + } else { + [self processIdentity:identityID request:request]; + } +} + +- (void)failIdentityRequest:(nullable QNIdentityRequestData *)request error:(NSError *)error { + BOOL mutationLockHeld = NO; + if (request) { + [self.identityMutationLock lock]; + mutationLockHeld = YES; + if (![self isActiveIdentityRequest:request]) { + [self.identityMutationLock unlock]; + return; } - self.pendingIdentityBlocks[identityId] = nil; + } + [self executeEntitlementsBlocksWithError:error]; + [self.remoteConfigManager userChangingRequestFailedWithError:error]; + [self finishIdentityRequest:request error:error]; + if (mutationLockHeld) { + [self.identityMutationLock unlock]; + } +} - [self userInfo:^(QONUser * _Nullable user, NSError * _Nullable error) { - for (QONUserInfoCompletionHandler completion in completions) { - run_block_on_main(completion, user, error); - } - }]; +- (void)finishIdentityRequest:(nullable QNIdentityRequestData *)request error:(nullable NSError *)error { + if (!request) { + // Retained for focused tests of processIdentity:. Public identify always + // owns a request record and therefore takes the guarded path below. + self.identityInProgress = NO; + [self handlePendingRequests:error]; + return; + } + + [self.identityStateLock lock]; + if (self.activeIdentityRequest != request) { + [self.identityStateLock unlock]; + return; + } + self.activeIdentityRequest = nil; + self.identityInProgress = NO; + [self.identityStateLock unlock]; + + [self deliverIdentityRequest:request error:error]; + [self handlePendingRequests:error]; } -- (void)fireIdentityError:(NSError * _Nullable)error identityId:(NSString *)identityId { - NSMutableArray *completions = self.pendingIdentityBlocks[identityId]; - if (!completions) { - return; +- (void)deliverIdentityRequest:(QNIdentityRequestData *)request error:(nullable NSError *)error { + NSArray *completions = [request.completions copy]; + [request.completions removeAllObjects]; + if (completions.count == 0) { + return; + } + if (error) { + for (QONUserInfoCompletionHandler completion in completions) { + run_block_on_main(completion, nil, error); } - self.pendingIdentityBlocks[identityId] = nil; + return; + } + [self userInfo:^(QONUser * _Nullable user, NSError * _Nullable userInfoError) { for (QONUserInfoCompletionHandler completion in completions) { - run_block_on_main(completion, nil, error); + run_block_on_main(completion, user, userInfoError); } + }]; } diff --git a/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigListRequestData.h b/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigListRequestData.h index 8a802032..1ff56f76 100644 --- a/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigListRequestData.h +++ b/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigListRequestData.h @@ -15,7 +15,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, copy, nullable) NSArray *contextKeys; @property (nonatomic, assign) BOOL includeEmptyContextKey; -@property (nonatomic, copy, nonnull) QONRemoteConfigListCompletionHandler completion; +@property (nonatomic, copy, nullable) QONRemoteConfigListCompletionHandler completion; +@property (nonatomic, assign, getter=isCompleted) BOOL completed; - (instancetype)initWithCompletion:(QONRemoteConfigListCompletionHandler)completion; diff --git a/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigManager.h b/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigManager.h index 7d9484ba..8d6f41e8 100644 --- a/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigManager.h +++ b/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigManager.h @@ -11,16 +11,33 @@ #import "QONExperiment.h" @class QONRemoteConfigService, QNProductCenterManager, QNUserPropertiesManager, QONFallbackService; +@protocol QNLocalStorage; NS_ASSUME_NONNULL_BEGIN +// Internal diagnostic contract. It intentionally lives outside the public +// headers so durable fallback observability can evolve without changing the +// customer-facing Remote Config model. +typedef NS_ENUM(NSInteger, QONRemoteConfigDeliveryOrigin) { + QONRemoteConfigDeliveryOriginUnknown = 0, + QONRemoteConfigDeliveryOriginServer = 1, + QONRemoteConfigDeliveryOriginMemory = 2, + QONRemoteConfigDeliveryOriginRetryBaseline = 3, + QONRemoteConfigDeliveryOriginDiskLastKnownGood = 4, + QONRemoteConfigDeliveryOriginBundle = 5, +}; + @interface QONRemoteConfigManager : NSObject @property (nonatomic, strong) QONRemoteConfigService *remoteConfigService; @property (nonatomic, strong) QONFallbackService *fallbackService; @property (nonatomic, strong) QNProductCenterManager *productCenterManager; @property (nonatomic, strong) QNUserPropertiesManager *userPropertiesManager; +@property (atomic, assign, readonly) QONRemoteConfigDeliveryOrigin lastDeliveryOrigin; + +- (instancetype)initWithLocalStorage:(nullable id)localStorage; +- (void)userChangingRequestStarted; - (void)userChangingRequestFailedWithError:(NSError *)error; - (void)handlePendingRequests; - (void)obtainRemoteConfigWithContextKey:(NSString * _Nullable)contextKey completion:(QONRemoteConfigCompletionHandler)completion; @@ -31,14 +48,15 @@ NS_ASSUME_NONNULL_BEGIN - (void)attachUserToRemoteConfiguration:(NSString *)remoteConfigurationId completion:(QONRemoteConfigurationAttachCompletionHandler)completion; - (void)detachUserFromRemoteConfiguration:(NSString *)remoteConfigurationId completion:(QONRemoteConfigurationAttachCompletionHandler)completion; - (void)userHasBeenChanged; +- (void)userHasBeenChangedToUserID:(NSString *)userID; /** Marks every cached remote config stale so the next load fetches a fresh targeting evaluation. Non-destructive: loading states and pending completions survive; the cache generation bump keeps in-flight loads from re-caching a superseded response and re-issues an awaited in-flight load once - (DEV-1236 B4). Runs synchronously on the caller thread — see the - implementation note about ordering with handlePendingRequests. + (DEV-1236 B4). Synchronously joins the manager's serial state executor, which + preserves ordering with handlePendingRequests and identity changes. */ - (void)invalidateRemoteConfigsCache; diff --git a/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigManager.m b/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigManager.m index 55088af3..58ee1a36 100644 --- a/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigManager.m +++ b/Sources/Qonversion/Qonversion/Main/QONRemoteConfigManager/QONRemoteConfigManager.m @@ -19,13 +19,96 @@ #import "NSError+Sugare.h" #import "QONFallbackObject.h" #import "QNUtils.h" +#import "QNLocalStorage.h" +#import "QNAPIClient.h" +#import "QONRemoteConfig+Protected.h" +#import "QONRemoteConfigurationSource+Protected.h" +#import "QONExperiment+Protected.h" +#import "QONExperimentGroup+Protected.h" static NSString *const kEmptyContextKey = @""; +static NSString *const kRemoteConfigLKGStorageKey = @"com.qonversion.keys.remote-config-lkg"; +static NSString *const kRemoteConfigQonversionErrorDomain = @"com.qonversion.io"; +static NSInteger const kRemoteConfigLKGSchemaVersion = 2; +static NSUInteger const kRemoteConfigLKGMaxEntries = 64; +static NSUInteger const kRemoteConfigLKGMaxBytes = 512 * 1024; +static char kRemoteConfigStateQueueKey; + +static NSString *const kLKGSchemaVersion = @"schema_version"; +static NSString *const kLKGEntries = @"entries"; +static NSString *const kLKGProjectKey = @"project_key"; +static NSString *const kLKGEffectiveAPIKey = @"effective_api_key"; +static NSString *const kLKGEnvironment = @"environment"; +static NSString *const kLKGUserID = @"user_id"; +static NSString *const kLKGContextKey = @"context_key"; +static NSString *const kLKGConfig = @"config"; +static NSString *const kLKGPayload = @"payload"; +static NSString *const kLKGSource = @"source"; +static NSString *const kLKGIdentifier = @"identifier"; +static NSString *const kLKGName = @"name"; +static NSString *const kLKGType = @"type"; +static NSString *const kLKGAssignmentType = @"assignment_type"; +static NSString *const kLKGExperiment = @"experiment"; +static NSString *const kLKGGroup = @"group"; + +static BOOL QONRemoteConfigIsKnownSourceType(NSInteger type) { + switch (type) { + case QONRemoteConfigurationSourceTypeExperimentControlGroup: + case QONRemoteConfigurationSourceTypeExperimentTreatmentGroup: + case QONRemoteConfigurationSourceTypeRemoteConfiguration: + return YES; + default: + return NO; + } +} + +static BOOL QONRemoteConfigIsKnownAssignmentType(NSInteger type) { + switch (type) { + case QONRemoteConfigurationAssignmentTypeAuto: + case QONRemoteConfigurationAssignmentTypeManual: + case QONRemoteConfigurationAssignmentTypeFrozen: + return YES; + default: + return NO; + } +} + +static BOOL QONRemoteConfigIsKnownExperimentGroupType(NSInteger type) { + switch (type) { + case QONExperimentGroupTypeControl: + case QONExperimentGroupTypeTreatment: + return YES; + default: + return NO; + } +} + +static BOOL QONRemoteConfigIsExactIntegralNumber(id value) { + if (![value isKindOfClass:[NSNumber class]] || + CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID()) { + return NO; + } + NSNumber *number = value; + return [number isEqualToNumber:@(number.integerValue)]; +} + +@interface QONRemoteConfigCacheScope : NSObject + +@property (nonatomic, copy) NSString *projectKey; +@property (nonatomic, copy) NSString *effectiveAPIKey; +@property (nonatomic, copy) NSString *environment; +@property (nonatomic, copy) NSString *userID; + +@end + +@implementation QONRemoteConfigCacheScope +@end @interface QONRemoteConfigManager () @property (nonatomic, strong) NSMutableDictionary *loadingStates; @property (nonatomic, strong) NSMutableArray *listRequests; +@property (nonatomic, strong) NSMutableArray *activeListRequests; @property (nonatomic, strong) QONFallbackObject *fallbackData; // Bumped on every cache invalidation (attach/detach, user change). Loads @@ -33,30 +116,656 @@ @interface QONRemoteConfigManager () // in-flight response evaluated before the invalidating event must not be // re-cached as fresh. Completions are still delivered either way. @property (atomic, assign) NSUInteger cacheGeneration; +// Cleared when the next identify attempt starts, so that attempt's requests +// wait for its own success/failure instead of inheriting the previous error. +@property (nonatomic, strong, nullable) NSError *pendingUserChangeError; +@property (nonatomic, strong, nullable) id localStorage; +@property (atomic, assign, readwrite) QONRemoteConfigDeliveryOrigin lastDeliveryOrigin; +@property (nonatomic, strong) dispatch_queue_t stateQueue; +@property (nonatomic, strong) NSMutableArray *deferredUserCallbacks; @end @implementation QONRemoteConfigManager - (instancetype)init { + return [self initWithLocalStorage:nil]; +} + +- (instancetype)initWithLocalStorage:(id)localStorage { self = [super init]; if (self) { _remoteConfigService = [QONRemoteConfigService new]; _loadingStates = [NSMutableDictionary new]; _listRequests = [NSMutableArray new]; + _activeListRequests = [NSMutableArray new]; _fallbackService = [QONFallbackService new]; + _localStorage = localStorage; + _lastDeliveryOrigin = QONRemoteConfigDeliveryOriginUnknown; + _stateQueue = dispatch_queue_create("io.qonversion.remote-config-state", DISPATCH_QUEUE_SERIAL); + dispatch_queue_set_specific(_stateQueue, &kRemoteConfigStateQueueKey, (__bridge void *)self, NULL); } return self; } +- (BOOL)isOnStateQueue { + return dispatch_get_specific(&kRemoteConfigStateQueueKey) == (__bridge void *)self; +} + +- (void)performStateSync:(dispatch_block_t)block { + if ([self isOnStateQueue]) { + block(); + } else { + __block NSArray *callbacks = nil; + dispatch_sync(self.stateQueue, ^{ + NSAssert(self.deferredUserCallbacks == nil, @"Remote Config callback collector must not be nested across state transactions"); + self.deferredUserCallbacks = [NSMutableArray new]; + block(); + callbacks = [self.deferredUserCallbacks copy]; + self.deferredUserCallbacks = nil; + }); + // Preserve the pre-existing callback contract: cache-hit completions run + // on the API caller's thread, and network completions run on the service + // callback thread. Manager state is already committed and unlocked here, + // so re-entrant SDK calls cannot deadlock the serial executor. + for (dispatch_block_t callback in callbacks) { + callback(); + } + } +} + +- (void)deferUserCallback:(dispatch_block_t)callback { + if (!callback) { + return; + } + NSAssert([self isOnStateQueue] && self.deferredUserCallbacks != nil, + @"User callbacks must be deferred by an active state transaction"); + [self.deferredUserCallbacks addObject:[callback copy]]; +} + +- (NSString *)normalizedContextKey:(NSString *)contextKey { + return contextKey ?: kEmptyContextKey; +} + +- (NSData *)serializedJSONDataForObject:(id)object { + if (![NSJSONSerialization isValidJSONObject:object]) { + return nil; + } + return [NSJSONSerialization dataWithJSONObject:object options:0 error:nil]; +} + +- (QONRemoteConfigCacheScope *)currentRemoteConfigCacheScope { + if (!self.localStorage) { + return nil; + } + + QNAPIClient *apiClient = self.remoteConfigService.apiClient; + if (apiClient.apiKey.length == 0 || apiClient.userID.length == 0) { + return nil; + } + + QONRemoteConfigCacheScope *scope = [QONRemoteConfigCacheScope new]; + scope.projectKey = [apiClient.apiKey copy]; + scope.effectiveAPIKey = apiClient.debug + ? [NSString stringWithFormat:@"test_%@", apiClient.apiKey] + : [apiClient.apiKey copy]; + scope.environment = apiClient.debug ? @"sandbox" : @"production"; + scope.userID = [apiClient.userID copy]; + return scope; +} + +- (BOOL)cacheScope:(QONRemoteConfigCacheScope *)scope equalsScope:(QONRemoteConfigCacheScope *)otherScope { + if (!scope || !otherScope) { + return scope == otherScope; + } + + return [scope.projectKey isEqualToString:otherScope.projectKey] && + [scope.effectiveAPIKey isEqualToString:otherScope.effectiveAPIKey] && + [scope.environment isEqualToString:otherScope.environment] && + [scope.userID isEqualToString:otherScope.userID]; +} + +- (void)clearPersistentRemoteConfigLKG { + if (!self.localStorage) { + return; + } + + @try { + [self.localStorage removeObjectForKey:kRemoteConfigLKGStorageKey]; + } @catch (__unused NSException *exception) { + // Corrupt custom storage must never turn a Remote Config fallback into a + // process crash. There is no useful recovery action left if removal itself + // fails, so the archive is ignored for this request. + } +} + +- (NSArray *)validatedLKGEntriesFromStoredRoot:(id)storedRoot invalid:(BOOL *)invalid { + if (!storedRoot) { + return @[]; + } + if (![storedRoot isKindOfClass:[NSDictionary class]]) { + if (invalid) *invalid = YES; + return nil; + } + + NSDictionary *root = storedRoot; + NSNumber *version = root[kLKGSchemaVersion]; + NSArray *entries = root[kLKGEntries]; + if (![version isKindOfClass:[NSNumber class]] || version.integerValue != kRemoteConfigLKGSchemaVersion || + ![entries isKindOfClass:[NSArray class]] || entries.count > kRemoteConfigLKGMaxEntries || + ![NSJSONSerialization isValidJSONObject:root]) { + if (invalid) *invalid = YES; + return nil; + } + + NSData *serializedRoot = [NSJSONSerialization dataWithJSONObject:root options:0 error:nil]; + if (!serializedRoot || serializedRoot.length > kRemoteConfigLKGMaxBytes) { + if (invalid) *invalid = YES; + return nil; + } + + for (id entryObject in entries) { + if (![entryObject isKindOfClass:[NSDictionary class]]) { + if (invalid) *invalid = YES; + return nil; + } + NSDictionary *entry = entryObject; + if (![entry[kLKGProjectKey] isKindOfClass:[NSString class]] || + ![entry[kLKGEffectiveAPIKey] isKindOfClass:[NSString class]] || + ![entry[kLKGEnvironment] isKindOfClass:[NSString class]] || + ![entry[kLKGUserID] isKindOfClass:[NSString class]] || + ![entry[kLKGContextKey] isKindOfClass:[NSString class]] || + ![entry[kLKGConfig] isKindOfClass:[NSDictionary class]]) { + if (invalid) *invalid = YES; + return nil; + } + if (![self remoteConfigFromStoredDictionary:entry[kLKGConfig] + expectedContextKey:entry[kLKGContextKey]]) { + if (invalid) *invalid = YES; + return nil; + } + } + + return entries; +} + +- (NSArray *)loadPersistentLKGEntries { + if (!self.localStorage) { + return @[]; + } + + id storedRoot = nil; + @try { + storedRoot = [self.localStorage loadObjectForKey:kRemoteConfigLKGStorageKey]; + } @catch (__unused NSException *exception) { + [self clearPersistentRemoteConfigLKG]; + return @[]; + } + + BOOL invalid = NO; + NSArray *entries = [self validatedLKGEntriesFromStoredRoot:storedRoot invalid:&invalid]; + if (invalid) { + [self clearPersistentRemoteConfigLKG]; + return @[]; + } + return entries ?: @[]; +} + +- (void)storePersistentLKGEntries:(NSArray *)entries { + if (!self.localStorage) { + return; + } + + NSArray *sourceEntries = entries ?: @[]; + NSDictionary *emptyRoot = @{ + kLKGSchemaVersion: @(kRemoteConfigLKGSchemaVersion), + kLKGEntries: @[], + }; + NSData *emptyRootData = [self serializedJSONDataForObject:emptyRoot]; + if (!emptyRootData || emptyRootData.length > kRemoteConfigLKGMaxBytes) { + [self clearPersistentRemoteConfigLKG]; + return; + } + + // Build the newest suffix in one pass. Serializing each candidate exactly + // once avoids repeatedly encoding a multi-megabyte aggregate while evicting + // old entries one by one. For compact JSON, replacing the empty [] in the + // root costs the sum of entry byte lengths plus one comma per extra entry. + NSUInteger firstCandidateIndex = sourceEntries.count > kRemoteConfigLKGMaxEntries + ? sourceEntries.count - kRemoteConfigLKGMaxEntries + : 0; + NSUInteger cumulativeBytes = emptyRootData.length; + NSMutableArray *newestFirstEntries = [NSMutableArray new]; + for (NSUInteger index = sourceEntries.count; index > firstCandidateIndex; index--) { + NSDictionary *entry = sourceEntries[index - 1]; + NSData *entryData = [self serializedJSONDataForObject:entry]; + if (!entryData) { + [self clearPersistentRemoteConfigLKG]; + return; + } + NSUInteger separatorBytes = newestFirstEntries.count > 0 ? 1 : 0; + NSUInteger availableBytes = kRemoteConfigLKGMaxBytes - cumulativeBytes; + if (separatorBytes > availableBytes || + entryData.length > availableBytes - separatorBytes) { + break; + } + [newestFirstEntries addObject:entry]; + cumulativeBytes += entryData.length + separatorBytes; + } + + if (newestFirstEntries.count == 0) { + [self clearPersistentRemoteConfigLKG]; + return; + } + + NSArray *boundedEntries = newestFirstEntries.reverseObjectEnumerator.allObjects; + // Only Foundation property-list/JSON value classes cross the archive + // boundary. SDK model instances are deliberately reconstructed explicitly, + // avoiding class-name-coupled NSCoding archives across SDK upgrades. + NSDictionary *root = @{ + kLKGSchemaVersion: @(kRemoteConfigLKGSchemaVersion), + kLKGEntries: boundedEntries, + }; + NSData *serializedRoot = [self serializedJSONDataForObject:root]; + if (!serializedRoot || serializedRoot.length > kRemoteConfigLKGMaxBytes) { + [self clearPersistentRemoteConfigLKG]; + return; + } + + @try { + [self.localStorage storeObject:root forKey:kRemoteConfigLKGStorageKey]; + } @catch (__unused NSException *exception) { + [self clearPersistentRemoteConfigLKG]; + } +} + +- (NSDictionary *)storedDictionaryForRemoteConfig:(QONRemoteConfig *)remoteConfig + contextKey:(NSString *)contextKey { + if (!remoteConfig || !remoteConfig.source || remoteConfig.source.identifier.length == 0) { + return nil; + } + + NSString *normalizedContextKey = [self normalizedContextKey:contextKey]; + NSString *sourceContextKey = [self normalizedContextKey:remoteConfig.source.contextKey]; + if (![sourceContextKey isEqualToString:normalizedContextKey]) { + return nil; + } + if (remoteConfig.payload && ![remoteConfig.payload isKindOfClass:[NSDictionary class]]) { + return nil; + } + if (remoteConfig.payload && ![NSJSONSerialization isValidJSONObject:remoteConfig.payload]) { + return nil; + } + if (!QONRemoteConfigIsKnownSourceType(remoteConfig.source.type) || + !QONRemoteConfigIsKnownAssignmentType(remoteConfig.source.assignmentType) || + (remoteConfig.experiment && + !QONRemoteConfigIsKnownExperimentGroupType(remoteConfig.experiment.group.type))) { + return nil; + } + + NSDictionary *source = @{ + kLKGIdentifier: remoteConfig.source.identifier, + kLKGName: remoteConfig.source.name ?: @"", + kLKGType: @(remoteConfig.source.type), + kLKGAssignmentType: @(remoteConfig.source.assignmentType), + kLKGContextKey: sourceContextKey, + }; + + id experiment = [NSNull null]; + if (remoteConfig.experiment) { + QONExperimentGroup *group = remoteConfig.experiment.group; + if (!group || remoteConfig.experiment.identifier.length == 0 || group.identifier.length == 0) { + return nil; + } + experiment = @{ + kLKGIdentifier: remoteConfig.experiment.identifier, + kLKGName: remoteConfig.experiment.name ?: @"", + kLKGGroup: @{ + kLKGIdentifier: group.identifier, + kLKGName: group.name ?: @"", + kLKGType: @(group.type), + }, + }; + } + + NSDictionary *storedConfig = @{ + kLKGPayload: remoteConfig.payload ?: [NSNull null], + kLKGSource: source, + kLKGExperiment: experiment, + }; + return [NSJSONSerialization isValidJSONObject:storedConfig] ? storedConfig : nil; +} + +- (QONRemoteConfig *)remoteConfigFromStoredDictionary:(NSDictionary *)storedConfig + expectedContextKey:(NSString *)expectedContextKey { + if (![storedConfig isKindOfClass:[NSDictionary class]]) { + return nil; + } + + id payloadObject = storedConfig[kLKGPayload]; + NSDictionary *payload = nil; + if (payloadObject != [NSNull null]) { + if (![payloadObject isKindOfClass:[NSDictionary class]] || + ![NSJSONSerialization isValidJSONObject:payloadObject]) { + return nil; + } + payload = payloadObject; + } + + NSDictionary *sourceData = storedConfig[kLKGSource]; + if (![sourceData isKindOfClass:[NSDictionary class]] || + ![sourceData[kLKGIdentifier] isKindOfClass:[NSString class]] || + [sourceData[kLKGIdentifier] length] == 0 || + ![sourceData[kLKGName] isKindOfClass:[NSString class]] || + !QONRemoteConfigIsExactIntegralNumber(sourceData[kLKGType]) || + !QONRemoteConfigIsExactIntegralNumber(sourceData[kLKGAssignmentType]) || + ![sourceData[kLKGContextKey] isKindOfClass:[NSString class]] || + ![sourceData[kLKGContextKey] isEqualToString:[self normalizedContextKey:expectedContextKey]]) { + return nil; + } + + NSString *sourceContextKey = sourceData[kLKGContextKey]; + NSInteger sourceType = [sourceData[kLKGType] integerValue]; + NSInteger assignmentType = [sourceData[kLKGAssignmentType] integerValue]; + if (!QONRemoteConfigIsKnownSourceType(sourceType) || + !QONRemoteConfigIsKnownAssignmentType(assignmentType)) { + return nil; + } + QONRemoteConfigurationSource *source = [[QONRemoteConfigurationSource alloc] + initWithIdentifier:sourceData[kLKGIdentifier] + name:sourceData[kLKGName] + type:sourceType + assignmentType:assignmentType + contextKey:sourceContextKey.length > 0 ? sourceContextKey : nil]; + + QONExperiment *experiment = nil; + id experimentObject = storedConfig[kLKGExperiment]; + if (experimentObject != [NSNull null]) { + if (![experimentObject isKindOfClass:[NSDictionary class]]) { + return nil; + } + NSDictionary *experimentData = experimentObject; + NSDictionary *groupData = experimentData[kLKGGroup]; + if (![experimentData[kLKGIdentifier] isKindOfClass:[NSString class]] || + [experimentData[kLKGIdentifier] length] == 0 || + ![experimentData[kLKGName] isKindOfClass:[NSString class]] || + ![groupData isKindOfClass:[NSDictionary class]] || + ![groupData[kLKGIdentifier] isKindOfClass:[NSString class]] || + [groupData[kLKGIdentifier] length] == 0 || + ![groupData[kLKGName] isKindOfClass:[NSString class]] || + !QONRemoteConfigIsExactIntegralNumber(groupData[kLKGType])) { + return nil; + } + NSInteger groupType = [groupData[kLKGType] integerValue]; + if (!QONRemoteConfigIsKnownExperimentGroupType(groupType)) { + return nil; + } + QONExperimentGroup *group = [[QONExperimentGroup alloc] + initWithIdentifier:groupData[kLKGIdentifier] + type:groupType + name:groupData[kLKGName]]; + experiment = [[QONExperiment alloc] initWithIdentifier:experimentData[kLKGIdentifier] + name:experimentData[kLKGName] + group:group]; + } + + return [[QONRemoteConfig alloc] initWithPayload:payload experiment:experiment source:source]; +} + +- (BOOL)entry:(NSDictionary *)entry matchesScope:(QONRemoteConfigCacheScope *)scope { + return [entry[kLKGProjectKey] isEqualToString:scope.projectKey] && + [entry[kLKGEffectiveAPIKey] isEqualToString:scope.effectiveAPIKey] && + [entry[kLKGEnvironment] isEqualToString:scope.environment] && + [entry[kLKGUserID] isEqualToString:scope.userID]; +} + +- (BOOL)isPersistentLKGEntryWithinQuota:(NSDictionary *)entry { + NSDictionary *singleEntryRoot = @{ + kLKGSchemaVersion: @(kRemoteConfigLKGSchemaVersion), + kLKGEntries: @[entry], + }; + if (![NSJSONSerialization isValidJSONObject:singleEntryRoot]) { + return NO; + } + NSData *serializedRoot = [NSJSONSerialization dataWithJSONObject:singleEntryRoot options:0 error:nil]; + return serializedRoot && serializedRoot.length <= kRemoteConfigLKGMaxBytes; +} + +- (void)storeServerRemoteConfig:(QONRemoteConfig *)remoteConfig + contextKey:(NSString *)contextKey + scope:(QONRemoteConfigCacheScope *)scope { + if (!scope || !self.localStorage) { + return; + } + NSString *normalizedContextKey = [self normalizedContextKey:contextKey]; + NSDictionary *storedConfig = [self storedDictionaryForRemoteConfig:remoteConfig contextKey:normalizedContextKey]; + if (!storedConfig) { + return; + } + + NSDictionary *newEntry = @{ + kLKGProjectKey: scope.projectKey, + kLKGEffectiveAPIKey: scope.effectiveAPIKey, + kLKGEnvironment: scope.environment, + kLKGUserID: scope.userID, + kLKGContextKey: normalizedContextKey, + kLKGConfig: storedConfig, + }; + // Reject one pathological payload without evicting unrelated valid entries. + if (![self isPersistentLKGEntryWithinQuota:newEntry]) { + return; + } + + @synchronized (self) { + NSMutableArray *entries = [[self loadPersistentLKGEntries] mutableCopy]; + NSIndexSet *existing = [entries indexesOfObjectsPassingTest:^BOOL(NSDictionary *entry, NSUInteger idx, BOOL *stop) { + return [self entry:entry matchesScope:scope] && + [entry[kLKGContextKey] isEqualToString:normalizedContextKey]; + }]; + [entries removeObjectsAtIndexes:existing]; + [entries addObject:newEntry]; + [self storePersistentLKGEntries:entries]; + } +} + +- (QONRemoteConfig *)persistentLKGForContextKey:(NSString *)contextKey + scope:(QONRemoteConfigCacheScope *)scope { + if (!scope || !self.localStorage) { + return nil; + } + NSString *normalizedContextKey = [self normalizedContextKey:contextKey]; + @synchronized (self) { + NSMutableArray *entries = [[self loadPersistentLKGEntries] mutableCopy]; + for (NSUInteger index = 0; index < entries.count; index++) { + NSDictionary *entry = entries[index]; + if ([self entry:entry matchesScope:scope] && + [entry[kLKGContextKey] isEqualToString:normalizedContextKey]) { + QONRemoteConfig *config = [self remoteConfigFromStoredDictionary:entry[kLKGConfig] + expectedContextKey:normalizedContextKey]; + if (!config) { + [self clearPersistentRemoteConfigLKG]; + return nil; + } + if (index + 1 < entries.count) { + // Array order is the persisted LRU index: oldest first, newest last. + [entries removeObjectAtIndex:index]; + [entries addObject:entry]; + [self storePersistentLKGEntries:entries]; + } + return config; + } + } + } + return nil; +} + +- (void)removePersistentLKGForContextKey:(NSString *)contextKey + scope:(QONRemoteConfigCacheScope *)scope { + if (!scope || !self.localStorage) { + return; + } + NSString *normalizedContextKey = [self normalizedContextKey:contextKey]; + @synchronized (self) { + NSMutableArray *entries = [[self loadPersistentLKGEntries] mutableCopy]; + NSIndexSet *matches = [entries indexesOfObjectsPassingTest:^BOOL(NSDictionary *entry, NSUInteger idx, BOOL *stop) { + return [self entry:entry matchesScope:scope] && + [entry[kLKGContextKey] isEqualToString:normalizedContextKey]; + }]; + if (matches.count == 0) { + return; + } + [entries removeObjectsAtIndexes:matches]; + if (entries.count == 0) { + [self clearPersistentRemoteConfigLKG]; + } else { + [self storePersistentLKGEntries:entries]; + } + } +} + +- (NSArray *)persistentLKGForContextKeys:(NSArray *)contextKeys + includeEmptyContextKey:(BOOL)includeEmptyContextKey + scope:(QONRemoteConfigCacheScope *)scope { + if (!scope || !self.localStorage) { + return @[]; + } + + NSMutableSet *requestedKeys = nil; + if (contextKeys) { + requestedKeys = [NSMutableSet setWithArray:contextKeys]; + if (includeEmptyContextKey) { + [requestedKeys addObject:kEmptyContextKey]; + } + } + + NSMutableArray *configs = [NSMutableArray new]; + @synchronized (self) { + NSArray *entries = [self loadPersistentLKGEntries]; + NSMutableArray *untouchedEntries = [NSMutableArray new]; + NSMutableArray *accessedEntries = [NSMutableArray new]; + for (NSDictionary *entry in entries) { + if (![self entry:entry matchesScope:scope]) { + [untouchedEntries addObject:entry]; + continue; + } + NSString *contextKey = entry[kLKGContextKey]; + if (requestedKeys && ![requestedKeys containsObject:contextKey]) { + [untouchedEntries addObject:entry]; + continue; + } + QONRemoteConfig *config = [self remoteConfigFromStoredDictionary:entry[kLKGConfig] + expectedContextKey:contextKey]; + if (!config) { + [self clearPersistentRemoteConfigLKG]; + return @[]; + } + [configs addObject:config]; + [accessedEntries addObject:entry]; + } + if (accessedEntries.count > 0) { + [untouchedEntries addObjectsFromArray:accessedEntries]; + [self storePersistentLKGEntries:untouchedEntries]; + } + } + return configs; +} + +- (void)replacePersistentLKGWithServerList:(QONRemoteConfigList *)remoteConfigList + contextKeys:(NSArray *)contextKeys + includeEmptyContextKey:(BOOL)includeEmptyContextKey + scope:(QONRemoteConfigCacheScope *)scope { + if (!scope || !self.localStorage || !remoteConfigList) { + return; + } + + NSMutableSet *replacedKeys = nil; + if (contextKeys) { + replacedKeys = [NSMutableSet setWithArray:contextKeys]; + if (includeEmptyContextKey) { + [replacedKeys addObject:kEmptyContextKey]; + } + } + + NSMutableArray *newEntries = [NSMutableArray new]; + NSMutableSet *uncacheableReturnedKeys = [NSMutableSet new]; + NSMutableSet *cacheableReturnedKeys = [NSMutableSet new]; + for (QONRemoteConfig *config in remoteConfigList.remoteConfigs) { + NSString *contextKey = [self normalizedContextKey:config.source.contextKey]; + if (replacedKeys && ![replacedKeys containsObject:contextKey]) { + continue; + } + NSDictionary *storedConfig = [self storedDictionaryForRemoteConfig:config contextKey:contextKey]; + if (!storedConfig) { + if (![cacheableReturnedKeys containsObject:contextKey]) { + [uncacheableReturnedKeys addObject:contextKey]; + } + continue; + } + NSDictionary *newEntry = @{ + kLKGProjectKey: scope.projectKey, + kLKGEffectiveAPIKey: scope.effectiveAPIKey, + kLKGEnvironment: scope.environment, + kLKGUserID: scope.userID, + kLKGContextKey: contextKey, + kLKGConfig: storedConfig, + }; + if (![self isPersistentLKGEntryWithinQuota:newEntry]) { + if (![cacheableReturnedKeys containsObject:contextKey]) { + [uncacheableReturnedKeys addObject:contextKey]; + } + continue; + } + [cacheableReturnedKeys addObject:contextKey]; + [uncacheableReturnedKeys removeObject:contextKey]; + NSIndexSet *duplicateIndexes = [newEntries indexesOfObjectsPassingTest:^BOOL(NSDictionary *entry, NSUInteger idx, BOOL *stop) { + return [entry[kLKGContextKey] isEqualToString:contextKey]; + }]; + [newEntries removeObjectsAtIndexes:duplicateIndexes]; + if (newEntries.count == kRemoteConfigLKGMaxEntries) { + NSString *droppedContextKey = newEntries.firstObject[kLKGContextKey]; + [newEntries removeObjectAtIndex:0]; + [cacheableReturnedKeys removeObject:droppedContextKey]; + [uncacheableReturnedKeys addObject:droppedContextKey]; + } + [newEntries addObject:newEntry]; + } + + @synchronized (self) { + NSMutableArray *entries = [NSMutableArray new]; + for (NSDictionary *entry in [self loadPersistentLKGEntries]) { + BOOL sameScope = [self entry:entry matchesScope:scope]; + BOOL keyCoveredByResponse = !replacedKeys || [replacedKeys containsObject:entry[kLKGContextKey]]; + BOOL shouldPreserveUncacheableLKG = [uncacheableReturnedKeys containsObject:entry[kLKGContextKey]]; + BOOL shouldReplace = sameScope && keyCoveredByResponse && !shouldPreserveUncacheableLKG; + if (!shouldReplace) { + [entries addObject:entry]; + } + } + [entries addObjectsFromArray:newEntries]; + [self storePersistentLKGEntries:entries]; + } +} + - (void)handlePendingRequests { + if (![self isOnStateQueue]) { + [self performStateSync:^{ + [self handlePendingRequests]; + }]; + return; + } + + if ([self.productCenterManager isUserStable]) { + // A successful identity boundary supersedes the previous terminal error; + // new requests may now replay against the stable scope. + self.pendingUserChangeError = nil; + } + for (NSString *contextKey in self.loadingStates) { QONRemoteConfigLoadingState *loadingState = [self loadingStateForContextKey:contextKey]; if (loadingState && loadingState.completions.count > 0) { - [self obtainRemoteConfigWithContextKey:contextKey - completion:^(QONRemoteConfig * _Nullable remoteConfig, NSError * _Nullable error) {}]; + [self resumeRemoteConfigLoadForContextKey:contextKey loadingState:loadingState]; } } @@ -65,14 +774,32 @@ - (void)handlePendingRequests { for (QONRemoteConfigListRequestData *listRequest in requestsToSend) { if (listRequest.contextKeys) { - [self obtainRemoteConfigListWithContextKeys:listRequest.contextKeys includeEmptyContextKey:listRequest.includeEmptyContextKey completion:listRequest.completion]; + [self processRemoteConfigListRequestWithContextKeys:listRequest]; } else { - [self obtainRemoteConfigList:listRequest.completion]; + [self processRemoteConfigListRequest:listRequest]; } } } +- (void)userChangingRequestStarted { + [self performStateSync:^{ + self.pendingUserChangeError = nil; + }]; +} + - (void)userChangingRequestFailedWithError:(NSError *)error { + if (![self isOnStateQueue]) { + [self performStateSync:^{ + [self userChangingRequestFailedWithError:error]; + }]; + return; + } + + self.pendingUserChangeError = error; + + [self.listRequests removeAllObjects]; + NSArray *activeListRequests = [self.activeListRequests copy]; + for (NSString *contextKey in self.loadingStates) { QONRemoteConfigLoadingState *loadingState = [self loadingStateForContextKey:contextKey]; if (loadingState) { @@ -84,27 +811,58 @@ - (void)userChangingRequestFailedWithError:(NSError *)error { [self executeRemoteConfigCompletionsWithContextKey:contextKey remoteConfig:nil error:error]; } } + + for (QONRemoteConfigListRequestData *listRequest in activeListRequests) { + [self completeRemoteConfigListRequest:listRequest remoteConfigList:nil error:error]; + } } -// Public cache invalidation seam (DEV-1236 B4). Deliberately synchronous on -// the caller thread: handlePendingRequests is synchronous too, and the -// same-uid identify path relies on the invalidate-then-replay order — hopping -// only this call to the main queue would let the replay hit a still-warm -// cache and orphan queued completions. The generation bump is taken under the -// lock; the loadingStates sweep shares the manager's pre-existing -// unsynchronized access pattern (same as handlePendingRequests). +// Public cache invalidation seam (DEV-1236 B4). Deliberately synchronous: the +// same-uid identify path relies on invalidate-then-replay ordering. All state +// transitions share one serial executor, so an identity mutation cannot land +// between an in-flight response's scope check and completion delivery. - (void)invalidateRemoteConfigsCache { - [self invalidateLoadedConfigs]; + [self performStateSync:^{ + [self invalidateLoadedConfigs]; + }]; } - (void)userHasBeenChanged { - [self bumpCacheGeneration]; - self.loadingStates = [NSMutableDictionary new]; + [self performStateSync:^{ + self.pendingUserChangeError = nil; + [self bumpCacheGeneration]; + [self replaceLoadingStatesPreservingPendingCompletions]; + }]; } -// The increment is a read-modify-write: user changes fire from network -// callback threads while attach/detach invalidations run on the caller -// thread, so it is taken under the lock to avoid losing a bump. +- (void)userHasBeenChangedToUserID:(NSString *)userID { + [self performStateSync:^{ + self.pendingUserChangeError = nil; + // QNAPIClient identity and the manager's scope boundary are one atomic + // state transition. No response can observe a new API uid with the old + // loading-state map (or the inverse). + [self.remoteConfigService.apiClient setUserID:userID]; + [self bumpCacheGeneration]; + [self replaceLoadingStatesPreservingPendingCompletions]; + }]; +} + +- (void)replaceLoadingStatesPreservingPendingCompletions { + NSMutableDictionary *newStates = [NSMutableDictionary new]; + [self.loadingStates enumerateKeysAndObjectsUsingBlock:^(NSString *contextKey, QONRemoteConfigLoadingState *oldState, BOOL *stop) { + if (oldState.completions.count == 0) { + return; + } + QONRemoteConfigLoadingState *newState = [QONRemoteConfigLoadingState new]; + [newState.completions addObjectsFromArray:oldState.completions]; + [oldState.completions removeAllObjects]; + newStates[contextKey] = newState; + }]; + self.loadingStates = newStates; +} + +// Keep the increment atomic even for any future internal call site that is not +// yet confined to the state executor. - (void)bumpCacheGeneration { @synchronized (self) { self.cacheGeneration += 1; @@ -112,92 +870,206 @@ - (void)bumpCacheGeneration { } - (void)obtainRemoteConfigWithContextKey:(NSString * _Nullable)contextKey completion:(QONRemoteConfigCompletionHandler)completion { + if (![self isOnStateQueue]) { + [self performStateSync:^{ + [self obtainRemoteConfigWithContextKey:contextKey completion:completion]; + }]; + return; + } + + if (![self.productCenterManager isUserStable] && self.pendingUserChangeError) { + NSError *error = self.pendingUserChangeError; + [self deferUserCallback:^{ + completion(nil, error); + }]; + return; + } + QONRemoteConfigLoadingState *loadingState = [self loadingStateForContextKey:contextKey]; if (loadingState == nil) { loadingState = [QONRemoteConfigLoadingState new]; self.loadingStates[contextKey ?: kEmptyContextKey] = loadingState; } + [loadingState.completions addObject:completion]; BOOL isUserStable = [self.productCenterManager isUserStable]; if (!isUserStable || loadingState.isInProgress) { - [loadingState.completions addObject:completion]; - return; } - + + [self resumeRemoteConfigLoadForContextKey:contextKey loadingState:loadingState]; +} + +- (void)resumeRemoteConfigLoadForContextKey:(NSString *)contextKey + loadingState:(QONRemoteConfigLoadingState *)loadingState { + if (loadingState.completions.count == 0 || loadingState.isInProgress || + ![self.productCenterManager isUserStable]) { + return; + } + if (loadingState.loadedConfig) { // The cached config is served as is, but properties set right before this // call must still reach the server — otherwise a cache hit swallows both // the property flush and the request. [self.userPropertiesManager forceSendProperties:nil]; + if (![self.productCenterManager isUserStable]) { + return; + } QONRemoteConfig *cachedConfig = loadingState.loadedConfig; // A retry resolved by a warm cache consumes its stashed baseline — a // leftover stash must not resurface on a later, unrelated failure. loadingState.retryBaseline = nil; + self.lastDeliveryOrigin = QONRemoteConfigDeliveryOriginMemory; // Queued completions can be stranded on a warm state (e.g. a list load // re-caches a key whose superseded single-key load was re-issued, or a // completion queued while the user was unstable meets a warm cache on // replay) — drain them together with the direct caller, or they never // fire at all. [self executeRemoteConfigCompletionsWithContextKey:contextKey remoteConfig:cachedConfig error:nil]; - return completion(cachedConfig, nil); + return; } - + + [self startRemoteConfigLoadForContextKey:contextKey loadingState:loadingState]; +} + +- (QONRemoteConfigLoadingState *)movePendingCompletionsFromLoadingState:(QONRemoteConfigLoadingState *)oldState + contextKey:(NSString *)contextKey { + QONRemoteConfigLoadingState *liveState = [self loadingStateForContextKey:contextKey]; + if (liveState == oldState || liveState == nil) { + liveState = [QONRemoteConfigLoadingState new]; + self.loadingStates[contextKey ?: kEmptyContextKey] = liveState; + } + if (oldState.completions.count > 0) { + [liveState.completions addObjectsFromArray:oldState.completions]; + [oldState.completions removeAllObjects]; + } + return liveState; +} + +- (void)startRemoteConfigLoadForContextKey:(NSString *)contextKey + loadingState:(QONRemoteConfigLoadingState *)loadingState { loadingState.isInProgress = YES; NSUInteger generationAtStart = self.cacheGeneration; + QONRemoteConfigCacheScope *scopeAtStart = [self currentRemoteConfigCacheScope]; __block __weak QONRemoteConfigManager *weakSelf = self; [self.userPropertiesManager forceSendProperties:^{ - [weakSelf.remoteConfigService loadRemoteConfig:contextKey completion:^(QONRemoteConfig * _Nullable remoteConfig, NSError * _Nullable error) { - loadingState.isInProgress = NO; - if (error) { - if (error.shouldFireFallback) { - [weakSelf actualizeFallbackData]; - QONRemoteConfig *remoteConfig; - if (contextKey.length == 0) { - remoteConfig = [weakSelf.fallbackData.remoteConfigList remoteConfigForEmptyContextKey]; - } else { - remoteConfig = [weakSelf.fallbackData.remoteConfigList remoteConfigForContextKey:contextKey]; + [weakSelf performStateSync:^{ + QONRemoteConfigCacheScope *scopeBeforeRequest = [weakSelf currentRemoteConfigCacheScope]; + BOOL userBecameUnstable = ![weakSelf.productCenterManager isUserStable]; + BOOL preflightScopeChanged = ![weakSelf cacheScope:scopeAtStart equalsScope:scopeBeforeRequest]; + BOOL preflightStateOrphaned = [weakSelf loadingStateForContextKey:contextKey] != loadingState; + if (userBecameUnstable || preflightScopeChanged || preflightStateOrphaned) { + loadingState.isInProgress = NO; + QONRemoteConfigLoadingState *liveState = loadingState; + if (preflightScopeChanged || preflightStateOrphaned) { + liveState = [weakSelf movePendingCompletionsFromLoadingState:loadingState contextKey:contextKey]; + } + // resumeRemoteConfigLoad... keeps the waiters live while identity is + // unstable and starts exactly one request once handlePendingRequests + // observes a stable user. + [weakSelf resumeRemoteConfigLoadForContextKey:contextKey loadingState:liveState]; + return; + } + + [weakSelf.remoteConfigService loadRemoteConfig:contextKey completion:^(QONRemoteConfig * _Nullable remoteConfig, NSError * _Nullable error) { + [weakSelf performStateSync:^{ + loadingState.isInProgress = NO; + + // A response that started for an old identity/project (or whose loading + // state was orphaned by userHasBeenChanged) must never cross that scope + // boundary. Carry both the initiating caller and queued waiters into one + // fresh request for the current identity instead. + QONRemoteConfigCacheScope *currentScope = [weakSelf currentRemoteConfigCacheScope]; + BOOL userBecameUnstable = ![weakSelf.productCenterManager isUserStable]; + BOOL scopeChanged = ![weakSelf cacheScope:scopeAtStart equalsScope:currentScope]; + BOOL stateOrphaned = [weakSelf loadingStateForContextKey:contextKey] != loadingState; + if (userBecameUnstable || scopeChanged || stateOrphaned) { + QONRemoteConfigLoadingState *liveState = loadingState; + if (scopeChanged || stateOrphaned) { + liveState = [weakSelf movePendingCompletionsFromLoadingState:loadingState contextKey:contextKey]; + } + [weakSelf resumeRemoteConfigLoadForContextKey:contextKey loadingState:liveState]; + return; } - if (remoteConfig) { - // The only signal a developer gets that this is not a fresh - // targeting evaluation — a silently served bundle would let a - // stale-config loop ship unnoticed. - QONVERSION_LOG(@"⚠️ Serving the bundled fallback remote config for context key '%@' — not a fresh targeting evaluation (%@)", contextKey ?: @"", error.localizedDescription); - [weakSelf fireRemoteConfig:remoteConfig contextKey:contextKey loadingState:loadingState error:nil generation:generationAtStart isFallback:YES completion:completion]; + if (error) { + if (error.shouldFireFallback) { + QONRemoteConfig *diskLKG = [weakSelf persistentLKGForContextKey:contextKey scope:scopeAtStart]; + if (diskLKG) { + QONVERSION_LOG(@"⚠️ Serving disk last-known-good remote config for context key '%@' after a transient refresh failure (%@)", contextKey ?: @"", error.localizedDescription); + [weakSelf fireRemoteConfig:diskLKG contextKey:contextKey loadingState:loadingState error:nil generation:generationAtStart deliveryOrigin:QONRemoteConfigDeliveryOriginDiskLastKnownGood scope:scopeAtStart]; + return; + } + [weakSelf actualizeFallbackData]; + QONRemoteConfig *fallbackConfig; + if (contextKey.length == 0) { + fallbackConfig = [weakSelf.fallbackData.remoteConfigList remoteConfigForEmptyContextKey]; + } else { + fallbackConfig = [weakSelf.fallbackData.remoteConfigList remoteConfigForContextKey:contextKey]; + } + + if (fallbackConfig) { + // The only signal a developer gets that this is not a fresh + // targeting evaluation — a silently served bundle would let a + // stale-config loop ship unnoticed. + QONVERSION_LOG(@"⚠️ Serving the bundled fallback remote config for context key '%@' — not a fresh targeting evaluation (%@)", contextKey ?: @"", error.localizedDescription); + [weakSelf fireRemoteConfig:fallbackConfig contextKey:contextKey loadingState:loadingState error:nil generation:generationAtStart deliveryOrigin:QONRemoteConfigDeliveryOriginBundle scope:scopeAtStart]; + } else { + [weakSelf fireRemoteConfig:nil contextKey:contextKey loadingState:loadingState error:error generation:generationAtStart deliveryOrigin:QONRemoteConfigDeliveryOriginUnknown scope:scopeAtStart]; + } + } else { + if ([error.domain isEqualToString:kRemoteConfigQonversionErrorDomain] && + error.code == QONErrorCodeRemoteConfigurationNotAvailable) { + // A healthy server has authoritatively said this context no longer + // has a config. Keeping an older disk entry would resurrect a + // removed assignment during the next outage. + [weakSelf removePersistentLKGForContextKey:contextKey scope:scopeAtStart]; + } + [weakSelf fireRemoteConfig:nil contextKey:contextKey loadingState:loadingState error:error generation:generationAtStart deliveryOrigin:QONRemoteConfigDeliveryOriginUnknown scope:scopeAtStart]; + } } else { - [weakSelf fireRemoteConfig:nil contextKey:contextKey loadingState:loadingState error:error generation:generationAtStart isFallback:NO completion:completion]; + [weakSelf fireRemoteConfig:remoteConfig contextKey:contextKey loadingState:loadingState error:nil generation:generationAtStart deliveryOrigin:QONRemoteConfigDeliveryOriginServer scope:scopeAtStart]; } - } else { - [weakSelf fireRemoteConfig:nil contextKey:contextKey loadingState:loadingState error:error generation:generationAtStart isFallback:NO completion:completion]; - } - } else { - [weakSelf fireRemoteConfig:remoteConfig contextKey:contextKey loadingState:loadingState error:nil generation:generationAtStart isFallback:NO completion:completion]; - } + }]; + }]; }]; }]; } -- (void)fireRemoteConfig:(QONRemoteConfig *)remoteConfig contextKey:(NSString *)contextKey loadingState:(QONRemoteConfigLoadingState *)loadingState error:(NSError *)error generation:(NSUInteger)generation isFallback:(BOOL)isFallback completion:(QONRemoteConfigCompletionHandler)completion { +- (void)fireRemoteConfig:(QONRemoteConfig *)remoteConfig + contextKey:(NSString *)contextKey + loadingState:(QONRemoteConfigLoadingState *)loadingState + error:(NSError *)error + generation:(NSUInteger)generation + deliveryOrigin:(QONRemoteConfigDeliveryOrigin)deliveryOrigin + scope:(QONRemoteConfigCacheScope *)scope { + if (![self.productCenterManager isUserStable]) { + // The response callback crossed into an identity window after its first + // boundary check. Keep every completion live; handlePendingRequests will + // replay them once the identity is stable again. + [self resumeRemoteConfigLoadForContextKey:contextKey loadingState:loadingState]; + return; + } if (error) { QONRemoteConfig *baseline = loadingState.retryBaseline; loadingState.retryBaseline = nil; - if (baseline) { + if (baseline && error.shouldFireFallback) { // A failed retry of a superseded load degrades to the baseline — a // real user-specific evaluation seconds old — for everyone, including - // callers who joined during the retry window. + // callers who joined during the retry window. Authoritative client + // errors must propagate instead of being hidden by a stale evaluation. + self.lastDeliveryOrigin = QONRemoteConfigDeliveryOriginRetryBaseline; [self executeRemoteConfigCompletionsWithContextKey:contextKey remoteConfig:baseline error:nil]; - completion(baseline, nil); return; } [self executeRemoteConfigCompletionsWithContextKey:contextKey remoteConfig:nil error:error]; - completion(nil, error); return; } - if (isFallback) { + if (deliveryOrigin == QONRemoteConfigDeliveryOriginDiskLastKnownGood || + deliveryOrigin == QONRemoteConfigDeliveryOriginBundle) { // The bundled fallback is a local last-resort payload, not a fresh // targeting evaluation — deliver it without caching so the next call // retries the network instead of pinning the fallback until the next @@ -207,57 +1079,130 @@ - (void)fireRemoteConfig:(QONRemoteConfig *)remoteConfig contextKey:(NSString *) QONRemoteConfig *baseline = loadingState.retryBaseline; loadingState.retryBaseline = nil; QONRemoteConfig *result = baseline ?: remoteConfig; + self.lastDeliveryOrigin = baseline ? QONRemoteConfigDeliveryOriginRetryBaseline : deliveryOrigin; [self executeRemoteConfigCompletionsWithContextKey:contextKey remoteConfig:result error:nil]; - completion(result, nil); return; } // A successful (or delivered-as-is) response supersedes any stashed baseline. loadingState.retryBaseline = nil; + self.lastDeliveryOrigin = deliveryOrigin; NSUInteger currentGeneration = self.cacheGeneration; if (generation == currentGeneration) { // Cache only when no invalidation happened while the load was in flight — // a superseded evaluation must not be re-cached as fresh. + if (deliveryOrigin == QONRemoteConfigDeliveryOriginServer) { + [self storeServerRemoteConfig:remoteConfig contextKey:contextKey scope:scope]; + } loadingState.loadedConfig = remoteConfig; + } else if ([self loadingStateForContextKey:contextKey] == loadingState && + loadingState.loadedConfig) { + // Another current-generation request (most notably a list request) may + // have warmed this same state while the superseded single-key response + // was in flight. That value has already crossed the current generation + // and scope checks, so it is fresher than this response. Serve every + // waiter from it instead of issuing a redundant request and leaving the + // queue dependent on a network completion nobody needed. + loadingState.retryBaseline = nil; + self.lastDeliveryOrigin = QONRemoteConfigDeliveryOriginMemory; + [self executeRemoteConfigCompletionsWithContextKey:contextKey + remoteConfig:loadingState.loadedConfig + error:nil]; + return; } else if ([self loadingStateForContextKey:contextKey] == loadingState && loadingState.reissuedForGeneration != currentGeneration) { // The cache was invalidated while this load was in flight, so this // evaluation is already superseded. Re-issue the load once so the waiters // receive a fresh evaluation instead of the stale one. The state must // still be live: a user switch replaces the map, and an orphaned state - // must not fire a request nobody awaits. Unlike Android, the initiating - // caller's completion is NOT queued in loadingState.completions — it is - // the `completion` argument here — so the re-issue is not gated on - // completions.count. The queued waiters are snapshotted and carried - // through the retry together with the direct completion, keeping the - // superseded (but valid) evaluation as a baseline: a failed retry - // degrades to the baseline instead of surfacing an error where the - // caller previously received a success. The generation cap guards a - // concurrent re-entry; the retry count is bounded structurally — one + // must not fire a request nobody awaits. All callers live in + // loadingState.completions, so the same state can be + // retried without snapshot/wrapper duplication. The superseded (but valid) + // evaluation remains a baseline: a failed retry degrades to it instead of + // surfacing an error. The generation cap guards a concurrent re-entry; one // load, hence one superseded response, per invalidation. loadingState.reissuedForGeneration = currentGeneration; // The stash makes the never-worse guarantee uniform: the retry's failure // handlers prefer it over both the error and the bundled fallback, // reaching late joiners queued during the retry window too. loadingState.retryBaseline = remoteConfig; - NSArray *waiters = [loadingState.completions copy]; - [loadingState.completions removeAllObjects]; - QONRemoteConfig *baseline = remoteConfig; - QONRemoteConfigCompletionHandler deliverToAll = ^(QONRemoteConfig * _Nullable freshConfig, NSError * _Nullable retryError) { - QONRemoteConfig *result = freshConfig ?: baseline; - for (QONRemoteConfigCompletionHandler waiter in waiters) { - waiter(result, nil); - } - completion(result, nil); - }; - [self obtainRemoteConfigWithContextKey:contextKey completion:deliverToAll]; + [self startRemoteConfigLoadForContextKey:contextKey loadingState:loadingState]; return; } [self executeRemoteConfigCompletionsWithContextKey:contextKey remoteConfig:remoteConfig error:nil]; - completion(remoteConfig, nil); +} + +- (void)enqueueRemoteConfigListRequest:(QONRemoteConfigListRequestData *)request { + if (request.isCompleted || [self.listRequests indexOfObjectIdenticalTo:request] != NSNotFound) { + return; + } + [self.listRequests addObject:request]; +} + +- (void)completeRemoteConfigListRequest:(QONRemoteConfigListRequestData *)request + remoteConfigList:(QONRemoteConfigList *)remoteConfigList + error:(NSError *)error { + if (![self isOnStateQueue]) { + [self performStateSync:^{ + [self completeRemoteConfigListRequest:request remoteConfigList:remoteConfigList error:error]; + }]; + return; + } + if (request.isCompleted) { + return; + } + request.completed = YES; + [self.listRequests removeObjectIdenticalTo:request]; + [self.activeListRequests removeObjectIdenticalTo:request]; + QONRemoteConfigListCompletionHandler completion = request.completion; + request.completion = nil; + if (!completion) { + return; + } + [self deferUserCallback:^{ + completion(remoteConfigList, error); + }]; +} + +- (BOOL)failRemoteConfigListRequestWithLastUserChangeError:(QONRemoteConfigListRequestData *)request { + NSError *error = self.pendingUserChangeError; + if (!error) { + return NO; + } + [self completeRemoteConfigListRequest:request remoteConfigList:nil error:error]; + return YES; } - (void)obtainRemoteConfigListWithContextKeys:(NSArray *)contextKeys includeEmptyContextKey:(BOOL)includeEmptyContextKey completion:(QONRemoteConfigListCompletionHandler)completion { + if (![self isOnStateQueue]) { + [self performStateSync:^{ + [self obtainRemoteConfigListWithContextKeys:contextKeys includeEmptyContextKey:includeEmptyContextKey completion:completion]; + }]; + return; + } + + QONRemoteConfigListRequestData *request = [[QONRemoteConfigListRequestData alloc] initWithContextKeys:contextKeys + includeEmptyContextKey:includeEmptyContextKey + completion:completion]; + [self.activeListRequests addObject:request]; + [self processRemoteConfigListRequestWithContextKeys:request]; +} + +- (void)processRemoteConfigListRequestWithContextKeys:(QONRemoteConfigListRequestData *)request { + if (request.isCompleted) { + return; + } + NSArray *contextKeys = request.contextKeys; + BOOL includeEmptyContextKey = request.includeEmptyContextKey; + + if (![self.productCenterManager isUserStable]) { + if ([self failRemoteConfigListRequestWithLastUserChangeError:request]) { + return; + } + [self enqueueRemoteConfigListRequest:request]; + return; + } + NSMutableArray *allKeys = [contextKeys mutableCopy]; if (includeEmptyContextKey) { [allKeys addObject:kEmptyContextKey]; @@ -277,63 +1222,120 @@ - (void)obtainRemoteConfigListWithContextKeys:(NSArray *)contextKeys // pending properties so they are not swallowed by the hit. Gated on user // stability (parity with the single-key path, which checks stability before // its cache hit) so the flush cannot POST mid-identify to a switching uid. - if ([self.productCenterManager isUserStable]) { - [self.userPropertiesManager forceSendProperties:nil]; + if (![self.productCenterManager isUserStable]) { + [self enqueueRemoteConfigListRequest:request]; + return; + } + [self.userPropertiesManager forceSendProperties:nil]; + if (![self.productCenterManager isUserStable]) { + [self enqueueRemoteConfigListRequest:request]; + return; } + self.lastDeliveryOrigin = QONRemoteConfigDeliveryOriginMemory; QONRemoteConfigList *remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:configs]; - return completion(remoteConfigList, nil); - } - - BOOL isUserStable = [self.productCenterManager isUserStable]; - if (!isUserStable) { - QONRemoteConfigListRequestData *requestData = [[QONRemoteConfigListRequestData alloc] initWithContextKeys:contextKeys includeEmptyContextKey:includeEmptyContextKey completion:completion]; - [self.listRequests addObject:requestData]; - + [self completeRemoteConfigListRequest:request remoteConfigList:remoteConfigList error:nil]; return; } - + __block __weak QONRemoteConfigManager *weakSelf = self; + QONRemoteConfigCacheScope *scopeAtStart = [self currentRemoteConfigCacheScope]; + NSMutableDictionary *stateMapAtStart = self.loadingStates; [self.userPropertiesManager forceSendProperties:^{ - QONRemoteConfigListCompletionHandler completionWrapper = [weakSelf remoteConfigListCompletionWrapper:completion contextKeys:contextKeys includeEmptyContextKey:includeEmptyContextKey]; - [weakSelf.remoteConfigService loadRemoteConfigList:contextKeys includeEmptyContextKey:includeEmptyContextKey completion:completionWrapper]; + [weakSelf performStateSync:^{ + if (request.isCompleted) { + return; + } + QONRemoteConfigCacheScope *currentScope = [weakSelf currentRemoteConfigCacheScope]; + if (![weakSelf.productCenterManager isUserStable]) { + [weakSelf enqueueRemoteConfigListRequest:request]; + return; + } + if (![weakSelf cacheScope:scopeAtStart equalsScope:currentScope] || stateMapAtStart != weakSelf.loadingStates) { + [weakSelf processRemoteConfigListRequestWithContextKeys:request]; + return; + } + QONRemoteConfigListCompletionHandler completionWrapper = [weakSelf remoteConfigListCompletionWrapperForRequest:request scope:scopeAtStart]; + [weakSelf.remoteConfigService loadRemoteConfigList:contextKeys includeEmptyContextKey:includeEmptyContextKey completion:completionWrapper]; + }]; }]; } - (void)obtainRemoteConfigList:(QONRemoteConfigListCompletionHandler)completion { - BOOL isUserStable = [self.productCenterManager isUserStable]; - if (!isUserStable) { - QONRemoteConfigListRequestData *requestData = [[QONRemoteConfigListRequestData alloc] initWithCompletion:completion]; - [self.listRequests addObject:requestData]; - + if (![self isOnStateQueue]) { + [self performStateSync:^{ + [self obtainRemoteConfigList:completion]; + }]; + return; + } + + QONRemoteConfigListRequestData *request = [[QONRemoteConfigListRequestData alloc] initWithCompletion:completion]; + [self.activeListRequests addObject:request]; + [self processRemoteConfigListRequest:request]; +} + +- (void)processRemoteConfigListRequest:(QONRemoteConfigListRequestData *)request { + if (request.isCompleted) { + return; + } + + if (![self.productCenterManager isUserStable]) { + if ([self failRemoteConfigListRequestWithLastUserChangeError:request]) { + return; + } + [self enqueueRemoteConfigListRequest:request]; return; } __block __weak QONRemoteConfigManager *weakSelf = self; + QONRemoteConfigCacheScope *scopeAtStart = [self currentRemoteConfigCacheScope]; + NSMutableDictionary *stateMapAtStart = self.loadingStates; [self.userPropertiesManager forceSendProperties:^{ - QONRemoteConfigListCompletionHandler completionWrapper = [weakSelf remoteConfigListCompletionWrapper:completion contextKeys:nil includeEmptyContextKey:YES]; - [weakSelf.remoteConfigService loadRemoteConfigList:completionWrapper]; + [weakSelf performStateSync:^{ + if (request.isCompleted) { + return; + } + QONRemoteConfigCacheScope *currentScope = [weakSelf currentRemoteConfigCacheScope]; + if (![weakSelf.productCenterManager isUserStable]) { + [weakSelf enqueueRemoteConfigListRequest:request]; + return; + } + if (![weakSelf cacheScope:scopeAtStart equalsScope:currentScope] || stateMapAtStart != weakSelf.loadingStates) { + [weakSelf processRemoteConfigListRequest:request]; + return; + } + QONRemoteConfigListCompletionHandler completionWrapper = [weakSelf remoteConfigListCompletionWrapperForRequest:request scope:scopeAtStart]; + [weakSelf.remoteConfigService loadRemoteConfigList:completionWrapper]; + }]; }]; } - (void)attachUserToExperiment:(NSString *)experimentId groupId:(NSString *)groupId completion:(QONExperimentAttachCompletionHandler)completion { - [self invalidateLoadedConfigs]; + [self performStateSync:^{ + [self invalidateLoadedConfigs]; + }]; [self.remoteConfigService attachUserToExperiment:experimentId groupId:groupId completion:completion]; } - (void)detachUserFromExperiment:(NSString *)experimentId completion:(QONExperimentAttachCompletionHandler)completion { - [self invalidateLoadedConfigs]; + [self performStateSync:^{ + [self invalidateLoadedConfigs]; + }]; [self.remoteConfigService detachUserFromExperiment:experimentId completion:completion]; } - (void)attachUserToRemoteConfiguration:(NSString *)remoteConfigurationId completion:(QONRemoteConfigurationAttachCompletionHandler)completion { - [self invalidateLoadedConfigs]; + [self performStateSync:^{ + [self invalidateLoadedConfigs]; + }]; [self.remoteConfigService attachUserToRemoteConfiguration:remoteConfigurationId completion:completion]; } - (void)detachUserFromRemoteConfiguration:(NSString *)remoteConfigurationId completion:(QONRemoteConfigurationAttachCompletionHandler)completion { - [self invalidateLoadedConfigs]; + [self performStateSync:^{ + [self invalidateLoadedConfigs]; + }]; [self.remoteConfigService detachUserFromRemoteConfiguration:remoteConfigurationId completion:completion]; } @@ -357,7 +1359,9 @@ - (void)executeRemoteConfigCompletionsWithContextKey:(NSString *)contextKey remo [loadingState.completions removeAllObjects]; for (QONRemoteConfigCompletionHandler completion in completions) { - completion(remoteConfig, error); + [self deferUserCallback:^{ + completion(remoteConfig, error); + }]; } } } @@ -367,47 +1371,163 @@ - (QONRemoteConfigLoadingState *)loadingStateForContextKey:(NSString *)contextKe return self.loadingStates[key]; } -- (QONRemoteConfigListCompletionHandler)remoteConfigListCompletionWrapper:(QONRemoteConfigListCompletionHandler)completion contextKeys:(NSArray *)contextKeys includeEmptyContextKey:(BOOL)includeEmptyContextKey { +- (NSArray *)mergedFallbackConfigsForContextKeys:(NSArray *)contextKeys + includeEmptyContextKey:(BOOL)includeEmptyContextKey + diskConfigs:(NSArray *)diskConfigs + bundleConfigList:(QONRemoteConfigList *)bundleConfigList { + NSArray *bundleConfigs = bundleConfigList.remoteConfigs ?: @[]; + NSMutableDictionary *diskByKey = [NSMutableDictionary new]; + NSMutableDictionary *bundleByKey = [NSMutableDictionary new]; + for (QONRemoteConfig *config in diskConfigs) { + diskByKey[[self normalizedContextKey:config.source.contextKey]] = config; + } + for (QONRemoteConfig *config in bundleConfigs) { + bundleByKey[[self normalizedContextKey:config.source.contextKey]] = config; + } + + NSMutableArray *merged = [NSMutableArray new]; + if (contextKeys) { + NSMutableArray *requestedKeys = [NSMutableArray new]; + NSMutableSet *seenKeys = [NSMutableSet new]; + for (NSString *contextKey in contextKeys) { + NSString *normalizedKey = [self normalizedContextKey:contextKey]; + if (![seenKeys containsObject:normalizedKey]) { + [seenKeys addObject:normalizedKey]; + [requestedKeys addObject:normalizedKey]; + } + } + if (includeEmptyContextKey && ![seenKeys containsObject:kEmptyContextKey]) { + [requestedKeys addObject:kEmptyContextKey]; + } + + // Resolve independently per requested key. A partial disk snapshot must + // not hide bundled defaults for keys the device has never fetched. + for (NSString *contextKey in requestedKeys) { + QONRemoteConfig *config = diskByKey[contextKey] ?: bundleByKey[contextKey]; + if (config) { + [merged addObject:config]; + } + } + return merged; + } + + // The unfiltered list has no caller-provided ordering. Preserve disk LRU + // order, then fill only missing context keys from the bundled snapshot. + NSMutableSet *includedKeys = [NSMutableSet new]; + for (QONRemoteConfig *config in diskConfigs) { + NSString *contextKey = [self normalizedContextKey:config.source.contextKey]; + if (![includedKeys containsObject:contextKey]) { + [includedKeys addObject:contextKey]; + [merged addObject:config]; + } + } + for (QONRemoteConfig *config in bundleConfigs) { + NSString *contextKey = [self normalizedContextKey:config.source.contextKey]; + if (![includedKeys containsObject:contextKey]) { + [includedKeys addObject:contextKey]; + [merged addObject:config]; + } + } + return merged; +} + +- (QONRemoteConfigListCompletionHandler)remoteConfigListCompletionWrapperForRequest:(QONRemoteConfigListRequestData *)request + scope:(QONRemoteConfigCacheScope *)scopeAtStart { + NSArray *contextKeys = request.contextKeys; + BOOL includeEmptyContextKey = request.includeEmptyContextKey; NSMutableDictionary *localLoadingStates = self.loadingStates; NSUInteger generationAtStart = self.cacheGeneration; __block __weak QONRemoteConfigManager *weakSelf = self; return ^(QONRemoteConfigList * _Nullable remoteConfigList, NSError * _Nullable error) { - if (error) { - [weakSelf actualizeFallbackData]; - if (weakSelf.fallbackData.remoteConfigList) { + [weakSelf performStateSync:^{ + if (request.isCompleted) { + return; + } + if (![weakSelf.productCenterManager isUserStable]) { + [weakSelf enqueueRemoteConfigListRequest:request]; + return; + } + QONRemoteConfigCacheScope *currentScope = [weakSelf currentRemoteConfigCacheScope]; + BOOL scopeChanged = ![weakSelf cacheScope:scopeAtStart equalsScope:currentScope]; + BOOL stateMapOrphaned = localLoadingStates != weakSelf.loadingStates; + if (scopeChanged || stateMapOrphaned) { if (contextKeys) { - // Filter the BUNDLED fallback list — the network list is nil here. - NSArray *remoteConfigs = [weakSelf remoteConfigsForContextKeys:contextKeys remoteConfigList:weakSelf.fallbackData.remoteConfigList includeEmptyContextKey:includeEmptyContextKey]; - remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:remoteConfigs]; + [weakSelf processRemoteConfigListRequestWithContextKeys:request]; } else { - remoteConfigList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:weakSelf.fallbackData.remoteConfigList.remoteConfigs]; + [weakSelf processRemoteConfigListRequest:request]; } - // The bundled fallback is a local last-resort payload, not a fresh - // targeting evaluation — deliver it without caching (see the - // single-key path), so the next call retries the network. - QONVERSION_LOG(@"⚠️ Serving the bundled fallback remote config list — not a fresh targeting evaluation (%@)", error.localizedDescription); - completion(remoteConfigList, nil); - } else { - completion(nil, error); + return; + } + if (![weakSelf.productCenterManager isUserStable]) { + [weakSelf enqueueRemoteConfigListRequest:request]; + return; + } + + if (error) { + if (error.shouldFireFallback) { + NSArray *diskConfigs = [weakSelf persistentLKGForContextKeys:contextKeys + includeEmptyContextKey:includeEmptyContextKey + scope:scopeAtStart]; + [weakSelf actualizeFallbackData]; + NSArray *mergedConfigs = [weakSelf mergedFallbackConfigsForContextKeys:contextKeys + includeEmptyContextKey:includeEmptyContextKey + diskConfigs:diskConfigs + bundleConfigList:weakSelf.fallbackData.remoteConfigList]; + if (mergedConfigs.count > 0) { + // A disk/bundle fallback is not a fresh targeting evaluation. + // Deliver it without warming memory so the next call retries the + // network instead of pinning the fallback until invalidation. + BOOL usedDiskLKG = diskConfigs.count > 0; + if (![weakSelf.productCenterManager isUserStable]) { + [weakSelf enqueueRemoteConfigListRequest:request]; + return; + } + weakSelf.lastDeliveryOrigin = usedDiskLKG + ? QONRemoteConfigDeliveryOriginDiskLastKnownGood + : QONRemoteConfigDeliveryOriginBundle; + QONVERSION_LOG(@"⚠️ Serving %@ remote config list after a transient refresh failure (%@)", usedDiskLKG ? @"disk/bundle last-known-good" : @"bundled fallback", error.localizedDescription); + QONRemoteConfigList *fallbackList = [[QONRemoteConfigList alloc] initWithRemoteConfigs:mergedConfigs]; + [weakSelf completeRemoteConfigListRequest:request remoteConfigList:fallbackList error:nil]; + return; + } + } + if (![weakSelf.productCenterManager isUserStable]) { + [weakSelf enqueueRemoteConfigListRequest:request]; + return; + } + [weakSelf completeRemoteConfigListRequest:request remoteConfigList:nil error:error]; + return; } - return; - } - if (remoteConfigList && generationAtStart == weakSelf.cacheGeneration) { - // Cache only when no invalidation happened while the list load was in - // flight — pre-attach evaluations must not be re-cached as fresh. The - // list is still delivered below either way. - for (QONRemoteConfig *remoteConfig in remoteConfigList.remoteConfigs) { - NSString *contextKey = remoteConfig.source.contextKey ?: kEmptyContextKey; - QONRemoteConfigLoadingState *loadingState = localLoadingStates[contextKey] ?: [QONRemoteConfigLoadingState new]; - loadingState.loadedConfig = remoteConfig; - localLoadingStates[contextKey] = loadingState; + if (![weakSelf.productCenterManager isUserStable]) { + [weakSelf enqueueRemoteConfigListRequest:request]; + return; + } + weakSelf.lastDeliveryOrigin = QONRemoteConfigDeliveryOriginServer; + if (remoteConfigList && generationAtStart == weakSelf.cacheGeneration) { + // Cache only when no invalidation happened while the list load was in + // flight — pre-attach evaluations must not be re-cached as fresh. The + // list is still delivered below either way. + [weakSelf replacePersistentLKGWithServerList:remoteConfigList + contextKeys:contextKeys + includeEmptyContextKey:includeEmptyContextKey + scope:scopeAtStart]; + for (QONRemoteConfig *remoteConfig in remoteConfigList.remoteConfigs) { + NSString *contextKey = remoteConfig.source.contextKey ?: kEmptyContextKey; + QONRemoteConfigLoadingState *loadingState = localLoadingStates[contextKey] ?: [QONRemoteConfigLoadingState new]; + loadingState.loadedConfig = remoteConfig; + localLoadingStates[contextKey] = loadingState; + } } - } - completion(remoteConfigList, nil); + if (![weakSelf.productCenterManager isUserStable]) { + [weakSelf enqueueRemoteConfigListRequest:request]; + return; + } + [weakSelf completeRemoteConfigListRequest:request remoteConfigList:remoteConfigList error:nil]; + }]; }; } diff --git a/Sources/Qonversion/Qonversion/Mappers/QONRemoteConfigMapper/QONRemoteConfigMapper.m b/Sources/Qonversion/Qonversion/Mappers/QONRemoteConfigMapper/QONRemoteConfigMapper.m index 417cd607..55e77343 100644 --- a/Sources/Qonversion/Qonversion/Mappers/QONRemoteConfigMapper/QONRemoteConfigMapper.m +++ b/Sources/Qonversion/Qonversion/Mappers/QONRemoteConfigMapper/QONRemoteConfigMapper.m @@ -19,6 +19,7 @@ NSString *const kRemoteConfigurationAssignmentTypeAuto = @"auto"; NSString *const kRemoteConfigurationAssignmentTypeManual = @"manual"; +NSString *const kRemoteConfigurationAssignmentTypeFrozen = @"frozen"; NSString *const kRemoteConfigurationSourceTypeControlGroup = @"experiment_control_group"; NSString *const kRemoteConfigurationSourceTypeTreatmentGroup = @"experiment_treatment_group"; @@ -46,7 +47,8 @@ - (instancetype)init { _remoteConfigurationAssignmentTypes = @{ kRemoteConfigurationAssignmentTypeAuto: @(QONRemoteConfigurationAssignmentTypeAuto), - kRemoteConfigurationAssignmentTypeManual: @(QONRemoteConfigurationAssignmentTypeManual) + kRemoteConfigurationAssignmentTypeManual: @(QONRemoteConfigurationAssignmentTypeManual), + kRemoteConfigurationAssignmentTypeFrozen: @(QONRemoteConfigurationAssignmentTypeFrozen) }; _remoteConfigurationSourceTypes = @{ @@ -63,12 +65,25 @@ - (QONRemoteConfig * _Nullable)mapRemoteConfig:(NSDictionary *)remoteConfigData if (![remoteConfigData isKindOfClass:[NSDictionary class]]) { return nil; } - NSDictionary *payload = remoteConfigData[@"payload"]; - NSDictionary *experimentData = remoteConfigData[@"experiment"]; + id payloadObject = remoteConfigData[@"payload"]; + NSDictionary *payload = [payloadObject isKindOfClass:[NSDictionary class]] ? payloadObject : nil; + if (payloadObject && payloadObject != [NSNull null] && !payload) { + return nil; + } + + id experimentObject = remoteConfigData[@"experiment"]; + NSDictionary *experimentData = [experimentObject isKindOfClass:[NSDictionary class]] ? experimentObject : nil; + if (experimentObject && experimentObject != [NSNull null] && !experimentData) { + return nil; + } + NSDictionary *remoteConfigurationSourceData = remoteConfigData[@"source"]; QONRemoteConfigurationSource *remoteConfigurationSource = [self mapRemoteConfigurationSource:remoteConfigurationSourceData]; QONExperiment *experiment = [self mapExperiment:experimentData]; + if (!remoteConfigurationSource || (experimentData && !experiment)) { + return nil; + } return [[QONRemoteConfig alloc] initWithPayload:payload experiment:experiment source:remoteConfigurationSource]; } @@ -103,6 +118,10 @@ - (QONExperiment *)mapExperiment:(NSDictionary *)experimentData { } NSString *experimentId = experimentData[@"uid"]; NSString *experimentName = experimentData[@"name"]; + if (![experimentId isKindOfClass:[NSString class]] || experimentId.length == 0 || + ![experimentName isKindOfClass:[NSString class]]) { + return nil; + } return [[QONExperiment alloc] initWithIdentifier:experimentId name:experimentName group:group]; } @@ -114,15 +133,27 @@ - (QONRemoteConfigurationSource *)mapRemoteConfigurationSource:(NSDictionary *)r NSString *uid = remoteConfigurationSourceData[@"uid"]; NSString *name = remoteConfigurationSourceData[@"name"]; - NSString *contextKey = remoteConfigurationSourceData[@"context_key"]; + NSString *typeRawValue = remoteConfigurationSourceData[@"type"]; + NSString *assignmentTypeRawValue = remoteConfigurationSourceData[@"assignment_type"]; + if (![uid isKindOfClass:[NSString class]] || uid.length == 0 || + ![name isKindOfClass:[NSString class]] || + ![typeRawValue isKindOfClass:[NSString class]] || + ![assignmentTypeRawValue isKindOfClass:[NSString class]]) { + return nil; + } + + id contextKeyObject = remoteConfigurationSourceData[@"context_key"]; + if (contextKeyObject && contextKeyObject != [NSNull null] && + ![contextKeyObject isKindOfClass:[NSString class]]) { + return nil; + } + NSString *contextKey = [contextKeyObject isKindOfClass:[NSString class]] ? contextKeyObject : nil; if ([contextKey isEqualToString:@""]) { contextKey = nil; } - NSString *typeRawValue = remoteConfigurationSourceData[@"type"]; QONRemoteConfigurationSourceType type = [self mapRemoteConfigurationSourceTypeFromString:typeRawValue]; - NSString *assignmentTypeRawValue = remoteConfigurationSourceData[@"assignment_type"]; QONRemoteConfigurationAssignmentType assignmentType = [self mapRemoteConfigurationAssignmentTypeFromString:assignmentTypeRawValue]; return [[QONRemoteConfigurationSource alloc] initWithIdentifier:uid name:name type:type assignmentType:assignmentType contextKey:contextKey]; @@ -136,6 +167,11 @@ - (QONExperimentGroup *)mapExperimentGroup:(NSDictionary *)experimentGroupData { NSString *groupId = experimentGroupData[@"uid"]; NSString *groupName = experimentGroupData[@"name"]; NSString *groupTypeRawValue = experimentGroupData[@"type"]; + if (![groupId isKindOfClass:[NSString class]] || groupId.length == 0 || + ![groupName isKindOfClass:[NSString class]] || + ![groupTypeRawValue isKindOfClass:[NSString class]]) { + return nil; + } QONExperimentGroupType groupType = [self mapGroupTypeFromString:groupTypeRawValue]; return [[QONExperimentGroup alloc] initWithIdentifier:groupId type:groupType name:groupName]; diff --git a/Sources/Qonversion/Qonversion/Models/Protected/QONRemoteConfig+Protected.h b/Sources/Qonversion/Qonversion/Models/Protected/QONRemoteConfig+Protected.h index 3711a8cf..c8a49759 100644 --- a/Sources/Qonversion/Qonversion/Models/Protected/QONRemoteConfig+Protected.h +++ b/Sources/Qonversion/Qonversion/Models/Protected/QONRemoteConfig+Protected.h @@ -14,7 +14,9 @@ NS_ASSUME_NONNULL_BEGIN @interface QONRemoteConfig () -- (instancetype)initWithPayload:(NSDictionary *)payload experiment:(QONExperiment *)experiment source:(QONRemoteConfigurationSource *)source; +- (instancetype)initWithPayload:(nullable NSDictionary *)payload + experiment:(nullable QONExperiment *)experiment + source:(QONRemoteConfigurationSource *)source; @end diff --git a/Sources/Qonversion/Qonversion/Models/Protected/QONRemoteConfigurationSource+Protected.h b/Sources/Qonversion/Qonversion/Models/Protected/QONRemoteConfigurationSource+Protected.h index d0988f65..cb3cb97d 100644 --- a/Sources/Qonversion/Qonversion/Models/Protected/QONRemoteConfigurationSource+Protected.h +++ b/Sources/Qonversion/Qonversion/Models/Protected/QONRemoteConfigurationSource+Protected.h @@ -16,7 +16,7 @@ NS_ASSUME_NONNULL_BEGIN name:(NSString *)name type:(QONRemoteConfigurationSourceType)type assignmentType:(QONRemoteConfigurationAssignmentType)assignmentType - contextKey:(NSString *)contextKey; + contextKey:(nullable NSString *)contextKey; @end diff --git a/Sources/Qonversion/Qonversion/Services/QONRemoteConfigService/QONRemoteConfigService.m b/Sources/Qonversion/Qonversion/Services/QONRemoteConfigService/QONRemoteConfigService.m index baf9019e..05d8b160 100644 --- a/Sources/Qonversion/Qonversion/Services/QONRemoteConfigService/QONRemoteConfigService.m +++ b/Sources/Qonversion/Qonversion/Services/QONRemoteConfigService/QONRemoteConfigService.m @@ -10,6 +10,7 @@ #import "QNAPIClient.h" #import "QONRemoteConfigMapper.h" #import "QONRemoteConfig.h" +#import "QONRemoteConfigList.h" #import "QONErrors.h" static NSString *const kNoRemoteConfigurationErrorMessage = @"Remote configuration is not available for the current user or for the provided context key"; @@ -34,12 +35,26 @@ - (void)loadRemoteConfig:(NSString * _Nullable)contextKey completion:(QONRemoteC completion(nil, error); return; } + + // A successful response without a source is the API's authoritative + // "there is no applicable configuration" sentinel. Preserve that public + // contract before strict shape/context validation so callers can + // distinguish removal from a transient or malformed response. + if ([dict isKindOfClass:[NSDictionary class]]) { + id sourceObject = dict[@"source"]; + if (!sourceObject || sourceObject == [NSNull null]) { + completion(nil, [QONErrors errorWithCode:QONErrorCodeRemoteConfigurationNotAvailable + message:kNoRemoteConfigurationErrorMessage]); + return; + } + } QONRemoteConfig *config = [weakSelf.mapper mapRemoteConfig:dict]; - if (config.source == nil) { - NSError *error = [QONErrors errorWithCode:QONErrorCodeRemoteConfigurationNotAvailable message:kNoRemoteConfigurationErrorMessage]; - completion(nil, error); + if (![weakSelf isValidRemoteConfig:config] || + ![[weakSelf normalizedContextKey:config.source.contextKey] + isEqualToString:[weakSelf normalizedContextKey:contextKey]]) { + completion(nil, [QONErrors internalErrorWithCode:QONErrorCodeResponseParsingFailed]); return; } @@ -56,6 +71,10 @@ - (void)loadRemoteConfigList:(QONRemoteConfigListCompletionHandler)completion { } QONRemoteConfigList *configList = [weakSelf.mapper mapRemoteConfigList:arr]; + if (![weakSelf isValidRemoteConfigList:configList expectedCount:arr.count allowedContextKeys:nil]) { + completion(nil, [QONErrors internalErrorWithCode:QONErrorCodeResponseParsingFailed]); + return; + } completion(configList, error); }]; } @@ -69,10 +88,78 @@ - (void)loadRemoteConfigList:(NSArray *)contextKeys includeEmptyCont } QONRemoteConfigList *configList = [weakSelf.mapper mapRemoteConfigList:arr]; + NSMutableSet *allowedContextKeys = [NSMutableSet new]; + for (NSString *contextKey in contextKeys) { + [allowedContextKeys addObject:[weakSelf normalizedContextKey:contextKey]]; + } + if (includeEmptyContextKey) { + [allowedContextKeys addObject:@""]; + } + if (![weakSelf isValidRemoteConfigList:configList + expectedCount:arr.count + allowedContextKeys:allowedContextKeys]) { + completion(nil, [QONErrors internalErrorWithCode:QONErrorCodeResponseParsingFailed]); + return; + } completion(configList, error); }]; } +- (NSString *)normalizedContextKey:(NSString *)contextKey { + return contextKey ?: @""; +} + +- (BOOL)isValidRemoteConfig:(QONRemoteConfig *)config { + id sourceIdentifier = config.source.identifier; + id sourceName = config.source.name; + id sourceContextKey = config.source.contextKey; + if (!config || !config.source || + ![sourceIdentifier isKindOfClass:[NSString class]] || [sourceIdentifier length] == 0 || + ![sourceName isKindOfClass:[NSString class]] || + (sourceContextKey && ![sourceContextKey isKindOfClass:[NSString class]]) || + (config.payload && ![config.payload isKindOfClass:[NSDictionary class]]) || + config.source.type == QONRemoteConfigurationSourceTypeUnknown || + config.source.assignmentType == QONRemoteConfigurationAssignmentTypeUnknown) { + return NO; + } + if (config.experiment) { + id experimentIdentifier = config.experiment.identifier; + id experimentName = config.experiment.name; + id groupIdentifier = config.experiment.group.identifier; + id groupName = config.experiment.group.name; + if (!config.experiment.group || + ![experimentIdentifier isKindOfClass:[NSString class]] || [experimentIdentifier length] == 0 || + ![experimentName isKindOfClass:[NSString class]] || + ![groupIdentifier isKindOfClass:[NSString class]] || [groupIdentifier length] == 0 || + ![groupName isKindOfClass:[NSString class]] || + config.experiment.group.type == QONExperimentGroupTypeUnknown) { + return NO; + } + } + return YES; +} + +- (BOOL)isValidRemoteConfigList:(QONRemoteConfigList *)configList + expectedCount:(NSUInteger)expectedCount + allowedContextKeys:(NSSet *)allowedContextKeys { + if (!configList || configList.remoteConfigs.count != expectedCount) { + return NO; + } + NSMutableSet *seenContextKeys = [NSMutableSet new]; + for (QONRemoteConfig *config in configList.remoteConfigs) { + if (![self isValidRemoteConfig:config]) { + return NO; + } + NSString *contextKey = [self normalizedContextKey:config.source.contextKey]; + if ([seenContextKeys containsObject:contextKey] || + (allowedContextKeys && ![allowedContextKeys containsObject:contextKey])) { + return NO; + } + [seenContextKeys addObject:contextKey]; + } + return YES; +} + - (void)attachUserToExperiment:(NSString *)experimentId groupId:(NSString *)groupId completion:(QONExperimentAttachCompletionHandler)completion { [self.apiClient attachUserToExperiment:experimentId groupId:groupId completion:^(NSError * _Nullable error) { if (error) { diff --git a/Sources/Qonversion/Qonversion/Utils/NSError+Sugare/NSError+Sugare.m b/Sources/Qonversion/Qonversion/Utils/NSError+Sugare/NSError+Sugare.m index 5c8682f6..c2cde99e 100644 --- a/Sources/Qonversion/Qonversion/Utils/NSError+Sugare/NSError+Sugare.m +++ b/Sources/Qonversion/Qonversion/Utils/NSError+Sugare/NSError+Sugare.m @@ -22,9 +22,26 @@ - (BOOL)shouldFireFallback { // collides with unrelated domains (e.g. POSIX EAGAIN). BOOL isRateLimited = [self.domain isEqualToString:QonversionErrorDomain] && self.code == QONErrorCodeApiRateLimitExceeded; - if (self.code == NSURLErrorNotConnectedToInternet || + BOOL isTransientURLFailure = [self.domain isEqualToString:NSURLErrorDomain] && + (self.code == NSURLErrorNotConnectedToInternet || + self.code == NSURLErrorTimedOut || + self.code == NSURLErrorNetworkConnectionLost || + self.code == NSURLErrorCannotConnectToHost || + self.code == NSURLErrorCannotFindHost || + self.code == NSURLErrorDNSLookupFailed || + self.code == NSURLErrorCallIsActive || + self.code == NSURLErrorDataNotAllowed); + BOOL isServerFailure = [self.domain isEqualToString:QonversionErrorDomain] && + self.code >= kInternalServerErrorFirstCode && self.code <= kInternalServerErrorLastCode; + // QNAPIClient intentionally normalizes empty bodies, invalid JSON, and + // response-shape failures to the public internal-error code. Those failures + // say nothing authoritative about the user's assignment, so LKG is safe. + BOOL isInternalResponseFailure = [self.domain isEqualToString:QonversionErrorDomain] && + self.code == QONErrorCodeInternalError; + if (isTransientURLFailure || isRateLimited || - (self.code >= kInternalServerErrorFirstCode && self.code <= kInternalServerErrorLastCode)) { + isServerFailure || + isInternalResponseFailure) { return YES; } else { return NO;