Prechádzať zdrojové kódy

App distribution signin persist (#6063)

* Pull out calls to the FAD API to make them reuseable

* Fix tests and run styl updates

* Add in UserDefaults storage after a tester has signed in

* Add testing for the main AppDistribution file

* Ran style.sh

* Remove FIRFADLocalStorage and call GULUserDefaults directly. Update tests to stop mocking on teardown.
Cleo Schneider 5 rokov pred
rodič
commit
1c9d9964c0

+ 2 - 0
FirebaseAppDistribution.podspec

@@ -28,12 +28,14 @@ iOS SDK for App Distribution for Firebase.
     'FirebaseInstallations/Source/Library/Private/*.h',
     'GoogleDataTransport/GDTCORLibrary/Internal/*.h',
     'GoogleUtilities/AppDelegateSwizzler/Private/*.h',
+    'GoogleUtilities/UserDefaults/Private/*.h',
   ]
   s.public_header_files = base_dir + 'Public/*.h'
   s.private_header_files = base_dir + 'Private/*.h'
 
   s.dependency 'FirebaseCore', '~> 6.8'
   s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 6.7'
+  s.dependency 'GoogleUtilities/UserDefaults', '~>6.7'
   s.dependency 'FirebaseInstallations', '~> 1.1'
   s.dependency 'GoogleDataTransport', '~> 7.0'
 

+ 62 - 43
FirebaseAppDistribution/Sources/FIRAppDistribution.m

@@ -16,6 +16,7 @@
 #import "FirebaseCore/Sources/Private/FirebaseCoreInternal.h"
 #import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h"
 #import "GoogleUtilities/AppDelegateSwizzler/Private/GULAppDelegateSwizzler.h"
+#import "GoogleUtilities/UserDefaults/Private/GULUserDefaults.h"
 
 #import "FIRAppDistribution+Private.h"
 #import "FIRAppDistributionAppDelegateInterceptor.h"
@@ -52,13 +53,14 @@ NSString *const kCodeHashKey = @"codeHash";
 
 NSString *const kAuthErrorMessage = @"Unable to authenticate the tester";
 NSString *const kAuthCancelledErrorMessage = @"Tester cancelled sign-in";
+NSString *const kFIRFADSignInStateKey = @"FIRFADSignInState";
 
 @synthesize isTesterSignedIn = _isTesterSignedIn;
 
 - (BOOL)isTesterSignedIn {
-  //  FIRFADInfoLog(@"Checking if tester is signed in");
-  //  return [self tryInitializeAuthState];
-  return NO;
+  BOOL signInState = [[GULUserDefaults standardUserDefaults] boolForKey:kFIRFADSignInStateKey];
+  FIRFADInfoLog(@"Tester is %@signed in.", signInState ? @"" : @"not ");
+  return signInState;
 }
 
 #pragma mark - Singleton Support
@@ -121,14 +123,17 @@ NSString *const kAuthCancelledErrorMessage = @"Tester cancelled sign-in";
 
   // In the component creation block, we return an instance of `FIRAppDistribution`. Cast it and
   // return it.
-  NSLog(@"Instance returned! %@", instance);
+  FIRFADDebugLog(@"Instance returned: %@", instance);
   return (FIRAppDistribution *)instance;
 }
 
 - (void)signInTesterWithCompletion:(void (^)(NSError *_Nullable error))completion {
-  NSLog(@"Testing: App Distribution sign in");
+  FIRFADDebugLog(@"Prompting tester for sign in");
 
-  // TODO: Check if tester is already signed in
+  if ([self isTesterSignedIn]) {
+    completion(nil);
+    return;
+  }
 
   [self.appDelegateInterceptor initializeUIState];
   FIRInstallations *installations = [FIRInstallations installations];
@@ -137,7 +142,11 @@ NSString *const kAuthCancelledErrorMessage = @"Tester cancelled sign-in";
   [installations installationIDWithCompletion:^(NSString *__nullable identifier,
                                                 NSError *__nullable error) {
     if (error) {
-      completion(error);
+      NSString *description = error.userInfo[NSLocalizedDescriptionKey]
+                                  ? error.userInfo[NSLocalizedDescriptionKey]
+                                  : @"Failed to retrieve Installation ID.";
+      completion([self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorUnknown
+                                             message:description]);
       return;
     }
 
@@ -146,17 +155,32 @@ NSString *const kAuthCancelledErrorMessage = @"Tester cancelled sign-in";
                          @"installations/%@/buildalerts?appName=%@",
                          [[FIRApp defaultApp] options].googleAppID, identifier, [self getAppName]];
 
-    NSLog(@"Registration URL: %@", requestURL);
+    FIRFADDebugLog(@"Registration URL: %@", requestURL);
 
     [self.appDelegateInterceptor
         appDistributionRegistrationFlow:[[NSURL alloc] initWithString:requestURL]
                          withCompletion:^(NSError *_Nullable error) {
-                           NSLog(@"Sign in flow is in completion!!");
+                           FIRFADInfoLog(@"Tester sign in complete.");
+                           if (!error) {
+                             [self persistTesterSignInState];
+                           }
                            completion(error);
                          }];
   }];
 }
 
+- (void)persistTesterSignInState {
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        if (error) {
+          FIRFADErrorLog(@"Could not fetch releases with code %ld - %@", [error code],
+                         [error localizedDescription]);
+          return;
+        }
+        [[GULUserDefaults standardUserDefaults] setBool:YES forKey:kFIRFADSignInStateKey];
+      }];
+}
+
 - (NSString *)getAppName {
   NSBundle *mainBundle = [NSBundle mainBundle];
 
@@ -174,18 +198,8 @@ NSString *const kAuthCancelledErrorMessage = @"Tester cancelled sign-in";
 }
 
 - (void)signOutTester {
-  // FIRFADInfoLog(@"Tester sign out");
-  //  NSError *error;
-  //  BOOL didClearAuthState = [self.authPersistence clearAuthState:&error];
-  //  if (!didClearAuthState) {
-  //    FIRFADErrorLog(@"Error clearing token from keychain: %@", [error localizedDescription]);
-  //    [self logUnderlyingKeychainError:error];
-  //
-  //  } else {
-  //    FIRFADInfoLog(@"Successfully cleared auth state from keychain");
-  //  }
-
-  self.isTesterSignedIn = false;
+  FIRFADDebugLog(@"Tester is signed out.");
+  [[GULUserDefaults standardUserDefaults] setBool:NO forKey:kFIRFADSignInStateKey];
 }
 
 - (NSError *)NSErrorForErrorCodeAndMessage:(FIRAppDistributionError)errorCode
@@ -195,27 +209,32 @@ NSString *const kAuthCancelledErrorMessage = @"Tester cancelled sign-in";
 }
 
 - (NSError *_Nullable)handleFetchReleasesError:(NSError *)error {
-  FIRFADErrorLog(@"Failed to retrieve releases: %ld", (long)[error code]);
-  switch ([error code]) {
-    case FIRFADApiErrorTimeout:
-      return [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorNetworkFailure
-                                         message:@"Failed to fetch releases due to timeout."];
-    case FIRFADApiErrorUnauthenticated:
-    case FIRFADApiErrorUnauthorized:
-    case FIRFADApiTokenGenerationFailure:
-    case FIRFADApiInstallationIdentifierError:
-      return [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorAuthenticationFailure
-                                         message:@"Could not authenticate tester"];
-    case FIRApiErrorUnknownFailure:
-    case FIRApiErrorParseFailure:
-      return [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorUnknown
-                                         message:@"Failed to fetch releases for unknown reason."];
-    default:
-      return nil;
+  if ([error domain] == kFIRFADApiErrorDomain) {
+    FIRFADErrorLog(@"Failed to retrieve releases: %ld", (long)[error code]);
+    switch ([error code]) {
+      case FIRFADApiErrorTimeout:
+        return [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorNetworkFailure
+                                           message:@"Failed to fetch releases due to timeout."];
+      case FIRFADApiErrorUnauthenticated:
+      case FIRFADApiErrorUnauthorized:
+      case FIRFADApiTokenGenerationFailure:
+      case FIRFADApiInstallationIdentifierError:
+      case FIRFADApiErrorNotFound:
+        return [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorAuthenticationFailure
+                                           message:@"Could not authenticate tester"];
+      default:
+        return [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorUnknown
+                                           message:@"Failed to fetch releases for unknown reason."];
+    }
   }
+
+  FIRFADErrorLog(@"Failed to retrieve releases with unexpected domain %@: %ld", [error domain],
+                 (long)[error code]);
+  return [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorUnknown
+                                     message:@"Failed to fetch releases for unknown reason."];
 }
 
-- (void)fetchReleases:(FIRAppDistributionUpdateCheckCompletion)completion {
+- (void)fetchNewLatestRelease:(FIRAppDistributionUpdateCheckCompletion)completion {
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
         if (error) {
@@ -249,9 +268,9 @@ NSString *const kAuthCancelledErrorMessage = @"Tester cancelled sign-in";
 }
 
 - (void)checkForUpdateWithCompletion:(FIRAppDistributionUpdateCheckCompletion)completion {
-  NSLog(@"CheckForUpdateWithCompletion");
-  if (false) {
-    [self fetchReleases:completion];
+  FIRFADInfoLog(@"CheckForUpdateWithCompletion");
+  if ([self isTesterSignedIn]) {
+    [self fetchNewLatestRelease:completion];
   } else {
     UIAlertController *alert = [UIAlertController
         alertControllerWithTitle:@"Enable in-app alerts"
@@ -269,7 +288,7 @@ NSString *const kAuthCancelledErrorMessage = @"Tester cancelled sign-in";
                                      return;
                                    }
 
-                                   [self fetchReleases:completion];
+                                   [self fetchNewLatestRelease:completion];
                                  }];
                                }];
 

+ 2 - 0
FirebaseAppDistribution/Sources/FIRAppDistributionAppDelegateInterceptor.m

@@ -57,6 +57,7 @@ SFAuthenticationSession *_safariAuthenticationVC;
         completionHandler:^(NSURL *_Nullable callbackURL, NSError *_Nullable error) {
           [self resetUIState];
           NSLog(@"Testing: Sign in Complete!");
+          // TODO (b/161538029): Map these errors to AppDistribution error codes
           completion(error);
         }];
 
@@ -74,6 +75,7 @@ SFAuthenticationSession *_safariAuthenticationVC;
         completionHandler:^(NSURL *_Nullable callbackURL, NSError *_Nullable error) {
           [self resetUIState];
           NSLog(@"Testing: Sign in Complete!");
+          // TODO (b/161538029): Map these errors to AppDistribution error codes
           completion(error);
         }];
 

+ 0 - 6
FirebaseAppDistribution/Sources/FIRFADApiService.m

@@ -190,12 +190,6 @@ NSString *const kResponseReleasesKey = @"releases";
   }
 
   NSArray *releases = [serializedResponse objectForKey:kResponseReleasesKey];
-  if (releases.count == 0) {
-    [self handleError:error
-          description:@"No releases found for tester."
-                 code:FIRFADApiErrorNotFound];
-    return nil;
-  }
 
   return releases;
 }

+ 0 - 36
FirebaseAppDistribution/Sources/FIRFADLocalStorage.m

@@ -1,36 +0,0 @@
-// Copyright 2020 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-#import "FIRFADLocalStorage+Private.h"
-
-@implementation FIRFADLocalStorage
-
-- (instancetype)init {
-  self = [super init];
-
-  return self;
-}
-
-- (BOOL)isTesterSignedIn {
-  return YES;
-}
-
-- (BOOL)persistSignInState:(NSError *_Nullable)error {
-  return YES;
-}
-
-- (BOOL)clearSignInState:(NSError *_Nullable)error {
-  return YES;
-}
-
-@end

+ 0 - 30
FirebaseAppDistribution/Sources/Private/FIRFADLocalStorage+Private.h

@@ -1,30 +0,0 @@
-// Copyright 2020 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-#import <Foundation/Foundation.h>
-
-NS_ASSUME_NONNULL_BEGIN
-
-@interface FIRFADLocalStorage : NSObject
-
-- (instancetype)init NS_UNAVAILABLE;
-
-- (BOOL)isTesterSignedIn;
-
-- (BOOL)persistSignInState:(NSError *_Nullable)error;
-
-- (BOOL)clearSignInState:(NSError *_Nullable)error;
-
-@end
-
-NS_ASSUME_NONNULL_END

+ 341 - 5
FirebaseAppDistribution/Tests/Unit/FIRAppDistributionTests.m

@@ -13,12 +13,19 @@
 // limitations under the License.
 
 #import <Foundation/Foundation.h>
+#import <OCMock/OCMock.h>
 #import <XCTest/XCTest.h>
 
 #import "FirebaseAppDistribution/FIRAppDistribution.h"
+#import "FirebaseAppDistribution/FIRAppDistributionAppDelegateInterceptor.h"
+#import "FirebaseAppDistribution/FIRAppDistributionMachO+Private.h"
+#import "FirebaseAppDistribution/FIRFADApiService+Private.h"
 #import "FirebaseCore/Sources/Private/FirebaseCoreInternal.h"
+#import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h"
+#import "GoogleUtilities/AppDelegateSwizzler/Private/GULAppDelegateSwizzler.h"
+#import "GoogleUtilities/UserDefaults/Private/GULUserDefaults.h"
 
-@interface FIRAppDistributionSampleTests : XCTestCase
+@interface FIRAppDistributionTests : XCTestCase
 
 @property(nonatomic, strong) FIRAppDistribution *appDistribution;
 
@@ -28,19 +35,348 @@
 
 - (instancetype)initWithApp:(FIRApp *)app appInfo:(NSDictionary *)appInfo;
 
+- (void)fetchNewLatestRelease:(FIRAppDistributionUpdateCheckCompletion)completion;
+
+- (NSError *)handleFetchReleasesError:(NSError *)error;
+
 @end
 
-@implementation FIRAppDistributionSampleTests
+@implementation FIRAppDistributionTests {
+  id _mockFIRAppClass;
+  id _mockFIRFADApiService;
+  id _mockAppDelegateInterceptor;
+  id _mockFIRInstallations;
+  id _mockInstallationToken;
+  id _mockMachO;
+  NSString *_mockAuthToken;
+  NSString *_mockInstallationId;
+  NSArray *_mockReleases;
+  NSString *_mockCodeHash;
+}
 
 - (void)setUp {
   [super setUp];
+  _mockAuthToken = @"this-is-an-auth-token";
+  _mockCodeHash = @"this-is-a-fake-code-hash";
+  _mockFIRAppClass = OCMClassMock([FIRApp class]);
+  _mockFIRFADApiService = OCMClassMock([FIRFADApiService class]);
+  _mockAppDelegateInterceptor = OCMClassMock([FIRAppDistributionAppDelegateInterceptor class]);
+  _mockFIRInstallations = OCMClassMock([FIRInstallations class]);
+  _mockInstallationToken = OCMClassMock([FIRInstallationsAuthTokenResult class]);
+  _mockMachO = OCMClassMock([FIRAppDistributionMachO class]);
+  id mockBundle = OCMClassMock([NSBundle class]);
+  OCMStub([_mockFIRAppClass defaultApp]).andReturn(_mockFIRAppClass);
+  OCMStub([_mockAppDelegateInterceptor sharedInstance]).andReturn(_mockAppDelegateInterceptor);
+  OCMStub([_mockAppDelegateInterceptor initializeUIState])
+      .andDo(^(NSInvocation *invocation){
+      });
+  OCMStub([_mockFIRInstallations installations]).andReturn(_mockFIRInstallations);
+  OCMStub([_mockInstallationToken authToken]).andReturn(_mockAuthToken);
+  OCMStub([_mockMachO alloc]).andReturn(_mockMachO);
+  OCMStub([_mockMachO initWithPath:OCMOCK_ANY]).andReturn(_mockMachO);
+  OCMStub([mockBundle mainBundle]).andReturn(mockBundle);
+  OCMStub([mockBundle executablePath]).andReturn(@"this-is-a-fake-executablePath");
 
   NSDictionary<NSString *, NSString *> *dict = [[NSDictionary<NSString *, NSString *> alloc] init];
-  self.appDistribution = [[FIRAppDistribution alloc] initWithApp:nil appInfo:dict];
+  self.appDistribution = [[FIRAppDistribution alloc] initWithApp:_mockFIRAppClass appInfo:dict];
+
+  _mockInstallationId = @"this-id-is-fake-ccccc";
+  _mockReleases = @[
+    @{
+      @"codeHash" : @"this-is-another-code-hash",
+      @"displayVersion" : @"1.0.0",
+      @"buildVersion" : @"111",
+      @"releaseNotes" : @"This is a release",
+      @"downloadUrl" : @"http://faketyfakefake.download"
+    },
+    @{
+      @"latest" : @YES,
+      @"codeHash" : _mockCodeHash,
+      @"displayVersion" : @"1.0.1",
+      @"buildVersion" : @"112",
+      @"releaseNotes" : @"This is a release too",
+      @"downloadUrl" : @"http://faketyfakefake.download"
+    }
+  ];
+}
+
+- (void)tearDown {
+  [super tearDown];
+  [[GULUserDefaults standardUserDefaults] removeObjectForKey:@"FIRFADSignInState"];
+  [_mockFIRAppClass stopMocking];
+  [_mockFIRFADApiService stopMocking];
+  [_mockAppDelegateInterceptor stopMocking];
+  [_mockFIRInstallations stopMocking];
+  [_mockInstallationToken stopMocking];
+  [_mockMachO stopMocking];
+}
+
+- (void)mockInstallationIdCompletion:(NSString *_Nullable)identifier
+                               error:(NSError *_Nullable)error {
+  [OCMStub([_mockFIRInstallations installationIDWithCompletion:OCMOCK_ANY])
+      andDo:^(NSInvocation *invocation) {
+        void (^handler)(NSString *identifier, NSError *_Nullable error);
+        [invocation getArgument:&handler atIndex:2];
+        handler(identifier, error);
+      }];
+}
+
+- (void)mockAppDelegateCompletion:(NSError *_Nullable)error {
+  [OCMStub([_mockAppDelegateInterceptor appDistributionRegistrationFlow:OCMOCK_ANY
+                                                         withCompletion:OCMOCK_ANY])
+      andDo:^(NSInvocation *invocation) {
+        void (^handler)(NSError *_Nullable error);
+        [invocation getArgument:&handler atIndex:3];
+        handler(error);
+      }];
+}
+
+- (void)mockFetchReleasesCompletion:(NSArray *)releases error:(NSError *)error {
+  [OCMStub([_mockFIRFADApiService fetchReleasesWithCompletion:OCMOCK_ANY])
+      andDo:^(NSInvocation *invocation) {
+        void (^handler)(NSArray *releases, NSError *_Nullable error);
+        [invocation getArgument:&handler atIndex:2];
+        handler(releases, error);
+      }];
+}
+
+- (void)testInitWithApp {
+  XCTAssertNotNil([self appDistribution]);
+}
+
+- (void)testSignInWithCompletionPersistSignInStateSuccess {
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockAppDelegateCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Persist sign in state succeeds."];
+
+  [[self appDistribution] signInTesterWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  XCTAssertTrue([[self appDistribution] isTesterSignedIn]);
+}
+
+- (void)testSignInWithCompletionInstallationIDNotFoundFailure {
+  NSError *mockError =
+      [NSError errorWithDomain:@"this.is.fake"
+                          code:3
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  [self mockInstallationIdCompletion:_mockInstallationId error:mockError];
+  [self mockAppDelegateCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Persist sign in state fails."];
+
+  [[self appDistribution] signInTesterWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNotNil(error);
+    XCTAssertEqual([error code], FIRAppDistributionErrorUnknown);
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  XCTAssertFalse([[self appDistribution] isTesterSignedIn]);
+}
+
+- (void)testSignInWithCompletionDelegateFailureDoesNotPersist {
+  NSError *mockError =
+      [NSError errorWithDomain:@"fake.app.delegate.domain"
+                          code:4
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockAppDelegateCompletion:mockError];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:
+                @"Persist sign in state fails when the delegate recieves a failure."];
+
+  [[self appDistribution] signInTesterWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNotNil(error);
+    // TODO (b/161538029): Map these errors to AppDistribution error codes
+    XCTAssertEqual([error code], 4);
+    [expectation fulfill];
+  }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  XCTAssertFalse([[self appDistribution] isTesterSignedIn]);
+}
+
+- (void)testSignInWithCompletionFetchReleasesFailureDoesNotPersist {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorTimeout
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockAppDelegateCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:mockError];
+  XCTestExpectation *expectation = [self
+      expectationWithDescription:@"Persist sign in state fails when we fail to fetch releases."];
+  [[self appDistribution] signInTesterWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  XCTAssertFalse([[self appDistribution] isTesterSignedIn]);
+}
+
+- (void)testSignOutSuccess {
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockAppDelegateCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Persist sign out state succeeds."];
+
+  [[self appDistribution] signInTesterWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertTrue([[self appDistribution] isTesterSignedIn]);
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [[self appDistribution] signOutTester];
+  XCTAssertFalse([[self appDistribution] isTesterSignedIn]);
+}
+
+- (void)testFetchNewLatestReleaseSuccess {
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  OCMStub([_mockMachO codeHash]).andReturn(@"this-is-old");
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch latest release succeeds."];
+  [[self appDistribution] fetchNewLatestRelease:^(FIRAppDistributionRelease *_Nullable release,
+                                                  NSError *_Nullable error) {
+    XCTAssertNotNil(release);
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+}
+
+- (void)testFetchNewLatestReleaseNoNewRelease {
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  OCMStub([_mockMachO codeHash]).andReturn(_mockCodeHash);
+
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch latest release with no new release succeeds."];
+  [expectation setInverted:YES];
+
+  [[self appDistribution] fetchNewLatestRelease:^(FIRAppDistributionRelease *_Nullable release,
+                                                  NSError *_Nullable error) {
+    XCTAssertNil(release);
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+}
+
+- (void)testFetchNewLatestReleaseFailure {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorTimeout
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  [self mockFetchReleasesCompletion:nil error:mockError];
+  OCMStub([_mockMachO codeHash]).andReturn(@"this-is-old");
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"Fetch latest release fails."];
+
+  [[self appDistribution] fetchNewLatestRelease:^(FIRAppDistributionRelease *_Nullable release,
+                                                  NSError *_Nullable error) {
+    XCTAssertNil(release);
+    XCTAssertNotNil(error);
+    XCTAssertEqual([error code], FIRAppDistributionErrorNetworkFailure);
+    XCTAssertEqual([error domain], FIRAppDistributionErrorDomain);
+    [expectation fulfill];
+  }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+}
+
+- (void)testHandleFetchReleasesErrorTimeout {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorTimeout
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] handleFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorNetworkFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorUnauthenticated {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorUnauthenticated
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] handleFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorAuthenticationFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorUnauthorized {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorUnauthorized
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] handleFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorAuthenticationFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorTokenGenerationFailure {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiTokenGenerationFailure
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] handleFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorAuthenticationFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorInstallationIdentifierFailure {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiInstallationIdentifierError
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] handleFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorAuthenticationFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorNotFound {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorNotFound
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] handleFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorAuthenticationFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorApiDomainErrorUnknown {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:209
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] handleFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorUnknown);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
 }
 
-- (void)testGetSingleton {
-  XCTAssertNotNil(self.appDistribution);
+- (void)testHandleFetchReleasesErrorUnknownDomainError {
+  NSError *mockError =
+      [NSError errorWithDomain:@"this.is.not.an.api.failure"
+                          code:4
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] handleFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorUnknown);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
 }
 
 @end

+ 56 - 95
FirebaseAppDistribution/Tests/Unit/FIRFADApiServiceTests.m

@@ -69,6 +69,10 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
 
 - (void)tearDown {
   [super tearDown];
+  [_mockFIRAppClass stopMocking];
+  [_mockFIRInstallations stopMocking];
+  [_mockInstallationToken stopMocking];
+  [_mockURLSession stopMocking];
 }
 
 - (void)mockInstallationAuthCompletion:(FIRInstallationsAuthTokenResult *_Nullable)token
@@ -116,25 +120,19 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Generate auth token succeeds."];
 
-  __block FIRInstallationsAuthTokenResult *returnedAuthTokenResult;
-  __block NSString *returnedID;
-  __block NSError *returnedError;
   [FIRFADApiService
       generateAuthTokenWithCompletion:^(NSString *_Nullable identifier,
                                         FIRInstallationsAuthTokenResult *_Nullable authTokenResult,
                                         NSError *_Nullable error) {
-        returnedID = identifier;
-        returnedAuthTokenResult = authTokenResult;
-        returnedError = error;
+        XCTAssertNotNil(authTokenResult);
+        XCTAssertNotNil(identifier);
+        XCTAssertNil(error);
+        XCTAssertEqual(identifier, self->_mockInstallationId);
+        XCTAssertEqual([authTokenResult authToken], self -> _mockAuthToken);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNotNil(returnedAuthTokenResult);
-  XCTAssertNotNil(returnedID);
-  XCTAssertNil(returnedError);
-  XCTAssertEqual(returnedID, self->_mockInstallationId);
-  XCTAssertEqual(returnedAuthTokenResult.authToken, self->_mockAuthToken);
 }
 
 - (void)testGenerateAuthTokenWithCompletionAuthTokenFailure {
@@ -146,24 +144,18 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Generate auth token fails to generate auth token."];
 
-  __block FIRInstallationsAuthTokenResult *returnedAuthTokenResult;
-  __block NSString *returnedID;
-  __block NSError *returnedError;
   [FIRFADApiService
       generateAuthTokenWithCompletion:^(NSString *_Nullable identifier,
                                         FIRInstallationsAuthTokenResult *_Nullable authTokenResult,
                                         NSError *_Nullable error) {
-        returnedID = identifier;
-        returnedAuthTokenResult = authTokenResult;
-        returnedError = error;
+        XCTAssertNil(identifier);
+        XCTAssertNil(authTokenResult);
+        XCTAssertNotNil(error);
+        XCTAssertEqual(error.code, FIRFADApiTokenGenerationFailure);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNil(returnedID);
-  XCTAssertNil(returnedAuthTokenResult);
-  XCTAssertNotNil(returnedError);
-  XCTAssertEqual(returnedError.code, FIRFADApiTokenGenerationFailure);
 }
 
 - (void)testGenerateAuthTokenWithCompletionIDFailure {
@@ -176,24 +168,18 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Generate auth token fails to find ID."];
 
-  __block FIRInstallationsAuthTokenResult *returnedAuthTokenResult;
-  __block NSString *returnedID;
-  __block NSError *returnedError;
   [FIRFADApiService
       generateAuthTokenWithCompletion:^(NSString *_Nullable identifier,
                                         FIRInstallationsAuthTokenResult *_Nullable authTokenResult,
                                         NSError *_Nullable error) {
-        returnedID = identifier;
-        returnedAuthTokenResult = authTokenResult;
-        returnedError = error;
+        XCTAssertNil(identifier);
+        XCTAssertNil(authTokenResult);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiInstallationIdentifierError);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNil(returnedID);
-  XCTAssertNil(returnedAuthTokenResult);
-  XCTAssertNotNil(returnedError);
-  XCTAssertEqual(returnedError.code, FIRFADApiInstallationIdentifierError);
 }
 
 - (void)testFetchReleasesWithCompletionSuccess {
@@ -206,19 +192,15 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Fetch releases succeeds with two releases."];
 
-  __block NSArray *returnedReleases;
-  __block NSError *returnedError;
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
-        returnedReleases = releases;
-        returnedError = error;
+        XCTAssertNil(error);
+        XCTAssertNotNil(releases);
+        XCTAssertEqual([releases count], 2);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNil(returnedError);
-  XCTAssertNotNil(returnedReleases);
-  XCTAssertEqual(returnedReleases.count, 2);
 }
 
 - (void)testFetchReleasesWithCompletionUnknownFailure {
@@ -229,12 +211,17 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   [self mockUrlSessionResponse:_mockReleases
                       response:fakeResponse
                          error:[NSError errorWithDomain:kFakeErrorDomain code:1 userInfo:@{}]];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases fails with unknown error."];
+
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
         XCTAssertNil(releases);
         XCTAssertNotNil(error);
         XCTAssertEqual(error.code, FIRApiErrorUnknownFailure);
+        [expectation fulfill];
       }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
 }
 
 - (void)testFetchReleasesWithCompletionUnauthenticatedFailure {
@@ -243,12 +230,18 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
   [self mockInstallationIdCompletion:_mockInstallationId error:nil];
   [self mockUrlSessionResponse:_mockReleases response:fakeResponse error:nil];
+
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases fails with unknown error."];
+
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
         XCTAssertNil(releases);
         XCTAssertNotNil(error);
         XCTAssertEqual(error.code, FIRFADApiErrorUnauthenticated);
+        [expectation fulfill];
       }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
 }
 
 - (void)testFetchReleasesWithCompletionUnauthorized400Failure {
@@ -261,19 +254,15 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Fetch releases rejects with a 400."];
 
-  __block NSArray *returnedReleases;
-  __block NSError *returnedError;
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
-        returnedReleases = releases;
-        returnedError = error;
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiErrorUnauthorized);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNil(returnedReleases);
-  XCTAssertNotNil(returnedError);
-  XCTAssertEqual(returnedError.code, FIRFADApiErrorUnauthorized);
 }
 
 - (void)testFetchReleasesWithCompletionUnauthorized403Failure {
@@ -285,19 +274,15 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Fetch releases rejects with a 403."];
 
-  __block NSArray *returnedReleases;
-  __block NSError *returnedError;
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
-        returnedReleases = releases;
-        returnedError = error;
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiErrorUnauthorized);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNil(returnedReleases);
-  XCTAssertNotNil(returnedError);
-  XCTAssertEqual(returnedError.code, FIRFADApiErrorUnauthorized);
 }
 
 - (void)testFetchReleasesWithCompletionUnauthorized404Failure {
@@ -309,19 +294,15 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Fetch releases rejects with a 404."];
 
-  __block NSArray *returnedReleases;
-  __block NSError *returnedError;
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
-        returnedReleases = releases;
-        returnedError = error;
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiErrorUnauthorized);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNil(returnedReleases);
-  XCTAssertNotNil(returnedError);
-  XCTAssertEqual(returnedError.code, FIRFADApiErrorUnauthorized);
 }
 
 - (void)testFetchReleasesWithCompletionTimeout408Failure {
@@ -333,19 +314,15 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Fetch releases rejects with 408."];
 
-  __block NSArray *returnedReleases;
-  __block NSError *returnedError;
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
-        returnedReleases = releases;
-        returnedError = error;
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiErrorTimeout);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNil(returnedReleases);
-  XCTAssertNotNil(returnedError);
-  XCTAssertEqual(returnedError.code, FIRFADApiErrorTimeout);
 }
 
 - (void)testFetchReleasesWithCompletionTimeout504Failure {
@@ -357,19 +334,15 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Fetch releases rejects with a 504."];
 
-  __block NSArray *returnedReleases;
-  __block NSError *returnedError;
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
-        returnedReleases = releases;
-        returnedError = error;
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiErrorTimeout);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNil(returnedReleases);
-  XCTAssertNotNil(returnedError);
-  XCTAssertEqual(returnedError.code, FIRFADApiErrorTimeout);
 }
 
 - (void)testFetchReleasesWithCompletionUnknownStatusCodeFailure {
@@ -381,22 +354,18 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Fetch releases rejects with a 500."];
 
-  __block NSArray *returnedReleases;
-  __block NSError *returnedError;
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
-        returnedReleases = releases;
-        returnedError = error;
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRApiErrorUnknownFailure);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNil(returnedReleases);
-  XCTAssertNotNil(returnedError);
-  XCTAssertEqual(returnedError.code, FIRApiErrorUnknownFailure);
 }
 
-- (void)testFetchReleasesWithCompletionNoReleasesFoundFailure {
+- (void)testFetchReleasesWithCompletionNoReleasesFoundSuccess {
   NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
   OCMStub([fakeResponse statusCode]).andReturn(200);
   [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
@@ -405,19 +374,15 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Fetch releases rejects with a not found exception."];
 
-  __block NSArray *returnedReleases;
-  __block NSError *returnedError;
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
-        returnedReleases = releases;
-        returnedError = error;
+        XCTAssertNotNil(releases);
+        XCTAssertNil(error);
+        XCTAssertEqual([releases count], 0);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNil(returnedReleases);
-  XCTAssertNotNil(returnedError);
-  XCTAssertEqual(returnedError.code, FIRFADApiErrorNotFound);
 }
 
 - (void)testFetchReleasesWithCompletionParsingFailure {
@@ -432,19 +397,15 @@ NSString *const kFakeErrorDomain = @"test.failure.domain";
   XCTestExpectation *expectation =
       [self expectationWithDescription:@"Fetch releases rejects with a parsing failure."];
 
-  __block NSArray *returnedReleases;
-  __block NSError *returnedError;
   [FIRFADApiService
       fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
-        returnedReleases = releases;
-        returnedError = error;
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRApiErrorParseFailure);
         [expectation fulfill];
       }];
 
   [self waitForExpectations:@[ expectation ] timeout:5.0];
-  XCTAssertNil(returnedReleases);
-  XCTAssertNotNil(returnedError);
-  XCTAssertEqual(returnedError.code, FIRApiErrorParseFailure);
 }
 
 @end