Переглянути джерело

GDT: Retry uploading batches failed to upload before the app termination (#5942)

* GDTCCTUploaderTest: test a lost batch upload retry

* GDTCCTUploader: refactoring to prepare for lost batches uploading

* GDTCCTUploader: Upload previously batched events first

* Fix GDTCCTTestStorage

* Refactor GDTCCTUploaderTest

* GDTCORFlatFileStorageTest: batch tests

* GDTCORFlatFileStorage: refactor removeBatchWithID to prepare for eventsForbatchID

* eventsInBatchWithID implementation

* Fix tests

* Uploader fix for empty batch state

* GDTCORUploadCoordinator: high priority targets

* style

* GDTCCTUploaderTest

* GDTCCTUploaderTest: cleanup

* Cleanup and API docs.

* GDTCORUploadCoordinator: revert high priority targets

* GDTCCTUploader: make sure GDTCOREventQoSFast is part of a regular batch as well.

* Cleanup

* GDTCCTUploader: don't report ready for upload until there are events.

* GDTCCTUploaderTest: move failed batch back to the queue

* GDTCORFlatFileStorage: removeBatchWithID with deleteEvents==NO

* GDTCORFlatFileStorage: remove batch directory

* style

* GDTCORFlatFileStorage: comments and debug logs

* formatting

* GDTCCTUploaderTest: recover tests

* GDTCCTTestServer: support for async request handler

* GDTCCTUploaderTest: ongoing request tests

* GDTCORStorageProtocol: remove redundant `eventsInBatchWithID` method.

* GDTCORFlatFileStorageTest: `removeBatchWithID` tests

* Cleanup

* GDTCORFlatFileStorage: cleanup

* GDTCORFlatFileStorageTest: better coverage and cleanup

* Comments and improvements

* GDTCCTUploaderTest: reduce code duplication

* GDTCCTUploaderTest: conditions tests

* GDTCCTUploader: fix next request wait time

* run ./scripts/style.sh
Maksym Malyhin 5 роки тому
батько
коміт
4dd48bdcd9

+ 4 - 4
GoogleDataTransport.podspec

@@ -125,13 +125,13 @@ Shared library for iOS SDK data transport needs.
     end
   end
 
-  common_test_sources = ['GoogleDataTransport/GDTCCTTests/Common/**/*.{h,m}']
+  common_cct_test_sources = ['GoogleDataTransport/GDTCCTTests/Common/**/*.{h,m}']
 
   # Test specs
   s.test_spec 'CCT-Tests-Unit' do |test_spec|
     test_spec.platforms = {:ios => '8.0', :osx => '10.11', :tvos => '10.0'}
     test_spec.requires_app_host = false
-    test_spec.source_files = ['GoogleDataTransport/GDTCCTTests/Unit/**/*.{h,m}'] + common_test_sources
+    test_spec.source_files = ['GoogleDataTransport/GDTCCTTests/Unit/**/*.{h,m}'] + common_cct_test_sources + common_test_sources
     test_spec.resources = ['GoogleDataTransport/GDTCCTTests/Data/**/*']
     test_spec.pod_target_xcconfig = header_search_paths
     test_spec.dependency 'GCDWebServer'
@@ -140,7 +140,7 @@ Shared library for iOS SDK data transport needs.
   s.test_spec 'CCT-Tests-Integration' do |test_spec|
     test_spec.platforms = {:ios => '8.0', :osx => '10.11', :tvos => '10.0'}
     test_spec.requires_app_host = false
-    test_spec.source_files = ['GoogleDataTransport/GDTCCTTests/Integration/**/*.{h,m}'] + common_test_sources
+    test_spec.source_files = ['GoogleDataTransport/GDTCCTTests/Integration/**/*.{h,m}'] + common_cct_test_sources
     test_spec.resources = ['GoogleDataTransport/GDTCCTTests/Data/**/*']
     test_spec.pod_target_xcconfig = header_search_paths
   end
@@ -152,7 +152,7 @@ Shared library for iOS SDK data transport needs.
       test_spec.requires_app_host = true
       test_spec.app_host_name = 'GoogleDataTransport/CCTTestApp'
       test_spec.dependency 'GoogleDataTransport/CCTTestApp'
-      test_spec.source_files = ['GoogleDataTransport/GDTCCTTests/Monkey/**/*.{swift}'] + common_test_sources
+      test_spec.source_files = ['GoogleDataTransport/GDTCCTTests/Monkey/**/*.{swift}'] + common_cct_test_sources
       test_spec.info_plist = {
         'GDT_MONKEYTEST' => '1'
       }

+ 298 - 152
GoogleDataTransport/GDTCCTLibrary/GDTCCTUploader.m

@@ -35,6 +35,8 @@
 
 #import "GoogleDataTransport/GDTCCTLibrary/Protogen/nanopb/cct.nanopb.h"
 
+NS_ASSUME_NONNULL_BEGIN
+
 #ifdef GDTCOR_VERSION
 #define STR(x) STR_EXPAND(x)
 #define STR_EXPAND(x) #x
@@ -56,11 +58,25 @@ static NSString *const kLibraryDataFLLNextUploadTimeKey = @"GDTCCTUploaderFLLNex
 NSNotificationName const GDTCCTUploadCompleteNotification = @"com.GDTCCTUploader.UploadComplete";
 #endif  // #if !NDEBUG
 
+typedef void (^GDTCCTUploaderURLTaskCompletion)(NSNumber *batchID,
+                                                NSSet<GDTCOREvent *> *_Nullable events,
+                                                NSData *_Nullable data,
+                                                NSURLResponse *_Nullable response,
+                                                NSError *_Nullable error);
+
+typedef void (^GDTCCTUploaderEventBatchBlock)(NSNumber *_Nullable batchID,
+                                              NSSet<GDTCOREvent *> *_Nullable events);
+
 @interface GDTCCTUploader () <NSURLSessionDelegate>
 
-// Redeclared as readwrite.
+/// Redeclared as readwrite.
 @property(nullable, nonatomic, readwrite) NSURLSessionUploadTask *currentTask;
 
+/// A flag indicating if there is an ongoing upload. The current implementation supports only a
+/// single upload operation. If `uploadTarget` method is called when  `isCurrentlyUploading == YES`
+/// then no new uploads will be started.
+@property(atomic) BOOL isCurrentlyUploading;
+
 @end
 
 @implementation GDTCCTUploader
@@ -190,193 +206,324 @@ NSNotificationName const GDTCCTUploadCompleteNotification = @"com.GDTCCTUploader
 #
 
 - (void)uploadTarget:(GDTCORTarget)target withConditions:(GDTCORUploadConditions)conditions {
-  __block GDTCORBackgroundIdentifier bgID = GDTCORBackgroundIdentifierInvalid;
-  bgID = [[GDTCORApplication sharedApplication]
+  __block GDTCORBackgroundIdentifier backgroundTaskID = GDTCORBackgroundIdentifierInvalid;
+
+  dispatch_block_t backgroundTaskCompletion = ^{
+    // End the background task if there was one.
+    if (backgroundTaskID != GDTCORBackgroundIdentifierInvalid) {
+      [[GDTCORApplication sharedApplication] endBackgroundTask:backgroundTaskID];
+      backgroundTaskID = GDTCORBackgroundIdentifierInvalid;
+    }
+  };
+
+  backgroundTaskID = [[GDTCORApplication sharedApplication]
       beginBackgroundTaskWithName:@"GDTCCTUploader-upload"
                 expirationHandler:^{
-                  if (bgID != GDTCORBackgroundIdentifierInvalid) {
+                  if (backgroundTaskID != GDTCORBackgroundIdentifierInvalid) {
                     // Cancel the upload and complete delivery.
                     [self.currentTask cancel];
 
                     // End the background task.
-                    [[GDTCORApplication sharedApplication] endBackgroundTask:bgID];
+                    backgroundTaskCompletion();
                   }
                 }];
 
   dispatch_async(_uploaderQueue, ^{
     id<GDTCORStorageProtocol> storage = GDTCORStorageInstanceForTarget(target);
 
-    if (![self readyToUploadTarget:target conditions:conditions]) {
-      return;
-    }
-    __block NSNumber *batchID;
+    // 1. Fetch events to upload.
+    [self batchToUploadForTarget:target
+                         storage:storage
+                      conditions:conditions
+                      completion:^(NSNumber *_Nullable batchID,
+                                   NSSet<GDTCOREvent *> *_Nullable events) {
+                        // 2. Check if there are events to upload.
+                        if (!events || events.count == 0) {
+                          dispatch_async(self.uploaderQueue, ^{
+                            GDTCORLogDebug(@"Target %ld reported as ready for upload, but no "
+                                           @"events were selected",
+                                           (long)target);
+                            self.isCurrentlyUploading = NO;
+                            backgroundTaskCompletion();
+                          });
+
+                          return;
+                        }
+                        // 3. Upload events.
+                        [self uploadBatchWithID:batchID
+                                         events:events
+                                         target:target
+                                        storage:storage
+                                     completion:^{
+                                       backgroundTaskCompletion();
+                                     }];
+                      }];
+  });
+}
 
+#pragma mark - Upload implementation details
+
+/** Performs URL request, handles the result and updates the uploader state. */
+- (void)uploadBatchWithID:(nullable NSNumber *)batchID
+                   events:(nullable NSSet<GDTCOREvent *> *)events
+                   target:(GDTCORTarget)target
+                  storage:(id<GDTCORStorageProtocol>)storage
+               completion:(dispatch_block_t)completion {
+  [self
+      sendURLRequestForBatchWithID:batchID
+                            events:events
+                            target:target
+                 completionHandler:^(NSNumber *_Nonnull batchID,
+                                     NSSet<GDTCOREvent *> *_Nullable events, NSData *_Nullable data,
+                                     NSURLResponse *_Nullable response, NSError *_Nullable error) {
+                   dispatch_async(self.uploaderQueue, ^{
+                     [self handleURLResponse:response
+                                        data:data
+                                       error:error
+                                      target:target
+                                     storage:storage
+                                     batchID:batchID];
 #if !NDEBUG
-    __block NSSet<GDTCOREvent *> *events;
-#endif  // !NDEBUG
+                     // Post a notification when in DEBUG mode to state how many packages
+                     // were uploaded. Useful for validation during tests.
+                     [[NSNotificationCenter defaultCenter]
+                         postNotificationName:GDTCCTUploadCompleteNotification
+                                       object:@(events.count)];
+#endif  // #if !NDEBUG
+                     self.isCurrentlyUploading = NO;
+                     completion();
+                   });
+                 }];
+}
 
-    id completionHandler = ^(NSData *_Nullable data, NSURLResponse *_Nullable response,
-                             NSError *_Nullable error) {
-      GDTCORLogDebug(@"%@", @"CCT: request completed");
-      if (error) {
-        GDTCORLogWarning(GDTCORMCWUploadFailed, @"There was an error uploading events: %@", error);
-      }
-      NSError *decodingError;
-      GDTCORClock *futureUploadTime;
-      if (data) {
-        gdt_cct_LogResponse logResponse = GDTCCTDecodeLogResponse(data, &decodingError);
-        if (!decodingError && logResponse.has_next_request_wait_millis) {
-          GDTCORLogDebug(
-              @"CCT: The backend responded asking to not upload for %lld millis from now.",
-              logResponse.next_request_wait_millis);
-          futureUploadTime =
-              [GDTCORClock clockSnapshotInTheFuture:logResponse.next_request_wait_millis];
-        } else if (decodingError) {
-          GDTCORLogDebug(@"There was a response decoding error: %@", decodingError);
-        }
-        pb_release(gdt_cct_LogResponse_fields, &logResponse);
-      }
-      if (!futureUploadTime) {
-        GDTCORLogDebug(@"%@", @"CCT: The backend response failed to parse, so the next request "
-                              @"won't occur until 15 minutes from now");
-        // 15 minutes from now.
-        futureUploadTime = [GDTCORClock clockSnapshotInTheFuture:15 * 60 * 1000];
-      }
-      switch (target) {
-        case kGDTCORTargetCCT:
-          self->_CCTNextUploadTime = futureUploadTime;
-          break;
-
-        case kGDTCORTargetFLL:
-          // Falls through.
-        case kGDTCORTargetCSH:
-          self->_FLLNextUploadTime = futureUploadTime;
-        default:
-          break;
-      }
+/** Validates events and sends URL request and calls completion with the result. Modifies uploading
+ * state in the case of the failure.*/
+- (void)sendURLRequestForBatchWithID:(nullable NSNumber *)batchID
+                              events:(nullable NSSet<GDTCOREvent *> *)events
+                              target:(GDTCORTarget)target
+                   completionHandler:(GDTCCTUploaderURLTaskCompletion)completionHandler {
+  dispatch_async(self.uploaderQueue, ^{
+    NSData *requestProtoData = [self constructRequestProtoWithEvents:events];
+    NSData *gzippedData = [GDTCCTCompressionHelper gzippedData:requestProtoData];
+    BOOL usingGzipData = gzippedData != nil && gzippedData.length < requestProtoData.length;
+    NSData *dataToSend = usingGzipData ? gzippedData : requestProtoData;
+    NSURLRequest *request = [self constructRequestForTarget:target data:dataToSend];
+    GDTCORLogDebug(@"CTT: request containing %lu events created: %@", (unsigned long)events.count,
+                   request);
+    NSSet<GDTCOREvent *> *eventsForDebug;
+#if !NDEBUG
+    eventsForDebug = events;
+#endif
+    self.currentTask = [self.uploaderSession
+        uploadTaskWithRequest:request
+                     fromData:dataToSend
+            completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
+                                NSError *_Nullable error) {
+              completionHandler(batchID, eventsForDebug, data, response, error);
+            }];
+    GDTCORLogDebug(@"%@", @"CCT: The upload task is about to begin.");
+    [self.currentTask resume];
+  });
+}
 
-      // Only retry if one of these codes is returned, or there was an error.
-      if (error || ((NSHTTPURLResponse *)response).statusCode == 429 ||
-          ((NSHTTPURLResponse *)response).statusCode == 503) {
-        [storage removeBatchWithID:batchID deleteEvents:NO onComplete:nil];
-      } else {
-        GDTCORLogDebug(@"%@", @"CCT: package delivered");
-        [storage removeBatchWithID:batchID deleteEvents:YES onComplete:nil];
-      }
+/** Handles URL request response. */
+- (void)handleURLResponse:(nullable NSURLResponse *)response
+                     data:(nullable NSData *)data
+                    error:(nullable NSError *)error
+                   target:(GDTCORTarget)target
+                  storage:(id<GDTCORStorageProtocol>)storage
+                  batchID:(NSNumber *)batchID {
+  GDTCORLogDebug(@"%@", @"CCT: request completed");
+  if (error) {
+    GDTCORLogWarning(GDTCORMCWUploadFailed, @"There was an error uploading events: %@", error);
+  }
+  NSError *decodingError;
+  GDTCORClock *futureUploadTime;
+  if (data) {
+    gdt_cct_LogResponse logResponse = GDTCCTDecodeLogResponse(data, &decodingError);
+    if (!decodingError && logResponse.has_next_request_wait_millis) {
+      GDTCORLogDebug(@"CCT: The backend responded asking to not upload for %lld millis from now.",
+                     logResponse.next_request_wait_millis);
+      futureUploadTime =
+          [GDTCORClock clockSnapshotInTheFuture:logResponse.next_request_wait_millis];
+    } else if (decodingError) {
+      GDTCORLogDebug(@"There was a response decoding error: %@", decodingError);
+    }
+    pb_release(gdt_cct_LogResponse_fields, &logResponse);
+  }
+  if (!futureUploadTime) {
+    GDTCORLogDebug(@"%@", @"CCT: The backend response failed to parse, so the next request "
+                          @"won't occur until 15 minutes from now");
+    // 15 minutes from now.
+    futureUploadTime = [GDTCORClock clockSnapshotInTheFuture:15 * 60 * 1000];
+  }
+  switch (target) {
+    case kGDTCORTargetCCT:
+      self->_CCTNextUploadTime = futureUploadTime;
+      break;
 
-#if !NDEBUG
-      // Post a notification when in DEBUG mode to state how many packages were uploaded. Useful
-      // for validation during tests.
-      [[NSNotificationCenter defaultCenter] postNotificationName:GDTCCTUploadCompleteNotification
-                                                          object:@(events.count)];
-#endif  // #if !NDEBUG
+    case kGDTCORTargetFLL:
+      // Falls through.
+    case kGDTCORTargetCSH:
+      self->_FLLNextUploadTime = futureUploadTime;
+      break;
+    default:
+      break;
+  }
 
-      // End the background task if there was one.
-      if (bgID != GDTCORBackgroundIdentifierInvalid) {
-        [[GDTCORApplication sharedApplication] endBackgroundTask:bgID];
-        bgID = GDTCORBackgroundIdentifierInvalid;
-      }
-      self.currentTask = nil;
-    };
+  // Only retry if one of these codes is returned, or there was an error.
+  if (error || ((NSHTTPURLResponse *)response).statusCode == 429 ||
+      ((NSHTTPURLResponse *)response).statusCode == 503) {
+    // Move the events back to the main storage to be uploaded on the next attempt.
+    [storage removeBatchWithID:batchID deleteEvents:NO onComplete:nil];
+  } else {
+    GDTCORLogDebug(@"%@", @"CCT: package delivered");
+    [storage removeBatchWithID:batchID deleteEvents:YES onComplete:nil];
+  }
 
+  self.currentTask = nil;
+}
+
+#pragma mark - Stored events upload
+
+/** Fetches a batch of pending events for the specified target and conditions. Passes `nil` to
+ * completion if there are no suitable events to upload. */
+- (void)batchToUploadForTarget:(GDTCORTarget)target
+                       storage:(id<GDTCORStorageProtocol>)storage
+                    conditions:(GDTCORUploadConditions)conditions
+                    completion:(GDTCCTUploaderEventBatchBlock)completion {
+  // 1. Check if the conditions for the target are suitable.
+  if (![self readyToUploadTarget:target conditions:conditions]) {
+    completion(nil, nil);
+    return;
+  }
+
+  // 2. Remove previously attempted batches
+  [self removeBatchesForTarget:target
+                       storage:storage
+                    onComplete:^{
+                      // There may be a big amount of events stored, so creating a batch may be an
+                      // expensive operation.
+
+                      // 3. Do a lightweight check if there are any events for the target first to
+                      // finish early if there are no.
+                      [storage hasEventsForTarget:target
+                                       onComplete:^(BOOL hasEvents) {
+                                         // 4. Proceed with fetching the events.
+                                         [self batchToUploadForTarget:target
+                                                              storage:storage
+                                                           conditions:conditions
+                                                            hasEvents:hasEvents
+                                                           completion:completion];
+                                       }];
+                    }];
+}
+
+/** Makes final checks before and makes */
+- (void)batchToUploadForTarget:(GDTCORTarget)target
+                       storage:(id<GDTCORStorageProtocol>)storage
+                    conditions:(GDTCORUploadConditions)conditions
+                     hasEvents:(BOOL)hasEvents
+                    completion:(GDTCCTUploaderEventBatchBlock)completion {
+  dispatch_async(self.uploaderQueue, ^{
+    if (!hasEvents) {
+      // No events to upload.
+      completion(nil, nil);
+      return;
+    }
+
+    // Check if the conditions are still met before starting upload.
+    if (![self readyToUploadTarget:target conditions:conditions]) {
+      completion(nil, nil);
+      return;
+    }
+
+    // All conditions have been checked and met. Lock uploader for this target to prevent other
+    // targets upload attempts.
+    self.isCurrentlyUploading = YES;
+
+    // Fetch a batch to upload and pass along.
     GDTCORStorageEventSelector *eventSelector = [self eventSelectorTarget:target
                                                            withConditions:conditions];
-    if (eventSelector != nil) {
-      [storage batchWithEventSelector:eventSelector
-                      batchExpiration:[NSDate dateWithTimeIntervalSinceNow:600]
-                           onComplete:^(NSNumber *_Nullable newBatchID,
-                                        NSSet<GDTCOREvent *> *_Nullable batchEvents) {
-                             if (!batchEvents || batchEvents.count == 0) {
-                               GDTCORLogDebug(@"Target %ld reported as ready for upload, but no "
-                                              @"events were selected",
-                                              (long)target);
-                               return;
-                             }
-                             batchID = newBatchID;
-#if !NDEBUG
-                             events = batchEvents;
-#endif  // !NDEBUG
-                             NSData *requestProtoData =
-                                 [self constructRequestProtoWithEvents:batchEvents];
-                             NSData *gzippedData =
-                                 [GDTCCTCompressionHelper gzippedData:requestProtoData];
-                             BOOL usingGzipData =
-                                 gzippedData != nil && gzippedData.length < requestProtoData.length;
-                             NSData *dataToSend = usingGzipData ? gzippedData : requestProtoData;
-                             NSURLRequest *request = [self constructRequestForTarget:target
-                                                                                data:dataToSend];
-                             GDTCORLogDebug(@"CTT: request containing %lu events created: %@",
-                                            (unsigned long)batchEvents.count, request);
-                             self.currentTask =
-                                 [self.uploaderSession uploadTaskWithRequest:request
-                                                                    fromData:dataToSend
-                                                           completionHandler:completionHandler];
-                             GDTCORLogDebug(@"%@", @"CCT: The upload task is about to begin.");
-                             [self.currentTask resume];
-                           }];
-    }
+    [storage batchWithEventSelector:eventSelector
+                    batchExpiration:[NSDate dateWithTimeIntervalSinceNow:600]
+                         onComplete:completion];
   });
 }
 
+- (void)removeBatchesForTarget:(GDTCORTarget)target
+                       storage:(id<GDTCORStorageProtocol>)storage
+                    onComplete:(dispatch_block_t)onComplete {
+  [storage batchIDsForTarget:target
+                  onComplete:^(NSSet<NSNumber *> *_Nullable batchIDs) {
+                    // No stored batches, no need to remove anything.
+                    if (batchIDs.count < 1) {
+                      onComplete();
+                      return;
+                    }
+
+                    dispatch_group_t dispatchGroup = dispatch_group_create();
+                    for (NSNumber *batchID in batchIDs) {
+                      dispatch_group_enter(dispatchGroup);
+
+                      // Remove batches and moves events back to the storage.
+                      [storage removeBatchWithID:batchID
+                                    deleteEvents:NO
+                                      onComplete:^{
+                                        dispatch_group_leave(dispatchGroup);
+                                      }];
+                    }
+
+                    // Wait until all batches are removed and call completion handler.
+                    dispatch_group_notify(dispatchGroup, self.uploaderQueue, ^{
+                      onComplete();
+                    });
+                  }];
+}
+
 #pragma mark - Private helper methods
 
 /** */
 - (BOOL)readyToUploadTarget:(GDTCORTarget)target conditions:(GDTCORUploadConditions)conditions {
-  id<GDTCORStorageProtocol> storage = GDTCORStorageInstanceForTarget(target);
-  if (target == kGDTCORTargetCSH) {
-    __block BOOL hasCSHEvents = NO;
-    dispatch_semaphore_t sema = dispatch_semaphore_create(0);
-    [storage hasEventsForTarget:target
-                     onComplete:^(BOOL hasEvents) {
-                       hasCSHEvents = hasEvents;
-                       dispatch_semaphore_signal(sema);
-                     }];
-    if (dispatch_semaphore_wait(sema, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)) != 0) {
-      GDTCORLogDebug(@"Timed out waiting for hasEventsForTarget: %ld", (long)target);
-      return NO;
-    }
-    return hasCSHEvents;
-  }
-  __block NSSet<NSNumber *> *batchIDs;
-  dispatch_semaphore_t sema = dispatch_semaphore_create(0);
-  [storage batchIDsForTarget:target
-                  onComplete:^(NSSet<NSNumber *> *_Nullable storedBatches) {
-                    batchIDs = storedBatches;
-                    dispatch_semaphore_signal(sema);
-                  }];
-  if (dispatch_semaphore_wait(sema, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)) != 0) {
-    GDTCORLogDebug(@"Timed out waiting for in-flight batch IDs for target: %ld", (long)target);
-    return NO;
-  }
-  if (self->_currentTask || batchIDs.count > 0) {
-    GDTCORLogWarning(GDTCORMCWUploadFailed, @"%@",
-                     @"An upload shouldn't be initiated with another in progress.");
+  if (self.isCurrentlyUploading) {
+    GDTCORLogDebug(@"%@", @"CCT: Wait until previous upload finishes. The current version supports "
+                          @"only a single batch uploading at the time.");
     return NO;
   }
 
-  if (batchIDs.count > 0) {
-    GDTCORLogDebug(@"%@", @"CCT: can't upload because a package is in flight");
+  // Not ready to upload with no network connection.
+  // TODO: Reconsider using reachability to prevent an upload attempt.
+  // See https://developer.apple.com/videos/play/wwdc2019/712/ (49:40) for more details.
+  if (conditions & GDTCORUploadConditionNoNetwork) {
+    GDTCORLogDebug(@"%@", @"CCT: Not ready to upload without a network connection.");
     return NO;
   }
-  if (self->_currentTask) {
-    GDTCORLogDebug(@"%@", @"CCT: can't upload because a task is in progress");
-    return NO;
+
+  // Upload events when there are with no additional conditions for kGDTCORTargetCSH.
+  if (target == kGDTCORTargetCSH) {
+    GDTCORLogDebug(@"%@",
+                   @"CCT: kGDTCORTargetCSH events are allowed to be uploaded straight away.");
+    return YES;
   }
+
+  // Upload events with no additional conditions if high priority.
   if ((conditions & GDTCORUploadConditionHighPriority) == GDTCORUploadConditionHighPriority) {
     GDTCORLogDebug(@"%@", @"CCT: a high priority event is allowing an upload");
     return YES;
   }
-  BOOL result = NO;
+
+  // Check next upload time for the target.
+  BOOL isAfterNextUploadTime = YES;
   switch (target) {
     case kGDTCORTargetCCT:
       if (self->_CCTNextUploadTime) {
-        result = [[GDTCORClock snapshot] isAfter:self->_CCTNextUploadTime];
+        isAfterNextUploadTime = [[GDTCORClock snapshot] isAfter:self->_CCTNextUploadTime];
       }
       break;
 
     case kGDTCORTargetFLL:
       if (self->_FLLNextUploadTime) {
-        result = [[GDTCORClock snapshot] isAfter:self->_FLLNextUploadTime];
+        isAfterNextUploadTime = [[GDTCORClock snapshot] isAfter:self->_FLLNextUploadTime];
       }
       break;
 
@@ -384,16 +531,16 @@ NSNotificationName const GDTCCTUploadCompleteNotification = @"com.GDTCCTUploader
       // The CSH backend should be handled above.
       break;
   }
-  if (result) {
+
+  if (isAfterNextUploadTime) {
     GDTCORLogDebug(@"CCT: can upload to target %ld because the request wait time has transpired",
                    (long)target);
   } else {
     GDTCORLogDebug(@"CCT: can't upload to target %ld because the backend asked to wait",
                    (long)target);
   }
-  result = YES;
-  GDTCORLogDebug(@"CCT: can upload to target %ld because nothing is preventing it", (long)target);
-  return result;
+
+  return isAfterNextUploadTime;
 }
 
 /** Constructs data given an upload package.
@@ -472,21 +619,18 @@ NSNotificationName const GDTCCTUploadCompleteNotification = @"com.GDTCCTUploader
 - (nullable GDTCORStorageEventSelector *)eventSelectorTarget:(GDTCORTarget)target
                                               withConditions:(GDTCORUploadConditions)conditions {
   id<GDTCORStorageProtocol> storage = GDTCORStorageInstanceForTarget(target);
-  if (conditions & GDTCORUploadConditionNoNetwork) {
-    return nil;
-  }
   if ((conditions & GDTCORUploadConditionHighPriority) == GDTCORUploadConditionHighPriority) {
     return [GDTCORStorageEventSelector eventSelectorForTarget:target];
   }
   NSMutableSet<NSNumber *> *qosTiers = [[NSMutableSet alloc] init];
   if (conditions & GDTCORUploadConditionWifiData) {
     [qosTiers addObjectsFromArray:@[
-      @(GDTCOREventQoSWifiOnly), @(GDTCOREventQosDefault), @(GDTCOREventQoSTelemetry),
-      @(GDTCOREventQoSUnknown)
+      @(GDTCOREventQoSFast), @(GDTCOREventQoSWifiOnly), @(GDTCOREventQosDefault),
+      @(GDTCOREventQoSTelemetry), @(GDTCOREventQoSUnknown)
     ]];
   }
   if (conditions & GDTCORUploadConditionMobileData) {
-    [qosTiers addObjectsFromArray:@[ @(GDTCOREventQosDefault) ]];
+    [qosTiers addObjectsFromArray:@[ @(GDTCOREventQoSFast), @(GDTCOREventQosDefault) ]];
   }
 
   __block NSInteger lastDayOfDailyUpload;
@@ -549,3 +693,5 @@ NSNotificationName const GDTCCTUploadCompleteNotification = @"com.GDTCCTUploader
 }
 
 @end
+
+NS_ASSUME_NONNULL_END

+ 34 - 0
GoogleDataTransport/GDTCCTTests/Common/TestStorage/GDTCCTTestStorage.h

@@ -15,9 +15,43 @@
  */
 
 #import <Foundation/Foundation.h>
+#import <XCTest/XCTest.h>
 
 #import "GoogleDataTransport/GDTCORLibrary/Public/GDTCORStorageProtocol.h"
 
+NS_ASSUME_NONNULL_BEGIN
+
+typedef void (^GDTCCTTestStorageBatchHandler)(GDTCORStorageEventSelector *_Nullable eventSelector,
+                                              NSDate *_Nullable expiration,
+                                              GDTCORStorageBatchBlock _Nullable completion);
+
+typedef void (^GDTCCTTestStorageHasEventsCompletion)(BOOL hasEvents);
+typedef void (^GDTCCTTestStorageHasEventsHandler)(GDTCORTarget target,
+                                                  GDTCCTTestStorageHasEventsCompletion completion);
+
 @interface GDTCCTTestStorage : NSObject <GDTCORStorageProtocol>
 
+#pragma mark - Method call expectations.
+
+@property(nonatomic, nullable) XCTestExpectation *batchWithEventSelectorExpectation;
+@property(nonatomic, nullable) XCTestExpectation *removeBatchAndDeleteEventsExpectation;
+@property(nonatomic, nullable) XCTestExpectation *removeBatchWithoutDeletingEventsExpectation;
+@property(nonatomic, nullable) XCTestExpectation *batchIDsForTargetExpectation;
+
+#pragma mark - Blocks to provide custom implementations for the methods.
+
+/// A block to override `batchWithEventSelector:batchExpiration:onComplete:` implementation.
+@property(nonatomic, copy, nullable) GDTCCTTestStorageBatchHandler batchWithEventSelectorHandler;
+/// A block to override `hasEventsForTarget:onComplete:` implementation.
+@property(nonatomic, copy, nullable) GDTCCTTestStorageHasEventsHandler hasEventsForTargetHandler;
+
+#pragma mark - Default test implementations
+
+/// Default test implementation for `batchWithEventSelector:batchExpiration:onComplete:`  method.
+- (void)defaultBatchWithEventSelector:(nonnull GDTCORStorageEventSelector *)eventSelector
+                      batchExpiration:(nonnull NSDate *)expiration
+                           onComplete:(nonnull GDTCORStorageBatchBlock)onComplete;
+
 @end
+
+NS_ASSUME_NONNULL_END

+ 50 - 22
GoogleDataTransport/GDTCCTTests/Common/TestStorage/GDTCCTTestStorage.m

@@ -26,12 +26,17 @@
   NSMutableDictionary<NSNumber *, NSSet<GDTCOREvent *> *> *_batches;
 }
 
+- (instancetype)init {
+  self = [super init];
+  if (self) {
+    _storedEvents = [[NSMutableDictionary alloc] init];
+    _batches = [[NSMutableDictionary alloc] init];
+  }
+  return self;
+}
+
 - (void)storeEvent:(GDTCOREvent *)event
         onComplete:(void (^_Nullable)(BOOL wasWritten, NSError *_Nullable))completion {
-  static dispatch_once_t onceToken;
-  dispatch_once(&onceToken, ^{
-    self->_storedEvents = [[NSMutableDictionary alloc] init];
-  });
   _storedEvents[event.eventID] = event;
   if (completion) {
     completion(YES, nil);
@@ -44,28 +49,29 @@
 
 - (void)batchWithEventSelector:(nonnull GDTCORStorageEventSelector *)eventSelector
                batchExpiration:(nonnull NSDate *)expiration
-                    onComplete:
-                        (nonnull void (^)(NSNumber *_Nullable batchID,
-                                          NSSet<GDTCOREvent *> *_Nullable events))onComplete {
-  static NSInteger count = 0;
-  NSNumber *batchID = @(count);
-  count++;
-  static dispatch_once_t onceToken;
-  dispatch_once(&onceToken, ^{
-    self->_batches = [[NSMutableDictionary alloc] init];
-  });
-  NSSet<GDTCOREvent *> *batchEvents = [NSSet setWithArray:[_storedEvents allValues]];
-  _batches[batchID] = batchEvents;
-  [_storedEvents removeAllObjects];
-  if (onComplete) {
-    onComplete(batchID, batchEvents);
+                    onComplete:(nonnull GDTCORStorageBatchBlock)onComplete {
+  if (self.batchWithEventSelectorHandler) {
+    self.batchWithEventSelectorHandler(eventSelector, expiration, onComplete);
+  } else {
+    [self defaultBatchWithEventSelector:eventSelector
+                        batchExpiration:expiration
+                             onComplete:onComplete];
   }
 }
 
 - (void)removeBatchWithID:(nonnull NSNumber *)batchID
              deleteEvents:(BOOL)deleteEvents
                onComplete:(void (^_Nullable)(void))onComplete {
-  [_batches removeObjectForKey:batchID];
+  if (deleteEvents) {
+    [_batches removeObjectForKey:batchID];
+    [self.removeBatchAndDeleteEventsExpectation fulfill];
+  } else {
+    for (GDTCOREvent *batchedEvent in _batches[batchID]) {
+      _storedEvents[batchedEvent.eventID] = batchedEvent;
+    }
+    [self.removeBatchWithoutDeletingEventsExpectation fulfill];
+  }
+
   if (onComplete) {
     onComplete();
   }
@@ -95,7 +101,9 @@
 }
 
 - (void)hasEventsForTarget:(GDTCORTarget)target onComplete:(nonnull void (^)(BOOL))onComplete {
-  if (onComplete) {
+  if (self.hasEventsForTargetHandler) {
+    self.hasEventsForTargetHandler(target, onComplete);
+  } else if (onComplete) {
     onComplete(NO);
   }
 }
@@ -105,12 +113,32 @@
 
 - (void)batchIDsForTarget:(GDTCORTarget)target
                onComplete:(nonnull void (^)(NSSet<NSNumber *> *_Nullable))onComplete {
+  [self.batchIDsForTargetExpectation fulfill];
   if (onComplete) {
-    onComplete(nil);
+    onComplete([NSSet setWithArray:[self->_batches allKeys]]);
   }
 }
 
 - (void)checkForExpirations {
 }
 
+#pragma mark - Default Implementations
+
+- (void)defaultBatchWithEventSelector:(nonnull GDTCORStorageEventSelector *)eventSelector
+                      batchExpiration:(nonnull NSDate *)expiration
+                           onComplete:(nonnull GDTCORStorageBatchBlock)onComplete {
+  static NSInteger count = 0;
+  NSNumber *batchID = @(count);
+  count++;
+
+  NSSet<GDTCOREvent *> *batchEvents = [NSSet setWithArray:[_storedEvents allValues]];
+  _batches[batchID] = batchEvents;
+  [_storedEvents removeAllObjects];
+
+  [self.batchWithEventSelectorExpectation fulfill];
+  if (onComplete) {
+    onComplete(batchID, batchEvents);
+  }
+}
+
 @end

+ 633 - 15
GoogleDataTransport/GDTCCTTests/Unit/GDTCCTUploaderTest.m

@@ -16,8 +16,8 @@
 
 #import <XCTest/XCTest.h>
 
-#import "GoogleDataTransport/GDTCORLibrary/Public/GDTCORRegistrar.h"
 #import "GoogleDataTransport/GDTCORLibrary/Public/GDTCORStorageProtocol.h"
+#import "GoogleDataTransport/GDTCORTests/Common/Categories/GDTCORRegistrar+Testing.h"
 
 #import "GoogleDataTransport/GDTCCTLibrary/Private/GDTCCTNanopbHelpers.h"
 #import "GoogleDataTransport/GDTCCTLibrary/Private/GDTCCTUploader.h"
@@ -29,32 +29,51 @@
 
 @interface GDTCCTUploaderTest : XCTestCase
 
+@property(nonatomic) GDTCCTUploader *uploader;
+
 /** An event generator for testing. */
 @property(nonatomic) GDTCCTEventGenerator *generator;
 
 /** The local HTTP server to use for testing. */
 @property(nonatomic) GDTCCTTestServer *testServer;
 
+@property(nonatomic) GDTCCTTestStorage *testStorage;
+
 @end
 
 @implementation GDTCCTUploaderTest
 
 - (void)setUp {
-  [[GDTCORRegistrar sharedInstance] registerStorage:[[GDTCCTTestStorage alloc] init]
-                                             target:kGDTCORTargetTest];
+  [super setUp];
+
+  self.testStorage = [[GDTCCTTestStorage alloc] init];
+
+  // Reset registrar to avoid real object access storage along with the tests.
+  [[GDTCORRegistrar sharedInstance] reset];
+  [[GDTCORRegistrar sharedInstance] registerStorage:self.testStorage target:kGDTCORTargetTest];
+
   self.generator = [[GDTCCTEventGenerator alloc] initWithTarget:kGDTCORTargetTest];
+
   self.testServer = [[GDTCCTTestServer alloc] init];
   [self.testServer registerLogBatchPath];
   [self.testServer start];
   XCTAssertTrue(self.testServer.isRunning);
+
+  self.uploader = [[GDTCCTUploader alloc] init];
+  self.uploader.testServerURL = [self.testServer.serverURL URLByAppendingPathComponent:@"logBatch"];
 }
 
 - (void)tearDown {
-  [super tearDown];
+  self.testServer.responseCompletedBlock = nil;
   [self.testServer stop];
+  self.testStorage = nil;
+  [super tearDown];
 }
 
-- (void)testCCTUploadGivenConditions {
+#pragma mark - Upload flow tests
+
+- (void)testUploadTargetWhenThereAreEventsToUpload {
+  // 0. Generate test events.
   id<GDTCORStorageProtocol> storage = GDTCORStorageInstanceForTarget(kGDTCORTargetTest);
   XCTAssertNotNil(storage);
   [[self.generator generateTheFiveConsistentEvents]
@@ -62,10 +81,459 @@
         [storage storeEvent:obj onComplete:nil];
       }];
 
-  GDTCCTUploader *uploader = [[GDTCCTUploader alloc] init];
-  uploader.testServerURL = [self.testServer.serverURL URLByAppendingPathComponent:@"logBatch"];
+  // 1. Set up expectations.
+  // 1.1. Set up all relevant storage expectations.
+  [self setUpStorageExpectations];
+
+  // 1.2. Expect `hasEventsForTarget:onComplete:` to be called.
+  XCTestExpectation *hasEventsExpectation = [self expectStorageHasEventsForTarget:kGDTCORTargetTest
+                                                                           result:YES];
+
+  // 1.3. Don't expect previously batched events to be removed (no batch present).
+  self.testStorage.removeBatchWithoutDeletingEventsExpectation.inverted = YES;
+
+  // 1.4. Expect a batch to be uploaded.
+  XCTestExpectation *responseSentExpectation = [self expectationTestServerSuccessRequestResponse];
+
+  // 2. Create uploader and start upload.
+  [self.uploader uploadTarget:kGDTCORTargetTest withConditions:GDTCORUploadConditionWifiData];
+
+  // 3. Wait for operations to complete in the specified order.
+  [self waitForExpectations:@[
+    self.testStorage.batchIDsForTargetExpectation,
+    self.testStorage.removeBatchWithoutDeletingEventsExpectation, hasEventsExpectation,
+    self.testStorage.batchWithEventSelectorExpectation, responseSentExpectation,
+    self.testStorage.removeBatchAndDeleteEventsExpectation
+  ]
+                    timeout:3
+               enforceOrder:YES];
+
+  // 4. Wait for upload operation to finish.
+  [self waitForUploadOperationsToFinish:self.uploader];
+}
+
+- (void)testUploadTargetWhenThereIsStoredBatchThenItIsUploadedFirst {
+  // 0. Generate test events.
+  // 0.1. Generate and store and an event.
+  [self.generator generateEvent:GDTCOREventQoSFast];
+  // 0.2. Batch the event.
+  [self batchEvents];
+
+  // 1. Set up expectations.
+  // 1.1. Set up all relevant storage expectations.
+  [self setUpStorageExpectations];
+
+  // 1.2. Expect `hasEventsForTarget:onComplete:` to be called.
+  XCTestExpectation *hasEventsExpectation = [self expectStorageHasEventsForTarget:kGDTCORTargetTest
+                                                                           result:YES];
+
+  // 1.3. Expect a batch to be uploaded.
+  XCTestExpectation *responseSentExpectation = [self expectationTestServerSuccessRequestResponse];
+
+  // 2. Create uploader and start upload.
+  [self.uploader uploadTarget:kGDTCORTargetTest withConditions:GDTCORUploadConditionWifiData];
+
+  // 3. Wait for operations to complete in the specified order.
+  [self waitForExpectations:@[
+    self.testStorage.batchIDsForTargetExpectation,
+    self.testStorage.removeBatchWithoutDeletingEventsExpectation, hasEventsExpectation,
+    self.testStorage.batchWithEventSelectorExpectation, responseSentExpectation,
+    self.testStorage.removeBatchAndDeleteEventsExpectation
+  ]
+                    timeout:3
+               enforceOrder:NO];
+
+  // 4. Wait for upload operation to finish.
+  [self waitForUploadOperationsToFinish:self.uploader];
+}
+
+/** Tests that when there is an ongoing upload no other uploads are started until the 1st finishes.
+ * Once 1st finished, another one can be started. */
+- (void)testUploadTargetWhenThereIsOngoingUploadThenNoOp {
+  // 0. Set up expectations to track 1st upload progress.
+  // 0.1. Generate and store and an event.
+  [self.generator generateEvent:GDTCOREventQoSFast];
+  // 0.2. Configure server request expectation.
+  // Block to call to finish the 1st request.
+  __block dispatch_block_t requestCompletionBlock;
+  __auto_type __weak weakSelf = self;
+  XCTestExpectation *serverRequestExpectation1 =
+      [self expectationWithDescription:@"serverRequestExpectation1"];
+  self.testServer.requestHandler =
+      ^(GCDWebServerRequest *_Nonnull request, GCDWebServerResponse *_Nullable suggestedResponse,
+        GCDWebServerCompletionBlock _Nonnull completionBlock) {
+        weakSelf.testServer.requestHandler = nil;
+        requestCompletionBlock = ^{
+          completionBlock(suggestedResponse);
+        };
+
+        [serverRequestExpectation1 fulfill];
+      };
+
+  // 0.3. Configure storage.
+  XCTestExpectation *hasEventsExpectation1 = [self expectStorageHasEventsForTarget:kGDTCORTargetTest
+                                                                            result:YES];
+
+  // 0.4. Start upload 1st upload.
+  [self.uploader uploadTarget:kGDTCORTargetTest withConditions:GDTCORUploadConditionWifiData];
+
+  // 0.4. Wait for server request to be sent.
+  [self waitForExpectations:@[ hasEventsExpectation1, serverRequestExpectation1 ] timeout:1];
+
+  // 1. Test 2nd request.
+  // 1.0 Generate and store and an event.
+  [self.generator generateEvent:GDTCOREventQoSFast];
+
+  // 1.1. Configure expectations for the 2nd request.
+  // 1.1.1. Set up all relevant storage expectations.
+  [self setUpStorageExpectations];
+
+  // 1.1.2. Don't expect any storage.
+  self.testStorage.batchIDsForTargetExpectation.inverted = YES;
+  self.testStorage.batchWithEventSelectorExpectation.inverted = YES;
+  self.testStorage.removeBatchWithoutDeletingEventsExpectation.inverted = YES;
+  self.testStorage.removeBatchAndDeleteEventsExpectation.inverted = YES;
+
+  XCTestExpectation *hasEventsExpectation2 = [self expectStorageHasEventsForTarget:kGDTCORTargetTest
+                                                                            result:YES];
+  hasEventsExpectation2.inverted = YES;
+
+  // 1.2. Start upload 2nd time.
+  [self.uploader uploadTarget:kGDTCORTargetTest withConditions:GDTCORUploadConditionWifiData];
+
+  // 1.3. Wait for expectations.
+  [self waitForExpectations:@[
+    self.testStorage.batchIDsForTargetExpectation, hasEventsExpectation2,
+    self.testStorage.batchWithEventSelectorExpectation,
+    self.testStorage.removeBatchWithoutDeletingEventsExpectation,
+    self.testStorage.removeBatchAndDeleteEventsExpectation
+  ]
+                    timeout:3];
+
+  // 1.4. Wait for 1st upload finish.
+  requestCompletionBlock();
+  [self waitForUploadOperationsToFinish:self.uploader];
+
+  // 3. Test another upload after the 1st finished.
+  // 3.1.1. Set up all relevant storage expectations.
+  [self setUpStorageExpectations];
+
+  // 3.1.2. Expect `hasEventsForTarget:onComplete:` to be called.
+  XCTestExpectation *hasEventsExpectation3 = [self expectStorageHasEventsForTarget:kGDTCORTargetTest
+                                                                            result:YES];
+
+  // 3.1.3. Don't expect previously batched events to be removed (no batch present).
+  self.testStorage.removeBatchWithoutDeletingEventsExpectation.inverted = YES;
+
+  // 3.1.4. Expect a batch to be uploaded.
+  XCTestExpectation *responseSentExpectation = [self expectationTestServerSuccessRequestResponse];
+
+  // 3.3.2. Start 3rd upload.
+  [self.uploader uploadTarget:kGDTCORTargetTest withConditions:GDTCORUploadConditionWifiData];
+
+  // 3.3. Wait for operations to complete in the specified order.
+  [self waitForExpectations:@[
+    self.testStorage.batchIDsForTargetExpectation,
+    self.testStorage.removeBatchWithoutDeletingEventsExpectation, hasEventsExpectation3,
+    self.testStorage.batchWithEventSelectorExpectation, responseSentExpectation,
+    self.testStorage.removeBatchAndDeleteEventsExpectation
+  ]
+                    timeout:3
+               enforceOrder:YES];
+
+  // 3.4. Wait for upload operation to finish.
+  [self waitForUploadOperationsToFinish:self.uploader];
+}
+
+- (void)testUploadTarget_WhenThereAreBothStoredBatchAndEvents_ThenRemoveBatchAndBatchThenAllEvents {
+  // 0. Generate test events.
+  // 0.1. Generate and store and an event.
+  [self.generator generateEvent:GDTCOREventQoSFast];
+  // 0.2. Batch the event.
+  [self batchEvents];
+  // 0.3. Generate one more event.
+  [self.generator generateEvent:GDTCOREventQoSFast];
+
+  // 1. Set up expectations.
+  // 1.1. Set up all relevant storage expectations.
+  [self setUpStorageExpectations];
+
+  // 1.2. Expect `hasEventsForTarget:onComplete:` to be called.
+  XCTestExpectation *hasEventsExpectation = [self expectStorageHasEventsForTarget:kGDTCORTargetTest
+                                                                           result:YES];
+
+  // 1.3. Expect a batch to be uploaded.
+  XCTestExpectation *responseSentExpectation = [self expectationTestServerSuccessRequestResponse];
+
+  // 1.2. Start upload.
+  [self.uploader uploadTarget:kGDTCORTargetTest withConditions:GDTCORUploadConditionWifiData];
+
+  // 1.3. Wait for operations to complete in the specified order.
+  [self waitForExpectations:@[
+    self.testStorage.batchIDsForTargetExpectation,
+    self.testStorage.removeBatchWithoutDeletingEventsExpectation, hasEventsExpectation,
+    self.testStorage.batchWithEventSelectorExpectation, responseSentExpectation,
+    self.testStorage.removeBatchAndDeleteEventsExpectation
+  ]
+                    timeout:3
+               enforceOrder:YES];
+
+  // 1.4. Wait for upload operation to finish.
+  [self waitForUploadOperationsToFinish:self.uploader];
+}
+
+- (void)testUploadTarget_WhenThereAreNoEventsFirstThenEventsAdded_ThenUploadNewEvent {
+  self.uploader.testServerURL = [self.testServer.serverURL URLByAppendingPathComponent:@"logBatch"];
+
+  // 1. Test stored batch upload.
+  // 1.1. Set up expectations.
+  // 1.1.1. Set up all relevant storage expectations.
+  [self setUpStorageExpectations];
+
+  // 1.1.2. Expect `hasEventsForTarget:onComplete:` to be called.
+  XCTestExpectation *hasEventsExpectation = [self expectStorageHasEventsForTarget:kGDTCORTargetTest
+                                                                           result:NO];
+
+  // 1.1.3. Don't expect events to be batched or deleted.
+  self.testStorage.removeBatchWithoutDeletingEventsExpectation.inverted = YES;
+  self.testStorage.removeBatchAndDeleteEventsExpectation.inverted = YES;
+  self.testStorage.batchWithEventSelectorExpectation.inverted = YES;
+
+  // 1.1.4. Don't expect a batch to be uploaded.
+  XCTestExpectation *responseSentExpectation1 = [self expectationTestServerSuccessRequestResponse];
+  responseSentExpectation1.inverted = YES;
+
+  // 1.2. Create uploader and start upload.
+  [self.uploader uploadTarget:kGDTCORTargetTest withConditions:GDTCORUploadConditionWifiData];
+
+  // 1.3. Wait for operations to complete in the specified order.
+  [self waitForExpectations:@[
+    self.testStorage.batchIDsForTargetExpectation,
+    self.testStorage.removeBatchWithoutDeletingEventsExpectation, hasEventsExpectation,
+    self.testStorage.batchWithEventSelectorExpectation, responseSentExpectation1,
+    self.testStorage.removeBatchAndDeleteEventsExpectation
+  ]
+                    timeout:3
+               enforceOrder:YES];
+
+  // 1.4. Wait for upload operation to finish.
+  [self waitForUploadOperationsToFinish:self.uploader];
+
+  // 2. Test stored events upload.
+  // 2.0. Generate and store and an event.
+  [self.generator generateEvent:GDTCOREventQoSFast];
+
+  // 2.1. Set up expectations.
+  // 2.1.1. Set up all relevant storage expectations.
+  [self setUpStorageExpectations];
+
+  // 2.1.2. Expect `hasEventsForTarget:onComplete:` to be called.
+  hasEventsExpectation = [self expectStorageHasEventsForTarget:kGDTCORTargetTest result:YES];
+
+  // 2.1.3. Don't expect previously batched events to be removed (no batch present).
+  self.testStorage.removeBatchWithoutDeletingEventsExpectation.inverted = YES;
+
+  // 2.1.4. Expect a batch to be uploaded.
+  XCTestExpectation *responseSentExpectation = [self expectationTestServerSuccessRequestResponse];
+
+  // 2.2. Create uploader and start upload.
+  [self.uploader uploadTarget:kGDTCORTargetTest withConditions:GDTCORUploadConditionWifiData];
+
+  // 2.3. Wait for operations to complete in the specified order.
+  [self waitForExpectations:@[
+    self.testStorage.batchIDsForTargetExpectation,
+    self.testStorage.removeBatchWithoutDeletingEventsExpectation, hasEventsExpectation,
+    self.testStorage.batchWithEventSelectorExpectation, responseSentExpectation,
+    self.testStorage.removeBatchAndDeleteEventsExpectation
+  ]
+                    timeout:3
+               enforceOrder:YES];
+
+  // 2.4. Wait for upload operation to finish.
+  [self waitForUploadOperationsToFinish:self.uploader];
+}
+
+#pragma mark - Storage interaction tests
+
+- (void)testStorageSelectorWhenConditionsHighPriority {
+  __weak id weakSelf = self;
+  [self assertStorageSelectorWithCondition:GDTCORUploadConditionHighPriority
+                           validationBlock:^(GDTCORStorageEventSelector *_Nullable eventSelector,
+                                             NSDate *expiration) {
+                             id self = weakSelf;
+                             XCTAssertLessThan([expiration timeIntervalSinceNow], 600);
+                             XCTAssertEqual(eventSelector.selectedTarget, kGDTCORTargetTest);
+                             XCTAssertNil(eventSelector.selectedEventIDs);
+                             XCTAssertNil(eventSelector.selectedMappingIDs);
+                             XCTAssertNil(eventSelector.selectedQosTiers);
+                           }];
+}
+
+- (void)testStorageSelectorWhenConditionsMobileData {
+  __weak id weakSelf = self;
+  [self
+      assertStorageSelectorWithCondition:GDTCORUploadConditionMobileData
+                         validationBlock:^(GDTCORStorageEventSelector *_Nullable eventSelector,
+                                           NSDate *expiration) {
+                           id self = weakSelf;
+                           XCTAssertLessThan([expiration timeIntervalSinceNow], 600);
+                           XCTAssertEqual(eventSelector.selectedTarget, kGDTCORTargetTest);
+                           XCTAssertNil(eventSelector.selectedEventIDs);
+                           XCTAssertNil(eventSelector.selectedMappingIDs);
+
+                           NSSet *expectedQoSTiers = [NSSet
+                               setWithArray:@[ @(GDTCOREventQoSFast), @(GDTCOREventQosDefault) ]];
+                           XCTAssertEqualObjects(eventSelector.selectedQosTiers, expectedQoSTiers);
+                         }];
+}
+
+- (void)testStorageSelectorWhenConditionsWifiData {
+  __weak id weakSelf = self;
+  [self
+      assertStorageSelectorWithCondition:GDTCORUploadConditionWifiData
+                         validationBlock:^(GDTCORStorageEventSelector *_Nullable eventSelector,
+                                           NSDate *expiration) {
+                           id self = weakSelf;
+                           XCTAssertLessThan([expiration timeIntervalSinceNow], 600);
+                           XCTAssertEqual(eventSelector.selectedTarget, kGDTCORTargetTest);
+                           XCTAssertNil(eventSelector.selectedEventIDs);
+                           XCTAssertNil(eventSelector.selectedMappingIDs);
+
+                           NSSet *expectedQoSTiers = [NSSet setWithArray:@[
+                             @(GDTCOREventQoSFast), @(GDTCOREventQoSWifiOnly),
+                             @(GDTCOREventQosDefault), @(GDTCOREventQoSTelemetry),
+                             @(GDTCOREventQoSUnknown)
+                           ]];
+                           XCTAssertEqualObjects(eventSelector.selectedQosTiers, expectedQoSTiers);
+                         }];
+}
+
+#pragma mark - Test ready for upload based on conditions
+
+- (void)testUploadTarget_WhenNoConnection_ThenDoNotUpload {
+  // 0. Generate and store and an event.
+  [self.generator generateEvent:GDTCOREventQoSFast];
+
+  // 1. Configure expectations for the 2nd request.
+  // 1.1. Set up all relevant storage expectations.
+  [self setUpStorageExpectations];
+
+  // 1.2. Don't expect any storage.
+  self.testStorage.batchIDsForTargetExpectation.inverted = YES;
+  self.testStorage.batchWithEventSelectorExpectation.inverted = YES;
+  self.testStorage.removeBatchWithoutDeletingEventsExpectation.inverted = YES;
+  self.testStorage.removeBatchAndDeleteEventsExpectation.inverted = YES;
+
+  XCTestExpectation *hasEventsExpectation2 = [self expectStorageHasEventsForTarget:kGDTCORTargetTest
+                                                                            result:YES];
+  hasEventsExpectation2.inverted = YES;
+
+  // 2. Start upload 2nd time.
+  [self.uploader uploadTarget:kGDTCORTargetTest withConditions:GDTCORUploadConditionNoNetwork];
+
+  // 3. Wait for expectations.
+  [self waitForExpectations:@[
+    self.testStorage.batchIDsForTargetExpectation, hasEventsExpectation2,
+    self.testStorage.batchWithEventSelectorExpectation,
+    self.testStorage.removeBatchWithoutDeletingEventsExpectation,
+    self.testStorage.removeBatchAndDeleteEventsExpectation
+  ]
+                    timeout:3];
+
+  // 4. Wait for 1st upload finish.
+  [self waitForUploadOperationsToFinish:self.uploader];
+}
+
+- (void)testUploadTarget_WhenBeforeServerNextUploadTimeForCCTAndFLLTargets_ThenDoNotUpload {
+  [self assertUploadTargetRespectsNextRequestWaitTime:60
+                                            forTarget:kGDTCORTargetCCT
+                                                  QoS:GDTCOREventQoSFast
+                                           conditions:GDTCORUploadConditionWifiData
+                         shouldWaitForNextRequestTime:NO
+                                        expectRequest:NO];
+
+  [self assertUploadTargetRespectsNextRequestWaitTime:60
+                                            forTarget:kGDTCORTargetFLL
+                                                  QoS:GDTCOREventQosDefault
+                                           conditions:GDTCORUploadConditionWifiData
+                         shouldWaitForNextRequestTime:NO
+                                        expectRequest:NO];
+}
+
+- (void)
+    testUploadTarget_WhenBeforeServerNextUploadTimeForCCTAndFLLTargetsAndHighPriority_ThenUpload {
+  [self assertUploadTargetRespectsNextRequestWaitTime:60
+                                            forTarget:kGDTCORTargetCCT
+                                                  QoS:GDTCOREventQoSFast
+                                           conditions:GDTCORUploadConditionHighPriority
+                         shouldWaitForNextRequestTime:NO
+                                        expectRequest:YES];
+
+  [self assertUploadTargetRespectsNextRequestWaitTime:60
+                                            forTarget:kGDTCORTargetFLL
+                                                  QoS:GDTCOREventQosDefault
+                                           conditions:GDTCORUploadConditionHighPriority
+                         shouldWaitForNextRequestTime:NO
+                                        expectRequest:YES];
+}
+
+- (void)testUploadTarget_WhenBeforeServerNextUploadTimeForOtherTargets_ThenUpload {
+  [self assertUploadTargetRespectsNextRequestWaitTime:60
+                                            forTarget:kGDTCORTargetTest
+                                                  QoS:GDTCOREventQoSFast
+                                           conditions:GDTCORUploadConditionWifiData
+                         shouldWaitForNextRequestTime:NO
+                                        expectRequest:YES];
+
+  [self assertUploadTargetRespectsNextRequestWaitTime:60
+                                            forTarget:kGDTCORTargetCSH
+                                                  QoS:GDTCOREventQosDefault
+                                           conditions:GDTCORUploadConditionWifiData
+                         shouldWaitForNextRequestTime:NO
+                                        expectRequest:YES];
+}
+
+- (void)testUploadTarget_WhenAfterServerNextUploadTimeForCCTAndFLLTargets_ThenUpload {
+  [self assertUploadTargetRespectsNextRequestWaitTime:1
+                                            forTarget:kGDTCORTargetCCT
+                                                  QoS:GDTCOREventQoSFast
+                                           conditions:GDTCORUploadConditionWifiData
+                         shouldWaitForNextRequestTime:YES
+                                        expectRequest:YES];
+
+  [self assertUploadTargetRespectsNextRequestWaitTime:1
+                                            forTarget:kGDTCORTargetFLL
+                                                  QoS:GDTCOREventQosDefault
+                                           conditions:GDTCORUploadConditionWifiData
+                         shouldWaitForNextRequestTime:YES
+                                        expectRequest:YES];
+}
+
+//// TODO: Tests for uploading several empty targets and then non-empty target.
+
+#pragma mark - Helpers
+
+- (NSNumber *)batchEvents {
+  XCTestExpectation *eventsBatched = [self expectationWithDescription:@"eventsBatched"];
+  __block NSNumber *batchID;
+  [self.testStorage
+      batchWithEventSelector:[GDTCORStorageEventSelector eventSelectorForTarget:kGDTCORTargetTest]
+             batchExpiration:[NSDate distantFuture]
+                  onComplete:^(NSNumber *_Nullable newBatchID,
+                               NSSet<GDTCOREvent *> *_Nullable batchEvents) {
+                    [eventsBatched fulfill];
+                    batchID = newBatchID;
+                  }];
+  [self waitForExpectations:@[ eventsBatched ] timeout:0.5];
+
+  XCTAssertNotNil(batchID);
+  return batchID;
+}
+
+- (XCTestExpectation *)expectationTestServerSuccessRequestResponse {
   __weak id weakSelf = self;
   XCTestExpectation *responseSentExpectation = [self expectationWithDescription:@"response sent"];
+
   self.testServer.responseCompletedBlock =
       ^(GCDWebServerRequest *_Nonnull request, GCDWebServerResponse *_Nonnull response) {
         // Redefining the self var addresses strong self capturing in the XCTAssert macros.
@@ -75,14 +543,164 @@
         XCTAssertEqual(response.statusCode, 200);
         XCTAssertTrue(response.hasBody);
       };
-  [uploader uploadTarget:kGDTCORTargetTest withConditions:GDTCORUploadConditionWifiData];
-  dispatch_sync(uploader.uploaderQueue, ^{
-    XCTAssertNotNil(uploader.currentTask);
-  });
-  [self waitForExpectations:@[ responseSentExpectation ] timeout:30.0];
-  dispatch_sync(uploader.uploaderQueue, ^{
-    XCTAssertNil(uploader.currentTask);
-  });
+  return responseSentExpectation;
+}
+
+- (void)setUpStorageExpectations {
+  self.testStorage.batchIDsForTargetExpectation =
+      [self expectationWithDescription:@"batchIDsForTargetExpectation"];
+  self.testStorage.batchWithEventSelectorExpectation =
+      [self expectationWithDescription:@"batchWithEventSelectorExpectation"];
+  self.testStorage.removeBatchWithoutDeletingEventsExpectation =
+      [self expectationWithDescription:@"removeBatchWithoutDeletingEventsExpectation"];
+  self.testStorage.removeBatchAndDeleteEventsExpectation =
+      [self expectationWithDescription:@"removeBatchAndDeleteEventsExpectation"];
+}
+
+- (void)waitForUploadOperationsToFinish:(GDTCCTUploader *)uploader {
+  XCTestExpectation *uploadFinishedExpectation =
+      [self expectationWithDescription:@"uploadFinishedExpectation"];
+  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)),
+                 uploader.uploaderQueue, ^{
+                   [uploadFinishedExpectation fulfill];
+                   XCTAssertNil(uploader.currentTask);
+                 });
+  [self waitForExpectations:@[ uploadFinishedExpectation ] timeout:1];
+}
+
+- (XCTestExpectation *)expectStorageHasEventsForTarget:(GDTCORTarget)expectedTarget
+                                                result:(BOOL)hasEvents {
+  XCTestExpectation *expectation = [self expectationWithDescription:NSStringFromSelector(_cmd)];
+
+  __weak __auto_type weakSelf = self;
+  self.testStorage.hasEventsForTargetHandler =
+      ^(GDTCORTarget target, GDTCCTTestStorageHasEventsCompletion _Nonnull completion) {
+        __auto_type self = weakSelf;
+        [expectation fulfill];
+        XCTAssertEqual(target, expectedTarget);
+        completion(hasEvents);
+      };
+
+  return expectation;
+}
+
+- (void)assertStorageSelectorWithCondition:(GDTCORUploadConditions)conditions
+                           validationBlock:(void (^)(GDTCORStorageEventSelector *_Nullable selector,
+                                                     NSDate *expirationDate))validationBlock {
+  XCTestExpectation *hasEventsExpectation = [self expectStorageHasEventsForTarget:kGDTCORTargetTest
+                                                                           result:YES];
+
+  XCTestExpectation *storageBatchExpectation =
+      [self expectationWithDescription:@"storageBatchExpectation"];
+
+  self.testStorage.batchWithEventSelectorHandler =
+      ^(GDTCORStorageEventSelector *_Nullable eventSelector, NSDate *_Nullable expiration,
+        GDTCORStorageBatchBlock _Nullable completion) {
+        // Redefining the self var addresses strong self capturing in the XCTAssert macros.
+        [storageBatchExpectation fulfill];
+
+        validationBlock(eventSelector, expiration);
+        completion(nil, nil);
+      };
+
+  [self.uploader uploadTarget:kGDTCORTargetTest withConditions:conditions];
+
+  [self waitForExpectations:@[ hasEventsExpectation, storageBatchExpectation ] timeout:1];
+}
+
+- (void)sendEventSuccessfully {
+  // 0. Generate test events.
+  [self.generator generateEvent:GDTCOREventQoSFast];
+
+  // 1. Set up expectations.
+  // 1.1. Set up all relevant storage expectations.
+  [self setUpStorageExpectations];
+
+  // 1.2. Expect `hasEventsForTarget:onComplete:` to be called.
+  XCTestExpectation *hasEventsExpectation =
+      [self expectStorageHasEventsForTarget:self.generator.target result:YES];
+
+  // 1.3. Don't expect previously batched events to be removed (no batch present).
+  self.testStorage.removeBatchWithoutDeletingEventsExpectation.inverted = YES;
+
+  // 1.4. Expect a batch to be uploaded.
+  XCTestExpectation *responseSentExpectation = [self expectationTestServerSuccessRequestResponse];
+
+  // 2. Create uploader and start upload.
+  [self.uploader uploadTarget:self.generator.target withConditions:GDTCORUploadConditionWifiData];
+
+  // 3. Wait for operations to complete in the specified order.
+  [self waitForExpectations:@[
+    self.testStorage.batchIDsForTargetExpectation,
+    self.testStorage.removeBatchWithoutDeletingEventsExpectation, hasEventsExpectation,
+    self.testStorage.batchWithEventSelectorExpectation, responseSentExpectation,
+    self.testStorage.removeBatchAndDeleteEventsExpectation
+  ]
+                    timeout:3
+               enforceOrder:YES];
+
+  // 4. Wait for upload operation to finish.
+  [self waitForUploadOperationsToFinish:self.uploader];
+}
+
+- (void)assertUploadTargetRespectsNextRequestWaitTime:(NSTimeInterval)nextRequestWaitTime
+                                            forTarget:(GDTCORTarget)target
+                                                  QoS:(GDTCOREventQoS)eventQoS
+                                           conditions:(GDTCORUploadConditions)conditions
+                         shouldWaitForNextRequestTime:(BOOL)shouldWaitForNextRequestTime
+                                        expectRequest:(BOOL)expectRequest {
+  // 0.1. Set response next request wait time.
+  self.testServer.responseNextRequestWaitTime = nextRequestWaitTime;
+  // 0.2. Use a target that should respect next upload time.
+  self.generator = [[GDTCCTEventGenerator alloc] initWithTarget:target];
+  // 0.3. Register storage for the target.
+  [[GDTCORRegistrar sharedInstance] reset];
+  [[GDTCORRegistrar sharedInstance] registerStorage:self.testStorage target:self.generator.target];
+  // 0.4. Send an event and receive response.
+  [self sendEventSuccessfully];
+  // 0.5. Generate another event to be sent.
+  [self.generator generateEvent:eventQoS];
+
+  // 0.6. Wait for the next request time.
+  if (shouldWaitForNextRequestTime) {
+    [[NSRunLoop currentRunLoop]
+        runUntilDate:[NSDate dateWithTimeIntervalSinceNow:nextRequestWaitTime + 0.5]];
+  }
+
+  // 1. Configure expectations for the 2nd request.
+  // 1.1. Set up all relevant storage expectations.
+  [self setUpStorageExpectations];
+  XCTestExpectation *hasEventsExpectation2 =
+      [self expectStorageHasEventsForTarget:self.generator.target result:YES];
+
+  // 1.2. Upload response expectation.
+  XCTestExpectation *responseSentExpectation = [self expectationTestServerSuccessRequestResponse];
+
+  // 1.3. Invert expectations if no actions expected.
+  if (!expectRequest) {
+    self.testStorage.batchIDsForTargetExpectation.inverted = YES;
+    self.testStorage.batchWithEventSelectorExpectation.inverted = YES;
+    self.testStorage.removeBatchAndDeleteEventsExpectation.inverted = YES;
+    hasEventsExpectation2.inverted = YES;
+    responseSentExpectation.inverted = YES;
+  }
+
+  self.testStorage.removeBatchWithoutDeletingEventsExpectation.inverted = YES;
+
+  // 2. Start upload 2nd time.
+  [self.uploader uploadTarget:self.generator.target withConditions:conditions];
+
+  // 3. Wait for expectations.
+  [self waitForExpectations:@[
+    self.testStorage.batchIDsForTargetExpectation, hasEventsExpectation2,
+    self.testStorage.batchWithEventSelectorExpectation, responseSentExpectation,
+    self.testStorage.removeBatchWithoutDeletingEventsExpectation,
+    self.testStorage.removeBatchAndDeleteEventsExpectation
+  ]
+                    timeout:3];
+
+  // 4. Wait for 1st upload finish.
+  [self waitForUploadOperationsToFinish:self.uploader];
 }
 
 @end

+ 10 - 0
GoogleDataTransport/GDTCCTTests/Unit/TestServer/GDTCCTTestServer.h

@@ -25,16 +25,26 @@ NS_ASSUME_NONNULL_BEGIN
 @class GCDWebServerRequest;
 @class GCDWebServerResponse;
 
+typedef void (^GDTCCTTestServerRequestHandler)(GCDWebServerRequest *request,
+                                               GCDWebServerResponse *_Nullable suggestedResponse,
+                                               GCDWebServerCompletionBlock completionBlock);
+
 /** This class provides a hermetic test service that runs on the test device/simulator. */
 @interface GDTCCTTestServer : NSObject
 
 /** The URL of the server. */
 @property(nonatomic, readonly) NSURL *serverURL;
 
+/** The value will be passed to `gdt_cct_LogResponse.next_request_wait_millis`. */
+@property(nonatomic) NSTimeInterval responseNextRequestWaitTime;
+
 /** Just before responding, this block will be scheduled to run on a global queue. */
 @property(nonatomic, copy, nullable) void (^responseCompletedBlock)
     (GCDWebServerRequest *request, GCDWebServerResponse *response);
 
+/** The provides an opportunity to overwrite or delay response to a request. */
+@property(nonatomic, copy, nullable) GDTCCTTestServerRequestHandler requestHandler;
+
 /** YES if the server is running, NO otherwise. */
 @property(nonatomic, readonly) BOOL isRunning;
 

+ 29 - 15
GoogleDataTransport/GDTCCTTests/Unit/TestServer/GDTCCTTestServer.m

@@ -43,6 +43,7 @@
     [GCDWebServer setLogLevel:3];
     _server = [[GCDWebServer alloc] init];
     _registeredTestPaths = [[NSMutableDictionary alloc] init];
+    _responseNextRequestWaitTime = 42.42;
   }
   return self;
 }
@@ -81,7 +82,7 @@
  */
 - (NSData *)responseData {
   gdt_cct_LogResponse logResponse = gdt_cct_LogResponse_init_default;
-  logResponse.next_request_wait_millis = 42424;
+  logResponse.next_request_wait_millis = self.responseNextRequestWaitTime * 1000;
   logResponse.has_next_request_wait_millis = 1;
 
   pb_ostream_t sizestream = PB_OSTREAM_SIZING;
@@ -105,23 +106,36 @@
 #pragma mark - HTTP Path handling methods
 
 - (void)registerLogBatchPath {
-  id processBlock = ^GCDWebServerResponse *(__kindof GCDWebServerRequest *request) {
-    GCDWebServerDataResponse *response =
-        [[GCDWebServerDataResponse alloc] initWithData:[self responseData]
-                                           contentType:@"application/text"];
-    response.gzipContentEncodingEnabled = YES;
-    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC),
-                   dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
-                     if (self.responseCompletedBlock) {
-                       self.responseCompletedBlock(request, response);
-                     }
-                   });
-    return response;
-  };
+  __auto_type __weak weakSelf = self;
   [self.server addHandlerForMethod:@"POST"
                               path:@"/logBatch"
                       requestClass:[GCDWebServerRequest class]
-                      processBlock:processBlock];
+                 asyncProcessBlock:^(__kindof GCDWebServerRequest *_Nonnull request,
+                                     GCDWebServerCompletionBlock _Nonnull completionBlock) {
+                   if (!weakSelf) {
+                     return;
+                   }
+                   __auto_type self = weakSelf;
+
+                   GCDWebServerDataResponse *response =
+                       [[GCDWebServerDataResponse alloc] initWithData:[self responseData]
+                                                          contentType:@"application/text"];
+                   response.gzipContentEncodingEnabled = YES;
+
+                   GCDWebServerCompletionBlock completionWithHook =
+                       ^(GCDWebServerResponse *_Nullable response) {
+                         if (self.responseCompletedBlock) {
+                           self.responseCompletedBlock(request, response);
+                         }
+                         completionBlock(response);
+                       };
+
+                   if (self.requestHandler) {
+                     self.requestHandler(request, response, completionWithHook);
+                   } else {
+                     completionWithHook(response);
+                   }
+                 }];
 }
 
 - (void)registerRedirectPaths {

+ 121 - 21
GoogleDataTransport/GDTCORLibrary/GDTCORFlatFileStorage.m

@@ -133,6 +133,7 @@ NSString *const kGDTCORBatchComponentsExpirationKey = @"GDTCORBatchComponentsExp
 
     // Check the QoS, if it's high priority, notify the target that it has a high priority event.
     if (event.qosTier == GDTCOREventQoSFast) {
+      // TODO: Remove a direct dependency on the upload coordinator.
       [self.uploadCoordinator forceUploadForTarget:target];
     }
 
@@ -182,7 +183,11 @@ NSString *const kGDTCORBatchComponentsExpirationKey = @"GDTCORBatchComponentsExp
         }
       }
       if (onComplete) {
-        onComplete(batchID, events);
+        if (events.count == 0) {
+          onComplete(nil, nil);
+        } else {
+          onComplete(batchID, events);
+        }
       }
     });
   };
@@ -220,36 +225,53 @@ NSString *const kGDTCORBatchComponentsExpirationKey = @"GDTCORBatchComponentsExp
              deleteEvents:(BOOL)deleteEvents
                onComplete:(void (^_Nullable)(void))onComplete {
   dispatch_async(_storageQueue, ^{
-    NSFileManager *fileManager = [NSFileManager defaultManager];
     NSError *error;
-    NSArray<NSString *> *batches =
-        [fileManager contentsOfDirectoryAtPath:[GDTCORFlatFileStorage batchDataStoragePath]
-                                         error:&error];
-    if (error) {
+    NSArray<NSString *> *batchDirPaths = [self batchDirPathsForBatchID:batchID error:&error];
+
+    if (batchDirPaths == nil) {
       if (onComplete) {
         onComplete();
       }
       return;
     }
-    for (NSString *path in batches) {
-      NSDictionary<NSString *, id> *components = [self batchComponentsFromFilename:path];
-      NSNumber *target = components[kGDTCORBatchComponentsTargetKey];
-      NSNumber *batchIDToRemove = components[kGDTCORBatchComponentsBatchIDKey];
-      if ([batchIDToRemove isEqual:batchID]) {
-        if (deleteEvents) {
-          NSString *deletionPath =
-              [[GDTCORFlatFileStorage batchDataStoragePath] stringByAppendingPathComponent:path];
-          [fileManager removeItemAtPath:deletionPath error:nil];
+
+    NSFileManager *fileManager = [NSFileManager defaultManager];
+
+    void (^removeBatchDir)(NSString *batchDirPath) = ^(NSString *batchDirPath) {
+      NSError *error;
+      if ([fileManager removeItemAtPath:batchDirPath error:&error]) {
+        GDTCORLogDebug(@"Batch removed at path: %@", batchDirPath);
+      } else {
+        GDTCORLogDebug(@"Failed to remove batch at path: %@", batchDirPath);
+      }
+    };
+
+    for (NSString *batchDirPath in batchDirPaths) {
+      if (deleteEvents) {
+        removeBatchDir(batchDirPath);
+      } else {
+        NSString *batchDirName = [batchDirPath lastPathComponent];
+        NSDictionary<NSString *, id> *components = [self batchComponentsFromFilename:batchDirName];
+        NSNumber *target = components[kGDTCORBatchComponentsTargetKey];
+        NSString *destinationPath = [[GDTCORFlatFileStorage eventDataStoragePath]
+            stringByAppendingPathComponent:target.stringValue];
+
+        // `- [NSFileManager moveItemAtPath:toPath:error:] method fails if an item by the
+        // destination path already exists (which usually is the case for the current method). Move
+        // the events one by one instead.
+        if ([self moveContentsOfDirectoryAtPath:batchDirPath to:destinationPath error:&error]) {
+          GDTCORLogDebug(@"Batched events at path: %@ moved back to the storage: %@", batchDirPath,
+                         destinationPath);
         } else {
-          NSString *destinationPath = [[GDTCORFlatFileStorage eventDataStoragePath]
-              stringByAppendingPathComponent:target.stringValue];
-          [fileManager moveItemAtPath:path toPath:destinationPath error:&error];
-          if (error) {
-            GDTCORLogDebug(@"Error encountered whilst moving events back: %@", error);
-          }
+          GDTCORLogDebug(@"Error encountered whilst moving events back: %@", error);
         }
+
+        // Even if not all events where moved back to the storage, there is not much can be done at
+        // this point, so cleanup batch directory now to avoid clattering.
+        removeBatchDir(batchDirPath);
       }
     }
+
     if (onComplete) {
       onComplete();
     }
@@ -378,6 +400,10 @@ NSString *const kGDTCORBatchComponentsExpirationKey = @"GDTCORBatchComponentsExp
       }
     }
 
+    // TODO: Events from expired batches with not expired events must be moved back to queue to
+    // avoid data loss.
+    // TODO: Storage may not have enough context to remove batches because a batch may be being
+    // uploaded but the storage has not context of it.
     NSString *batchDataPath = [GDTCORFlatFileStorage batchDataStoragePath];
     NSArray<NSString *> *batchDataPaths = [fileManager contentsOfDirectoryAtPath:batchDataPath
                                                                            error:nil];
@@ -439,6 +465,80 @@ NSString *const kGDTCORBatchComponentsExpirationKey = @"GDTCORBatchComponentsExp
   });
 }
 
+#pragma mark - Private not thread safe methods
+/** Looks for directory paths containing events for a batch with the specified ID.
+ * @param batchID A batch ID.
+ * @param outError A pointer to `NSError *` to assign as possible error to.
+ * @return An array of an array of paths to directories for event batches with a specified batch ID
+ * or `nil` in the case of an error. Usually returns a single path but potentially return more in
+ * cases when the app is terminated while uploading a batch.
+ */
+- (nullable NSArray<NSString *> *)batchDirPathsForBatchID:(NSNumber *)batchID
+                                                    error:(NSError **)outError {
+  NSFileManager *fileManager = [NSFileManager defaultManager];
+  NSError *error;
+  NSArray<NSString *> *batches =
+      [fileManager contentsOfDirectoryAtPath:[GDTCORFlatFileStorage batchDataStoragePath]
+                                       error:&error];
+  if (batches == nil) {
+    *outError = error;
+    GDTCORLogDebug(@"Failed to find event file paths for batchID: %@, error: %@", batchID, error);
+    return nil;
+  }
+
+  NSMutableArray<NSString *> *batchDirPaths = [NSMutableArray array];
+  for (NSString *path in batches) {
+    NSDictionary<NSString *, id> *components = [self batchComponentsFromFilename:path];
+    NSNumber *pathBatchID = components[kGDTCORBatchComponentsBatchIDKey];
+    if ([pathBatchID isEqual:batchID]) {
+      NSString *batchDirPath =
+          [[GDTCORFlatFileStorage batchDataStoragePath] stringByAppendingPathComponent:path];
+      [batchDirPaths addObject:batchDirPath];
+    }
+  }
+
+  return [batchDirPaths copy];
+}
+
+/** Makes a copy of the contents of a directory to a directory at the specified path.*/
+- (BOOL)moveContentsOfDirectoryAtPath:(NSString *)sourcePath
+                                   to:(NSString *)destinationPath
+                                error:(NSError **)outError {
+  NSFileManager *fileManager = [NSFileManager defaultManager];
+
+  NSError *error;
+  NSArray<NSString *> *contentsPaths = [fileManager contentsOfDirectoryAtPath:sourcePath
+                                                                        error:&error];
+  if (contentsPaths == nil) {
+    *outError = error;
+    return NO;
+  }
+
+  NSMutableArray<NSError *> *errors = [NSMutableArray array];
+  for (NSString *path in contentsPaths) {
+    NSString *contentDestinationPath = [destinationPath stringByAppendingPathComponent:path];
+    NSString *contentSourcePath = [sourcePath stringByAppendingPathComponent:path];
+
+    NSError *moveError;
+    if (![fileManager moveItemAtPath:contentSourcePath
+                              toPath:contentDestinationPath
+                               error:&moveError] &&
+        moveError) {
+      [errors addObject:moveError];
+    }
+  }
+
+  if (errors.count == 0) {
+    return YES;
+  } else {
+    NSError *combinedError = [NSError errorWithDomain:@"GDTCORFlatFileStorage"
+                                                 code:-1
+                                             userInfo:@{NSUnderlyingErrorKey : errors}];
+    *outError = combinedError;
+    return NO;
+  }
+}
+
 #pragma mark - Private helper methods
 
 + (NSString *)eventDataStoragePath {

+ 2 - 0
GoogleDataTransport/GDTCORLibrary/GDTCORUploadCoordinator.m

@@ -93,6 +93,8 @@
  */
 - (void)uploadTargets:(NSArray<NSNumber *> *)targets conditions:(GDTCORUploadConditions)conditions {
   dispatch_async(_coordinationQueue, ^{
+    // TODO: The reachability signal may be not reliable enough to prevent an upload attempt.
+    // See https://developer.apple.com/videos/play/wwdc2019/712/ (49:40) for more details.
     if ((conditions & GDTCORUploadConditionNoNetwork) == GDTCORUploadConditionNoNetwork) {
       return;
     }

+ 4 - 3
GoogleDataTransport/GDTCORLibrary/Public/GDTCORStorageProtocol.h

@@ -25,6 +25,9 @@
 
 NS_ASSUME_NONNULL_BEGIN
 
+typedef void (^GDTCORStorageBatchBlock)(NSNumber *_Nullable newBatchID,
+                                        NSSet<GDTCOREvent *> *_Nullable batchEvents);
+
 /** Defines the interface a storage subsystem is expected to implement. */
 @protocol GDTCORStorageProtocol <NSObject, GDTCORLifecycleProtocol>
 
@@ -54,9 +57,7 @@ NS_ASSUME_NONNULL_BEGIN
  */
 - (void)batchWithEventSelector:(nonnull GDTCORStorageEventSelector *)eventSelector
                batchExpiration:(nonnull NSDate *)expiration
-                    onComplete:
-                        (nonnull void (^)(NSNumber *_Nullable newBatchID,
-                                          NSSet<GDTCOREvent *> *_Nullable batchEvents))onComplete;
+                    onComplete:(nonnull GDTCORStorageBatchBlock)onComplete;
 
 /** Removes the event batch.
  *

+ 297 - 121
GoogleDataTransport/GDTCORTests/Unit/GDTCORFlatFileStorageTest.m

@@ -58,9 +58,6 @@
 
 @interface GDTCORFlatFileStorageTest : GDTCORTestCase
 
-/** The test backend implementation. */
-@property(nullable, nonatomic) GDTCORTestUploader *testBackend;
-
 /** The uploader fake. */
 @property(nonatomic) GDTCORUploadCoordinatorFake *uploaderFake;
 
@@ -70,11 +67,8 @@
 
 - (void)setUp {
   [super setUp];
-  self.testBackend = [[GDTCORTestUploader alloc] init];
   [[GDTCORRegistrar sharedInstance] reset];
   [[GDTCORFlatFileStorage sharedInstance] reset];
-  [[GDTCORRegistrar sharedInstance] registerUploader:_testBackend target:kGDTCORTargetTest];
-  [[GDTCORRegistrar sharedInstance] registerUploader:_testBackend target:kGDTCORTargetFLL];
   self.uploaderFake = [[GDTCORUploadCoordinatorFake alloc] init];
   [GDTCORFlatFileStorage sharedInstance].uploadCoordinator = self.uploaderFake;
   [[GDTCORFlatFileStorage sharedInstance] reset];
@@ -82,14 +76,13 @@
 }
 
 - (void)tearDown {
-  [super tearDown];
   dispatch_sync([GDTCORFlatFileStorage sharedInstance].storageQueue, ^{
                 });
   // Destroy these objects before the next test begins.
-  self.testBackend = nil;
   [GDTCORFlatFileStorage sharedInstance].uploadCoordinator =
       [GDTCORUploadCoordinator sharedInstance];
   self.uploaderFake = nil;
+  [super tearDown];
 }
 
 /** Generates and returns a set of events that are generated randomly and stored.
@@ -97,25 +90,39 @@
  * @return A set of randomly generated and stored events.
  */
 - (NSSet<GDTCOREvent *> *)generateEventsForStorageTesting {
-  GDTCORFlatFileStorage *storage = [GDTCORFlatFileStorage sharedInstance];
   NSMutableSet<GDTCOREvent *> *generatedEvents = [[NSMutableSet alloc] init];
   // Generate 100 test target events
-  for (int i = 0; i < 100; i++) {
-    GDTCOREvent *event = [GDTCOREventGenerator generateEventForTarget:kGDTCORTargetTest
-                                                              qosTier:nil
-                                                            mappingID:nil];
-    [generatedEvents addObject:event];
-    [storage storeEvent:event onComplete:nil];
-  }
+  [generatedEvents unionSet:[self generateEventsForTarget:kGDTCORTargetTest count:100]];
 
   // Generate 50 FLL target events.
-  for (int i = 0; i < 50; i++) {
-    GDTCOREvent *event = [GDTCOREventGenerator generateEventForTarget:kGDTCORTargetFLL
+  [generatedEvents unionSet:[self generateEventsForTarget:kGDTCORTargetFLL count:50]];
+
+  return generatedEvents;
+}
+
+- (NSSet<GDTCOREvent *> *)generateEventsForTarget:(GDTCORTarget)target count:(NSInteger)count {
+  GDTCORFlatFileStorage *storage = [GDTCORFlatFileStorage sharedInstance];
+  NSMutableSet<GDTCOREvent *> *generatedEvents = [[NSMutableSet alloc] init];
+
+  XCTestExpectation *generatedEventsStoredExpectation =
+      [self expectationWithDescription:@"generatedEventsStoredExpectation"];
+  generatedEventsStoredExpectation.expectedFulfillmentCount = count;
+
+  for (int i = 0; i < count; i++) {
+    GDTCOREvent *event = [GDTCOREventGenerator generateEventForTarget:target
                                                               qosTier:nil
                                                             mappingID:nil];
     [generatedEvents addObject:event];
-    [storage storeEvent:event onComplete:nil];
+    [storage storeEvent:event
+             onComplete:^(BOOL wasWritten, NSError *_Nullable error) {
+               XCTAssertTrue(wasWritten);
+               XCTAssertNil(error);
+               [generatedEventsStoredExpectation fulfill];
+             }];
   }
+
+  [self waitForExpectations:@[ generatedEventsStoredExpectation ] timeout:0.2 * count];
+
   return generatedEvents;
 }
 
@@ -776,108 +783,6 @@
   [self waitForExpectations:@[ expectation ] timeout:10];
 }
 
-/** Tests creating a batch and then deleting the files. */
-- (void)testRemoveBatchWithIDDeletingEvents {
-  GDTCORFlatFileStorage *storage = [GDTCORFlatFileStorage sharedInstance];
-  NSSet<GDTCOREvent *> *generatedEvents = [self generateEventsForStorageTesting];
-  __block NSUInteger testTargetSize = 0;
-  NSSet<GDTCOREvent *> *testTargetEvents = [generatedEvents
-      filteredSetUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(
-                                                 GDTCOREvent *_Nullable event,
-                                                 NSDictionary<NSString *, id> *_Nullable bindings) {
-        NSError *error;
-        testTargetSize +=
-            event.target == kGDTCORTargetTest ? GDTCOREncodeArchive(event, nil, &error).length : 0;
-        XCTAssertNil(error);
-        return event.target == kGDTCORTargetTest;
-      }]];
-  XCTAssertNotNil(testTargetEvents);
-
-  __block uint64_t totalSize;
-  [storage storageSizeWithCallback:^(uint64_t storageSize) {
-    totalSize = storageSize;
-  }];
-
-  XCTestExpectation *expectation = [self expectationWithDescription:@"batch callback invoked"];
-  GDTCORStorageEventSelector *eventSelector =
-      [GDTCORStorageEventSelector eventSelectorForTarget:kGDTCORTargetTest];
-  __block NSNumber *batchID;
-  [storage batchWithEventSelector:eventSelector
-                  batchExpiration:[NSDate dateWithTimeIntervalSinceNow:600]
-                       onComplete:^(NSNumber *_Nullable newBatchID,
-                                    NSSet<GDTCOREvent *> *_Nullable events) {
-                         batchID = newBatchID;
-                         XCTAssertNotNil(batchID);
-                         XCTAssertEqual(events.count, testTargetEvents.count);
-                         [expectation fulfill];
-                       }];
-  [self waitForExpectations:@[ expectation ] timeout:10];
-  expectation = [self expectationWithDescription:@"batch removal completion invoked"];
-  [storage removeBatchWithID:batchID
-                deleteEvents:YES
-                  onComplete:^{
-                    [expectation fulfill];
-                  }];
-  [self waitForExpectations:@[ expectation ] timeout:10];
-  expectation = [self expectationWithDescription:@"storageSize callback invoked"];
-  [storage storageSizeWithCallback:^(uint64_t storageSize) {
-    XCTAssertLessThan(storageSize * .95, totalSize - testTargetSize);  // .95 to allow overhead
-    [expectation fulfill];
-  }];
-  [self waitForExpectations:@[ expectation ] timeout:10];
-}
-
-/** Tests creating a batch and then deleting the files. */
-- (void)testRemoveBatchWithID {
-  GDTCORFlatFileStorage *storage = [GDTCORFlatFileStorage sharedInstance];
-  NSSet<GDTCOREvent *> *generatedEvents = [self generateEventsForStorageTesting];
-  __block NSUInteger testTargetSize = 0;
-  NSSet<GDTCOREvent *> *testTargetEvents = [generatedEvents
-      filteredSetUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(
-                                                 GDTCOREvent *_Nullable event,
-                                                 NSDictionary<NSString *, id> *_Nullable bindings) {
-        NSError *error;
-        testTargetSize +=
-            event.target == kGDTCORTargetTest ? GDTCOREncodeArchive(event, nil, &error).length : 0;
-        XCTAssertNil(error);
-        return event.target == kGDTCORTargetTest;
-      }]];
-  XCTAssertNotNil(testTargetEvents);
-
-  __block uint64_t totalSize;
-  [storage storageSizeWithCallback:^(uint64_t storageSize) {
-    totalSize = storageSize;
-  }];
-
-  XCTestExpectation *expectation = [self expectationWithDescription:@"batch callback invoked"];
-  GDTCORStorageEventSelector *eventSelector =
-      [GDTCORStorageEventSelector eventSelectorForTarget:kGDTCORTargetTest];
-  __block NSNumber *batchID;
-  [storage batchWithEventSelector:eventSelector
-                  batchExpiration:[NSDate dateWithTimeIntervalSinceNow:600]
-                       onComplete:^(NSNumber *_Nullable newBatchID,
-                                    NSSet<GDTCOREvent *> *_Nullable events) {
-                         batchID = newBatchID;
-                         XCTAssertNotNil(batchID);
-                         XCTAssertEqual(events.count, testTargetEvents.count);
-                         [expectation fulfill];
-                       }];
-  [self waitForExpectations:@[ expectation ] timeout:10];
-  expectation = [self expectationWithDescription:@"batch removal completion invoked"];
-  [storage removeBatchWithID:batchID
-                deleteEvents:YES
-                  onComplete:^{
-                    [expectation fulfill];
-                  }];
-  [self waitForExpectations:@[ expectation ] timeout:10];
-  expectation = [self expectationWithDescription:@"storageSize callback invoked"];
-  [storage storageSizeWithCallback:^(uint64_t storageSize) {
-    XCTAssertLessThan(storageSize * .95, totalSize - testTargetSize);  // .95 to allow overhead
-    [expectation fulfill];
-  }];
-  [self waitForExpectations:@[ expectation ] timeout:10];
-}
-
 /** Tests events expiring at a given time. */
 - (void)testCheckEventExpiration {
   NSTimeInterval delay = 10.0;
@@ -918,6 +823,23 @@
   [self waitForExpectations:@[ expectation ] timeout:10];
 }
 
+- (void)testBatchIDsForTarget {
+  __auto_type expectedBatch = [self generateAndBatchEvents];
+
+  XCTestExpectation *batchIDsExpectation = [self expectationWithDescription:@"batchIDsExpectation"];
+
+  [[GDTCORFlatFileStorage sharedInstance]
+      batchIDsForTarget:kGDTCORTargetTest
+             onComplete:^(NSSet<NSNumber *> *_Nullable batchIDs) {
+               [batchIDsExpectation fulfill];
+
+               XCTAssertEqual(batchIDs.count, 1);
+               XCTAssertEqualObjects([expectedBatch.allKeys firstObject], [batchIDs anyObject]);
+             }];
+
+  [self waitForExpectations:@[ batchIDsExpectation ] timeout:5];
+}
+
 /** Tests batch expiring at a given time. */
 - (void)testCheckBatchExpiration {
   GDTCORFlatFileStorage *storage = [GDTCORFlatFileStorage sharedInstance];
@@ -975,4 +897,258 @@
   [self waitForExpectations:@[ expectation ] timeout:10];
 }
 
+#pragma mark - Remove Batch tests
+
+- (void)testRemoveBatchWithIDWithNoDeletingEvents {
+  GDTCORFlatFileStorage *storage = [[GDTCORFlatFileStorage alloc] init];
+
+  // 0. Prepare a batch to remove.
+  __auto_type generatedBatch = [self generateAndBatchEvents];
+  NSNumber *batchIDToRemove = [generatedBatch.allKeys firstObject];
+  NSSet<GDTCOREvent *> *generatedEvents = generatedBatch[batchIDToRemove];
+
+  // 2. Remove batch.
+  XCTestExpectation *batchRemovedExpectation =
+      [self expectationWithDescription:@"batchRemovedExpectation"];
+  [storage removeBatchWithID:batchIDToRemove
+                deleteEvents:NO
+                  onComplete:^{
+                    [batchRemovedExpectation fulfill];
+                  }];
+  [self waitForExpectations:@[ batchRemovedExpectation ] timeout:0.5];
+
+  // 3. Validate no batches.
+  [self assertBatchIDs:nil inStorage:storage];
+
+  // 4. Validate events.
+  GDTCORStorageEventSelector *testEventsSelector =
+      [[GDTCORStorageEventSelector alloc] initWithTarget:kGDTCORTargetTest
+                                                eventIDs:nil
+                                              mappingIDs:nil
+                                                qosTiers:nil];
+  XCTestExpectation *eventsBatchedExpectation2 =
+      [self expectationWithDescription:@"eventsBatchedExpectation1"];
+  [storage batchWithEventSelector:testEventsSelector
+                  batchExpiration:[NSDate distantFuture]
+                       onComplete:^(NSNumber *_Nullable newBatchID,
+                                    NSSet<GDTCOREvent *> *_Nullable batchEvents) {
+                         [eventsBatchedExpectation2 fulfill];
+                         XCTAssertNotNil(newBatchID);
+                         XCTAssertEqual(generatedEvents.count, batchEvents.count);
+
+                         NSSet<NSString *> *batchEventsIDs =
+                             [batchEvents valueForKeyPath:@"eventID"];
+                         NSSet<NSString *> *generatedEventsIDs =
+                             [generatedEvents valueForKeyPath:@"eventID"];
+                         XCTAssertEqualObjects(batchEventsIDs, generatedEventsIDs);
+                       }];
+  [self waitForExpectations:@[ eventsBatchedExpectation2 ] timeout:0.5];
+}
+
+- (void)testRemoveBatchWithIDWithNoDeletingEventsConflictingEvents {
+  GDTCORFlatFileStorage *storage = [[GDTCORFlatFileStorage alloc] init];
+
+  // 0.1. Prepare a batch to remove.
+  __auto_type generatedBatch = [self generateAndBatchEvents];
+  NSNumber *batchIDToRemove = [generatedBatch.allKeys firstObject];
+  NSSet<GDTCOREvent *> *generatedEvents = generatedBatch[batchIDToRemove];
+
+  // 0.2. Store an event with conflicting ID.
+  [self storeEvent:[generatedEvents anyObject] inStorage:storage];
+
+  // 0.3. Store another event.
+  GDTCOREvent *differentEvent = [GDTCOREventGenerator generateEventForTarget:kGDTCORTargetTest
+                                                                     qosTier:nil
+                                                                   mappingID:nil];
+  [self storeEvent:differentEvent inStorage:storage];
+
+  NSMutableSet<GDTCOREvent *> *expectedEvents = [generatedEvents mutableCopy];
+  [expectedEvents addObject:differentEvent];
+
+  // 2. Remove batch.
+  XCTestExpectation *batchRemovedExpectation =
+      [self expectationWithDescription:@"batchRemovedExpectation"];
+  [storage removeBatchWithID:batchIDToRemove
+                deleteEvents:NO
+                  onComplete:^{
+                    [batchRemovedExpectation fulfill];
+                  }];
+  [self waitForExpectations:@[ batchRemovedExpectation ] timeout:0.5];
+
+  // 3. Validate no batches.
+  [self assertBatchIDs:nil inStorage:storage];
+
+  // 4. Validate events.
+  XCTestExpectation *eventsBatchedExpectation2 =
+      [self expectationWithDescription:@"eventsBatchedExpectation1"];
+  GDTCORStorageEventSelector *testEventsSelector =
+      [[GDTCORStorageEventSelector alloc] initWithTarget:kGDTCORTargetTest
+                                                eventIDs:nil
+                                              mappingIDs:nil
+                                                qosTiers:nil];
+  [storage batchWithEventSelector:testEventsSelector
+                  batchExpiration:[NSDate distantFuture]
+                       onComplete:^(NSNumber *_Nullable newBatchID,
+                                    NSSet<GDTCOREvent *> *_Nullable batchEvents) {
+                         [eventsBatchedExpectation2 fulfill];
+                         XCTAssertNotNil(newBatchID);
+                         XCTAssertEqual(expectedEvents.count, batchEvents.count);
+
+                         NSSet<NSString *> *batchEventsIDs =
+                             [batchEvents valueForKeyPath:@"eventID"];
+                         NSSet<NSString *> *expectedEventsIDs =
+                             [expectedEvents valueForKeyPath:@"eventID"];
+                         XCTAssertEqualObjects(batchEventsIDs, expectedEventsIDs);
+                       }];
+  [self waitForExpectations:@[ eventsBatchedExpectation2 ] timeout:0.5];
+}
+
+- (void)testRemoveBatchWithIDDeletingEvents {
+  GDTCORFlatFileStorage *storage = [[GDTCORFlatFileStorage alloc] init];
+
+  // 0. Prepare a batch to remove.
+  __auto_type generatedBatch = [self generateAndBatchEvents];
+  NSNumber *batchIDToRemove = [generatedBatch.allKeys firstObject];
+
+  // 2. Remove batch.
+  XCTestExpectation *batchRemovedExpectation =
+      [self expectationWithDescription:@"batchRemovedExpectation"];
+  [storage removeBatchWithID:batchIDToRemove
+                deleteEvents:YES
+                  onComplete:^{
+                    [batchRemovedExpectation fulfill];
+                  }];
+  [self waitForExpectations:@[ batchRemovedExpectation ] timeout:0.5];
+
+  // 3. Validate no batches.
+  [self assertBatchIDs:nil inStorage:storage];
+
+  // 4. Validate events.
+  XCTestExpectation *eventsBatchedExpectation2 =
+      [self expectationWithDescription:@"eventsBatchedExpectation1"];
+  GDTCORStorageEventSelector *testEventsSelector =
+      [[GDTCORStorageEventSelector alloc] initWithTarget:kGDTCORTargetTest
+                                                eventIDs:nil
+                                              mappingIDs:nil
+                                                qosTiers:nil];
+  [storage batchWithEventSelector:testEventsSelector
+                  batchExpiration:[NSDate distantFuture]
+                       onComplete:^(NSNumber *_Nullable newBatchID,
+                                    NSSet<GDTCOREvent *> *_Nullable batchEvents) {
+                         [eventsBatchedExpectation2 fulfill];
+                         XCTAssertNil(newBatchID);
+                         XCTAssertEqual(batchEvents.count, 0);
+                       }];
+  [self waitForExpectations:@[ eventsBatchedExpectation2 ] timeout:500];
+}
+
+/** Tests creating a batch and then deleting the files. */
+- (void)testRemoveBatchWithIDDeletingEventsStorageSize {
+  GDTCORFlatFileStorage *storage = [GDTCORFlatFileStorage sharedInstance];
+  NSSet<GDTCOREvent *> *generatedEvents = [self generateEventsForStorageTesting];
+  __block NSUInteger testTargetSize = 0;
+  NSSet<GDTCOREvent *> *testTargetEvents = [generatedEvents
+      filteredSetUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(
+                                                 GDTCOREvent *_Nullable event,
+                                                 NSDictionary<NSString *, id> *_Nullable bindings) {
+        NSError *error;
+        testTargetSize +=
+            event.target == kGDTCORTargetTest ? GDTCOREncodeArchive(event, nil, &error).length : 0;
+        XCTAssertNil(error);
+        return event.target == kGDTCORTargetTest;
+      }]];
+  XCTAssertNotNil(testTargetEvents);
+
+  __block uint64_t totalSize;
+  [storage storageSizeWithCallback:^(uint64_t storageSize) {
+    totalSize = storageSize;
+  }];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"batch callback invoked"];
+  GDTCORStorageEventSelector *eventSelector =
+      [GDTCORStorageEventSelector eventSelectorForTarget:kGDTCORTargetTest];
+  __block NSNumber *batchID;
+  [storage batchWithEventSelector:eventSelector
+                  batchExpiration:[NSDate dateWithTimeIntervalSinceNow:600]
+                       onComplete:^(NSNumber *_Nullable newBatchID,
+                                    NSSet<GDTCOREvent *> *_Nullable events) {
+                         batchID = newBatchID;
+                         XCTAssertNotNil(batchID);
+                         XCTAssertEqual(events.count, testTargetEvents.count);
+                         [expectation fulfill];
+                       }];
+  [self waitForExpectations:@[ expectation ] timeout:10];
+  expectation = [self expectationWithDescription:@"batch removal completion invoked"];
+  [storage removeBatchWithID:batchID
+                deleteEvents:YES
+                  onComplete:^{
+                    [expectation fulfill];
+                  }];
+  [self waitForExpectations:@[ expectation ] timeout:10];
+  expectation = [self expectationWithDescription:@"storageSize callback invoked"];
+  [storage storageSizeWithCallback:^(uint64_t storageSize) {
+    XCTAssertLessThan(storageSize * .95, totalSize - testTargetSize);  // .95 to allow overhead
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:10];
+}
+
+#pragma mark - Helpers
+
+- (NSDictionary<NSNumber *, NSSet<GDTCOREvent *> *> *)generateAndBatchEvents {
+  GDTCORFlatFileStorage *storage = [GDTCORFlatFileStorage sharedInstance];
+  NSSet<GDTCOREvent *> *events = [self generateEventsForTarget:kGDTCORTargetTest count:100];
+  XCTestExpectation *eventsGeneratedExpectation =
+      [self expectationWithDescription:@"eventsGeneratedExpectation"];
+  [storage hasEventsForTarget:kGDTCORTargetTest
+                   onComplete:^(BOOL hasEvents) {
+                     XCTAssertTrue(hasEvents);
+                     [eventsGeneratedExpectation fulfill];
+                   }];
+  [self waitForExpectations:@[ eventsGeneratedExpectation ] timeout:5];
+
+  // Batch generated events.
+  XCTestExpectation *batchCreatedExpectation =
+      [self expectationWithDescription:@"batchCreatedExpectation"];
+  __block NSNumber *batchID;
+  [storage
+      batchWithEventSelector:[GDTCORStorageEventSelector eventSelectorForTarget:kGDTCORTargetTest]
+             batchExpiration:[NSDate dateWithTimeIntervalSinceNow:1000]
+                  onComplete:^(NSNumber *_Nullable newBatchID,
+                               NSSet<GDTCOREvent *> *_Nullable events) {
+                    batchID = newBatchID;
+                    XCTAssertGreaterThan(events.count, 0);
+                    [batchCreatedExpectation fulfill];
+                  }];
+  [self waitForExpectations:@[ batchCreatedExpectation ] timeout:5];
+
+  return @{batchID : events};
+}
+
+- (void)assertBatchIDs:(NSSet<NSNumber *> *)expectedBatchIDs
+             inStorage:(GDTCORFlatFileStorage *)storage {
+  XCTestExpectation *batchIDsFetchedExpectation =
+      [self expectationWithDescription:@"batchIDsFetchedExpectation"];
+
+  [storage batchIDsForTarget:kGDTCORTargetTest
+                  onComplete:^(NSSet<NSNumber *> *_Nullable batchIDs) {
+                    [batchIDsFetchedExpectation fulfill];
+                    XCTAssertEqualObjects(batchIDs, expectedBatchIDs);
+                  }];
+
+  [self waitForExpectations:@[ batchIDsFetchedExpectation ] timeout:0.5];
+}
+
+- (void)storeEvent:(GDTCOREvent *)event inStorage:(GDTCORFlatFileStorage *)storage {
+  XCTestExpectation *eventStoredExpectation =
+      [self expectationWithDescription:@"eventStoredExpectation"];
+  [storage storeEvent:event
+           onComplete:^(BOOL wasWritten, NSError *_Nullable error) {
+             [eventStoredExpectation fulfill];
+             XCTAssertTrue(wasWritten);
+             XCTAssertNil(error);
+           }];
+  [self waitForExpectations:@[ eventStoredExpectation ] timeout:0.5];
+}
+
 @end