From a473212a0953c3bada05c0db4441d4683e040119 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:01:27 -0700 Subject: [PATCH] g-orchestrated: drive GIDEMMErrorHandlerTest through fake application and alert presenter --- .../Tests/Unit/GIDEMMErrorHandlerTest.m | 275 ++++++------------ 1 file changed, 93 insertions(+), 182 deletions(-) diff --git a/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m b/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m index 33594c68..284b97ef 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m @@ -19,20 +19,11 @@ #import #import +#import "GoogleSignIn/Sources/GIDApplication/Fake/GIDFakeApplication.h" +#import "GoogleSignIn/Sources/GIDEMMAlertPresenter/Fake/GIDFakeEMMAlertPresenter.h" #import "GoogleSignIn/Sources/GIDEMMErrorHandler.h" -#import "GoogleSignIn/Sources/GIDSignInStrings.h" #import "GoogleSignIn/Tests/Unit/UIAlertAction+Testing.h" -#ifdef SWIFT_PACKAGE -@import GoogleUtilities_MethodSwizzler; -@import GoogleUtilities_SwizzlerTestHelpers; -@import OCMock; -#else -#import -#import -#import -#endif - NS_ASSUME_NONNULL_BEGIN // Records how, and whether, a GIDEMMErrorHandler completion was called. @@ -59,53 +50,27 @@ @interface GIDEMMErrorHandlerTest : XCTestCase @end @implementation GIDEMMErrorHandlerTest { - // Whether key window has been set. - BOOL _keyWindowSet; - - // The view controller that has been presented, if any. - UIViewController *_presentedViewController; + // The window the fake application reports as key. + UIWindow *_keyWindow; + // Stands in for UIApplication. + GIDFakeApplication *_application; + // Records the alert the handler asks to present instead of showing it. + GIDFakeEMMAlertPresenter *_alertPresenter; + // The handler under test. + GIDEMMErrorHandler *_handler; } - (void)setUp { [super setUp]; - // GIDEMMErrorHandler is a singleton; a test that fails before dismissing its dialog would - // otherwise leave the pending-dialog flag set and break every test after it. - [[GIDEMMErrorHandler sharedInstance] setValue:@NO forKey:@"pendingDialog"]; - _keyWindowSet = NO; - _presentedViewController = nil; - UIWindow *fakeKeyWindow = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; - [GULSwizzler swizzleClass:[GIDEMMErrorHandler class] - selector:@selector(keyWindow) - isClassSelector:NO - withBlock:^() { return fakeKeyWindow; }]; - [GULSwizzler swizzleClass:[UIWindow class] - selector:@selector(makeKeyAndVisible) - isClassSelector:NO - withBlock:^() { self->_keyWindowSet = YES; }]; - [GULSwizzler swizzleClass:[UIViewController class] - selector:@selector(presentViewController:animated:completion:) - isClassSelector:NO - withBlock:^(id obj, id arg1) { self->_presentedViewController = arg1; }]; - [GULSwizzler swizzleClass:[GIDSignInStrings class] - selector:@selector(localizedStringForKey:text:) - isClassSelector:YES - withBlock:^(id obj, NSString *key, NSString *text) { return text; }]; + _keyWindow = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + _application = [[GIDFakeApplication alloc] init]; + _application.keyWindow = _keyWindow; + _alertPresenter = [[GIDFakeEMMAlertPresenter alloc] init]; + _handler = [[GIDEMMErrorHandler alloc] initWithApplication:_application + alertPresenter:_alertPresenter]; } - (void)tearDown { - [GULSwizzler unswizzleClass:[GIDEMMErrorHandler class] - selector:@selector(keyWindow) - isClassSelector:NO]; - [GULSwizzler unswizzleClass:[UIWindow class] - selector:@selector(makeKeyAndVisible) - isClassSelector:NO]; - [GULSwizzler unswizzleClass:[UIViewController class] - selector:@selector(presentViewController:animated:completion:) - isClassSelector:NO]; - [GULSwizzler unswizzleClass:[GIDSignInStrings class] - selector:@selector(localizedStringForKey:text:) - isClassSelector:YES]; - _presentedViewController = nil; [super tearDown]; } @@ -121,11 +86,11 @@ - (void)waitForMainQueue { // Returns the presented UIAlertController, or records a test failure and returns nil. - (nullable UIAlertController *)presentedAlert { - if (![_presentedViewController isKindOfClass:[UIAlertController class]]) { - XCTFail(@"Expected a presented UIAlertController, got %@.", _presentedViewController); + if (![_alertPresenter.presentedAlert isKindOfClass:[UIAlertController class]]) { + XCTFail(@"Expected a presented UIAlertController, got %@.", _alertPresenter.presentedAlert); return nil; } - return (UIAlertController *)_presentedViewController; + return _alertPresenter.presentedAlert; } // Asserts that the alert has a title and message and that its actions have the expected titles. @@ -147,33 +112,24 @@ - (void)tapActionTitled:(NSString *)title inAlert:(UIAlertController *)alert { title, [alert.actions valueForKey:@"title"]); } -// Expects opening a particular URL string in performing an action. +// Asserts that the given URL string was opened through the application in performing an action. - (void)expectOpenURLString:(NSString *)urlString inAction:(void (^)(void))action { - // Swizzle and mock [UIApplication sharedApplication] since it is unavailable in unit tests. - id mockApplication = OCMStrictClassMock([UIApplication class]); - [GULSwizzler swizzleClass:[UIApplication class] - selector:@selector(sharedApplication) - isClassSelector:YES - withBlock:^() { return mockApplication; }]; - [[mockApplication expect] openURL:[NSURL URLWithString:urlString] options:@{} completionHandler:nil]; action(); - [mockApplication verify]; - [GULSwizzler unswizzleClass:[UIApplication class] - selector:@selector(sharedApplication) - isClassSelector:YES]; + XCTAssertEqualObjects(_application.openedURLs, @[ [NSURL URLWithString:urlString] ]); } // Verifies that the handler doesn't handle non-exist error. - (void)testNoError { GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:@{ @"abc" : @123 } - completion:spy.completion]; + [_handler handleErrorFromResponse:@{ @"abc" : @123 } + completion:spy.completion]; XCTAssertEqual(spy.callCount, 0); [self waitForMainQueue]; XCTAssertEqual(spy.callCount, 1); XCTAssertFalse(spy.handled); - XCTAssertFalse(_keyWindowSet); - XCTAssertNil(_presentedViewController); + XCTAssertNil(_alertPresenter.presentedAlert); + XCTAssertEqual(_alertPresenter.dismissCount, 0u); + XCTAssertEqual(_application.openedURLs.count, 0u); } // Verifies that a non-string value under the `error` key is ignored rather than crashing. @@ -185,12 +141,15 @@ - (void)testNonStringErrorValue { for (id nonStringValue in nonStringValues) { GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : nonStringValue }; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:spy.completion]; + [_handler handleErrorFromResponse:response + completion:spy.completion]; XCTAssertEqual(spy.callCount, 0); [self waitForMainQueue]; XCTAssertEqual(spy.callCount, 1); XCTAssertFalse(spy.handled); + XCTAssertNil(_alertPresenter.presentedAlert); + XCTAssertEqual(_alertPresenter.dismissCount, 0u); + XCTAssertEqual(_application.openedURLs.count, 0u); } } @@ -198,40 +157,40 @@ - (void)testNonStringErrorValue { - (void)testNoEMMError { GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"invalid_token" }; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:spy.completion]; + [_handler handleErrorFromResponse:response + completion:spy.completion]; XCTAssertEqual(spy.callCount, 0); [self waitForMainQueue]; XCTAssertEqual(spy.callCount, 1); XCTAssertFalse(spy.handled); - XCTAssertFalse(_keyWindowSet); - XCTAssertNil(_presentedViewController); + XCTAssertNil(_alertPresenter.presentedAlert); + XCTAssertEqual(_alertPresenter.dismissCount, 0u); + XCTAssertEqual(_application.openedURLs.count, 0u); } // Verifies that the handler handles general EMM error with user tapping 'OK'. - (void)testGeneralEMMErrorOK { + NSUInteger dismissCountBefore = _alertPresenter.dismissCount; GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_something_wrong" }; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:spy.completion]; + [_handler handleErrorFromResponse:response + completion:spy.completion]; // The dialog is presented asynchronously on the main queue, so nothing has happened yet. XCTAssertEqual(spy.callCount, 0); - XCTAssertFalse(_keyWindowSet); - XCTAssertNil(_presentedViewController); + XCTAssertNil(_alertPresenter.presentedAlert); // A second EMM error while the first dialog is pending is reported as handled, but must not // present a second dialog. GIDEMMCompletionSpy *secondSpy = [[GIDEMMCompletionSpy alloc] init]; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:secondSpy.completion]; + [_handler handleErrorFromResponse:response + completion:secondSpy.completion]; XCTAssertEqual(secondSpy.callCount, 0); - XCTAssertFalse(_keyWindowSet); - XCTAssertNil(_presentedViewController); + XCTAssertNil(_alertPresenter.presentedAlert); [self waitForMainQueue]; XCTAssertEqual(secondSpy.callCount, 1); XCTAssertTrue(secondSpy.handled); - XCTAssertTrue(_keyWindowSet); + XCTAssertEqual(_alertPresenter.presentedOverKeyWindow, _keyWindow); UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"OK" ]]; @@ -240,68 +199,59 @@ - (void)testGeneralEMMErrorOK { [self tapActionTitled:@"OK" inAlert:alert]; XCTAssertEqual(spy.callCount, 1); XCTAssertTrue(spy.handled); + XCTAssertNil(_alertPresenter.presentedAlert); + XCTAssertEqual(_alertPresenter.dismissCount, dismissCountBefore + 1); } // Verifies that the pending-dialog flag is cleared when there is no key window, so a later -// EMM error can still present its dialog. `GIDEMMErrorHandler` is a process-wide singleton, -// so before this fix a single windowless error suppressed every dialog that followed. +// EMM error can still present its dialog. - (void)testNoKeyWindow_ClearsPendingDialogForNextError { - [GULSwizzler unswizzleClass:[GIDEMMErrorHandler class] - selector:@selector(keyWindow) - isClassSelector:NO]; - [GULSwizzler swizzleClass:[GIDEMMErrorHandler class] - selector:@selector(keyWindow) - isClassSelector:NO - withBlock:^() { return nil; }]; + NSUInteger dismissCountBefore = _alertPresenter.dismissCount; + _application.keyWindow = nil; GIDEMMCompletionSpy *firstSpy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_something_wrong" }; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:firstSpy.completion]; + [_handler handleErrorFromResponse:response + completion:firstSpy.completion]; [self waitForMainQueue]; XCTAssertEqual(firstSpy.callCount, 1); XCTAssertTrue(firstSpy.handled); - XCTAssertNil(_presentedViewController); + XCTAssertNil(_alertPresenter.presentedAlert); // Restore a working key window. - [GULSwizzler unswizzleClass:[GIDEMMErrorHandler class] - selector:@selector(keyWindow) - isClassSelector:NO]; - UIWindow *fakeKeyWindow = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; - [GULSwizzler swizzleClass:[GIDEMMErrorHandler class] - selector:@selector(keyWindow) - isClassSelector:NO - withBlock:^() { return fakeKeyWindow; }]; + _application.keyWindow = _keyWindow; GIDEMMCompletionSpy *secondSpy = [[GIDEMMCompletionSpy alloc] init]; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:secondSpy.completion]; - // Before the fix, this completed immediately with NO: the pending-dialog flag was still YES. + [_handler handleErrorFromResponse:response + completion:secondSpy.completion]; XCTAssertEqual(secondSpy.callCount, 0); [self waitForMainQueue]; + XCTAssertEqual(_alertPresenter.presentedOverKeyWindow, _keyWindow); UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"OK" ]]; [self tapActionTitled:@"OK" inAlert:alert]; XCTAssertEqual(secondSpy.callCount, 1); XCTAssertTrue(secondSpy.handled); + XCTAssertNil(_alertPresenter.presentedAlert); + XCTAssertEqual(_alertPresenter.dismissCount, dismissCountBefore + 1); } // Verifies that the handler handles EMM screenlock required error with user tapping 'Cancel'. - (void)testScreenlockRequiredCancel { + NSUInteger dismissCountBefore = _alertPresenter.dismissCount; GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_passcode_required" }; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:spy.completion]; + [_handler handleErrorFromResponse:response + completion:spy.completion]; // The dialog is presented asynchronously on the main queue, so nothing has happened yet. XCTAssertEqual(spy.callCount, 0); - XCTAssertFalse(_keyWindowSet); - XCTAssertNil(_presentedViewController); + XCTAssertNil(_alertPresenter.presentedAlert); [self waitForMainQueue]; - XCTAssertTrue(_keyWindowSet); + XCTAssertEqual(_alertPresenter.presentedOverKeyWindow, _keyWindow); UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"Cancel", @"Settings" ]]; @@ -310,21 +260,23 @@ - (void)testScreenlockRequiredCancel { [self tapActionTitled:@"Cancel" inAlert:alert]; XCTAssertEqual(spy.callCount, 1); XCTAssertTrue(spy.handled); + XCTAssertNil(_alertPresenter.presentedAlert); + XCTAssertEqual(_alertPresenter.dismissCount, dismissCountBefore + 1); } // Verifies that the handler handles EMM screenlock required error with user tapping 'Settings'. - (void)testScreenlockRequiredSettings { + NSUInteger dismissCountBefore = _alertPresenter.dismissCount; GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_passcode_required" }; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:spy.completion]; + [_handler handleErrorFromResponse:response + completion:spy.completion]; // The dialog is presented asynchronously on the main queue, so nothing has happened yet. XCTAssertEqual(spy.callCount, 0); - XCTAssertFalse(_keyWindowSet); - XCTAssertNil(_presentedViewController); + XCTAssertNil(_alertPresenter.presentedAlert); [self waitForMainQueue]; - XCTAssertTrue(_keyWindowSet); + XCTAssertEqual(_alertPresenter.presentedOverKeyWindow, _keyWindow); UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"Cancel", @"Settings" ]]; @@ -335,21 +287,23 @@ - (void)testScreenlockRequiredSettings { }]; XCTAssertEqual(spy.callCount, 1); XCTAssertTrue(spy.handled); + XCTAssertNil(_alertPresenter.presentedAlert); + XCTAssertEqual(_alertPresenter.dismissCount, dismissCountBefore + 1); } // Verifies that the handler handles EMM app verification required error without a URL. - (void)testAppVerificationNoURL { + NSUInteger dismissCountBefore = _alertPresenter.dismissCount; GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_app_verification_required" }; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:spy.completion]; + [_handler handleErrorFromResponse:response + completion:spy.completion]; // The dialog is presented asynchronously on the main queue, so nothing has happened yet. XCTAssertEqual(spy.callCount, 0); - XCTAssertFalse(_keyWindowSet); - XCTAssertNil(_presentedViewController); + XCTAssertNil(_alertPresenter.presentedAlert); [self waitForMainQueue]; - XCTAssertTrue(_keyWindowSet); + XCTAssertEqual(_alertPresenter.presentedOverKeyWindow, _keyWindow); UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"OK" ]]; @@ -358,22 +312,24 @@ - (void)testAppVerificationNoURL { [self tapActionTitled:@"OK" inAlert:alert]; XCTAssertEqual(spy.callCount, 1); XCTAssertTrue(spy.handled); + XCTAssertNil(_alertPresenter.presentedAlert); + XCTAssertEqual(_alertPresenter.dismissCount, dismissCountBefore + 1); } // Verifies that the handler handles EMM app verification required error user tapping 'Cancel'. - (void)testAppVerificationCancel { + NSUInteger dismissCountBefore = _alertPresenter.dismissCount; GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_app_verification_required: https://host.domain/path" }; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:spy.completion]; + [_handler handleErrorFromResponse:response + completion:spy.completion]; // The dialog is presented asynchronously on the main queue, so nothing has happened yet. XCTAssertEqual(spy.callCount, 0); - XCTAssertFalse(_keyWindowSet); - XCTAssertNil(_presentedViewController); + XCTAssertNil(_alertPresenter.presentedAlert); [self waitForMainQueue]; - XCTAssertTrue(_keyWindowSet); + XCTAssertEqual(_alertPresenter.presentedOverKeyWindow, _keyWindow); UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"Cancel", @"Connect" ]]; @@ -382,22 +338,24 @@ - (void)testAppVerificationCancel { [self tapActionTitled:@"Cancel" inAlert:alert]; XCTAssertEqual(spy.callCount, 1); XCTAssertTrue(spy.handled); + XCTAssertNil(_alertPresenter.presentedAlert); + XCTAssertEqual(_alertPresenter.dismissCount, dismissCountBefore + 1); } // Verifies that the handler handles EMM app verification required error user tapping 'Connect'. - (void)testAppVerificationConnect { + NSUInteger dismissCountBefore = _alertPresenter.dismissCount; GIDEMMCompletionSpy *spy = [[GIDEMMCompletionSpy alloc] init]; NSDictionary *response = @{ @"error" : @"emm_app_verification_required: https://host.domain/path" }; - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response - completion:spy.completion]; + [_handler handleErrorFromResponse:response + completion:spy.completion]; // The dialog is presented asynchronously on the main queue, so nothing has happened yet. XCTAssertEqual(spy.callCount, 0); - XCTAssertFalse(_keyWindowSet); - XCTAssertNil(_presentedViewController); + XCTAssertNil(_alertPresenter.presentedAlert); [self waitForMainQueue]; - XCTAssertTrue(_keyWindowSet); + XCTAssertEqual(_alertPresenter.presentedOverKeyWindow, _keyWindow); UIAlertController *alert = [self presentedAlert]; if (!alert) return; [self assertAlert:alert hasActionTitles:@[ @"Cancel", @"Connect" ]]; @@ -408,63 +366,16 @@ - (void)testAppVerificationConnect { }]; XCTAssertEqual(spy.callCount, 1); XCTAssertTrue(spy.handled); + XCTAssertNil(_alertPresenter.presentedAlert); + XCTAssertEqual(_alertPresenter.dismissCount, dismissCountBefore + 1); } // Verifies that the handler can handle sequential errors independently. - (void)testSequentialErrors { [self testGeneralEMMErrorOK]; - _keyWindowSet = NO; - _presentedViewController = nil; [self testScreenlockRequiredCancel]; } -// Temporarily disable testKeyWindow for Xcode 12 and under due to unexplained failure. -#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 150000 - -// Verifies that the `keyWindow` internal method works on all OS versions as expected. -- (void)testKeyWindow { - // The original method has been swizzled in `setUp` so get its original implementation to test. - typedef id (*KeyWindowSignature)(id, SEL); - KeyWindowSignature keyWindowFunction = (KeyWindowSignature) - [GULSwizzler originalImplementationForClass:[GIDEMMErrorHandler class] - selector:@selector(keyWindow) - isClassSelector:NO]; - UIWindow *mockKeyWindow = OCMClassMock([UIWindow class]); - OCMStub(mockKeyWindow.isKeyWindow).andReturn(YES); - UIApplication *mockApplication = OCMClassMock([UIApplication class]); - [GULSwizzler swizzleClass:[UIApplication class] - selector:@selector(sharedApplication) - isClassSelector:YES - withBlock:^{ return mockApplication; }]; -#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 150000 - if (@available(iOS 15, *)) { - UIWindowScene *mockWindowScene = OCMClassMock([UIWindowScene class]); - OCMStub(mockApplication.connectedScenes).andReturn(@[mockWindowScene]); - OCMStub(mockWindowScene.activationState).andReturn(UISceneActivationStateForegroundActive); - OCMStub(mockWindowScene.keyWindow).andReturn(mockKeyWindow); - } else -#endif // __IPHONE_OS_VERSION_MAX_ALLOWED >= 150000 - { -#if __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_15_0 - if (@available(iOS 13, *)) { - OCMStub(mockApplication.windows).andReturn(@[mockKeyWindow]); - } else { -#if __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_13_0 - OCMStub(mockApplication.keyWindow).andReturn(mockKeyWindow); -#endif // __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_13_0 - } -#endif // __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_15_0 - } - UIWindow *keyWindow = - keyWindowFunction([GIDEMMErrorHandler sharedInstance], @selector(keyWindow)); - XCTAssertEqual(keyWindow, mockKeyWindow); - [GULSwizzler unswizzleClass:[UIApplication class] - selector:@selector(sharedApplication) - isClassSelector:YES]; -} - -#endif // __IPHONE_OS_VERSION_MAX_ALLOWED >= 150000 - @end NS_ASSUME_NONNULL_END