aboutsummaryrefslogtreecommitdiffhomepage
path: root/Firestore/Example/Tests/Remote
diff options
context:
space:
mode:
authorGravatar Gil <mcg@google.com>2017-10-03 08:55:22 -0700
committerGravatar GitHub <noreply@github.com>2017-10-03 08:55:22 -0700
commitbde743ed25166a0b320ae157bfb1d68064f531c9 (patch)
tree4dd7525d9df32fa5dbdb721d4b0d4f9b87f5e884 /Firestore/Example/Tests/Remote
parentbf550507ffa8beee149383a5bf1e2363bccefbb4 (diff)
Release 4.3.0 (#327)
Initial release of Firestore at 0.8.0 Bump FirebaseCommunity to 0.1.3
Diffstat (limited to 'Firestore/Example/Tests/Remote')
-rw-r--r--Firestore/Example/Tests/Remote/FSTDatastoreTests.m58
-rw-r--r--Firestore/Example/Tests/Remote/FSTRemoteEventTests.m556
-rw-r--r--Firestore/Example/Tests/Remote/FSTSerializerBetaTests.m794
-rw-r--r--Firestore/Example/Tests/Remote/FSTStreamTests.m139
-rw-r--r--Firestore/Example/Tests/Remote/FSTWatchChange+Testing.h40
-rw-r--r--Firestore/Example/Tests/Remote/FSTWatchChange+Testing.m54
-rw-r--r--Firestore/Example/Tests/Remote/FSTWatchChangeTests.m66
7 files changed, 1707 insertions, 0 deletions
diff --git a/Firestore/Example/Tests/Remote/FSTDatastoreTests.m b/Firestore/Example/Tests/Remote/FSTDatastoreTests.m
new file mode 100644
index 0000000..511de72
--- /dev/null
+++ b/Firestore/Example/Tests/Remote/FSTDatastoreTests.m
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2017 Google
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import "Firestore/FIRFirestoreErrors.h"
+#import "Remote/FSTDatastore.h"
+
+#import <GRPCClient/GRPCCall.h>
+#import <XCTest/XCTest.h>
+
+@interface FSTDatastoreTests : XCTestCase
+@end
+
+@implementation FSTDatastoreTests
+
+- (void)testIsPermanentWriteError {
+ // From GRPCCall -cancel
+ NSError *error = [NSError errorWithDomain:FIRFirestoreErrorDomain
+ code:FIRFirestoreErrorCodeCancelled
+ userInfo:@{NSLocalizedDescriptionKey : @"Canceled by app"}];
+ XCTAssertFalse([FSTDatastore isPermanentWriteError:error]);
+
+ // From GRPCCall -startNextRead
+ error =
+ [NSError errorWithDomain:FIRFirestoreErrorDomain
+ code:FIRFirestoreErrorCodeResourceExhausted
+ userInfo:@{
+ NSLocalizedDescriptionKey :
+ @"Client does not have enough memory to hold the server response."
+ }];
+ XCTAssertFalse([FSTDatastore isPermanentWriteError:error]);
+
+ // From GRPCCall -startWithWriteable
+ error = [NSError errorWithDomain:FIRFirestoreErrorDomain
+ code:FIRFirestoreErrorCodeUnavailable
+ userInfo:@{NSLocalizedDescriptionKey : @"Connectivity lost."}];
+ XCTAssertFalse([FSTDatastore isPermanentWriteError:error]);
+
+ // User info doesn't matter:
+ error = [NSError errorWithDomain:FIRFirestoreErrorDomain
+ code:FIRFirestoreErrorCodeUnavailable
+ userInfo:nil];
+ XCTAssertFalse([FSTDatastore isPermanentWriteError:error]);
+}
+
+@end
diff --git a/Firestore/Example/Tests/Remote/FSTRemoteEventTests.m b/Firestore/Example/Tests/Remote/FSTRemoteEventTests.m
new file mode 100644
index 0000000..a172af7
--- /dev/null
+++ b/Firestore/Example/Tests/Remote/FSTRemoteEventTests.m
@@ -0,0 +1,556 @@
+/*
+ * Copyright 2017 Google
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import "Remote/FSTRemoteEvent.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Local/FSTQueryData.h"
+#import "Model/FSTDocument.h"
+#import "Model/FSTDocumentKey.h"
+#import "Remote/FSTExistenceFilter.h"
+#import "Remote/FSTWatchChange.h"
+
+#import "FSTHelpers.h"
+#import "FSTWatchChange+Testing.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTRemoteEventTests : XCTestCase
+@end
+
+@implementation FSTRemoteEventTests {
+ NSData *_resumeToken1;
+ NSMutableDictionary<NSNumber *, NSNumber *> *_noPendingResponses;
+}
+
+- (void)setUp {
+ _resumeToken1 = [@"resume1" dataUsingEncoding:NSUTF8StringEncoding];
+ _noPendingResponses = [NSMutableDictionary dictionary];
+}
+
+- (FSTWatchChangeAggregator *)aggregatorWithTargets:(NSArray<NSNumber *> *)targets
+ outstanding:
+ (NSDictionary<NSNumber *, NSNumber *> *)outstanding
+ changes:(NSArray<FSTWatchChange *> *)watchChanges {
+ NSMutableDictionary<NSNumber *, FSTQueryData *> *listens = [NSMutableDictionary dictionary];
+ FSTQueryData *dummyQueryData = [FSTQueryData alloc];
+ for (NSNumber *targetID in targets) {
+ listens[targetID] = dummyQueryData;
+ }
+ FSTWatchChangeAggregator *aggregator =
+ [[FSTWatchChangeAggregator alloc] initWithSnapshotVersion:FSTTestVersion(3)
+ listenTargets:listens
+ pendingTargetResponses:outstanding];
+ [aggregator addWatchChanges:watchChanges];
+ return aggregator;
+}
+
+- (void)testWillAccumulateDocumentAddedAndRemovedEvents {
+ FSTDocument *doc1 = FSTTestDoc(@"docs/1", 1, @{ @"value" : @1 }, NO);
+ FSTDocument *doc2 = FSTTestDoc(@"docs/2", 2, @{ @"value" : @2 }, NO);
+
+ FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1, @2, @3 ]
+ removedTargetIDs:@[ @4, @5, @6 ]
+ documentKey:doc1.key
+ document:doc1];
+
+ FSTWatchChange *change2 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1, @4 ]
+ removedTargetIDs:@[ @2, @6 ]
+ documentKey:doc2.key
+ document:doc2];
+
+ FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargets:@[ @1, @2, @3, @4, @5, @6 ]
+ outstanding:_noPendingResponses
+ changes:@[ change1, change2 ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.documentUpdates.count, 2);
+ XCTAssertEqualObjects(event.documentUpdates[doc1.key], doc1);
+ XCTAssertEqualObjects(event.documentUpdates[doc2.key], doc2);
+
+ XCTAssertEqual(event.targetChanges.count, 6);
+
+ FSTUpdateMapping *mapping1 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1, doc2 ] removedDocuments:@[]];
+ XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1);
+
+ FSTUpdateMapping *mapping2 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1 ] removedDocuments:@[ doc2 ]];
+ XCTAssertEqualObjects(event.targetChanges[@2].mapping, mapping2);
+
+ FSTUpdateMapping *mapping3 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1 ] removedDocuments:@[]];
+ XCTAssertEqualObjects(event.targetChanges[@3].mapping, mapping3);
+
+ FSTUpdateMapping *mapping4 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[ doc2 ] removedDocuments:@[ doc1 ]];
+ XCTAssertEqualObjects(event.targetChanges[@4].mapping, mapping4);
+
+ FSTUpdateMapping *mapping5 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[] removedDocuments:@[ doc1 ]];
+ XCTAssertEqualObjects(event.targetChanges[@5].mapping, mapping5);
+
+ FSTUpdateMapping *mapping6 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[] removedDocuments:@[ doc1, doc2 ]];
+ XCTAssertEqualObjects(event.targetChanges[@6].mapping, mapping6);
+}
+
+- (void)testWillIgnoreEventsForPendingTargets {
+ FSTDocument *doc1 = FSTTestDoc(@"docs/1", 1, @{ @"value" : @1 }, NO);
+ FSTDocument *doc2 = FSTTestDoc(@"docs/2", 2, @{ @"value" : @2 }, NO);
+
+ FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[]
+ documentKey:doc1.key
+ document:doc1];
+
+ FSTWatchChange *change2 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateRemoved
+ targetIDs:@[ @1 ]
+ cause:nil];
+
+ FSTWatchChange *change3 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateAdded
+ targetIDs:@[ @1 ]
+ cause:nil];
+
+ FSTWatchChange *change4 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[]
+ documentKey:doc2.key
+ document:doc2];
+
+ // We're waiting for the unwatch and watch ack
+ NSDictionary<NSNumber *, NSNumber *> *pendingResponses = @{ @1 : @2 };
+
+ FSTWatchChangeAggregator *aggregator =
+ [self aggregatorWithTargets:@[ @1 ]
+ outstanding:pendingResponses
+ changes:@[ change1, change2, change3, change4 ]];
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ // doc1 is ignored because it was part of an inactive target, but doc2 is in the changes
+ // because it become active.
+ XCTAssertEqual(event.documentUpdates.count, 1);
+ XCTAssertEqualObjects(event.documentUpdates[doc2.key], doc2);
+
+ XCTAssertEqual(event.targetChanges.count, 1);
+}
+
+- (void)testWillIgnoreEventsForRemovedTargets {
+ FSTDocument *doc1 = FSTTestDoc(@"docs/1", 1, @{ @"value" : @1 }, NO);
+
+ FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[]
+ documentKey:doc1.key
+ document:doc1];
+
+ FSTWatchChange *change2 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateRemoved
+ targetIDs:@[ @1 ]
+ cause:nil];
+
+ // We're waiting for the unwatch ack
+ NSDictionary<NSNumber *, NSNumber *> *pendingResponses = @{ @1 : @1 };
+
+ FSTWatchChangeAggregator *aggregator =
+ [self aggregatorWithTargets:@[] outstanding:pendingResponses changes:@[ change1, change2 ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ // doc1 is ignored because it was part of an inactive target
+ XCTAssertEqual(event.documentUpdates.count, 0);
+
+ // Target 1 is ignored because it was removed
+ XCTAssertEqual(event.targetChanges.count, 0);
+}
+
+- (void)testWillKeepResetMappingEvenWithUpdates {
+ FSTDocument *doc1 = FSTTestDoc(@"docs/1", 1, @{ @"value" : @1 }, NO);
+ FSTDocument *doc2 = FSTTestDoc(@"docs/2", 2, @{ @"value" : @2 }, NO);
+ FSTDocument *doc3 = FSTTestDoc(@"docs/3", 3, @{ @"value" : @3 }, NO);
+
+ FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[]
+ documentKey:doc1.key
+ document:doc1];
+ // Reset stream, ignoring doc1
+ FSTWatchChange *change2 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateReset
+ targetIDs:@[ @1 ]
+ cause:nil];
+
+ // Add doc2, doc3
+ FSTWatchChange *change3 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[]
+ documentKey:doc2.key
+ document:doc2];
+ FSTWatchChange *change4 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[]
+ documentKey:doc3.key
+ document:doc3];
+
+ // Remove doc2 again, should not show up in reset mapping
+ FSTWatchChange *change5 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[]
+ removedTargetIDs:@[ @1 ]
+ documentKey:doc2.key
+ document:doc2];
+
+ FSTWatchChangeAggregator *aggregator =
+ [self aggregatorWithTargets:@[ @1 ]
+ outstanding:_noPendingResponses
+ changes:@[ change1, change2, change3, change4, change5 ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.documentUpdates.count, 3);
+ XCTAssertEqualObjects(event.documentUpdates[doc1.key], doc1);
+ XCTAssertEqualObjects(event.documentUpdates[doc2.key], doc2);
+ XCTAssertEqualObjects(event.documentUpdates[doc3.key], doc3);
+
+ XCTAssertEqual(event.targetChanges.count, 1);
+
+ // Only doc3 is part of the new mapping
+ FSTResetMapping *expectedMapping = [FSTResetMapping mappingWithDocuments:@[ doc3 ]];
+
+ XCTAssertEqualObjects(event.targetChanges[@1].mapping, expectedMapping);
+}
+
+- (void)testWillHandleSingleReset {
+ // Reset target
+ FSTWatchChange *change = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateReset
+ targetIDs:@[ @1 ]
+ cause:nil];
+
+ FSTWatchChangeAggregator *aggregator =
+ [self aggregatorWithTargets:@[ @1 ] outstanding:_noPendingResponses changes:@[ change ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.documentUpdates.count, 0);
+
+ XCTAssertEqual(event.targetChanges.count, 1);
+
+ // Reset mapping is empty
+ FSTResetMapping *expectedMapping = [FSTResetMapping mappingWithDocuments:@[]];
+ XCTAssertEqualObjects(event.targetChanges[@1].mapping, expectedMapping);
+}
+
+- (void)testWillHandleTargetAddAndRemovalInSameBatch {
+ FSTDocument *doc1a = FSTTestDoc(@"docs/1", 1, @{ @"value" : @1 }, NO);
+ FSTDocument *doc1b = FSTTestDoc(@"docs/1", 1, @{ @"value" : @2 }, NO);
+
+ FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[ @2 ]
+ documentKey:doc1a.key
+ document:doc1a];
+
+ FSTWatchChange *change2 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @2 ]
+ removedTargetIDs:@[ @1 ]
+ documentKey:doc1b.key
+ document:doc1b];
+ FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargets:@[ @1, @2 ]
+ outstanding:_noPendingResponses
+ changes:@[ change1, change2 ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.documentUpdates.count, 1);
+ XCTAssertEqualObjects(event.documentUpdates[doc1b.key], doc1b);
+
+ XCTAssertEqual(event.targetChanges.count, 2);
+
+ FSTUpdateMapping *mapping1 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[] removedDocuments:@[ doc1b ]];
+ XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1);
+
+ FSTUpdateMapping *mapping2 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1b ] removedDocuments:@[]];
+ XCTAssertEqualObjects(event.targetChanges[@2].mapping, mapping2);
+}
+
+- (void)testTargetCurrentChangeWillMarkTheTargetCurrent {
+ FSTWatchChange *change = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent
+ targetIDs:@[ @1 ]
+ resumeToken:_resumeToken1];
+
+ FSTWatchChangeAggregator *aggregator =
+ [self aggregatorWithTargets:@[ @1 ] outstanding:_noPendingResponses changes:@[ change ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.documentUpdates.count, 0);
+ XCTAssertEqual(event.targetChanges.count, 1);
+ FSTTargetChange *targetChange = event.targetChanges[@1];
+ XCTAssertEqualObjects(targetChange.mapping, [[FSTUpdateMapping alloc] init]);
+ XCTAssertEqual(targetChange.currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent);
+ XCTAssertEqualObjects(targetChange.resumeToken, _resumeToken1);
+}
+
+- (void)testTargetAddedChangeWillResetPreviousState {
+ FSTDocument *doc1 = FSTTestDoc(@"docs/1", 1, @{ @"value" : @1 }, NO);
+ FSTDocument *doc2 = FSTTestDoc(@"docs/2", 2, @{ @"value" : @2 }, NO);
+
+ FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1, @3 ]
+ removedTargetIDs:@[ @2 ]
+ documentKey:doc1.key
+ document:doc1];
+ FSTWatchChange *change2 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent
+ targetIDs:@[ @1, @2, @3 ]
+ resumeToken:_resumeToken1];
+ FSTWatchChange *change3 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateRemoved
+ targetIDs:@[ @1 ]
+ cause:nil];
+ FSTWatchChange *change4 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateRemoved
+ targetIDs:@[ @2 ]
+ cause:nil];
+ FSTWatchChange *change5 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateAdded
+ targetIDs:@[ @1 ]
+ cause:nil];
+ FSTWatchChange *change6 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[ @3 ]
+ documentKey:doc2.key
+ document:doc2];
+
+ NSDictionary<NSNumber *, NSNumber *> *pendingResponses = @{ @1 : @2, @2 : @1 };
+
+ FSTWatchChangeAggregator *aggregator =
+ [self aggregatorWithTargets:@[ @1, @3 ]
+ outstanding:pendingResponses
+ changes:@[ change1, change2, change3, change4, change5, change6 ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.documentUpdates.count, 2);
+ XCTAssertEqualObjects(event.documentUpdates[doc1.key], doc1);
+ XCTAssertEqualObjects(event.documentUpdates[doc2.key], doc2);
+
+ // target 1 and 3 are affected (1 because of re-add), target 2 is not because of remove
+ XCTAssertEqual(event.targetChanges.count, 2);
+
+ // doc1 was before the remove, so it does not show up in the mapping
+ FSTUpdateMapping *mapping1 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[ doc2 ] removedDocuments:@[]];
+ XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1);
+ // Current was before the remove
+ XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateNone);
+
+ // Doc1 was before the remove
+ FSTUpdateMapping *mapping3 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1 ] removedDocuments:@[ doc2 ]];
+ XCTAssertEqualObjects(event.targetChanges[@3].mapping, mapping3);
+ // Current was before the remove
+ XCTAssertEqual(event.targetChanges[@3].currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent);
+ XCTAssertEqualObjects(event.targetChanges[@3].resumeToken, _resumeToken1);
+}
+
+- (void)testNoChangeWillStillMarkTheAffectedTargets {
+ FSTWatchChange *change = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateNoChange
+ targetIDs:@[ @1 ]
+ resumeToken:_resumeToken1];
+
+ FSTWatchChangeAggregator *aggregator =
+ [self aggregatorWithTargets:@[ @1 ] outstanding:_noPendingResponses changes:@[ change ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.documentUpdates.count, 0);
+ XCTAssertEqual(event.targetChanges.count, 1);
+ XCTAssertEqualObjects(event.targetChanges[@1].mapping, [[FSTUpdateMapping alloc] init]);
+ XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateNone);
+ XCTAssertEqualObjects(event.targetChanges[@1].resumeToken, _resumeToken1);
+}
+
+- (void)testExistenceFiltersWillReplacePreviousExistenceFilters {
+ FSTExistenceFilter *filter1 = [FSTExistenceFilter filterWithCount:1];
+ FSTExistenceFilter *filter2 = [FSTExistenceFilter filterWithCount:2];
+ FSTWatchChange *change1 = [FSTExistenceFilterWatchChange changeWithFilter:filter1 targetID:1];
+ FSTWatchChange *change2 = [FSTExistenceFilterWatchChange changeWithFilter:filter1 targetID:2];
+ // replace filter1 for target 2
+ FSTWatchChange *change3 = [FSTExistenceFilterWatchChange changeWithFilter:filter2 targetID:2];
+
+ FSTWatchChangeAggregator *aggregator =
+ [self aggregatorWithTargets:@[ @1, @2 ]
+ outstanding:_noPendingResponses
+ changes:@[ change1, change2, change3 ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.documentUpdates.count, 0);
+ XCTAssertEqual(event.targetChanges.count, 0);
+ XCTAssertEqual(aggregator.existenceFilters.count, 2);
+ XCTAssertEqual(aggregator.existenceFilters[@1], filter1);
+ XCTAssertEqual(aggregator.existenceFilters[@2], filter2);
+}
+
+- (void)testExistenceFilterMismatchResetsTarget {
+ FSTDocument *doc1 = FSTTestDoc(@"docs/1", 1, @{ @"value" : @1 }, NO);
+ FSTDocument *doc2 = FSTTestDoc(@"docs/2", 2, @{ @"value" : @2 }, NO);
+
+ FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[]
+ documentKey:doc1.key
+ document:doc1];
+
+ FSTWatchChange *change2 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[]
+ documentKey:doc2.key
+ document:doc2];
+
+ FSTWatchChange *change3 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent
+ targetIDs:@[ @1 ]
+ resumeToken:_resumeToken1];
+
+ FSTWatchChangeAggregator *aggregator =
+ [self aggregatorWithTargets:@[ @1 ]
+ outstanding:_noPendingResponses
+ changes:@[ change1, change2, change3 ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.documentUpdates.count, 2);
+ XCTAssertEqualObjects(event.documentUpdates[doc1.key], doc1);
+ XCTAssertEqualObjects(event.documentUpdates[doc2.key], doc2);
+
+ XCTAssertEqual(event.targetChanges.count, 1);
+
+ FSTUpdateMapping *mapping1 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1, doc2 ] removedDocuments:@[]];
+ XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1);
+ XCTAssertEqualObjects(event.targetChanges[@1].snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent);
+ XCTAssertEqualObjects(event.targetChanges[@1].resumeToken, _resumeToken1);
+
+ [event handleExistenceFilterMismatchForTargetID:@1];
+
+ // Mapping is reset
+ XCTAssertEqualObjects(event.targetChanges[@1].mapping, [[FSTResetMapping alloc] init]);
+ // Reset the resume snapshot
+ XCTAssertEqualObjects(event.targetChanges[@1].snapshotVersion, FSTTestVersion(0));
+ // Target needs to be set to not current
+ XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateMarkNotCurrent);
+ XCTAssertEqual(event.targetChanges[@1].resumeToken.length, 0);
+}
+
+- (void)testDocumentUpdate {
+ FSTDocument *doc1 = FSTTestDoc(@"docs/1", 1, @{ @"value" : @1 }, NO);
+ FSTDeletedDocument *deletedDoc1 =
+ [FSTDeletedDocument documentWithKey:doc1.key version:FSTTestVersion(3)];
+ FSTDocument *doc2 = FSTTestDoc(@"docs/2", 2, @{ @"value" : @2 }, NO);
+ FSTDocument *doc3 = FSTTestDoc(@"docs/3", 3, @{ @"value" : @3 }, NO);
+
+ FSTWatchChange *change1 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[]
+ documentKey:doc1.key
+ document:doc1];
+
+ FSTWatchChange *change2 = [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1 ]
+ removedTargetIDs:@[]
+ documentKey:doc2.key
+ document:doc2];
+
+ FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargets:@[ @1 ]
+ outstanding:_noPendingResponses
+ changes:@[ change1, change2 ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.documentUpdates.count, 2);
+ XCTAssertEqualObjects(event.documentUpdates[doc1.key], doc1);
+ XCTAssertEqualObjects(event.documentUpdates[doc2.key], doc2);
+
+ // Update doc1
+ [event addDocumentUpdate:deletedDoc1];
+ [event addDocumentUpdate:doc3];
+
+ XCTAssertEqualObjects(event.snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.documentUpdates.count, 3);
+ // doc1 is replaced
+ XCTAssertEqualObjects(event.documentUpdates[doc1.key], deletedDoc1);
+ // doc2 is untouched
+ XCTAssertEqualObjects(event.documentUpdates[doc2.key], doc2);
+ // doc3 is new
+ XCTAssertEqualObjects(event.documentUpdates[doc3.key], doc3);
+
+ // Target is unchanged
+ XCTAssertEqual(event.targetChanges.count, 1);
+
+ FSTUpdateMapping *mapping1 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[ doc1, doc2 ] removedDocuments:@[]];
+ XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1);
+}
+
+- (void)testResumeTokensHandledPerTarget {
+ NSData *resumeToken2 = [@"resume2" dataUsingEncoding:NSUTF8StringEncoding];
+ FSTWatchChange *change1 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent
+ targetIDs:@[ @1 ]
+ resumeToken:_resumeToken1];
+ FSTWatchChange *change2 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent
+ targetIDs:@[ @2 ]
+ resumeToken:resumeToken2];
+ FSTWatchChangeAggregator *aggregator = [self aggregatorWithTargets:@[ @1, @2 ]
+ outstanding:_noPendingResponses
+ changes:@[ change1, change2 ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqual(event.targetChanges.count, 2);
+
+ FSTUpdateMapping *mapping1 =
+ [FSTUpdateMapping mappingWithAddedDocuments:@[] removedDocuments:@[]];
+ XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1);
+ XCTAssertEqualObjects(event.targetChanges[@1].snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent);
+ XCTAssertEqualObjects(event.targetChanges[@1].resumeToken, _resumeToken1);
+
+ XCTAssertEqualObjects(event.targetChanges[@2].mapping, mapping1);
+ XCTAssertEqualObjects(event.targetChanges[@2].snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.targetChanges[@2].currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent);
+ XCTAssertEqualObjects(event.targetChanges[@2].resumeToken, resumeToken2);
+}
+
+- (void)testLastResumeTokenWins {
+ NSData *resumeToken2 = [@"resume2" dataUsingEncoding:NSUTF8StringEncoding];
+ NSData *resumeToken3 = [@"resume3" dataUsingEncoding:NSUTF8StringEncoding];
+
+ FSTWatchChange *change1 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent
+ targetIDs:@[ @1 ]
+ resumeToken:_resumeToken1];
+ FSTWatchChange *change2 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateReset
+ targetIDs:@[ @1 ]
+ resumeToken:resumeToken2];
+ FSTWatchChange *change3 = [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateReset
+ targetIDs:@[ @2 ]
+ resumeToken:resumeToken3];
+ FSTWatchChangeAggregator *aggregator =
+ [self aggregatorWithTargets:@[ @1, @2 ]
+ outstanding:_noPendingResponses
+ changes:@[ change1, change2, change3 ]];
+
+ FSTRemoteEvent *event = [aggregator remoteEvent];
+ XCTAssertEqual(event.targetChanges.count, 2);
+
+ FSTResetMapping *mapping1 = [FSTResetMapping mappingWithDocuments:@[]];
+ XCTAssertEqualObjects(event.targetChanges[@1].mapping, mapping1);
+ XCTAssertEqualObjects(event.targetChanges[@1].snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.targetChanges[@1].currentStatusUpdate, FSTCurrentStatusUpdateMarkCurrent);
+ XCTAssertEqualObjects(event.targetChanges[@1].resumeToken, resumeToken2);
+
+ XCTAssertEqualObjects(event.targetChanges[@2].mapping, mapping1);
+ XCTAssertEqualObjects(event.targetChanges[@2].snapshotVersion, FSTTestVersion(3));
+ XCTAssertEqual(event.targetChanges[@2].currentStatusUpdate, FSTCurrentStatusUpdateNone);
+ XCTAssertEqualObjects(event.targetChanges[@2].resumeToken, resumeToken3);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Firestore/Example/Tests/Remote/FSTSerializerBetaTests.m b/Firestore/Example/Tests/Remote/FSTSerializerBetaTests.m
new file mode 100644
index 0000000..c4cf9df
--- /dev/null
+++ b/Firestore/Example/Tests/Remote/FSTSerializerBetaTests.m
@@ -0,0 +1,794 @@
+/*
+ * Copyright 2017 Google
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import "Remote/FSTSerializerBeta.h"
+
+#import <GRPCClient/GRPCCall.h>
+#import <XCTest/XCTest.h>
+
+#import "Core/FSTQuery.h"
+#import "Core/FSTSnapshotVersion.h"
+#import "Core/FSTTimestamp.h"
+#import "Firestore/FIRFieldPath.h"
+#import "Firestore/FIRFirestoreErrors.h"
+#import "Firestore/FIRGeoPoint.h"
+#import "Local/FSTQueryData.h"
+#import "Model/FSTDatabaseID.h"
+#import "Model/FSTDocument.h"
+#import "Model/FSTDocumentKey.h"
+#import "Model/FSTFieldValue.h"
+#import "Model/FSTMutation.h"
+#import "Model/FSTMutationBatch.h"
+#import "Model/FSTPath.h"
+#import "Protos/objc/firestore/local/MaybeDocument.pbobjc.h"
+#import "Protos/objc/firestore/local/Mutation.pbobjc.h"
+#import "Protos/objc/google/firestore/v1beta1/Common.pbobjc.h"
+#import "Protos/objc/google/firestore/v1beta1/Document.pbobjc.h"
+#import "Protos/objc/google/firestore/v1beta1/Firestore.pbobjc.h"
+#import "Protos/objc/google/firestore/v1beta1/Query.pbobjc.h"
+#import "Protos/objc/google/firestore/v1beta1/Write.pbobjc.h"
+#import "Protos/objc/google/rpc/Status.pbobjc.h"
+#import "Protos/objc/google/type/Latlng.pbobjc.h"
+#import "Remote/FSTWatchChange.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTSerializerBeta (Test)
+- (GCFSValue *)encodedNull;
+- (GCFSValue *)encodedBool:(BOOL)value;
+- (GCFSValue *)encodedDouble:(double)value;
+- (GCFSValue *)encodedInteger:(int64_t)value;
+- (GCFSValue *)encodedString:(NSString *)value;
+- (GCFSValue *)encodedDate:(NSDate *)value;
+
+- (GCFSDocumentMask *)encodedFieldMask:(FSTFieldMask *)fieldMask;
+- (NSMutableArray<GCFSDocumentTransform_FieldTransform *> *)encodedFieldTransforms:
+ (NSArray<FSTFieldTransform *> *)fieldTransforms;
+
+- (GCFSStructuredQuery_Filter *)encodedRelationFilter:(FSTRelationFilter *)filter;
+@end
+
+@interface GCFSStructuredQuery_Order (Test)
++ (instancetype)messageWithProperty:(NSString *)property ascending:(BOOL)ascending;
+@end
+
+@implementation GCFSStructuredQuery_Order (Test)
+
++ (instancetype)messageWithProperty:(NSString *)property ascending:(BOOL)ascending {
+ GCFSStructuredQuery_Order *order = [GCFSStructuredQuery_Order message];
+ order.field.fieldPath = property;
+ order.direction = ascending ? GCFSStructuredQuery_Direction_Ascending
+ : GCFSStructuredQuery_Direction_Descending;
+ return order;
+}
+@end
+
+@interface FSTSerializerBetaTests : XCTestCase
+@property(nonatomic, strong) FSTSerializerBeta *serializer;
+@end
+
+@implementation FSTSerializerBetaTests
+
+- (void)setUp {
+ FSTDatabaseID *databaseID = [FSTDatabaseID databaseIDWithProject:@"p" database:@"d"];
+ self.serializer = [[FSTSerializerBeta alloc] initWithDatabaseID:databaseID];
+}
+
+- (void)testEncodesNull {
+ FSTFieldValue *model = [FSTNullValue nullValue];
+
+ GCFSValue *proto = [GCFSValue message];
+ proto.nullValue = GPBNullValue_NullValue;
+
+ [self assertRoundTripForModel:model proto:proto type:GCFSValue_ValueType_OneOfCase_NullValue];
+}
+
+- (void)testEncodesBool {
+ NSArray<NSNumber *> *examples = @[ @YES, @NO ];
+ for (NSNumber *example in examples) {
+ FSTFieldValue *model = FSTTestFieldValue(example);
+
+ GCFSValue *proto = [GCFSValue message];
+ proto.booleanValue = [example boolValue];
+
+ [self assertRoundTripForModel:model
+ proto:proto
+ type:GCFSValue_ValueType_OneOfCase_BooleanValue];
+ }
+}
+
+- (void)testEncodesIntegers {
+ NSArray<NSNumber *> *examples = @[ @(LLONG_MIN), @(-100), @(-1), @0, @1, @100, @(LLONG_MAX) ];
+ for (NSNumber *example in examples) {
+ FSTFieldValue *model = FSTTestFieldValue(example);
+
+ GCFSValue *proto = [GCFSValue message];
+ proto.integerValue = [example longLongValue];
+
+ [self assertRoundTripForModel:model
+ proto:proto
+ type:GCFSValue_ValueType_OneOfCase_IntegerValue];
+ }
+}
+
+- (void)testEncodesDoubles {
+ NSArray<NSNumber *> *examples = @[
+ // normal negative numbers.
+ @(-INFINITY), @(-DBL_MAX), @(LLONG_MIN * 1.0 - 1.0), @(-2.0), @(-1.1), @(-1.0), @(-DBL_MIN),
+
+ // negative smallest subnormal, zeroes, positive smallest subnormal
+ @(-0x1.0p-1074), @(-0.0), @(0.0), @(0x1.0p-1074),
+
+ // and the rest
+ @(DBL_MIN), @0.1, @1.1, @(LLONG_MAX * 1.0), @(DBL_MAX), @(INFINITY),
+
+ // NaN.
+ @(0.0 / 0.0)
+ ];
+ for (NSNumber *example in examples) {
+ FSTFieldValue *model = FSTTestFieldValue(example);
+
+ GCFSValue *proto = [GCFSValue message];
+ proto.doubleValue = [example doubleValue];
+
+ [self assertRoundTripForModel:model proto:proto type:GCFSValue_ValueType_OneOfCase_DoubleValue];
+ }
+}
+
+- (void)testEncodesStrings {
+ NSArray<NSString *> *examples = @[
+ @"",
+ @"a",
+ @"abc def",
+ @"æ",
+ @"\0\ud7ff\ue000\uffff",
+ @"(╯°□°)╯︵ ┻━┻",
+ ];
+ for (NSString *example in examples) {
+ FSTFieldValue *model = FSTTestFieldValue(example);
+
+ GCFSValue *proto = [GCFSValue message];
+ proto.stringValue = example;
+
+ [self assertRoundTripForModel:model proto:proto type:GCFSValue_ValueType_OneOfCase_StringValue];
+ }
+}
+
+- (void)testEncodesDates {
+ NSDateComponents *dateWithNanos = FSTTestDateComponents(2016, 1, 2, 10, 20, 50);
+ dateWithNanos.nanosecond = 500000000;
+
+ NSArray<NSDate *> *examples = @[
+ [[NSCalendar currentCalendar] dateFromComponents:dateWithNanos],
+ FSTTestDate(2016, 6, 17, 10, 50, 15)
+ ];
+
+ GCFSValue *timestamp1 = [GCFSValue message];
+ timestamp1.timestampValue.seconds = 1451730050;
+ timestamp1.timestampValue.nanos = 500000000;
+
+ GCFSValue *timestamp2 = [GCFSValue message];
+ timestamp2.timestampValue.seconds = 1466160615;
+ timestamp2.timestampValue.nanos = 0;
+ NSArray<GCFSValue *> *expectedTimestamps = @[ timestamp1, timestamp2 ];
+
+ for (NSUInteger i = 0; i < [examples count]; i++) {
+ [self assertRoundTripForModel:FSTTestFieldValue(examples[i])
+ proto:expectedTimestamps[i]
+ type:GCFSValue_ValueType_OneOfCase_TimestampValue];
+ }
+}
+
+- (void)testEncodesGeoPoints {
+ NSArray<FIRGeoPoint *> *examples =
+ @[ FSTTestGeoPoint(0, 0), FSTTestGeoPoint(1.24, 4.56), FSTTestGeoPoint(-90, 180) ];
+ for (FIRGeoPoint *example in examples) {
+ FSTFieldValue *model = FSTTestFieldValue(example);
+
+ GCFSValue *proto = [GCFSValue message];
+ proto.geoPointValue = [GTPLatLng message];
+ proto.geoPointValue.latitude = example.latitude;
+ proto.geoPointValue.longitude = example.longitude;
+
+ [self assertRoundTripForModel:model
+ proto:proto
+ type:GCFSValue_ValueType_OneOfCase_GeoPointValue];
+ }
+}
+
+- (void)testEncodesBlobs {
+ NSArray<NSData *> *examples = @[
+ FSTTestData(-1),
+ FSTTestData(0, -1),
+ FSTTestData(0, 1, 2, -1),
+ FSTTestData(255, -1),
+ FSTTestData(0, 1, 255, -1),
+ ];
+ for (NSData *example in examples) {
+ FSTFieldValue *model = FSTTestFieldValue(example);
+
+ GCFSValue *proto = [GCFSValue message];
+ proto.bytesValue = example;
+
+ [self assertRoundTripForModel:model proto:proto type:GCFSValue_ValueType_OneOfCase_BytesValue];
+ }
+}
+
+- (void)testEncodesResourceNames {
+ FSTDocumentKeyReference *reference = FSTTestRef(@"project", kDefaultDatabaseID, @"foo/bar");
+ GCFSValue *proto = [GCFSValue message];
+ proto.referenceValue = @"projects/project/databases/(default)/documents/foo/bar";
+
+ [self assertRoundTripForModel:FSTTestFieldValue(reference)
+ proto:proto
+ type:GCFSValue_ValueType_OneOfCase_ReferenceValue];
+}
+
+- (void)testEncodesArrays {
+ FSTFieldValue *model = FSTTestFieldValue(@[ @YES, @"foo" ]);
+
+ GCFSValue *proto = [GCFSValue message];
+ [proto.arrayValue.valuesArray addObjectsFromArray:@[
+ [self.serializer encodedBool:YES], [self.serializer encodedString:@"foo"]
+ ]];
+
+ [self assertRoundTripForModel:model proto:proto type:GCFSValue_ValueType_OneOfCase_ArrayValue];
+}
+
+- (void)testEncodesEmptyMap {
+ FSTFieldValue *model = [FSTObjectValue objectValue];
+
+ GCFSValue *proto = [GCFSValue message];
+ proto.mapValue = [GCFSMapValue message];
+
+ [self assertRoundTripForModel:model proto:proto type:GCFSValue_ValueType_OneOfCase_MapValue];
+}
+
+- (void)testEncodesNestedObjects {
+ FSTFieldValue *model = FSTTestFieldValue(@{
+ @"b" : @YES,
+ @"d" : @(DBL_MAX),
+ @"i" : @1,
+ @"n" : [NSNull null],
+ @"s" : @"foo",
+ @"a" : @[ @2, @"bar", @{@"b" : @NO} ],
+ @"o" : @{
+ @"d" : @100,
+ @"nested" : @{@"e" : @(LLONG_MIN)},
+ },
+ });
+
+ GCFSValue *innerObject = [GCFSValue message];
+ innerObject.mapValue.fields[@"b"] = [self.serializer encodedBool:NO];
+
+ GCFSValue *middleArray = [GCFSValue message];
+ [middleArray.arrayValue.valuesArray addObjectsFromArray:@[
+ [self.serializer encodedInteger:2], [self.serializer encodedString:@"bar"], innerObject
+ ]];
+
+ innerObject = [GCFSValue message];
+ innerObject.mapValue.fields[@"e"] = [self.serializer encodedInteger:LLONG_MIN];
+
+ GCFSValue *middleObject = [GCFSValue message];
+ [middleObject.mapValue.fields addEntriesFromDictionary:@{
+ @"d" : [self.serializer encodedInteger:100],
+ @"nested" : innerObject
+ }];
+
+ GCFSValue *proto = [GCFSValue message];
+ [proto.mapValue.fields addEntriesFromDictionary:@{
+ @"b" : [self.serializer encodedBool:YES],
+ @"d" : [self.serializer encodedDouble:DBL_MAX],
+ @"i" : [self.serializer encodedInteger:1],
+ @"n" : [self.serializer encodedNull],
+ @"s" : [self.serializer encodedString:@"foo"],
+ @"a" : middleArray,
+ @"o" : middleObject
+ }];
+
+ [self assertRoundTripForModel:model proto:proto type:GCFSValue_ValueType_OneOfCase_MapValue];
+}
+
+- (void)assertRoundTripForModel:(FSTFieldValue *)model
+ proto:(GCFSValue *)value
+ type:(GCFSValue_ValueType_OneOfCase)type {
+ GCFSValue *actualProto = [self.serializer encodedFieldValue:model];
+ XCTAssertEqual(actualProto.valueTypeOneOfCase, type);
+ XCTAssertEqualObjects(actualProto, value);
+
+ FSTFieldValue *actualModel = [self.serializer decodedFieldValue:value];
+ XCTAssertEqualObjects(actualModel, model);
+}
+
+- (void)testEncodesSetMutation {
+ FSTSetMutation *mutation = FSTTestSetMutation(@"docs/1", @{ @"a" : @"b", @"num" : @1 });
+ GCFSWrite *proto = [GCFSWrite message];
+ proto.update = [self.serializer encodedDocumentWithFields:mutation.value key:mutation.key];
+
+ [self assertRoundTripForMutation:mutation proto:proto];
+}
+
+- (void)testEncodesPatchMutation {
+ FSTPatchMutation *mutation =
+ FSTTestPatchMutation(@"docs/1",
+ @{ @"a" : @"b",
+ @"num" : @1,
+ @"some.de\\\\ep.th\\ing'" : @2 },
+ nil);
+ GCFSWrite *proto = [GCFSWrite message];
+ proto.update = [self.serializer encodedDocumentWithFields:mutation.value key:mutation.key];
+ proto.updateMask = [self.serializer encodedFieldMask:mutation.fieldMask];
+ proto.currentDocument.exists = YES;
+
+ [self assertRoundTripForMutation:mutation proto:proto];
+}
+
+- (void)testEncodesDeleteMutation {
+ FSTDeleteMutation *mutation = FSTTestDeleteMutation(@"docs/1");
+ GCFSWrite *proto = [GCFSWrite message];
+ proto.delete_p = @"projects/p/databases/d/documents/docs/1";
+
+ [self assertRoundTripForMutation:mutation proto:proto];
+}
+
+- (void)testEncodesTransformMutation {
+ FSTTransformMutation *mutation = FSTTestTransformMutation(@"docs/1", @[ @"a", @"bar.baz" ]);
+ GCFSWrite *proto = [GCFSWrite message];
+ proto.transform = [GCFSDocumentTransform message];
+ proto.transform.document = [self.serializer encodedDocumentKey:mutation.key];
+ proto.transform.fieldTransformsArray =
+ [self.serializer encodedFieldTransforms:mutation.fieldTransforms];
+ proto.currentDocument.exists = YES;
+
+ [self assertRoundTripForMutation:mutation proto:proto];
+}
+
+- (void)testEncodesSetMutationWithPrecondition {
+ FSTSetMutation *mutation = [[FSTSetMutation alloc]
+ initWithKey:FSTTestDocKey(@"foo/bar")
+ value:FSTTestObjectValue(
+ @{ @"a" : @"b",
+ @"num" : @1 })
+ precondition:[FSTPrecondition preconditionWithUpdateTime:FSTTestVersion(4)]];
+ GCFSWrite *proto = [GCFSWrite message];
+ proto.update = [self.serializer encodedDocumentWithFields:mutation.value key:mutation.key];
+ proto.currentDocument.updateTime =
+ [self.serializer encodedTimestamp:[[FSTTimestamp alloc] initWithSeconds:0 nanos:4000]];
+
+ [self assertRoundTripForMutation:mutation proto:proto];
+}
+
+- (void)assertRoundTripForMutation:(FSTMutation *)mutation proto:(GCFSWrite *)proto {
+ GCFSWrite *actualProto = [self.serializer encodedMutation:mutation];
+ XCTAssertEqualObjects(actualProto, proto);
+
+ FSTMutation *actualMutation = [self.serializer decodedMutation:proto];
+ XCTAssertEqualObjects(actualMutation, mutation);
+}
+
+- (void)testRoundTripSpecialFieldNames {
+ FSTMutation *set = FSTTestSetMutation(@"collection/key", @{
+ @"field" : [NSString stringWithFormat:@"field %d", 1],
+ @"field.dot" : @2,
+ @"field\\slash" : @3
+ });
+ GCFSWrite *encoded = [self.serializer encodedMutation:set];
+ FSTMutation *decoded = [self.serializer decodedMutation:encoded];
+ XCTAssertEqualObjects(set, decoded);
+}
+
+- (void)testEncodesListenRequestLabels {
+ FSTQuery *query = FSTTestQuery(@"collection/key");
+ FSTQueryData *queryData =
+ [[FSTQueryData alloc] initWithQuery:query targetID:2 purpose:FSTQueryPurposeListen];
+
+ NSDictionary<NSString *, NSString *> *result =
+ [self.serializer encodedListenRequestLabelsForQueryData:queryData];
+ XCTAssertNil(result);
+
+ queryData =
+ [[FSTQueryData alloc] initWithQuery:query targetID:2 purpose:FSTQueryPurposeLimboResolution];
+ result = [self.serializer encodedListenRequestLabelsForQueryData:queryData];
+ XCTAssertEqualObjects(result, @{@"goog-listen-tags" : @"limbo-document"});
+
+ queryData = [[FSTQueryData alloc] initWithQuery:query
+ targetID:2
+ purpose:FSTQueryPurposeExistenceFilterMismatch];
+ result = [self.serializer encodedListenRequestLabelsForQueryData:queryData];
+ XCTAssertEqualObjects(result, @{@"goog-listen-tags" : @"existence-filter-mismatch"});
+}
+
+- (void)testEncodesRelationFilter {
+ FSTRelationFilter *input = FSTTestFilter(@"item.part.top", @"==", @"food");
+ GCFSStructuredQuery_Filter *actual = [self.serializer encodedRelationFilter:input];
+
+ GCFSStructuredQuery_Filter *expected = [GCFSStructuredQuery_Filter message];
+ GCFSStructuredQuery_FieldFilter *prop = expected.fieldFilter;
+ prop.field.fieldPath = @"item.part.top";
+ prop.op = GCFSStructuredQuery_FieldFilter_Operator_Equal;
+ prop.value.stringValue = @"food";
+ XCTAssertEqualObjects(actual, expected);
+}
+
+#pragma mark - encodedQuery
+
+- (void)testEncodesFirstLevelKeyQueries {
+ FSTQuery *q = [FSTQuery queryWithPath:FSTTestPath(@"docs/1")];
+ FSTQueryData *model = [self queryDataForQuery:q];
+
+ GCFSTarget *expected = [GCFSTarget message];
+ [expected.documents.documentsArray addObject:@"projects/p/databases/d/documents/docs/1"];
+ expected.targetId = 1;
+
+ [self assertRoundTripForQueryData:model proto:expected];
+}
+
+- (void)testEncodesFirstLevelAncestorQueries {
+ FSTQuery *q = [FSTQuery queryWithPath:FSTTestPath(@"messages")];
+ FSTQueryData *model = [self queryDataForQuery:q];
+
+ GCFSTarget *expected = [GCFSTarget message];
+ expected.query.parent = @"projects/p/databases/d";
+ GCFSStructuredQuery_CollectionSelector *from = [GCFSStructuredQuery_CollectionSelector message];
+ from.collectionId = @"messages";
+ [expected.query.structuredQuery.fromArray addObject:from];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:kDocumentKeyPath ascending:YES]];
+ expected.targetId = 1;
+
+ [self assertRoundTripForQueryData:model proto:expected];
+}
+
+- (void)testEncodesNestedAncestorQueries {
+ FSTQuery *q = [FSTQuery queryWithPath:FSTTestPath(@"rooms/1/messages/10/attachments")];
+ FSTQueryData *model = [self queryDataForQuery:q];
+
+ GCFSTarget *expected = [GCFSTarget message];
+ expected.query.parent = @"projects/p/databases/d/documents/rooms/1/messages/10";
+ GCFSStructuredQuery_CollectionSelector *from = [GCFSStructuredQuery_CollectionSelector message];
+ from.collectionId = @"attachments";
+ [expected.query.structuredQuery.fromArray addObject:from];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:kDocumentKeyPath ascending:YES]];
+ expected.targetId = 1;
+
+ [self assertRoundTripForQueryData:model proto:expected];
+}
+
+- (void)testEncodesSingleFiltersAtFirstLevelCollections {
+ FSTQuery *q = [[FSTQuery queryWithPath:FSTTestPath(@"docs")]
+ queryByAddingFilter:FSTTestFilter(@"prop", @"<", @(42))];
+ FSTQueryData *model = [self queryDataForQuery:q];
+
+ GCFSTarget *expected = [GCFSTarget message];
+ expected.query.parent = @"projects/p/databases/d";
+ GCFSStructuredQuery_CollectionSelector *from = [GCFSStructuredQuery_CollectionSelector message];
+ from.collectionId = @"docs";
+ [expected.query.structuredQuery.fromArray addObject:from];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:@"prop" ascending:YES]];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:kDocumentKeyPath ascending:YES]];
+
+ GCFSStructuredQuery_FieldFilter *filter = expected.query.structuredQuery.where.fieldFilter;
+ filter.field.fieldPath = @"prop";
+ filter.op = GCFSStructuredQuery_FieldFilter_Operator_LessThan;
+ filter.value.integerValue = 42;
+ expected.targetId = 1;
+
+ [self assertRoundTripForQueryData:model proto:expected];
+}
+
+- (void)testEncodesMultipleFiltersOnDeeperCollections {
+ FSTQuery *q = [[[FSTQuery queryWithPath:FSTTestPath(@"rooms/1/messages/10/attachments")]
+ queryByAddingFilter:FSTTestFilter(@"prop", @">=", @(42))]
+ queryByAddingFilter:FSTTestFilter(@"author", @"==", @"dimond")];
+ FSTQueryData *model = [self queryDataForQuery:q];
+
+ GCFSTarget *expected = [GCFSTarget message];
+ expected.query.parent = @"projects/p/databases/d/documents/rooms/1/messages/10";
+ GCFSStructuredQuery_CollectionSelector *from = [GCFSStructuredQuery_CollectionSelector message];
+ from.collectionId = @"attachments";
+ [expected.query.structuredQuery.fromArray addObject:from];
+
+ GCFSStructuredQuery_Filter *filter1 = [GCFSStructuredQuery_Filter message];
+ GCFSStructuredQuery_FieldFilter *field1 = filter1.fieldFilter;
+ field1.field.fieldPath = @"prop";
+ field1.op = GCFSStructuredQuery_FieldFilter_Operator_GreaterThanOrEqual;
+ field1.value.integerValue = 42;
+
+ GCFSStructuredQuery_Filter *filter2 = [GCFSStructuredQuery_Filter message];
+ GCFSStructuredQuery_FieldFilter *field2 = filter2.fieldFilter;
+ field2.field.fieldPath = @"author";
+ field2.op = GCFSStructuredQuery_FieldFilter_Operator_Equal;
+ field2.value.stringValue = @"dimond";
+
+ GCFSStructuredQuery_CompositeFilter *composite =
+ expected.query.structuredQuery.where.compositeFilter;
+ composite.op = GCFSStructuredQuery_CompositeFilter_Operator_And;
+ [composite.filtersArray addObject:filter1];
+ [composite.filtersArray addObject:filter2];
+
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:@"prop" ascending:YES]];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:kDocumentKeyPath ascending:YES]];
+ expected.targetId = 1;
+
+ [self assertRoundTripForQueryData:model proto:expected];
+}
+
+- (void)testEncodesNullFilter {
+ [self unaryFilterTestWithValue:[NSNull null]
+ expectedUnaryOperator:GCFSStructuredQuery_UnaryFilter_Operator_IsNull];
+}
+
+- (void)testEncodesNanFilter {
+ [self unaryFilterTestWithValue:@(NAN)
+ expectedUnaryOperator:GCFSStructuredQuery_UnaryFilter_Operator_IsNan];
+}
+
+- (void)unaryFilterTestWithValue:(id)value
+ expectedUnaryOperator:(GCFSStructuredQuery_UnaryFilter_Operator)
+ operator{
+ FSTQuery *q = [[FSTQuery queryWithPath:FSTTestPath(@"docs")]
+ queryByAddingFilter:FSTTestFilter(@"prop", @"==", value)];
+ FSTQueryData *model = [self queryDataForQuery:q];
+
+ GCFSTarget *expected = [GCFSTarget message];
+ expected.query.parent = @"projects/p/databases/d";
+ GCFSStructuredQuery_CollectionSelector *from = [GCFSStructuredQuery_CollectionSelector message];
+ from.collectionId = @"docs";
+ [expected.query.structuredQuery.fromArray addObject:from];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:kDocumentKeyPath ascending:YES]];
+
+ GCFSStructuredQuery_UnaryFilter *filter = expected.query.structuredQuery.where.unaryFilter;
+ filter.field.fieldPath = @"prop";
+ filter.op = operator;
+ expected.targetId = 1;
+
+ [self assertRoundTripForQueryData:model proto:expected];
+}
+
+- (void)testEncodesSortOrders {
+ FSTQuery *q = [[FSTQuery queryWithPath:FSTTestPath(@"docs")]
+ queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"prop")
+ ascending:YES]];
+ FSTQueryData *model = [self queryDataForQuery:q];
+
+ GCFSTarget *expected = [GCFSTarget message];
+ expected.query.parent = @"projects/p/databases/d";
+ GCFSStructuredQuery_CollectionSelector *from = [GCFSStructuredQuery_CollectionSelector message];
+ from.collectionId = @"docs";
+ [expected.query.structuredQuery.fromArray addObject:from];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:@"prop" ascending:YES]];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:kDocumentKeyPath ascending:YES]];
+ expected.targetId = 1;
+
+ [self assertRoundTripForQueryData:model proto:expected];
+}
+
+- (void)testEncodesSortOrdersDescending {
+ FSTQuery *q = [[FSTQuery queryWithPath:FSTTestPath(@"rooms/1/messages/10/attachments")]
+ queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"prop")
+ ascending:NO]];
+ FSTQueryData *model = [self queryDataForQuery:q];
+
+ GCFSTarget *expected = [GCFSTarget message];
+ expected.query.parent = @"projects/p/databases/d/documents/rooms/1/messages/10";
+ GCFSStructuredQuery_CollectionSelector *from = [GCFSStructuredQuery_CollectionSelector message];
+ from.collectionId = @"attachments";
+ [expected.query.structuredQuery.fromArray addObject:from];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:@"prop" ascending:NO]];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:kDocumentKeyPath ascending:NO]];
+ expected.targetId = 1;
+
+ [self assertRoundTripForQueryData:model proto:expected];
+}
+
+- (void)testEncodesLimits {
+ FSTQuery *q = [[FSTQuery queryWithPath:FSTTestPath(@"docs")] queryBySettingLimit:26];
+ FSTQueryData *model = [self queryDataForQuery:q];
+
+ GCFSTarget *expected = [GCFSTarget message];
+ expected.query.parent = @"projects/p/databases/d";
+ GCFSStructuredQuery_CollectionSelector *from = [GCFSStructuredQuery_CollectionSelector message];
+ from.collectionId = @"docs";
+ [expected.query.structuredQuery.fromArray addObject:from];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:kDocumentKeyPath ascending:YES]];
+ expected.query.structuredQuery.limit.value = 26;
+ expected.targetId = 1;
+
+ [self assertRoundTripForQueryData:model proto:expected];
+}
+
+- (void)testEncodesResumeTokens {
+ FSTQuery *q = [FSTQuery queryWithPath:FSTTestPath(@"docs")];
+ FSTQueryData *model = [[FSTQueryData alloc] initWithQuery:q
+ targetID:1
+ purpose:FSTQueryPurposeListen
+ snapshotVersion:[FSTSnapshotVersion noVersion]
+ resumeToken:FSTTestData(1, 2, 3, -1)];
+
+ GCFSTarget *expected = [GCFSTarget message];
+ expected.query.parent = @"projects/p/databases/d";
+ GCFSStructuredQuery_CollectionSelector *from = [GCFSStructuredQuery_CollectionSelector message];
+ from.collectionId = @"docs";
+ [expected.query.structuredQuery.fromArray addObject:from];
+ [expected.query.structuredQuery.orderByArray
+ addObject:[GCFSStructuredQuery_Order messageWithProperty:kDocumentKeyPath ascending:YES]];
+ expected.targetId = 1;
+ expected.resumeToken = FSTTestData(1, 2, 3, -1);
+
+ [self assertRoundTripForQueryData:model proto:expected];
+}
+
+- (FSTQueryData *)queryDataForQuery:(FSTQuery *)query {
+ return [[FSTQueryData alloc] initWithQuery:query
+ targetID:1
+ purpose:FSTQueryPurposeListen
+ snapshotVersion:[FSTSnapshotVersion noVersion]
+ resumeToken:[NSData data]];
+}
+
+- (void)assertRoundTripForQueryData:(FSTQueryData *)queryData proto:(GCFSTarget *)proto {
+ // Verify that the encoded FSTQueryData matches the target.
+ GCFSTarget *actualProto = [self.serializer encodedTarget:queryData];
+ XCTAssertEqualObjects(actualProto, proto);
+
+ // We don't have deserialization logic for full targets since they're not used for RPC
+ // interaction, but the query deserialization only *is* used for the local store.
+ FSTQuery *actualModel;
+ if (proto.targetTypeOneOfCase == GCFSTarget_TargetType_OneOfCase_Query) {
+ actualModel = [self.serializer decodedQueryFromQueryTarget:proto.query];
+ } else {
+ actualModel = [self.serializer decodedQueryFromDocumentsTarget:proto.documents];
+ }
+ XCTAssertEqualObjects(actualModel, queryData.query);
+}
+
+- (void)testConvertsTargetChangeWithAdded {
+ FSTWatchChange *expected =
+ [[FSTWatchTargetChange alloc] initWithState:FSTWatchTargetChangeStateAdded
+ targetIDs:@[ @1, @4 ]
+ resumeToken:[NSData data]
+ cause:nil];
+ GCFSListenResponse *listenResponse = [GCFSListenResponse message];
+ listenResponse.targetChange.targetChangeType = GCFSTargetChange_TargetChangeType_Add;
+ [listenResponse.targetChange.targetIdsArray addValue:1];
+ [listenResponse.targetChange.targetIdsArray addValue:4];
+ FSTWatchChange *actual = [self.serializer decodedWatchChange:listenResponse];
+
+ XCTAssertEqualObjects(actual, expected);
+}
+
+- (void)testConvertsTargetChangeWithRemoved {
+ FSTWatchChange *expected = [[FSTWatchTargetChange alloc]
+ initWithState:FSTWatchTargetChangeStateRemoved
+ targetIDs:@[ @1, @4 ]
+ resumeToken:FSTTestData(0, 1, 2, -1)
+ cause:[NSError errorWithDomain:FIRFirestoreErrorDomain
+ code:FIRFirestoreErrorCodePermissionDenied
+ userInfo:@{
+ NSLocalizedDescriptionKey : @"Error message",
+ }]];
+ GCFSListenResponse *listenResponse = [GCFSListenResponse message];
+ listenResponse.targetChange.targetChangeType = GCFSTargetChange_TargetChangeType_Remove;
+ listenResponse.targetChange.cause.code = FIRFirestoreErrorCodePermissionDenied;
+ listenResponse.targetChange.cause.message = @"Error message";
+ listenResponse.targetChange.resumeToken = FSTTestData(0, 1, 2, -1);
+ [listenResponse.targetChange.targetIdsArray addValue:1];
+ [listenResponse.targetChange.targetIdsArray addValue:4];
+ FSTWatchChange *actual = [self.serializer decodedWatchChange:listenResponse];
+
+ XCTAssertEqualObjects(actual, expected);
+}
+
+- (void)testConvertsTargetChangeWithNoChange {
+ FSTWatchChange *expected =
+ [[FSTWatchTargetChange alloc] initWithState:FSTWatchTargetChangeStateNoChange
+ targetIDs:@[ @1, @4 ]
+ resumeToken:[NSData data]
+ cause:nil];
+ GCFSListenResponse *listenResponse = [GCFSListenResponse message];
+ listenResponse.targetChange.targetChangeType = GCFSTargetChange_TargetChangeType_NoChange;
+ [listenResponse.targetChange.targetIdsArray addValue:1];
+ [listenResponse.targetChange.targetIdsArray addValue:4];
+ FSTWatchChange *actual = [self.serializer decodedWatchChange:listenResponse];
+
+ XCTAssertEqualObjects(actual, expected);
+}
+
+- (void)testConvertsDocumentChangeWithTargetIds {
+ FSTWatchChange *expected = [[FSTDocumentWatchChange alloc]
+ initWithUpdatedTargetIDs:@[ @1, @2 ]
+ removedTargetIDs:@[]
+ documentKey:FSTTestDocKey(@"coll/1")
+ document:FSTTestDoc(@"coll/1", 5, @{@"foo" : @"bar"}, NO)];
+ GCFSListenResponse *listenResponse = [GCFSListenResponse message];
+ listenResponse.documentChange.document.name = @"projects/p/databases/d/documents/coll/1";
+ listenResponse.documentChange.document.updateTime.nanos = 5000;
+ GCFSValue *fooValue = [GCFSValue message];
+ fooValue.stringValue = @"bar";
+ [listenResponse.documentChange.document.fields setObject:fooValue forKey:@"foo"];
+ [listenResponse.documentChange.targetIdsArray addValue:1];
+ [listenResponse.documentChange.targetIdsArray addValue:2];
+ FSTWatchChange *actual = [self.serializer decodedWatchChange:listenResponse];
+
+ XCTAssertEqualObjects(actual, expected);
+}
+
+- (void)testConvertsDocumentChangeWithRemovedTargetIds {
+ FSTWatchChange *expected = [[FSTDocumentWatchChange alloc]
+ initWithUpdatedTargetIDs:@[ @2 ]
+ removedTargetIDs:@[ @1 ]
+ documentKey:FSTTestDocKey(@"coll/1")
+ document:FSTTestDoc(@"coll/1", 5, @{@"foo" : @"bar"}, NO)];
+ GCFSListenResponse *listenResponse = [GCFSListenResponse message];
+ listenResponse.documentChange.document.name = @"projects/p/databases/d/documents/coll/1";
+ listenResponse.documentChange.document.updateTime.nanos = 5000;
+ GCFSValue *fooValue = [GCFSValue message];
+ fooValue.stringValue = @"bar";
+ [listenResponse.documentChange.document.fields setObject:fooValue forKey:@"foo"];
+ [listenResponse.documentChange.removedTargetIdsArray addValue:1];
+ [listenResponse.documentChange.targetIdsArray addValue:2];
+ FSTWatchChange *actual = [self.serializer decodedWatchChange:listenResponse];
+
+ XCTAssertEqualObjects(actual, expected);
+}
+
+- (void)testConvertsDocumentChangeWithDeletions {
+ FSTWatchChange *expected =
+ [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[]
+ removedTargetIDs:@[ @1, @2 ]
+ documentKey:FSTTestDocKey(@"coll/1")
+ document:FSTTestDeletedDoc(@"coll/1", 5)];
+ GCFSListenResponse *listenResponse = [GCFSListenResponse message];
+ listenResponse.documentDelete.document = @"projects/p/databases/d/documents/coll/1";
+ listenResponse.documentDelete.readTime.nanos = 5000;
+ [listenResponse.documentDelete.removedTargetIdsArray addValue:1];
+ [listenResponse.documentDelete.removedTargetIdsArray addValue:2];
+ FSTWatchChange *actual = [self.serializer decodedWatchChange:listenResponse];
+
+ XCTAssertEqualObjects(actual, expected);
+}
+
+- (void)testConvertsDocumentChangeWithRemoves {
+ FSTWatchChange *expected =
+ [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[]
+ removedTargetIDs:@[ @1, @2 ]
+ documentKey:FSTTestDocKey(@"coll/1")
+ document:nil];
+ GCFSListenResponse *listenResponse = [GCFSListenResponse message];
+ listenResponse.documentRemove.document = @"projects/p/databases/d/documents/coll/1";
+ [listenResponse.documentRemove.removedTargetIdsArray addValue:1];
+ [listenResponse.documentRemove.removedTargetIdsArray addValue:2];
+ FSTWatchChange *actual = [self.serializer decodedWatchChange:listenResponse];
+
+ XCTAssertEqualObjects(actual, expected);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Firestore/Example/Tests/Remote/FSTStreamTests.m b/Firestore/Example/Tests/Remote/FSTStreamTests.m
new file mode 100644
index 0000000..f27b200
--- /dev/null
+++ b/Firestore/Example/Tests/Remote/FSTStreamTests.m
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2017 Google
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import "Remote/FSTDatastore.h"
+
+#import <OCMock/OCMock.h>
+#import <XCTest/XCTest.h>
+
+#import "Auth/FSTEmptyCredentialsProvider.h"
+#import "Core/FSTDatabaseInfo.h"
+#import "Model/FSTDatabaseID.h"
+#import "Protos/objc/google/firestore/v1beta1/Firestore.pbrpc.h"
+#import "Util/FSTDispatchQueue.h"
+
+/** Expose otherwise private methods for testing. */
+@interface FSTStream (Testing)
+
+- (void)writesFinishedWithError:(NSError *_Nullable)error;
+
+@end
+
+@interface FSTStreamTests : XCTestCase
+@end
+
+@implementation FSTStreamTests {
+ dispatch_queue_t _testQueue;
+ FSTDatabaseInfo *_databaseInfo;
+ FSTDispatchQueue *_workerDispatchQueue;
+ id<FSTCredentialsProvider> _credentials;
+}
+
+- (void)setUp {
+ [super setUp];
+
+ FSTDatabaseID *databaseID =
+ [FSTDatabaseID databaseIDWithProject:@"project" database:kDefaultDatabaseID];
+ _databaseInfo = [FSTDatabaseInfo databaseInfoWithDatabaseID:databaseID
+ persistenceKey:@"test"
+ host:@"test-host"
+ sslEnabled:NO];
+
+ _testQueue = dispatch_queue_create("com.firebase.testing", DISPATCH_QUEUE_SERIAL);
+ _workerDispatchQueue = [FSTDispatchQueue queueWith:_testQueue];
+ _credentials = [[FSTEmptyCredentialsProvider alloc] init];
+}
+
+- (void)tearDown {
+ [super tearDown];
+}
+
+- (void)testWatchStreamStop {
+ id delegate = OCMStrictProtocolMock(@protocol(FSTWatchStreamDelegate));
+
+ FSTWatchStream *stream =
+ OCMPartialMock([[FSTWatchStream alloc] initWithDatabase:_databaseInfo
+ workerDispatchQueue:_workerDispatchQueue
+ credentials:_credentials
+ responseMessageClass:[GCFSWriteResponse class]
+ delegate:delegate]);
+ OCMStub([stream createRPCWithRequestsWriter:[OCMArg any]]).andReturn(nil);
+
+ // Start the stream up but that's not really the interesting bit. This is complicated by the fact
+ // that startup involves redispatching after credentials are returned.
+ dispatch_semaphore_t openCompleted = dispatch_semaphore_create(0);
+ OCMStub([delegate watchStreamDidOpen]).andDo(^(NSInvocation *invocation) {
+ dispatch_semaphore_signal(openCompleted);
+ });
+ dispatch_async(_testQueue, ^{
+ [stream start];
+ });
+ dispatch_semaphore_wait(openCompleted, DISPATCH_TIME_FOREVER);
+ OCMVerifyAll(delegate);
+
+ // Stop must not call watchStreamDidClose because the full implementation of the delegate could
+ // attempt to restart the stream in the event it had pending watches.
+ dispatch_sync(_testQueue, ^{
+ [stream stop];
+ });
+ OCMVerifyAll(delegate);
+
+ // Simulate a final callback from GRPC
+ [stream writesFinishedWithError:nil];
+ // Drain queue
+ dispatch_sync(_testQueue, ^{
+ });
+ OCMVerifyAll(delegate);
+}
+
+- (void)testWriteStreamStop {
+ id delegate = OCMStrictProtocolMock(@protocol(FSTWriteStreamDelegate));
+
+ FSTWriteStream *stream =
+ OCMPartialMock([[FSTWriteStream alloc] initWithDatabase:_databaseInfo
+ workerDispatchQueue:_workerDispatchQueue
+ credentials:_credentials
+ responseMessageClass:[GCFSWriteResponse class]
+ delegate:delegate]);
+ OCMStub([stream createRPCWithRequestsWriter:[OCMArg any]]).andReturn(nil);
+
+ // Start the stream up but that's not really the interesting bit.
+ dispatch_semaphore_t openCompleted = dispatch_semaphore_create(0);
+ OCMStub([delegate writeStreamDidOpen]).andDo(^(NSInvocation *invocation) {
+ dispatch_semaphore_signal(openCompleted);
+ });
+ dispatch_async(_testQueue, ^{
+ [stream start];
+ });
+ dispatch_semaphore_wait(openCompleted, DISPATCH_TIME_FOREVER);
+ OCMVerifyAll(delegate);
+
+ // Stop must not call writeStreamDidClose because the full implementation of this delegate could
+ // attempt to restart the stream in the event it had pending writes.
+ dispatch_sync(_testQueue, ^{
+ [stream stop];
+ });
+ OCMVerifyAll(delegate);
+
+ // Simulate a final callback from GRPC
+ [stream writesFinishedWithError:nil];
+ // Drain queue
+ dispatch_sync(_testQueue, ^{
+ });
+ OCMVerifyAll(delegate);
+}
+
+@end
diff --git a/Firestore/Example/Tests/Remote/FSTWatchChange+Testing.h b/Firestore/Example/Tests/Remote/FSTWatchChange+Testing.h
new file mode 100644
index 0000000..f94fe05
--- /dev/null
+++ b/Firestore/Example/Tests/Remote/FSTWatchChange+Testing.h
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2017 Google
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import <Foundation/Foundation.h>
+
+#import "Core/FSTTypes.h"
+#import "Remote/FSTWatchChange.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** FSTWatchTargetChange is a change to a watch target. */
+@interface FSTWatchTargetChange (Testing)
+
++ (instancetype)changeWithState:(FSTWatchTargetChangeState)state
+ targetIDs:(NSArray<NSNumber *> *)targetIDs;
+
++ (instancetype)changeWithState:(FSTWatchTargetChangeState)state
+ targetIDs:(NSArray<NSNumber *> *)targetIDs
+ cause:(nullable NSError *)cause;
+
++ (instancetype)changeWithState:(FSTWatchTargetChangeState)state
+ targetIDs:(NSArray<NSNumber *> *)targetIDs
+ resumeToken:(nullable NSData *)resumeToken;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Firestore/Example/Tests/Remote/FSTWatchChange+Testing.m b/Firestore/Example/Tests/Remote/FSTWatchChange+Testing.m
new file mode 100644
index 0000000..cb5e479
--- /dev/null
+++ b/Firestore/Example/Tests/Remote/FSTWatchChange+Testing.m
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2017 Google
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import "FSTWatchChange+Testing.h"
+
+#import "Model/FSTDocument.h"
+#import "Remote/FSTWatchChange.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@implementation FSTWatchTargetChange (Testing)
+
++ (instancetype)changeWithState:(FSTWatchTargetChangeState)state
+ targetIDs:(NSArray<NSNumber *> *)targetIDs {
+ return [[FSTWatchTargetChange alloc] initWithState:state
+ targetIDs:targetIDs
+ resumeToken:[NSData data]
+ cause:nil];
+}
+
++ (instancetype)changeWithState:(FSTWatchTargetChangeState)state
+ targetIDs:(NSArray<NSNumber *> *)targetIDs
+ cause:(nullable NSError *)cause {
+ return [[FSTWatchTargetChange alloc] initWithState:state
+ targetIDs:targetIDs
+ resumeToken:[NSData data]
+ cause:cause];
+}
+
++ (instancetype)changeWithState:(FSTWatchTargetChangeState)state
+ targetIDs:(NSArray<NSNumber *> *)targetIDs
+ resumeToken:(nullable NSData *)resumeToken {
+ return [[FSTWatchTargetChange alloc] initWithState:state
+ targetIDs:targetIDs
+ resumeToken:resumeToken
+ cause:nil];
+}
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/Firestore/Example/Tests/Remote/FSTWatchChangeTests.m b/Firestore/Example/Tests/Remote/FSTWatchChangeTests.m
new file mode 100644
index 0000000..ccbd644
--- /dev/null
+++ b/Firestore/Example/Tests/Remote/FSTWatchChangeTests.m
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2017 Google
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#import "Remote/FSTWatchChange.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Model/FSTDocument.h"
+#import "Remote/FSTExistenceFilter.h"
+
+#import "FSTHelpers.h"
+#import "FSTWatchChange+Testing.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTWatchChangeTests : XCTestCase
+@end
+
+@implementation FSTWatchChangeTests
+
+- (void)testDocumentChange {
+ FSTMaybeDocument *doc = FSTTestDoc(@"a/b", 1, @{}, NO);
+ FSTDocumentWatchChange *change =
+ [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[ @1, @2, @3 ]
+ removedTargetIDs:@[ @4, @5 ]
+ documentKey:doc.key
+ document:doc];
+ XCTAssertEqual(change.updatedTargetIDs.count, 3);
+ XCTAssertEqual(change.removedTargetIDs.count, 2);
+ // Testing object identity here is fine.
+ XCTAssertEqual(change.document, doc);
+}
+
+- (void)testExistenceFilterChange {
+ FSTExistenceFilter *filter = [FSTExistenceFilter filterWithCount:7];
+ FSTExistenceFilterWatchChange *change =
+ [FSTExistenceFilterWatchChange changeWithFilter:filter targetID:5];
+ XCTAssertEqual(change.filter.count, 7);
+ XCTAssertEqual(change.targetID, 5);
+}
+
+- (void)testWatchTargetChange {
+ FSTWatchTargetChange *change =
+ [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateReset
+ targetIDs:@[ @1, @2 ]
+ cause:nil];
+ XCTAssertEqual(change.state, FSTWatchTargetChangeStateReset);
+ XCTAssertEqual(change.targetIDs.count, 2);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END