diff --git a/Firestore/Example/Tests/Integration/API/FIRQueryTests.mm b/Firestore/Example/Tests/Integration/API/FIRQueryTests.mm index f311a140de0..93791bae165 100644 --- a/Firestore/Example/Tests/Integration/API/FIRQueryTests.mm +++ b/Firestore/Example/Tests/Integration/API/FIRQueryTests.mm @@ -22,6 +22,16 @@ #import "Firestore/Example/Tests/Util/FSTHelpers.h" #import "Firestore/Example/Tests/Util/FSTIntegrationTestCase.h" +namespace { + +NSArray *SortedStringsNotIn(NSSet *set, NSSet *remove) { + NSMutableSet *mutableSet = [NSMutableSet setWithSet:set]; + [mutableSet minusSet:remove]; + return [mutableSet.allObjects sortedArrayUsingSelector:@selector(caseInsensitiveCompare:)]; +} + +} // namespace + @interface FIRQueryTests : FSTIntegrationTestCase @end @@ -1180,4 +1190,88 @@ - (void)testOrderByEquality { matchesResult:@[ @"doc6", @"doc3" ]]; } +- (void)testResumingAQueryShouldUseExistenceFilterToDetectDeletes { + // Prepare the names and contents of the 100 documents to create. + NSMutableDictionary *> *testDocs = + [[NSMutableDictionary alloc] init]; + for (int i = 0; i < 100; i++) { + [testDocs setValue:@{@"key" : @42} forKey:[NSString stringWithFormat:@"doc%@", @(1000 + i)]]; + } + + // Create 100 documents in a new collection. + FIRCollectionReference *collRef = [self collectionRefWithDocuments:testDocs]; + + // Run a query to populate the local cache with the 100 documents and a resume token. + FIRQuerySnapshot *querySnapshot1 = [self readDocumentSetForRef:collRef + source:FIRFirestoreSourceDefault]; + XCTAssertEqual(querySnapshot1.count, 100, @"querySnapshot1.count has an unexpected value"); + NSArray *createdDocuments = + FIRDocumentReferenceArrayFromQuerySnapshot(querySnapshot1); + + // Delete 50 of the 100 documents. Do this in a transaction, rather than + // [FIRDocumentReference deleteDocument], to avoid affecting the local cache. + NSSet *deletedDocumentIds; + { + NSMutableArray *deletedDocumentIdsAccumulator = [[NSMutableArray alloc] init]; + XCTestExpectation *expectation = [self expectationWithDescription:@"DeleteTransaction"]; + [collRef.firestore + runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **) { + for (decltype(createdDocuments.count) i = 0; i < createdDocuments.count; i += 2) { + FIRDocumentReference *documentToDelete = createdDocuments[i]; + [transaction deleteDocument:documentToDelete]; + [deletedDocumentIdsAccumulator addObject:documentToDelete.documentID]; + } + return @"document deletion successful"; + } + completion:^(id, NSError *) { + [expectation fulfill]; + }]; + [self awaitExpectation:expectation]; + deletedDocumentIds = [NSSet setWithArray:deletedDocumentIdsAccumulator]; + } + XCTAssertEqual(deletedDocumentIds.count, 50u, @"deletedDocumentIds has the wrong size"); + + // Wait for 10 seconds, during which Watch will stop tracking the query and will send an existence + // filter rather than "delete" events when the query is resumed. + [NSThread sleepForTimeInterval:10.0f]; + + // Resume the query and save the resulting snapshot for verification. + FIRQuerySnapshot *querySnapshot2 = [self readDocumentSetForRef:collRef + source:FIRFirestoreSourceDefault]; + + // Verify that the snapshot from the resumed query contains the expected documents; that is, + // that it contains the 50 documents that were _not_ deleted. + // TODO(b/270731363): Remove the "if" condition below once the Firestore Emulator is fixed to + // send an existence filter. At the time of writing, the Firestore emulator fails to send an + // existence filter, resulting in the client including the deleted documents in the snapshot + // of the resumed query. + if (!([FSTIntegrationTestCase isRunningAgainstEmulator] && querySnapshot2.count == 100)) { + NSSet *actualDocumentIds = + [NSSet setWithArray:FIRQuerySnapshotGetIDs(querySnapshot2)]; + NSSet *expectedDocumentIds; + { + NSMutableArray *expectedDocumentIdsAccumulator = [[NSMutableArray alloc] init]; + for (FIRDocumentReference *documentRef in createdDocuments) { + if (![deletedDocumentIds containsObject:documentRef.documentID]) { + [expectedDocumentIdsAccumulator addObject:documentRef.documentID]; + } + } + expectedDocumentIds = [NSSet setWithArray:expectedDocumentIdsAccumulator]; + } + if (![actualDocumentIds isEqualToSet:expectedDocumentIds]) { + NSArray *unexpectedDocumentIds = + SortedStringsNotIn(actualDocumentIds, expectedDocumentIds); + NSArray *missingDocumentIds = + SortedStringsNotIn(expectedDocumentIds, actualDocumentIds); + XCTFail(@"querySnapshot2 contained %lu documents (expected %lu): " + @"%lu unexpected and %lu missing; " + @"unexpected documents: %@; missing documents: %@", + (unsigned long)actualDocumentIds.count, (unsigned long)expectedDocumentIds.count, + (unsigned long)unexpectedDocumentIds.count, (unsigned long)missingDocumentIds.count, + [unexpectedDocumentIds componentsJoinedByString:@", "], + [missingDocumentIds componentsJoinedByString:@", "]); + } + } +} + @end diff --git a/Firestore/Example/Tests/Util/FSTIntegrationTestCase.h b/Firestore/Example/Tests/Util/FSTIntegrationTestCase.h index 6b4ee5fb356..c33f3f5bc97 100644 --- a/Firestore/Example/Tests/Util/FSTIntegrationTestCase.h +++ b/Firestore/Example/Tests/Util/FSTIntegrationTestCase.h @@ -142,6 +142,9 @@ NSArray *FIRQuerySnapshotGetIDs(FIRQuerySnapshot *docs); * in order of { type, doc title, doc data }. */ NSArray *> *FIRQuerySnapshotGetDocChangesData(FIRQuerySnapshot *docs); +/** Gets the FIRDocumentReference objects from a FIRQuerySnapshot and returns them. */ +NSArray *FIRDocumentReferenceArrayFromQuerySnapshot(FIRQuerySnapshot *); + #if __cplusplus } // extern "C" #endif diff --git a/Firestore/Example/Tests/Util/FSTIntegrationTestCase.mm b/Firestore/Example/Tests/Util/FSTIntegrationTestCase.mm index b5274df086b..07460c6ecf3 100644 --- a/Firestore/Example/Tests/Util/FSTIntegrationTestCase.mm +++ b/Firestore/Example/Tests/Util/FSTIntegrationTestCase.mm @@ -25,6 +25,7 @@ #import #import #import +#import #include #include @@ -393,11 +394,48 @@ - (FIRCollectionReference *)collectionRefWithDocuments: - (void)writeAllDocuments:(NSDictionary *> *)documents toCollection:(FIRCollectionReference *)collection { + NSMutableArray *commits = [[NSMutableArray alloc] init]; + __block FIRWriteBatch *writeBatch = nil; + __block int writeBatchSize = 0; + [documents enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSDictionary *value, BOOL *) { - FIRDocumentReference *ref = [collection documentWithPath:key]; - [self writeDocumentRef:ref data:value]; + if (writeBatch == nil) { + writeBatch = [collection.firestore batch]; + } + + [writeBatch setData:value forDocument:[collection documentWithPath:key]]; + writeBatchSize++; + + // Write batches are capped at 500 writes. Use 400 just to be safe. + if (writeBatchSize == 400) { + XCTestExpectation *commitExpectation = [self expectationWithDescription:@"WriteBatch commit"]; + [writeBatch commitWithCompletion:^(NSError *_Nullable error) { + [commitExpectation fulfill]; + if (error != nil) { + XCTFail(@"WriteBatch commit failed: %@", error); + } + }]; + [commits addObject:commitExpectation]; + writeBatch = nil; + writeBatchSize = 0; + } }]; + + if (writeBatch != nil) { + XCTestExpectation *commitExpectation = [self expectationWithDescription:@"WriteBatch commit"]; + [writeBatch commitWithCompletion:^(NSError *_Nullable error) { + [commitExpectation fulfill]; + if (error != nil) { + XCTFail(@"WriteBatch commit failed: %@", error); + } + }]; + [commits addObject:commitExpectation]; + } + + for (XCTestExpectation *commitExpectation in commits) { + [self awaitExpectation:commitExpectation]; + } } - (void)readerAndWriterOnDocumentRef:(void (^)(FIRDocumentReference *readerRef, @@ -590,6 +628,16 @@ - (void)waitUntil:(BOOL (^)())predicate { return result; } +extern "C" NSArray *FIRDocumentReferenceArrayFromQuerySnapshot( + FIRQuerySnapshot *docs) { + NSMutableArray *documentReferenceAccumulator = + [[NSMutableArray alloc] init]; + for (FIRDocumentSnapshot *documentSnapshot in docs.documents) { + [documentReferenceAccumulator addObject:documentSnapshot.reference]; + } + return [documentReferenceAccumulator copy]; +} + @end NS_ASSUME_NONNULL_END